From 3ecabd8457c188fd8b8beb507f39b00b72b5ecc5 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 16 Oct 2026 15:05:48 +0200 Subject: [PATCH 1/2] feat(framework): support pracht as a framework that owns its Cloudflare build Detect pracht from @pracht/adapter-cloudflare and deploy the Worker that `pracht build` writes to dist/server/worker.js. In dev, wrap pracht's server entry so cron and queue triggers reach the generated dispatch, and import Void's env module from the entry so lazily loaded routes can use void/db on the first request. Framework presets can now set the asset trailing-slash policy their Worker relies on. --- docs/.vitepress/config.ts | 2 + docs/guide/app-types.md | 17 +- docs/integrations/frameworks/overview.md | 21 +-- docs/integrations/frameworks/pracht.md | 109 ++++++++++++ packages/deploy-cloudflare/src/framework.ts | 4 + .../test/unit/framework.test.ts | 22 +++ .../void/src/cli/deploy-cloudflare-static.ts | 1 + .../void/src/cli/deploy-managed-framework.ts | 5 +- packages/void/src/cli/init.ts | 20 +++ packages/void/src/cli/preset.ts | 19 ++- packages/void/src/plugin-framework.ts | 86 ++++++++++ packages/void/src/plugin-inference.ts | 3 + .../void/test/unit/framework-preset.test.ts | 11 ++ .../void/test/unit/plugin-inference.test.ts | 24 +++ .../test/unit/plugin-vite-environment.test.ts | 156 +++++++++++++++++- 15 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 docs/integrations/frameworks/pracht.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fdb5d1c82..e0de0e19c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -91,6 +91,7 @@ const integrationItems = [ { text: 'SvelteKit', link: '/integrations/frameworks/sveltekit' }, { text: 'Nuxt', link: '/integrations/frameworks/nuxt' }, { text: 'Analog', link: '/integrations/frameworks/analog' }, + { text: 'pracht', link: '/integrations/frameworks/pracht' }, ], }, { text: 'Coding Agents', link: '/integrations/agents' }, @@ -333,6 +334,7 @@ export default extendConfig( SvelteKit: 'logos:svelte-icon', Nuxt: 'logos:nuxt-icon', Analog: 'logos:analog', + pracht: 'logos:preact', }; const itemIcons: Record = { 'Coding Agents': 'lucide:bot', diff --git a/docs/guide/app-types.md b/docs/guide/app-types.md index 24e91204c..81768fab8 100644 --- a/docs/guide/app-types.md +++ b/docs/guide/app-types.md @@ -30,14 +30,15 @@ Void apps can also use [`output: 'static'`](./ssg.md) to pre-render all pages at Void supports deploying Vite-based meta-framework apps with `void deploy`. The framework owns routing and SSR. Void handles [binding inference](../reference/resource-inference.md), [typed DB queries](./database.md), migrations, and deployment. -| Framework | Detected from package | Setup guide | -| --------------------------------------------------- | ----------------------- | ----------------------------------------------------- | -| [TanStack Start](https://tanstack.com/start/latest) | `@tanstack/react-start` | [Guide](../integrations/frameworks/tanstack-start.md) | -| [React Router v7](https://reactrouter.com/) | `@react-router/dev` | [Guide](../integrations/frameworks/react-router.md) | -| [SvelteKit](https://svelte.dev/docs/kit) | `@sveltejs/kit` | [Guide](../integrations/frameworks/sveltekit.md) | -| [Nuxt](https://nuxt.com/) | `nuxt` | [Guide](../integrations/frameworks/nuxt.md) | -| [Analog](https://analogjs.org/) | `@analogjs/platform` | [Guide](../integrations/frameworks/analog.md) | -| [Astro](https://astro.build/) | `astro` | [Guide](../integrations/frameworks/astro.md) | +| Framework | Detected from package | Setup guide | +| --------------------------------------------------- | ---------------------------- | ----------------------------------------------------- | +| [TanStack Start](https://tanstack.com/start/latest) | `@tanstack/react-start` | [Guide](../integrations/frameworks/tanstack-start.md) | +| [React Router v7](https://reactrouter.com/) | `@react-router/dev` | [Guide](../integrations/frameworks/react-router.md) | +| [SvelteKit](https://svelte.dev/docs/kit) | `@sveltejs/kit` | [Guide](../integrations/frameworks/sveltekit.md) | +| [Nuxt](https://nuxt.com/) | `nuxt` | [Guide](../integrations/frameworks/nuxt.md) | +| [Analog](https://analogjs.org/) | `@analogjs/platform` | [Guide](../integrations/frameworks/analog.md) | +| [Astro](https://astro.build/) | `astro` | [Guide](../integrations/frameworks/astro.md) | +| [pracht](https://pracht.resynapse.dev/) | `@pracht/adapter-cloudflare` | [Guide](../integrations/frameworks/pracht.md) | Add `voidPlugin()` to the framework's Vite config to get binding inference, typed DB generation, migration management, cron jobs, queues, and caching. Void-managed auth is not supported in framework mode; use Better Auth's official integration for your framework. See the [Meta Frameworks Integration](../integrations/frameworks/overview.md) for supported features and setup guides. diff --git a/docs/integrations/frameworks/overview.md b/docs/integrations/frameworks/overview.md index 8a7e460d7..36c94ae82 100644 --- a/docs/integrations/frameworks/overview.md +++ b/docs/integrations/frameworks/overview.md @@ -8,20 +8,21 @@ Keep your framework's routes and rendering, and add Void for databases, storage, Add `voidPlugin()` to the framework's Vite config. It detects resource usage, generates database types, manages migrations, and prepares the app for `void deploy`. -TanStack Start and React Router use the Cloudflare Vite plugin provided by Void. SvelteKit, Nuxt, Analog, and Astro use their own Cloudflare adapters. The setup differs, but your framework continues to handle routing and rendering. +TanStack Start and React Router use the Cloudflare Vite plugin provided by Void. SvelteKit, Nuxt, Analog, Astro, and pracht use their own Cloudflare adapters. The setup differs, but your framework continues to handle routing and rendering. ![void relationships with meta frameworks and deployment targets](./void-relationships.svg) ## Supported Frameworks -| Framework | Package | Integration | -| ------------------------------------- | ----------------------- | ---------------------------------------------------------------------------- | -| [TanStack Start](./tanstack-start.md) | `@tanstack/react-start` | Vite plugin composition. `voidPlugin()` runs alongside the framework plugin. | -| [React Router v7](./react-router.md) | `@react-router/dev` | Vite plugin composition. Setup matches TanStack Start. | -| [SvelteKit](./sveltekit.md) | `@sveltejs/kit` | Vite-based, uses its own Cloudflare adapter | -| [Nuxt](./nuxt.md) | `nuxt` | Own CLI and build toolchain | -| [Analog](./analog.md) | `@analogjs/platform` | Vite-based, uses Nitro with Cloudflare preset | -| [Astro **v6+**](./astro.md) | `astro` | Own CLI and Cloudflare adapter (v6 required) | +| Framework | Package | Integration | +| ------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------- | +| [TanStack Start](./tanstack-start.md) | `@tanstack/react-start` | Vite plugin composition. `voidPlugin()` runs alongside the framework plugin. | +| [React Router v7](./react-router.md) | `@react-router/dev` | Vite plugin composition. Setup matches TanStack Start. | +| [SvelteKit](./sveltekit.md) | `@sveltejs/kit` | Vite-based, uses its own Cloudflare adapter | +| [Nuxt](./nuxt.md) | `nuxt` | Own CLI and build toolchain | +| [Analog](./analog.md) | `@analogjs/platform` | Vite-based, uses Nitro with Cloudflare preset | +| [Astro **v6+**](./astro.md) | `astro` | Own CLI and Cloudflare adapter (v6 required) | +| [pracht](./pracht.md) | `@pracht/adapter-cloudflare` | Own CLI and Cloudflare adapter | Detection is automatic. The CLI reads your `package.json` dependencies. @@ -40,7 +41,7 @@ With `voidPlugin()` added to the framework's Vite config, frameworks get most of For framework apps, Void revalidation caches public HTML document responses with both `void deploy` and `void deploy --platform cloudflare`. Client data requests continue to use the framework's own behavior. A response with `Set-Cookie` or `Cache-Control: private`, `no-store`, or `no-cache` is never shared. -Frameworks can also access bindings directly via the framework's own mechanisms (e.g. `env` from `cloudflare:workers` in SvelteKit 3 and Analog, `platform.env` in SvelteKit 2, `event.context.cloudflare.env` in Nuxt, `Astro.locals.runtime.env` in Astro). +Frameworks can also access bindings directly via the framework's own mechanisms (e.g. `env` from `cloudflare:workers` in SvelteKit 3 and Analog, `platform.env` in SvelteKit 2, `event.context.cloudflare.env` in Nuxt, `Astro.locals.runtime.env` in Astro, `context.env` in pracht). ## Configuration diff --git a/docs/integrations/frameworks/pracht.md b/docs/integrations/frameworks/pracht.md new file mode 100644 index 000000000..e9b421047 --- /dev/null +++ b/docs/integrations/frameworks/pracht.md @@ -0,0 +1,109 @@ +--- +outline: deep +--- + +# pracht + +Use Void with [pracht](https://pracht.resynapse.dev/) for resource detection, typed database queries, and migrations. pracht keeps its own build and Cloudflare adapter; Void plugs into its Vite configuration. + +## Setup + +### 1. Create a new project + +```bash +npm create pracht@latest my-app -- --adapter=cf +cd my-app +``` + +### 2. Install dependencies + +```bash +npm install -D void +``` + +### 3. Configure `vite.config.ts` + +Add `voidPlugin()` before `pracht()`, and point the Cloudflare adapter at the Cloudflare config Void generates: + +```ts +import { cloudflareAdapter } from '@pracht/adapter-cloudflare'; +import { pracht } from '@pracht/vite-plugin'; +import { defineConfig } from 'vite'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [ + voidPlugin(), + pracht({ adapter: cloudflareAdapter({ configPath: './.void-wrangler.jsonc' }) }), + ], +}); +``` + +### 4. Create `void.config.ts` + +Void uses this config for the Cloudflare adapter during development and deployment. Move any bindings from the `wrangler.jsonc` that `create-pracht` wrote into it, then delete that file: + +```ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + cloudflare: { + name: 'my-app', + compatibility_date: '2026-02-24', + compatibility_flags: ['nodejs_als'], + }, +}); +``` + +`nodejs_als` is required for `void/*` runtime helpers (for example `void/db`, `void/kv`) in pracht. + +### 5. Deploy + +```bash +void auth login +void deploy +``` + +`void deploy` runs `pracht build` and deploys `dist/server/worker.js` with the assets in `dist/client`. + +## Using Void Features + +Most [Void platform features](../../guide/app-types.md#void-apps) work with pracht in both `pracht dev` and production. + +Void-managed auth is not supported in framework mode yet. Use [`@pracht/session`](https://pracht.resynapse.dev/docs/recipes-auth) or Better Auth directly for now. + +`pracht build` prerenders `ssg` and `isg` routes without Cloudflare bindings, so a route whose loader uses `void/*` helpers must use `render: "ssr"`. + +### Database + +Use `void/db` in loaders, API routes, and middleware: + +```tsx +import { users } from '@schema'; +import { db } from 'void/db'; + +export async function loader() { + return { users: await db.select().from(users) }; +} +``` + +### Other resources + +Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`. + +### Environment Variables + +Declare variables in `env.ts` and read them with `import { env } from "void/env"`. See [Environment Variables](../../guide/env-vars.md) for local values, production secrets, and public `VITE_*` values. + +## Accessing Bindings Directly + +pracht's Cloudflare adapter passes the Worker `env` to loaders and API routes as `context.env`: + +```ts +import type { LoaderArgs } from '@pracht/core'; + +export async function loader({ context }: LoaderArgs) { + const { results } = await context.env.DB.prepare('SELECT * FROM users').all(); + return { users: results }; +} +``` diff --git a/packages/deploy-cloudflare/src/framework.ts b/packages/deploy-cloudflare/src/framework.ts index c12b462c9..151038354 100644 --- a/packages/deploy-cloudflare/src/framework.ts +++ b/packages/deploy-cloudflare/src/framework.ts @@ -13,6 +13,7 @@ export type CloudflareFrameworkDeployConfig = Readonly<{ compatibilityDate: string; compatibilityFlags?: ReadonlyArray; assetsIgnore?: ReadonlyArray; + htmlHandling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none'; crons?: ReadonlyArray; requiredSecrets?: ReadonlyArray; versionMetadataBinding?: string; @@ -56,6 +57,9 @@ export function writeCloudflareFrameworkDeployConfig( upsert(['assets', 'binding'], 'ASSETS'); upsert(['assets', 'run_worker_first'], true); upsert(['assets', 'not_found_handling'], 'none'); + if (options.htmlHandling) { + upsert(['assets', 'html_handling'], options.htmlHandling); + } if (options.crons !== undefined) { upsert(['triggers', 'crons'], [...options.crons]); } diff --git a/packages/deploy-cloudflare/test/unit/framework.test.ts b/packages/deploy-cloudflare/test/unit/framework.test.ts index c5b017f47..b7a12fc09 100644 --- a/packages/deploy-cloudflare/test/unit/framework.test.ts +++ b/packages/deploy-cloudflare/test/unit/framework.test.ts @@ -32,6 +32,28 @@ describe('Cloudflare framework deployment', () => { }, ); + it.each([undefined, 'drop-trailing-slash'] as const)( + 'writes the framework asset trailing-slash policy (%s)', + (htmlHandling) => { + const root = mkdtempSync(join(tmpdir(), 'void-framework-html-')); + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync(configPath, '{}'); + writeCloudflareFrameworkDeployConfig({ + configPath, + deployPointerPath: join(root, '.wrangler/deploy/config.json'), + name: 'framework-app', + accountId: 'account-id', + main: 'dist/server/worker.js', + assetsDirectory: 'dist/client', + compatibilityDate: '2026-09-11', + htmlHandling, + }); + expect( + (parseRootWranglerConfig(configPath).assets as { html_handling?: string }).html_handling, + ).toBe(htmlHandling); + }, + ); + it('preserves root state and points Wrangler at the standalone Worker', () => { const root = mkdtempSync(join(tmpdir(), 'void-framework-config-')); const configPath = join(root, 'wrangler.jsonc'); diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index d6bd48da1..5500f6401 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -139,6 +139,7 @@ export async function prepareCloudflareFrameworkArtifact(options: { ? preset.cloudflareCompatibilityFlags : [...(preset.cloudflareCompatibilityFlags ?? []), 'nodejs_compat'], crons: [...new Set(crons.flatMap((job) => job.crons))], + ...(preset.htmlHandling ? { htmlHandling: preset.htmlHandling } : {}), requiredSecrets: requiredSecretNames, versionMetadataBinding, ...(ai ? { ai: { ...ai, gateway: voidConfig.ai?.gateway } } : {}), diff --git a/packages/void/src/cli/deploy-managed-framework.ts b/packages/void/src/cli/deploy-managed-framework.ts index cf6eae8d0..7522f8416 100644 --- a/packages/void/src/cli/deploy-managed-framework.ts +++ b/packages/void/src/cli/deploy-managed-framework.ts @@ -208,7 +208,10 @@ export async function runFrameworkDeploy( prerender, vars: voidConfig.worker?.vars, // Not `routing.notFound`; see `warnIgnoredNotFoundForFramework`. - assetConfig: { not_found_handling: 'none' }, + assetConfig: { + not_found_handling: 'none', + ...(fwPreset.htmlHandling ? { html_handling: fwPreset.htmlHandling } : {}), + }, packageOptions, hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', headerRules, diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 2b26fad89..020a98464 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1632,6 +1632,8 @@ function getDevCommand(root: string, framework?: DetectedFramework): string { return 'nuxt dev'; case 'astro': return 'astro dev'; + case 'pracht': + return 'pracht dev'; default: return formatProjectCommand(root, 'vite dev'); } @@ -2428,6 +2430,24 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { return; } + if (framework.name === 'pracht') { + note( + `Detected pracht. Void init does not patch vite.config.* automatically.\n\n` + + `Add \`voidPlugin()\` before \`pracht()\`, and point the Cloudflare adapter at the generated Cloudflare config:\n\n` + + `import { cloudflareAdapter } from "@pracht/adapter-cloudflare";\n` + + `import { pracht } from "@pracht/vite-plugin";\n` + + `import { voidPlugin } from "void";\n\n` + + `export default defineConfig({\n` + + ` plugins: [\n` + + ` voidPlugin(),\n` + + ` pracht({ adapter: cloudflareAdapter({ configPath: "./.void-wrangler.jsonc" }) }),\n` + + ` ],\n` + + `});`, + 'Manual pracht config step', + ); + return; + } + noteManualViteConfigSetup(); } diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 6b6b4a471..dea1d0c46 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -68,6 +68,8 @@ export type FrameworkPreset = { hashedAssetsPrefix?: string; /** Whether the worker entry needs bundling (bare imports not resolved by framework build). */ bundle?: boolean; + /** Asset trailing-slash policy the framework's Worker relies on. Cloudflare's default applies when unset. */ + htmlHandling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none'; }; /** @@ -78,7 +80,7 @@ export type FrameworkPreset = { * Vite-compatible build command (`vite build` or `vp build`) and have their * output structure determined by the framework's Vite plugin. * - * Class B/C frameworks (sveltekit, nuxt, analog, astro) use their own build CLI + * Class B/C frameworks (sveltekit, nuxt, analog, astro, pracht) use their own build CLI * and have framework-specific output conventions. */ export const FRAMEWORK_PRESETS: Record = { @@ -149,6 +151,21 @@ export const FRAMEWORK_PRESETS: Record = { assetsDir: 'dist/client', hashedAssetsPrefix: '_astro', }, + // `pracht build` prerenders after its Vite builds, then writes `worker.js`: + // `server.js` beside it also exports the prerender metadata, which workerd + // rejects as entrypoints. + pracht: { + cloudflareArtifact: 'worker-output', + cloudflareCompatibilityFlags: ['nodejs_als'], + buildCommand: 'pracht build', + workerDir: 'dist/server', + workerMain: 'worker.js', + assetsDir: 'dist/client', + hashedAssetsPrefix: 'assets', + // The Worker reads prerendered `/about/index.html` through ASSETS for `/about`; the + // default policy answers that read with a redirect to `/about/`. + htmlHandling: 'drop-trailing-slash', + }, }; export type StaticPreset = { diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 9427b811f..4676d01ad 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -44,6 +44,9 @@ const voidDir = dirname(fileURLToPath(import.meta.url)); const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; +const PRACHT_SERVER_ENTRY = 'virtual:pracht/server'; +const PRACHT_DEV_SERVER_ENTRY = '\0void:pracht-dev-server-entry'; + export function createFrameworkPlugins(input: { readonly localD1: ReturnType; readonly root: string; @@ -297,6 +300,86 @@ export default { return ${scopeAstroRequests ? 'withRuntimeEnv(env, handle)' : 'handle()'}; }, }; +`; + }, + }, + } + : undefined; + + // Pracht's Cloudflare adapter makes `virtual:pracht/server` the Worker entry. + // - The entry imports Void's env module, so it is evaluated when the Worker starts. Pracht + // loads route modules lazily: otherwise the first request that reaches void/db would also + // be the one that first evaluates the env module, before its `cloudflare:workers` import + // has settled, and fail with "Cloudflare env is unavailable". + // - In dev, Void wraps the entry so `/__void/scheduled` and `/__void/queue` reach the + // generated trigger dispatch, and, for PostgreSQL and MySQL, each request runs inside + // Void's request scope as in the Astro wrapper above. The dispatch is imported only for + // those requests, so the token it embeds never enters another module. Builds are not + // wrapped: `pracht build` names its server bundle after this entry and imports the + // bundle's named exports to prerender, and `void deploy` wraps the built `worker.js` for + // crons and queues. + let prachtServe = false; + const prachtServerEntryPlugin: Plugin | undefined = + detected.name === 'pracht' + ? { + name: 'void:pracht-server-entry', + enforce: 'pre', + configResolved(resolvedConfig) { + prachtServe = resolvedConfig.command === 'serve'; + }, + transform: { + filter: { id: /^virtual:pracht\/server$/ }, + handler(code) { + if (this.environment?.name === 'client') { + return; + } + return { code: `import ${JSON.stringify(envModuleFile)};\n${code}`, map: null }; + }, + }, + resolveId: { + filter: { id: /^virtual:pracht\/server$/ }, + handler(_id, importer) { + if ( + !prachtServe || + importer === PRACHT_DEV_SERVER_ENTRY || + this.environment?.name === 'client' + ) { + return; + } + return PRACHT_DEV_SERVER_ENTRY; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:pracht-dev-server-entry$/ }, + handler() { + const scoped = dialect !== 'sqlite'; + const body = ` if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx);`; + return `import original from ${JSON.stringify(PRACHT_SERVER_ENTRY)}; +${scoped ? `import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};\n` : ''}export * from ${JSON.stringify(PRACHT_SERVER_ENTRY)}; + +export default { + ...original, +${ + scoped + ? ` fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { +${body.replace(/^/gm, ' ')} + }); + },` + : ` async fetch(request, env, ctx) { +${body} + },` +} +}; `; }, }, @@ -365,6 +448,7 @@ export default { wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), ...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []), + ...(prachtServerEntryPlugin ? [prachtServerEntryPlugin] : []), svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, @@ -399,6 +483,8 @@ export default { ...(detected.name === 'astro' ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)] : []), + // So does pracht, through its Cloudflare adapter. + ...(detected.name === 'pracht' ? [createBareVoidRedirectPlugin()] : []), implicitDeployGuard, ]; } diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index c85a9b1fb..0f097171e 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -70,6 +70,9 @@ const FRAMEWORK_PACKAGES: Record = { nuxt: { name: 'nuxt', class: 'c' }, '@analogjs/platform': { name: 'analog', class: 'c' }, astro: { name: 'astro', class: 'c' }, + // Pracht's Vite plugin also targets Node, Vercel, and static hosts; only its + // Cloudflare adapter produces the Worker that Void deploys. + '@pracht/adapter-cloudflare': { name: 'pracht', class: 'c' }, }; /** diff --git a/packages/void/test/unit/framework-preset.test.ts b/packages/void/test/unit/framework-preset.test.ts index 80420fff3..d1912e3fc 100644 --- a/packages/void/test/unit/framework-preset.test.ts +++ b/packages/void/test/unit/framework-preset.test.ts @@ -38,6 +38,17 @@ describe('FRAMEWORK_PRESETS', () => { expect(preset.assetsDir).toBe('dist/client'); }); + it('has preset for pracht', () => { + const preset = FRAMEWORK_PRESETS.pracht; + expect(preset).toBeDefined(); + expect(preset.cloudflareArtifact).toBe('worker-output'); + expect(preset.buildCommand).toBe('pracht build'); + expect(preset.workerDir).toBe('dist/server'); + expect(preset.workerMain).toBe('worker.js'); + expect(preset.assetsDir).toBe('dist/client'); + expect(preset.htmlHandling).toBe('drop-trailing-slash'); + }); + it.each([ ['tanstack-start', 'dist/server'], ['react-router', 'build/server'], diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 66b0ec898..7841bcb54 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -333,6 +333,30 @@ describe('detectFramework', () => { expect(result).toEqual({ name: 'vinext-pages', class: 'a' }); }); + it('detects pracht from its Cloudflare adapter', () => { + const root = createTempRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + dependencies: { '@pracht/adapter-cloudflare': '^0.1.0', '@pracht/core': '^0.1.0' }, + devDependencies: { '@pracht/vite-plugin': '^0.1.0' }, + }), + ); + expect(detectFramework(root)).toEqual({ name: 'pracht', class: 'c' }); + }); + + it('does not detect pracht without its Cloudflare adapter', () => { + const root = createTempRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + dependencies: { '@pracht/adapter-node': '^0.1.0', '@pracht/core': '^0.1.0' }, + devDependencies: { '@pracht/vite-plugin': '^0.1.0' }, + }), + ); + expect(detectFramework(root)).toBeUndefined(); + }); + it('lets framework-mode configResolved run without touching websocket TDZ state', () => { const root = createTempRoot(); writeCompatConfig(root); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 0a5174f95..e718dcfb7 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -11,7 +11,7 @@ import { import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -1254,3 +1254,157 @@ export default { }, ); }); + +describe('pracht server entry', () => { + const entry = 'virtual:pracht/server'; + const devEntry = '\0void:pracht-dev-server-entry'; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const envFile = fileURLToPath(import.meta.resolve('#self/_env')); + + type WrapperModule = { + named: string; + default: { + marker: string; + fetch(request: Request, env: unknown, ctx: unknown): Promise; + }; + }; + + function createPrachtRoot(database: 'pg' | undefined): string { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { '@pracht/adapter-cloudflare': '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database, worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + return root; + } + + function startDevServer(root: string) { + return createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + } + + const loadedCode = (loaded: unknown): string => + typeof loaded === 'string' ? loaded : ((loaded as { code?: string } | null)?.code ?? ''); + + /** Import the wrapper with stand-ins for pracht's entry and the generated trigger dispatch. */ + async function importWrapper( + root: string, + code: string, + dispatch: string, + ): Promise { + writeFileSync( + join(root, 'pracht-entry.mjs'), + `export const named = "kept"; +export default { + marker: "kept", + fetch() { + return new Response("pracht"); + }, +}; +`, + ); + writeFileSync(join(root, 'dispatch.mjs'), dispatch); + writeFileSync( + join(root, 'entry.mjs'), + code + .replaceAll(JSON.stringify(entry), '"./pracht-entry.mjs"') + .replace( + /^import \{ withRuntimeEnv \} from .*;$/m, + `import { withRuntimeEnv } from ${JSON.stringify(envUrl)};`, + ) + .replace( + JSON.stringify(normalizePath(join(root, '.void/dev-triggers.ts'))), + '"./dispatch.mjs"', + ), + ); + return (await import(pathToFileURL(join(root, 'entry.mjs')).href)) as WrapperModule; + } + + it.each(['pg', undefined] as const)( + 'wraps the pracht server entry in dev and sends dev triggers to the dispatch (database: %s)', + async (database) => { + const root = createPrachtRoot(database); + const server = await startDevServer(root); + let code: string; + try { + const container = server.environments.ssr!.pluginContainer; + expect((await container.resolveId(entry))?.id).toBe(devEntry); + // The wrapper imports pracht's own entry. + expect((await container.resolveId(entry, devEntry))?.id).not.toBe(devEntry); + code = loadedCode(await container.load(devEntry)); + } finally { + await server.close(); + } + expect(code.includes('withRuntimeEnv')).toBe(database !== undefined); + // The dispatch embeds the dev-trigger token: it is imported only for a trigger request. + expect(code).not.toMatch(/^import .*dev-triggers/m); + + const wrapper = await importWrapper( + root, + code, + `export async function handleDevTrigger(request, env, ctx) { + return Response.json({ pathname: new URL(request.url).pathname, env, ctx }); +} +`, + ); + expect([wrapper.named, wrapper.default.marker]).toEqual(['kept', 'kept']); + const trigger = await wrapper.default.fetch( + new Request('http://localhost/__void/queue', { method: 'POST' }), + { DB: 'binding' }, + { id: 'ctx' }, + ); + expect(await trigger.json()).toEqual({ + pathname: '/__void/queue', + env: { DB: 'binding' }, + ctx: { id: 'ctx' }, + }); + const page = await wrapper.default.fetch( + new Request('http://localhost/__void/queue'), + {}, + {}, + ); + expect(await page.text()).toBe('pracht'); + }, + ); + + it('leaves the built entry in place and evaluates the env module with it', async () => { + const root = createPrachtRoot(undefined); + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + const plugin = config.plugins.find( + (candidate) => candidate.name === 'void:pracht-server-entry', + )!; + const context = { environment: { name: 'ssr' } }; + const resolveId = getHookHandler(plugin.resolveId) as ( + this: typeof context, + source: string, + importer: string | undefined, + ) => unknown; + expect(await resolveId.call(context, entry, undefined)).toBeUndefined(); + + const transform = getHookHandler(plugin.transform) as ( + this: typeof context, + code: string, + id: string, + ) => unknown; + expect(loadedCode(await transform.call(context, 'export default {};', entry))).toBe( + `import ${JSON.stringify(envFile)};\nexport default {};`, + ); + const client = { environment: { name: 'client' } }; + expect(await transform.call(client, 'export default {};', entry)).toBeUndefined(); + }); +}); From 1a710242d6e6c8b4772dbf5ad963c7300d640acd Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 16 Oct 2026 17:46:31 +0200 Subject: [PATCH 2/2] chore(framework): trim pracht integration comments --- packages/void/src/cli/preset.ts | 8 ++------ packages/void/src/plugin-framework.ts | 16 +++------------- packages/void/src/plugin-inference.ts | 2 -- .../test/unit/plugin-vite-environment.test.ts | 3 --- 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index dea1d0c46..2be384596 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -68,7 +68,7 @@ export type FrameworkPreset = { hashedAssetsPrefix?: string; /** Whether the worker entry needs bundling (bare imports not resolved by framework build). */ bundle?: boolean; - /** Asset trailing-slash policy the framework's Worker relies on. Cloudflare's default applies when unset. */ + /** Assets `html_handling`; Cloudflare's default applies when unset. */ htmlHandling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none'; }; @@ -151,9 +151,6 @@ export const FRAMEWORK_PRESETS: Record = { assetsDir: 'dist/client', hashedAssetsPrefix: '_astro', }, - // `pracht build` prerenders after its Vite builds, then writes `worker.js`: - // `server.js` beside it also exports the prerender metadata, which workerd - // rejects as entrypoints. pracht: { cloudflareArtifact: 'worker-output', cloudflareCompatibilityFlags: ['nodejs_als'], @@ -162,8 +159,7 @@ export const FRAMEWORK_PRESETS: Record = { workerMain: 'worker.js', assetsDir: 'dist/client', hashedAssetsPrefix: 'assets', - // The Worker reads prerendered `/about/index.html` through ASSETS for `/about`; the - // default policy answers that read with a redirect to `/about/`. + // pracht links to `/about`, not `/about/`. htmlHandling: 'drop-trailing-slash', }, }; diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 4676d01ad..1962cff46 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -306,18 +306,9 @@ export default { } : undefined; - // Pracht's Cloudflare adapter makes `virtual:pracht/server` the Worker entry. - // - The entry imports Void's env module, so it is evaluated when the Worker starts. Pracht - // loads route modules lazily: otherwise the first request that reaches void/db would also - // be the one that first evaluates the env module, before its `cloudflare:workers` import - // has settled, and fail with "Cloudflare env is unavailable". - // - In dev, Void wraps the entry so `/__void/scheduled` and `/__void/queue` reach the - // generated trigger dispatch, and, for PostgreSQL and MySQL, each request runs inside - // Void's request scope as in the Astro wrapper above. The dispatch is imported only for - // those requests, so the token it embeds never enters another module. Builds are not - // wrapped: `pracht build` names its server bundle after this entry and imports the - // bundle's named exports to prerender, and `void deploy` wraps the built `worker.js` for - // crons and queues. + // Pracht loads routes lazily, so its entry imports the env module up front; otherwise the + // first void/db call can run before `cloudflare:workers` has settled. Only dev wraps the + // entry for triggers: `void deploy` wraps the built `worker.js` instead. let prachtServe = false; const prachtServerEntryPlugin: Plugin | undefined = detected.name === 'pracht' @@ -483,7 +474,6 @@ ${body} ...(detected.name === 'astro' ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)] : []), - // So does pracht, through its Cloudflare adapter. ...(detected.name === 'pracht' ? [createBareVoidRedirectPlugin()] : []), implicitDeployGuard, ]; diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index 0f097171e..17bd8ba85 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -70,8 +70,6 @@ const FRAMEWORK_PACKAGES: Record = { nuxt: { name: 'nuxt', class: 'c' }, '@analogjs/platform': { name: 'analog', class: 'c' }, astro: { name: 'astro', class: 'c' }, - // Pracht's Vite plugin also targets Node, Vercel, and static hosts; only its - // Cloudflare adapter produces the Worker that Void deploys. '@pracht/adapter-cloudflare': { name: 'pracht', class: 'c' }, }; diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index e718dcfb7..e3e60574b 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1297,7 +1297,6 @@ describe('pracht server entry', () => { const loadedCode = (loaded: unknown): string => typeof loaded === 'string' ? loaded : ((loaded as { code?: string } | null)?.code ?? ''); - /** Import the wrapper with stand-ins for pracht's entry and the generated trigger dispatch. */ async function importWrapper( root: string, code: string, @@ -1340,14 +1339,12 @@ export default { try { const container = server.environments.ssr!.pluginContainer; expect((await container.resolveId(entry))?.id).toBe(devEntry); - // The wrapper imports pracht's own entry. expect((await container.resolveId(entry, devEntry))?.id).not.toBe(devEntry); code = loadedCode(await container.load(devEntry)); } finally { await server.close(); } expect(code.includes('withRuntimeEnv')).toBe(database !== undefined); - // The dispatch embeds the dev-trigger token: it is imported only for a trigger request. expect(code).not.toMatch(/^import .*dev-triggers/m); const wrapper = await importWrapper(