diff --git a/docs/integrations/frameworks/tanstack-start.md b/docs/integrations/frameworks/tanstack-start.md index 0c7550384..67a93b653 100644 --- a/docs/integrations/frameworks/tanstack-start.md +++ b/docs/integrations/frameworks/tanstack-start.md @@ -71,6 +71,31 @@ Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI]( 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. +## Custom Worker Entry + +Set `cloudflare.main` in `void.config.ts` when your Worker needs its own entry module, for example to export a Durable Object class: + +```ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + cloudflare: { main: './server.ts' }, +}); +``` + +The path is relative to your app root. Default-export the TanStack request handler and add your named exports in that module: + +```ts +import handler from '@tanstack/react-start/server-entry'; + +export { Counter } from './counter'; +export default handler; +``` + +Void preserves these named exports in development, production builds, and deploys. Without `cloudflare.main`, Void uses TanStack's default server entry. A package entry such as `@tanstack/react-start/server-entry` is resolved as a module. + +Bind an exported Durable Object or Workflow class under `cloudflare` as well, for example in `cloudflare.durable_objects`. These settings apply when you deploy with `void deploy --platform cloudflare`. On a Void platform, use [Durable State](../../guide/durable-state.md) instead. + ## Accessing Bindings Directly You can also access Cloudflare bindings via the `cloudflare:workers` module: diff --git a/docs/reference/api/deploy.md b/docs/reference/api/deploy.md index 2c1278d60..a2d76224d 100644 --- a/docs/reference/api/deploy.md +++ b/docs/reference/api/deploy.md @@ -21,3 +21,35 @@ const { artifact, assets } = await prepareDeployArtifact('/path/to/app'); Preparation packages existing build output; it does not build, contact a platform, provision resources, or deploy. It supports Void Cloudflare Worker builds, SPAs, and static sites. Node targets, Pages `output: 'static'`, remote data, deploy-time prerendering, Sandbox, and Live resources require the normal `void deploy` workflow instead. Run preparation inside the same isolated environment as the app build. App configuration and build plugins execute project code; keep platform credentials on the publishing server. + +## Cloudflare Build Output + +::: warning Experimental +`prepareCfBuildOutput` follows Cloudflare's draft Build Output format. Its options and the prepared directory can change between Void releases. +::: + +Use `prepareCfBuildOutput` to convert an existing Cloudflare Worker build into a disposable Cloudflare Build Output directory: + +```ts +import { prepareCfBuildOutput } from 'void/deploy'; + +const prepared = await prepareCfBuildOutput('/path/to/app/dist/server', { mode: 'staging' }); +if (!prepared) { + throw new Error('The Worker build could not be prepared.'); +} + +try { + // Inspect or pass prepared.directory to your Cloudflare publication workflow. +} finally { + await prepared.dispose(); +} +``` + +Pass the Worker output directory that contains a generated `wrangler.json`, such as `dist/ssr`, `dist/rsc`, or `dist/server`. When Cloudflare's build tooling built the app, pass the project root that contains its native Build Output at `.cloudflare/output/v0` instead. Preparation copies modules and assets without changing that input. Native output keeps its module types, auxiliary Workers, Container declarations, and build metadata. Preparation does not provision resources, upload a Worker, or activate a deployment. Container images built from a local Dockerfile exist only in the Docker environment that built them, so publish from that environment. + +| Option | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `accountId` | Cloudflare account the build must target. A different account in the build returns `null`. When omitted, the build's account is kept or left unset. | +| `mode` | Vite build mode. Native output keeps the mode it was built with, and a different value returns `null`. For other output it defaults to `production`. | + +The result contains `directory`, an optional `assetsDirectory` pointing to the input assets, and an asynchronous `dispose()` method. Always dispose the result after use. Unsupported build configuration returns `null`. A directory without a Worker build, a missing or invalid Worker module, or an asset symlink outside the asset directory throws an error. diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index b0ff0cfc3..21bef3df0 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -13,6 +13,9 @@ import { import { tmpdir } from 'node:os'; import { getWorkerBundleDir, + getWorkerAssetsDir, + getWorkerConfigPath, + getRootConfigPath, normalizeDirectoryName, readBuildOutput, writeAssets, @@ -62,6 +65,19 @@ function isInside(directory: string, path: string): boolean { return child !== '' && child !== '..' && !child.startsWith('../') && !isAbsolute(child); } +/** Asset copies follow symlinks, so a link may not reach outside the asset directory. */ +function assertAssetSymlinksInside(directory: string): void { + const realDirectory = realpathSync(directory); + for (const entry of readdirSync(directory, { recursive: true, withFileTypes: true })) { + if ( + entry.isSymbolicLink() && + !isInside(realDirectory, realpathSync(join(entry.parentPath, entry.name))) + ) { + throw new Error('The Worker assets contain a symlink outside the asset directory.'); + } + } +} + function copyBundle(source: string, destination: string): Record { const modules: Record = {}; for (const entry of readdirSync(source, { recursive: true, withFileTypes: true })) { @@ -104,17 +120,30 @@ function copyBundle(source: string, destination: string): Record { const source = resolve(workerDirectory); + if (existsSync(getRootConfigPath(source))) { + return prepareNativeBuildOutput(source, accountId, mode); + } + if (!existsSync(join(source, 'wrangler.json'))) { + throw new Error( + `No Cloudflare Worker build found in ${source}. Pass the project root that contains .cloudflare/output/v0, or the Worker output directory that contains wrangler.json.`, + ); + } const raw = record(JSON.parse(readFileSync(join(source, 'wrangler.json'), 'utf8'))); if (!raw) { return null; } - if (typeof raw.account_id === 'string' && raw.account_id !== accountId) { + if ( + typeof raw.account_id === 'string' && + accountId !== undefined && + raw.account_id !== accountId + ) { return null; } const config = migrateBuiltWranglerConfig(raw); @@ -149,7 +178,12 @@ export async function prepareCfBuildOutput( const bundle = getWorkerBundleDir(directory); mkdirSync(bundle, { recursive: true }); const modules = copyBundle(source, bundle); - await writeRootConfig(directory, { accountId }, { isPreview: false, mode: 'production' }); + const configuredAccountId = accountId ?? raw.account_id; + await writeRootConfig( + directory, + typeof configuredAccountId === 'string' ? { accountId: configuredAccountId } : {}, + { isPreview: false, mode: mode ?? 'production' }, + ); await writeWorkerConfig({ root: directory, config: config.worker, @@ -185,6 +219,7 @@ export async function prepareCfBuildOutput( await dispose(); return null; } + assertAssetSymlinksInside(assetPath); await writeAssets({ root: directory, sourceDirectory: assetPath }); } await readBuildOutput(directory); @@ -201,6 +236,90 @@ export async function prepareCfBuildOutput( } } +/** Copy and complete the native artifact without changing the Vite build's inputs or metadata. */ +async function prepareNativeBuildOutput( + source: string, + accountId?: string, + mode?: string, +): Promise { + const built = await readBuildOutput(source); + if ( + (accountId !== undefined && + built.rootConfig.accountId !== undefined && + accountId !== built.rootConfig.accountId) || + (mode !== undefined && mode !== built.rootConfig.buildContext.mode) + ) { + return null; + } + const directory = mkdtempSync(join(tmpdir(), 'void-cf-native-')); + const dispose = async () => { + rmSync(directory, { recursive: true, force: true }); + }; + try { + await writeRootConfig( + directory, + { + accountId: accountId ?? built.rootConfig.accountId, + complianceRegion: built.rootConfig.complianceRegion, + }, + { ...built.rootConfig.buildContext, mode: built.rootConfig.buildContext.mode }, + ); + for (const [name, worker] of Object.entries(built.workers)) { + if ( + worker.bundleDir && + (!worker.config.manifest || + worker.config.manifest.modules[worker.config.manifest.mainModule]?.type !== 'esm') + ) { + throw new Error('The native Worker build must declare its main ES module.'); + } + const configPath = getWorkerConfigPath(directory, name); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(worker.config)); + if (worker.bundleDir && worker.config.manifest) { + const bundle = getWorkerBundleDir(directory, name); + for (const module of Object.keys(worker.config.manifest.modules)) { + const destination = join(bundle, module); + if ( + // A backslash or a colon (a Windows drive) has no place in a bundle path. + module.includes('\\') || + module.includes(':') || + !isInside(bundle, resolve(bundle, module)) || + !isInside(worker.bundleDir, resolve(worker.bundleDir, module)) + ) { + throw new Error('Native Worker modules must stay inside the build bundle.'); + } + if (!existsSync(join(worker.bundleDir, module))) { + throw new Error(`Native Worker module ${module} is missing from the build bundle.`); + } + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(worker.bundleDir, module), destination); + } + } + if (worker.assetsDir) { + cpSync(worker.assetsDir, getWorkerAssetsDir(directory, name), { recursive: true }); + } + } + for (const container of built.containers) { + await writeContainerConfig({ + root: directory, + config: container.config, + directoryName: basename(dirname(container.configPath)), + }); + } + await readBuildOutput(directory); + return { + directory, + ...(built.workers.default.assetsDir + ? { assetsDirectory: built.workers.default.assetsDir } + : {}), + dispose, + }; + } catch (error) { + await dispose(); + throw error; + } +} + /** Bundle a generated Worker entry with Vite, then hand the result to `prepareCfBuildOutput`. */ async function prepareBundledBuildOutput(options: { projectRoot: string; diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index cb22a7d31..a302c686b 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -580,6 +580,8 @@ function generateWrapperSource( const lines: Array = []; lines.push(WRAPPER_MARKER); lines.push(`import original from "./${renamedEntry}";`); + // Durable Object, Workflow and entrypoint classes must stay exported from the uploaded module. + lines.push(`export * from "./${renamedEntry}";`); if (features.isr) { lines.push('import { handleFrameworkIsr } from "./__void_isr.mjs";'); @@ -739,6 +741,8 @@ function generateWrapperSource( } lines.push('export default {'); + // Keep the entry's own handlers, such as email or tail; Void's handlers below take precedence. + lines.push(' ...original,'); if (needsFetchWrap) { lines.push(' fetch: wrappedFetch,'); diff --git a/packages/void/src/deploy.ts b/packages/void/src/deploy.ts index 1c2fc2c17..40d7f0668 100644 --- a/packages/void/src/deploy.ts +++ b/packages/void/src/deploy.ts @@ -4,6 +4,7 @@ import { resolve, join } from 'pathslash'; import { analyzeApplication } from './application-routing.ts'; import { resolveAssetConfig, hasApiRoutes, hasDocumentRoutes } from './asset-routing.ts'; import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; +import { prepareCfBuildOutput as prepareInternalCfBuildOutput } from './cli/cf-build-output.ts'; import { assertDeploySchemaDriftFree } from './cli/deploy-drift.ts'; import { resolveDeployWorkerMain, @@ -22,6 +23,43 @@ import { collectRevalidate } from './isr-config.ts'; import { hasEmailHandlers } from './router/email.ts'; import { validateSsrEntry } from './ssr/entry.ts'; +/** + * A disposable Cloudflare Build Output directory. Experimental: it follows Cloudflare's draft + * Build Output format, which can change between Void releases. + */ +export type CfBuildOutput = { + directory: string; + /** The input's static assets, when the Worker has any. */ + assetsDirectory?: string; + dispose(): Promise; +}; + +export type PrepareCfBuildOutputOptions = { + /** The Cloudflare account the build must target. */ + accountId?: string; + /** The Vite build mode. Native output must match it; legacy output defaults to production. */ + mode?: string; +}; + +/** + * Convert an existing Cloudflare Worker build into a disposable Cloudflare Build Output directory. + * Experimental. Returns null for an unsupported build or a conflicting account or mode. + */ +export async function prepareCfBuildOutput( + directory: string, + options: PrepareCfBuildOutputOptions = {}, +): Promise { + const prepared = await prepareInternalCfBuildOutput(directory, options.accountId, options.mode); + // A fresh object keeps internal deploy state off the public result. + return ( + prepared && { + directory: prepared.directory, + ...(prepared.assetsDirectory ? { assetsDirectory: prepared.assetsDirectory } : {}), + dispose: prepared.dispose, + } + ); +} + export type PreparedDeployArtifact = { artifact: DeployArtifact; /** Static asset bytes keyed by URL path, encoded as base64. */ diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 431a3eec2..c4b435705 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -634,6 +634,7 @@ export function voidPlugin(options?: { devTriggerToken, dialect, config.routing?.notFound, + config.cloudflare?.main, ); // Class A builds write no `assets` config, and `not_found_handling` alone would keep // unmatched requests from the framework worker. Managed `void deploy` supplies the policy. @@ -661,7 +662,7 @@ export function voidPlugin(options?: { generateClassATriggerWrapper( root, framework, - getOriginalFrameworkEntry(framework, root), + getOriginalFrameworkEntry(framework, root, config.cloudflare?.main), true, dialect, resolved.build.assetsDir, diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 6a098e944..7debf81e4 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -1,5 +1,5 @@ -import { mkdirSync } from 'node:fs'; -import { join } from 'pathslash'; +import { mkdirSync, statSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'pathslash'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { type DatabaseDialect, type NotFoundHandling } from './config.ts'; import { isViteBuildOrPreview } from './plugin-dev-config.ts'; @@ -24,6 +24,7 @@ export function getFrameworkEntry( devTriggerToken: string, dialect: DatabaseDialect, notFound?: NotFoundHandling, + configuredMain?: unknown, ): string { const isBuild = isViteBuildOrPreview(); // In dev, eagerly write the dispatch file so the wrapper's dynamic @@ -37,7 +38,7 @@ export function getFrameworkEntry( return generateClassATriggerWrapper( root, framework, - getOriginalFrameworkEntry(framework, root), + getOriginalFrameworkEntry(framework, root, configuredMain), isBuild, dialect, undefined, @@ -45,11 +46,49 @@ export function getFrameworkEntry( ); } +const TANSTACK_SERVER_ENTRY = '@tanstack/react-start/server-entry'; + +/** + * Whether `main` names a file in the app rather than a package. A migrated TanStack config keeps + * `main: "@tanstack/react-start/server-entry"`, and a package subpath may end in `.js`, so a + * bare name counts as a file only when it exists in the app. + */ +function isFileEntry(root: string, main: string): boolean { + if (main.startsWith('.') || isAbsolute(main)) { + return true; + } + return statSync(resolve(root, main), { throwIfNoEntry: false })?.isFile() === true; +} + /** The framework's own server entry, before our trigger wrapper. */ -export function getOriginalFrameworkEntry(framework: Framework, root: string): string { +export function getOriginalFrameworkEntry( + framework: Framework, + root: string, + configuredMain?: unknown, +): string { switch (framework) { - case 'tanstack-start': - return '@tanstack/react-start/server-entry'; + case 'tanstack-start': { + if ( + configuredMain === undefined || + configuredMain === TANSTACK_SERVER_ENTRY || + configuredMain === `${TANSTACK_SERVER_ENTRY}.js` + ) { + return TANSTACK_SERVER_ENTRY; + } + if (typeof configuredMain !== 'string' || configuredMain.trim() === '') { + throw new Error('config: cloudflare.main must be a non-empty Worker entry path.'); + } + if (!isFileEntry(root, configuredMain)) { + return configuredMain; + } + const original = resolve(root, configuredMain); + if (original === resolve(root, '.void', 'tanstack-start-trigger-wrapper.ts')) { + throw new Error( + "config: cloudflare.main cannot point to Void's generated framework wrapper.", + ); + } + return original; + } case 'react-router': return generateReactRouterEntry(root); case 'vinext-app': @@ -123,6 +162,7 @@ ${fetchBody} },`; const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit. import original from ${JSON.stringify(original)}; +export * from ${JSON.stringify(original)}; ${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''} export default { ...original, diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index 59a1e118a..619646022 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -13,7 +13,16 @@ import { import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getWorkerAssetsDir, readBuildOutput } from '@cloudflare/build-output-utils'; +import { + getWorkerAssetsDir, + getWorkerBundleDir, + getWorkerConfigPath, + getRootConfigPath, + readBuildOutput, + writeRootConfig, + writeWorkerConfig, + writeContainerConfig, +} from '@cloudflare/build-output-utils'; import { cloudflare } from '@cloudflare/vite-plugin'; import { renderCloudflareStaticEntry } from '@void/deploy-cloudflare'; import { toSlash } from 'pathslash'; @@ -21,10 +30,10 @@ import { createBuilder } from 'vite'; import { afterEach, expect, it, vi } from 'vite-plus/test'; import { type CfBuildOutput, - prepareCfBuildOutput, prepareCfFrameworkBuildOutput, prepareCfStaticBuildOutput, } from '../../src/cli/cf-build-output.ts'; +import { prepareCfBuildOutput } from '../../src/deploy.ts'; const roots: Array = []; // These tests start a real cf CLI process (and one also runs a Vite build). @@ -77,6 +86,302 @@ function expectCfDryRun(directory: string, command = ['workers', 'versions', 'cr return dryRun; } +async function nativeBuild() { + const root = mkdtempSync(join(tmpdir(), 'void-native-cf-output-')); + roots.push(root); + await writeRootConfig( + root, + { accountId: 'account-id', complianceRegion: 'public' }, + { isPreview: false, mode: 'staging' }, + ); + const bundle = getWorkerBundleDir(root); + mkdirSync(join(bundle, 'assets'), { recursive: true }); + writeFileSync( + join(bundle, 'index.js'), + 'import data from "./assets/message.txt"; export default { fetch() { return new Response(data) } };', + ); + writeFileSync(join(bundle, 'assets/message.txt'), 'native text'); + writeFileSync(join(bundle, 'assets/font.woff2'), Buffer.from([0, 255, 4, 128])); + writeFileSync(join(bundle, 'assets/runtime.wasm'), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0])); + writeFileSync(join(bundle, 'index.js.map'), '{"version":3,"sourcesContent":["original source"]}'); + await writeWorkerConfig({ + root, + config: { + name: 'void-native-test', + compatibilityDate: '2026-10-12', + env: { DATA: { type: 'r2', name: 'native-bucket' }, TOKEN: { type: 'secret' } }, + }, + manifest: { + type: 'partial', + mainModule: 'index.js', + modules: { + 'assets/message.txt': { type: 'text' }, + 'assets/font.woff2': { type: 'data' }, + 'assets/runtime.wasm': { type: 'wasm' }, + }, + }, + }); + mkdirSync(getWorkerAssetsDir(root), { recursive: true }); + writeFileSync(join(getWorkerAssetsDir(root), 'index.html'), '

native

'); + return root; +} + +it( + 'prepares native output with complete module types, original mode and unchanged source files', + async () => { + const root = await nativeBuild(); + const paths = [ + getRootConfigPath(root), + getWorkerConfigPath(root), + join(getWorkerBundleDir(root), 'assets/font.woff2'), + join(getWorkerBundleDir(root), 'index.js.map'), + ]; + const before = paths.map((path) => readFileSync(path)); + await withPrepared(prepareCfBuildOutput(root), async (prepared) => { + const result = await readBuildOutput(prepared.directory); + expect(result.rootConfig).toMatchObject({ + accountId: 'account-id', + complianceRegion: 'public', + buildContext: { mode: 'staging', isPreview: false }, + }); + expect(result.workers.default.config.manifest).toMatchObject({ + type: 'complete', + mainModule: 'index.js', + modules: { + 'index.js': { type: 'esm' }, + 'index.js.map': { type: 'sourcemap' }, + 'assets/message.txt': { type: 'text' }, + 'assets/font.woff2': { type: 'data' }, + 'assets/runtime.wasm': { type: 'wasm' }, + }, + }); + expect(result.workers.default.config.env).toMatchObject({ + DATA: { type: 'r2', name: 'native-bucket' }, + TOKEN: { type: 'secret' }, + }); + expect( + readFileSync(join(getWorkerBundleDir(prepared.directory), 'assets/font.woff2')), + ).toEqual(before[2]); + expect( + readFileSync(join(getWorkerBundleDir(prepared.directory), 'assets/runtime.wasm')), + ).toEqual(readFileSync(join(getWorkerBundleDir(root), 'assets/runtime.wasm'))); + expect(readFileSync(join(getWorkerBundleDir(prepared.directory), 'index.js.map'))).toEqual( + before[3], + ); + expect( + readFileSync(join(getWorkerAssetsDir(prepared.directory), 'index.html'), 'utf8'), + ).toContain('native'); + expectCfDryRun(prepared.directory); + }); + expect(paths.map((path) => readFileSync(path))).toEqual(before); + }, + CF_BUILD_TIMEOUT_MS, +); + +it('rejects native artifact account and mode mismatches', async () => { + const root = await nativeBuild(); + await expect(prepareCfBuildOutput(root, { accountId: 'another-account' })).resolves.toBeNull(); + await expect(prepareCfBuildOutput(root, { mode: 'production' })).resolves.toBeNull(); +}); + +it('preserves native auxiliary Workers and already-built Container declarations', async () => { + const root = await nativeBuild(); + const bundle = getWorkerBundleDir(root, 'auxiliary'); + mkdirSync(bundle, { recursive: true }); + writeFileSync( + join(bundle, 'index.js'), + 'export default { fetch() { return new Response("aux") } };', + ); + await writeWorkerConfig({ + root, + directoryName: 'auxiliary', + config: { + name: 'void-native-aux', + compatibilityDate: '2026-10-12', + }, + manifest: { type: 'partial', mainModule: 'index.js', modules: {} }, + }); + await writeContainerConfig({ + root, + directoryName: 'container', + config: { + name: 'native-container', + maxInstances: 2, + image: { reference: 'docker.io/example/native:1' }, + }, + }); + await withPrepared( + prepareCfBuildOutput(root, { accountId: 'account-id', mode: 'staging' }), + async (prepared) => { + const result = await readBuildOutput(prepared.directory); + expect(result.workers.auxiliary.config.name).toBe('void-native-aux'); + expect(result.containers[0]?.config).toMatchObject({ + name: 'native-container', + image: { reference: 'docker.io/example/native:1' }, + }); + }, + ); +}); + +it.each(['../outside.js', '/outside.js', 'C:/outside.js', 'assets\\outside.js'])( + 'rejects a native manifest module outside its portable bundle: %s', + async (module) => { + const root = await nativeBuild(); + const path = getWorkerConfigPath(root); + const config = JSON.parse(readFileSync(path, 'utf8')); + config.manifest = { + type: 'complete', + mainModule: 'index.js', + modules: { + 'index.js': { type: 'esm' }, + [module]: { type: 'esm' }, + }, + }; + writeFileSync(path, JSON.stringify(config)); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('inside the build bundle'); + }, +); + +it('rejects an absent native main module', async () => { + const root = await nativeBuild(); + rmSync(join(getWorkerBundleDir(root), 'index.js')); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('main module'); +}); + +function completeNativeManifest(root: string, modules: Record): void { + const path = getWorkerConfigPath(root); + const config = JSON.parse(readFileSync(path, 'utf8')); + config.manifest = { type: 'complete', mainModule: 'index.js', modules }; + writeFileSync(path, JSON.stringify(config)); +} + +it('rejects a native main module that is not an ES module', async () => { + const root = await nativeBuild(); + completeNativeManifest(root, { 'index.js': { type: 'text' } }); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('must declare its main ES module'); +}); + +it('names a declared native module that is missing from the bundle', async () => { + const root = await nativeBuild(); + completeNativeManifest(root, { 'index.js': { type: 'esm' }, 'chunk.js': { type: 'esm' } }); + await expect(prepareCfBuildOutput(root)).rejects.toThrow( + 'Native Worker module chunk.js is missing from the build bundle.', + ); +}); + +it('names the expected inputs when a directory holds no Worker build', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-cf-build-output-empty-')); + roots.push(root); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('No Cloudflare Worker build found'); +}); + +it('returns only the public fields of the prepared output', async () => { + const output = worker({ name: 'void-test', compatibility_date: '2026-09-29', main: 'index.js' }); + await withPrepared(prepareCfBuildOutput(output), async (prepared) => { + expect(Object.keys(prepared).sort()).toEqual(['directory', 'dispose']); + }); +}); + +it.skipIf(process.platform === 'win32')( + 'rejects a legacy asset symlink outside the asset directory', + async () => { + const output = worker({ + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + assets: { directory: '../client' }, + }); + const client = join(dirname(output), 'client'); + const secret = join(dirname(output), 'secret.txt'); + mkdirSync(client, { recursive: true }); + writeFileSync(join(client, 'index.html'), '

ok

'); + writeFileSync(secret, 'private'); + symlinkSync(secret, join(client, 'leak.txt')); + await expect(prepareCfBuildOutput(output)).rejects.toThrow( + 'symlink outside the asset directory', + ); + }, +); + +it.skipIf(process.platform === 'win32')('rejects symlinked native modules', async () => { + const root = await nativeBuild(); + symlinkSync('index.js', join(getWorkerBundleDir(root), 'alias.js')); + await expect(prepareCfBuildOutput(root)).rejects.toThrow('Symlinks are not permitted'); +}); + +it.skipIf(process.platform === 'win32')('rejects symlinked native assets', async () => { + const root = await nativeBuild(); + const secret = join(root, 'secret.txt'); + writeFileSync(secret, 'private'); + symlinkSync(secret, join(getWorkerAssetsDir(root), 'leak.txt')); + // The native reader refuses every symlink, so no link reaches the copied assets. + await expect(prepareCfBuildOutput(root)).rejects.toThrow('Symlinks are not permitted'); +}); + +it('preserves assets-only native output without adding a Worker bundle', async () => { + const root = await nativeBuild(); + rmSync(getWorkerBundleDir(root), { recursive: true, force: true }); + await withPrepared(prepareCfBuildOutput(root), async (prepared) => { + const output = await readBuildOutput(prepared.directory); + expect(output.workers.default.bundleDir).toBeUndefined(); + expect(output.workers.default.config.manifest).toBeUndefined(); + expect( + readFileSync(join(getWorkerAssetsDir(prepared.directory), 'index.html'), 'utf8'), + ).toContain('native'); + }); +}); + +it.each([undefined, 'account-id'])( + 'prepares public build output without credentials for built account %s', + async (accountId) => { + const config = { + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + ...(accountId ? { account_id: accountId } : {}), + }; + const output = worker(config); + const authored = readFileSync(join(output, 'wrangler.json'), 'utf8'); + const disposed = await withPrepared(prepareCfBuildOutput(output), async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.rootConfig.accountId).toBe(accountId); + expect(built.rootConfig.buildContext).toEqual({ isPreview: false, mode: 'production' }); + expect(built.workers.default.config.manifest?.mainModule).toBe('index.js'); + expect(readFileSync(join(output, 'wrangler.json'), 'utf8')).toBe(authored); + }); + expect(existsSync(disposed.directory)).toBe(false); + }, +); + +it('records an explicit build mode and matching target account', async () => { + const output = worker({ + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + account_id: 'account-id', + }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id', mode: 'staging' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.rootConfig.accountId).toBe('account-id'); + expect(built.rootConfig.buildContext).toEqual({ isPreview: false, mode: 'staging' }); + }, + ); +}); + +it('rejects a mismatching explicit target account', async () => { + const output = worker({ + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + account_id: 'built-account', + }); + await expect( + prepareCfBuildOutput(output, { accountId: 'different-account' }), + ).resolves.toBeNull(); +}); + it( 'preserves additional module types from a real Cloudflare Vite build', async () => { @@ -127,34 +432,37 @@ export default { fetch() { expect( readdirSync(output, { recursive: true }).some((name) => String(name).endsWith('.css')), ).toBe(true); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - const manifest = built.workers.default.config.manifest!; - expect(manifest.type).toBe('complete'); - expect(manifest.modules['index.js']).toEqual({ type: 'esm' }); - expect(manifest.modules['index.js.map']).toEqual({ type: 'sourcemap' }); - for (const [name, type] of Object.entries({ - message: 'text', - page: 'text', - query: 'text', - bytes: 'data', - module: 'wasm', - })) { - const moduleName = Object.keys(manifest.modules).find((path) => - path.startsWith(`assets/${name}-`), - ); - expect(moduleName).toBeDefined(); - expect(manifest.modules[moduleName!]).toEqual({ type }); - expect( - readFileSync( - join(prepared.directory, '.cloudflare/output/v0/workers/default/bundle', moduleName!), - ), - ).toEqual(readFileSync(join(output, moduleName!))); - } - expect(manifest.modules['.vite/manifest.json']).toBeUndefined(); - expect(Object.keys(manifest.modules).some((name) => name.endsWith('.css'))).toBe(false); - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + const manifest = built.workers.default.config.manifest!; + expect(manifest.type).toBe('complete'); + expect(manifest.modules['index.js']).toEqual({ type: 'esm' }); + expect(manifest.modules['index.js.map']).toEqual({ type: 'sourcemap' }); + for (const [name, type] of Object.entries({ + message: 'text', + page: 'text', + query: 'text', + bytes: 'data', + module: 'wasm', + })) { + const moduleName = Object.keys(manifest.modules).find((path) => + path.startsWith(`assets/${name}-`), + ); + expect(moduleName).toBeDefined(); + expect(manifest.modules[moduleName!]).toEqual({ type }); + expect( + readFileSync( + join(prepared.directory, '.cloudflare/output/v0/workers/default/bundle', moduleName!), + ), + ).toEqual(readFileSync(join(output, moduleName!))); + } + expect(manifest.modules['.vite/manifest.json']).toBeUndefined(); + expect(Object.keys(manifest.modules).some((name) => name.endsWith('.css'))).toBe(false); + expectCfDryRun(prepared.directory); + }, + ); }, CF_BUILD_TIMEOUT_MS, ); @@ -178,7 +486,7 @@ it( mkdirSync(client); writeFileSync(join(client, 'index.html'), '

hello

'); const disposed = await withPrepared( - prepareCfBuildOutput(output, 'account-id'), + prepareCfBuildOutput(output, { accountId: 'account-id' }), async (prepared) => { const built = await readBuildOutput(prepared.directory); expect(built.rootConfig.accountId).toBe('account-id'); @@ -262,14 +570,17 @@ it( ); writeFileSync(join(output, 'assets/message.js'), 'export const message = "nested entry";\n'); rmSync(join(output, 'index.js')); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.manifest).toMatchObject({ - mainModule: 'assets/index-hash.js', - modules: { 'assets/message.js': { type: 'esm' } }, - }); - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.manifest).toMatchObject({ + mainModule: 'assets/index-hash.js', + modules: { 'assets/message.js': { type: 'esm' } }, + }); + expectCfDryRun(prepared.directory); + }, + ); }, CF_BUILD_TIMEOUT_MS, ); @@ -278,7 +589,7 @@ it.each(['../index.js', '/index.js', 'C:\\index.js'])( 'rejects an out-of-bundle entry %s', async (main) => { const output = worker({ name: 'void-test', compatibility_date: '2026-09-29', main }); - await expect(prepareCfBuildOutput(output, 'account-id')).rejects.toThrow( + await expect(prepareCfBuildOutput(output, { accountId: 'account-id' })).rejects.toThrow( 'inside its output directory', ); }, @@ -290,7 +601,7 @@ it('reports a missing built entry', async () => { compatibility_date: '2026-09-29', main: 'assets/missing.js', }); - await expect(prepareCfBuildOutput(output, 'account-id')).rejects.toThrow( + await expect(prepareCfBuildOutput(output, { accountId: 'account-id' })).rejects.toThrow( '"assets/missing.js" is missing', ); }); @@ -303,19 +614,22 @@ it('preserves binary font modules in a deployable Worker bundle', async () => { "import font from './assets/font.woff2'; export default { fetch() { return new Response(String(font.byteLength)) } };\n", ); writeFileSync(join(output, 'assets/font.woff2'), Buffer.from([1, 2, 3])); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.manifest).toMatchObject({ - mainModule: 'index.js', - modules: { 'assets/font.woff2': { type: 'data' } }, - }); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.manifest).toMatchObject({ + mainModule: 'index.js', + modules: { 'assets/font.woff2': { type: 'data' } }, + }); + }, + ); }); it('names the file when a plugin emits an unsupported module', async () => { const output = worker({ name: 'void-test', compatibility_date: '2026-09-29', main: 'index.js' }); writeFileSync(join(output, 'plugin.unknown'), 'unexpected'); - await expect(prepareCfBuildOutput(output, 'account-id')).rejects.toThrow( + await expect(prepareCfBuildOutput(output, { accountId: 'account-id' })).rejects.toThrow( 'unsupported module "plugin.unknown"', ); }); @@ -328,11 +642,14 @@ it('preserves limits and placement', async () => { limits: { cpu_ms: 100 }, placement: { mode: 'smart' }, }); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.limits).toEqual({ cpuMs: 100 }); - expect(built.workers.default.config.placement).toEqual({ mode: 'smart' }); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.limits).toEqual({ cpuMs: 100 }); + expect(built.workers.default.config.placement).toEqual({ mode: 'smart' }); + }, + ); }); it( @@ -347,17 +664,20 @@ it( keep_assets: true, vars: { EDITED: 'current' }, }); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.env).toMatchObject({ - EDITED: { type: 'text', value: 'current' }, - }); - expect(built.workers.default.config.unsafe?.metadata).toMatchObject({ - keep_bindings: ['plain_text', 'json', 'secret_text', 'secret_key'], - keep_assets: true, - }); - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.env).toMatchObject({ + EDITED: { type: 'text', value: 'current' }, + }); + expect(built.workers.default.config.unsafe?.metadata).toMatchObject({ + keep_bindings: ['plain_text', 'json', 'secret_text', 'secret_key'], + keep_assets: true, + }); + expectCfDryRun(prepared.directory); + }, + ); }, CF_BUILD_TIMEOUT_MS, ); @@ -381,22 +701,25 @@ it('deploys Worker Workflow declarations through cf build output', async () => { join(output, 'index.js'), 'export class Flow { async run() {} }\nexport default { fetch() { return new Response("ok") } };\n', ); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.env?.FLOW).toEqual({ - type: 'workflow', - name: 'void-test-flow', - worker: 'void-test', - exportName: 'Flow', - }); - expect(built.workers.default.config.exports?.Flow).toEqual({ - type: 'workflow', - name: 'void-test-flow', - schedules: '0 * * * *', - limits: { steps: 100 }, - }); - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.env?.FLOW).toEqual({ + type: 'workflow', + name: 'void-test-flow', + worker: 'void-test', + exportName: 'Flow', + }); + expect(built.workers.default.config.exports?.Flow).toEqual({ + type: 'workflow', + name: 'void-test-flow', + schedules: '0 * * * *', + limits: { steps: 100 }, + }); + expectCfDryRun(prepared.directory); + }, + ); }, 15_000); it('copies an internal symlinked Worker module into the prebuilt artifact', async () => { @@ -406,13 +729,18 @@ it('copies an internal symlinked Worker module into the prebuilt artifact', asyn main: 'index.js', }); symlinkSync('index.js', join(output, 'chunk.js')); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.manifest?.modules['chunk.js']).toEqual({ type: 'esm' }); - expect( - existsSync(join(prepared.directory, '.cloudflare/output/v0/workers/default/bundle/chunk.js')), - ).toBe(true); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.manifest?.modules['chunk.js']).toEqual({ type: 'esm' }); + expect( + existsSync( + join(prepared.directory, '.cloudflare/output/v0/workers/default/bundle/chunk.js'), + ), + ).toBe(true); + }, + ); }); it('keeps Durable Objects, Queue triggers, and Containers in cf prebuilt output', async () => { @@ -430,21 +758,24 @@ it('keeps Durable Objects, Queue triggers, and Containers in cf prebuilt output' consumers: [{ queue: 'jobs', max_batch_size: 10 }], }, }); - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - expect(built.workers.default.config.exports?.Counter).toEqual( - expect.objectContaining({ type: 'durable-object', storage: 'sqlite' }), - ); - expect(built.workers.default.config.env?.COUNTER).toEqual({ - type: 'unsafe:durable_object_namespace', - class_name: 'Counter', - }); - expect(built.workers.default.config.triggers).toContainEqual( - expect.objectContaining({ type: 'queue', name: 'jobs' }), - ); - expect(built.containers).toHaveLength(1); - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.exports?.Counter).toEqual( + expect.objectContaining({ type: 'durable-object', storage: 'sqlite' }), + ); + expect(built.workers.default.config.env?.COUNTER).toEqual({ + type: 'unsafe:durable_object_namespace', + class_name: 'Counter', + }); + expect(built.workers.default.config.triggers).toContainEqual( + expect.objectContaining({ type: 'queue', name: 'jobs' }), + ); + expect(built.containers).toHaveLength(1); + expectCfDryRun(prepared.directory); + }, + ); }, 15_000); it('bundles a generated static Worker and staged assets into prebuilt cf output', async () => { @@ -532,21 +863,25 @@ it.skipIf(process.platform === 'win32')( chmodSync(binary, 0o755); vi.stubEnv('WRANGLER_DOCKER_BIN', binary); let tag: string; - await withPrepared(prepareCfBuildOutput(output, 'account-id'), async (prepared) => { - const built = await readBuildOutput(prepared.directory); - const container = built.containers[0]!.config; - expect(container.schedulingPolicy).toBe('durable-object'); - if (container.schedulingPolicy !== 'durable-object') { - throw new Error('Expected a managed container'); - } - const image = container.images?.default; - expect(image).toEqual({ localReference: expect.stringMatching(/^void-sandbox-build:/) }); - if (!image || !('localReference' in image)) { - throw new Error('Expected a prepared local image'); - } - tag = image.localReference; - expectCfDryRun(prepared.directory); - }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + const container = built.containers[0]!.config; + expect(container.schedulingPolicy).toBe('durable-object'); + if (container.schedulingPolicy !== 'durable-object') { + throw new Error('Expected a managed container'); + } + const image = container.images?.default; + if (!image || !('localReference' in image)) { + throw new Error('Expected a prepared local image'); + } + expect(Object.keys(image)).toEqual(['localReference']); + expect(image.localReference.startsWith('void-sandbox-build:')).toBe(true); + tag = image.localReference; + expectCfDryRun(prepared.directory); + }, + ); const calls = readFileSync(log, 'utf8') .trim() .split('\n') diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 1a71c9a45..ccf37b590 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -10,10 +10,12 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { toSlash } from 'pathslash'; import { createServer, normalizePath, type Plugin, type ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { getOriginalFrameworkEntry } from '../../src/plugin-framework-entry.ts'; import { frameworkTriggersDispatchPath } from '../../src/plugin-framework-triggers.ts'; vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); @@ -68,6 +70,108 @@ afterEach(() => { } }); +describe('Class A framework custom entry', () => { + it.each(['serve', 'build'] as const)( + 'preserves a custom TanStack Worker entry and named exports during %s', + async (command) => { + const root = createTanStackRoot({ cloudflare: { main: './server.mjs' } }); + writeFileSync( + join(root, 'server.mjs'), + 'export class Counter {}\n' + + 'export const marker = "custom entry";\n' + + 'export default { fetch() { return new Response(marker); }, scheduled() { return "scheduled"; } };\n', + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const plugins = voidPlugin(); + if (command === 'build') { + const wrapperPlugin = plugins.find( + (plugin) => plugin.name === 'void:framework-build-wrapper', + ); + if (!wrapperPlugin) { + throw new Error('Expected Class A wrapper plugin.'); + } + callConfigResolved(wrapperPlugin, { + command, + build: { assetsDir: 'assets' } as ResolvedConfig['build'], + }); + } + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const wrapped = await server.ssrLoadModule( + join(root, '.void/tanstack-start-trigger-wrapper.ts'), + ); + const original = await server.ssrLoadModule(join(root, 'server.mjs')); + const response = await wrapped.default.fetch(new Request('https://example.test/'), {}, {}); + expect(await response.text()).toBe('custom entry'); + expect(wrapped.Counter).toBe(original.Counter); + expect(wrapped.marker).toBe('custom entry'); + expect(wrapped.default.scheduled()).toBe('scheduled'); + } finally { + await server.close(); + } + }, + ); + + it('keeps the default TanStack server entry when no custom entry is configured', () => { + expect(getOriginalFrameworkEntry('tanstack-start', createTanStackRoot())).toBe( + '@tanstack/react-start/server-entry', + ); + }); + + it.each(['@tanstack/react-start/server-entry', '@tanstack/react-start/server-entry.js'])( + 'keeps the default TanStack server entry for a migrated main of %s', + (main) => { + expect(getOriginalFrameworkEntry('tanstack-start', createTanStackRoot(), main)).toBe( + '@tanstack/react-start/server-entry', + ); + }, + ); + + it.each(['@acme/worker/entry', '@acme/worker/entry.js', 'acme-worker/entry.js'])( + 'leaves the package entry %s for Vite to resolve', + (main) => { + expect(getOriginalFrameworkEntry('tanstack-start', createTanStackRoot(), main)).toBe(main); + }, + ); + + it.each(['src/server.ts', 'src/worker.cjs', './src/server', '../shared/server.tsx'])( + 'reads the file entry %s from the app root', + (main) => { + const root = createTanStackRoot(); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/server.ts'), 'export default {};\n'); + writeFileSync(join(root, 'src/worker.cjs'), 'module.exports = {};\n'); + expect(getOriginalFrameworkEntry('tanstack-start', root, main)).toBe( + toSlash(resolve(root, main)), + ); + }, + ); + + it.each(['', ' ', null, false, 1])('rejects an invalid custom entry %s', (main) => { + expect(() => getOriginalFrameworkEntry('tanstack-start', createTanStackRoot(), main)).toThrow( + 'cloudflare.main must be a non-empty Worker entry path', + ); + }); + + it('rejects a custom entry that points back to the generated wrapper', () => { + const root = createTanStackRoot(); + expect(() => + getOriginalFrameworkEntry( + 'tanstack-start', + root, + './.void/../.void/tanstack-start-trigger-wrapper.ts', + ), + ).toThrow("cloudflare.main cannot point to Void's generated framework wrapper"); + }); +}); + describe('Class A framework build wrapper', () => { it.each([ [undefined, 'none'], @@ -224,6 +328,7 @@ describe('Class A framework request scope', () => { expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. import original from "@tanstack/react-start/server-entry"; +export * from "@tanstack/react-start/server-entry"; export default { ...original, @@ -242,6 +347,7 @@ export default { `); expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. import original from "@tanstack/react-start/server-entry"; +export * from "@tanstack/react-start/server-entry"; export default { ...original, @@ -261,6 +367,7 @@ export default { expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. import original from "@tanstack/react-start/server-entry"; +export * from "@tanstack/react-start/server-entry"; import { withRuntimeEnv } from "void/_env"; export default { @@ -282,6 +389,7 @@ export default { `); expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. import original from "@tanstack/react-start/server-entry"; +export * from "@tanstack/react-start/server-entry"; import { withRuntimeEnv } from "void/_env"; export default { diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index b67080dfe..af170019d 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -85,6 +85,34 @@ describe('repeated wrapper generation', () => { expect((await worker.fetch(new Request('https://app/__void/ready'), {}, {})).status).toBe(200); }); + it('keeps the named exports of the wrapped entry', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-wrapper-exports-')); + roots.push(root); + writeFileSync( + join(root, 'index.mjs'), + 'export class Counter {}\nexport default { fetch() { return new Response("app"); } };', + ); + await generateWrapper(root, 'index.mjs', root, { ...base, readiness: true }); + const wrapped = await import(pathToFileURL(join(root, 'index.mjs')).href); + // Cloudflare rejects an upload whose Durable Object class is not exported. + expect(Object.keys(wrapped).sort()).toEqual(['Counter', 'default']); + expect(wrapped.Counter.name).toBe('Counter'); + }); + + it("keeps the entry's own default handlers", async () => { + const root = mkdtempSync(join(tmpdir(), 'void-wrapper-handlers-')); + roots.push(root); + writeFileSync( + join(root, 'index.mjs'), + 'export default { fetch() { return new Response("app"); }, scheduled() { return "cron"; }, email() { return "mail"; } };', + ); + await generateWrapper(root, 'index.mjs', root, { ...base, readiness: true }); + const worker = (await import(pathToFileURL(join(root, 'index.mjs')).href)).default; + expect(worker.scheduled()).toBe('cron'); + expect(worker.email()).toBe('mail'); + expect((await worker.fetch(new Request('https://app/__void/ready'), {}, {})).status).toBe(200); + }); + it('fails readiness when the wrapped entry has no fetch handler', async () => { const root = mkdtempSync(join(tmpdir(), 'void-wrapper-no-fetch-')); roots.push(root);