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..2be384596 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; + /** Assets `html_handling`; 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,17 @@ export const FRAMEWORK_PRESETS: Record = { assetsDir: 'dist/client', hashedAssetsPrefix: '_astro', }, + pracht: { + cloudflareArtifact: 'worker-output', + cloudflareCompatibilityFlags: ['nodejs_als'], + buildCommand: 'pracht build', + workerDir: 'dist/server', + workerMain: 'worker.js', + assetsDir: 'dist/client', + hashedAssetsPrefix: 'assets', + // pracht links to `/about`, not `/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..1962cff46 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,77 @@ export default { return ${scopeAstroRequests ? 'withRuntimeEnv(env, handle)' : 'handle()'}; }, }; +`; + }, + }, + } + : undefined; + + // 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' + ? { + 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 +439,7 @@ export default { wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), ...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []), + ...(prachtServerEntryPlugin ? [prachtServerEntryPlugin] : []), svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, @@ -399,6 +474,7 @@ export default { ...(detected.name === 'astro' ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)] : []), + ...(detected.name === 'pracht' ? [createBareVoidRedirectPlugin()] : []), implicitDeployGuard, ]; } diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index c85a9b1fb..17bd8ba85 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -70,6 +70,7 @@ const FRAMEWORK_PACKAGES: Record = { nuxt: { name: 'nuxt', class: 'c' }, '@analogjs/platform': { name: 'analog', class: 'c' }, astro: { name: 'astro', class: 'c' }, + '@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..e3e60574b 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,154 @@ 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 ?? ''); + + 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); + 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); + 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(); + }); +});