From a49f8bfbe5dbef610cbe9d8e7e9744b8625b92b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B7=E3=83=A5=E3=83=AC=E3=83=83=E3=82=AF=E5=80=89?= =?UTF-8?q?=E7=94=B0?= <32031454+ryuseikurata@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:45:55 +0900 Subject: [PATCH 1/6] fix: preserve custom TanStack Worker entries and expose CF build output preparation --- .../integrations/frameworks/tanstack-start.md | 23 ++++++ docs/reference/api/deploy.md | 25 ++++++ packages/void/src/cli/cf-build-output.ts | 18 ++++- packages/void/src/deploy.ts | 2 + packages/void/src/index.ts | 3 +- packages/void/src/plugin-framework-entry.ts | 29 +++++-- .../void/test/unit/cf-build-output.test.ts | 48 +++++++++++- .../test/unit/plugin-class-a-wrapper.test.ts | 78 +++++++++++++++++++ 8 files changed, 215 insertions(+), 11 deletions(-) diff --git a/docs/integrations/frameworks/tanstack-start.md b/docs/integrations/frameworks/tanstack-start.md index 0c7550384..dc0159518 100644 --- a/docs/integrations/frameworks/tanstack-start.md +++ b/docs/integrations/frameworks/tanstack-start.md @@ -71,6 +71,29 @@ 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 and production builds. Without `cloudflare.main`, Void uses TanStack's default server entry. + ## 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..9e467a143 100644 --- a/docs/reference/api/deploy.md +++ b/docs/reference/api/deploy.md @@ -21,3 +21,28 @@ 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 + +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', undefined, '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(); +} +``` + +The input directory must contain the built Worker modules and its generated `wrangler.json`. Preparation copies the Worker modules and configured assets without changing that input. It does not provision resources, upload the Worker, or activate a deployment. Local Dockerfile container images require their normal Docker build environment. + +The optional second argument pins the target account. A conflicting account in the built configuration returns `null`. When omitted, preparation preserves an account from the build or leaves it unset; credentials are not required. The third argument records the Vite build mode in the output metadata and 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`; invalid or missing Worker modules throw an error. diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index b0ff0cfc3..65b9684c8 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -104,17 +104,22 @@ function copyBundle(source: string, destination: string): Record { const source = resolve(workerDirectory); 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 +154,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 }, + ); await writeWorkerConfig({ root: directory, config: config.worker, diff --git a/packages/void/src/deploy.ts b/packages/void/src/deploy.ts index 1c2fc2c17..9f616befa 100644 --- a/packages/void/src/deploy.ts +++ b/packages/void/src/deploy.ts @@ -22,6 +22,8 @@ import { collectRevalidate } from './isr-config.ts'; import { hasEmailHandlers } from './router/email.ts'; import { validateSsrEntry } from './ssr/entry.ts'; +export { prepareCfBuildOutput, type CfBuildOutput } from './cli/cf-build-output.ts'; + 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..ffb3ce979 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 { 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, @@ -46,10 +47,27 @@ export function getFrameworkEntry( } /** 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) { + return '@tanstack/react-start/server-entry'; + } + if (typeof configuredMain !== 'string' || configuredMain.trim() === '') { + throw new Error('config: cloudflare.main must be a non-empty Worker entry path.'); + } + 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 +141,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..ac2f4f39f 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -21,10 +21,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 +77,52 @@ function expectCfDryRun(directory: string, command = ['workers', 'versions', 'cr return dryRun; } +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, 'account-id', '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, 'different-account')).resolves.toBeNull(); +}); + it( 'preserves additional module types from a real Cloudflare Vite build', async () => { 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..23a5af138 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; 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 +69,79 @@ 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(['', ' ', 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 +298,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 +317,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 +337,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 +359,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 { From 018b0eca828ea8c9a0021199fe5244373326166e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B7=E3=83=A5=E3=83=AC=E3=83=83=E3=82=AF=E5=80=89?= =?UTF-8?q?=E7=94=B0?= <32031454+ryuseikurata@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:04:00 +0900 Subject: [PATCH 2/6] feat: prepare native Cloudflare build output without changing inputs --- docs/reference/api/deploy.md | 4 +- packages/void/src/cli/cf-build-output.ts | 90 ++++++++- .../void/test/unit/cf-build-output.test.ts | 189 +++++++++++++++++- 3 files changed, 278 insertions(+), 5 deletions(-) diff --git a/docs/reference/api/deploy.md b/docs/reference/api/deploy.md index 9e467a143..5c7927416 100644 --- a/docs/reference/api/deploy.md +++ b/docs/reference/api/deploy.md @@ -41,8 +41,8 @@ try { } ``` -The input directory must contain the built Worker modules and its generated `wrangler.json`. Preparation copies the Worker modules and configured assets without changing that input. It does not provision resources, upload the Worker, or activate a deployment. Local Dockerfile container images require their normal Docker build environment. +Pass the project root for native Cloudflare Build Output at `.cloudflare/output/v0`, or the Worker output directory containing a generated `wrangler.json`. Preparation copies modules and assets without changing that input. Native output retains its module types, auxiliary Workers, Container declarations, and build metadata. It does not provision resources, upload a Worker, or activate a deployment. Local Dockerfile container images in legacy output require their normal Docker build environment. -The optional second argument pins the target account. A conflicting account in the built configuration returns `null`. When omitted, preparation preserves an account from the build or leaves it unset; credentials are not required. The third argument records the Vite build mode in the output metadata and defaults to `production`. +The optional second argument pins the target account. A conflicting account in the built configuration returns `null`. When omitted, preparation preserves an account from the build or leaves it unset; credentials are not required. Native output keeps its original build mode; an explicitly different third argument returns `null`. For legacy output, the third argument records the Vite build mode and 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`; invalid or missing Worker modules throw an error. diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index 65b9684c8..50151fbb9 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, @@ -108,9 +111,12 @@ function copyBundle(source: string, destination: string): Record { const source = resolve(workerDirectory); + if (existsSync(getRootConfigPath(source))) { + return prepareNativeBuildOutput(source, accountId, mode); + } const raw = record(JSON.parse(readFileSync(join(source, 'wrangler.json'), 'utf8'))); if (!raw) { return null; @@ -158,7 +164,7 @@ export async function prepareCfBuildOutput( await writeRootConfig( directory, typeof configuredAccountId === 'string' ? { accountId: configuredAccountId } : {}, - { isPreview: false, mode }, + { isPreview: false, mode: mode ?? 'production' }, ); await writeWorkerConfig({ root: directory, @@ -211,6 +217,86 @@ 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 ( + module.includes('\\') || + /^[a-z]:/i.test(module) || + !isInside(bundle, resolve(bundle, module)) || + !isInside(worker.bundleDir, resolve(worker.bundleDir, module)) + ) { + throw new Error('Native Worker modules must stay inside 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/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index ac2f4f39f..a7b956736 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'; @@ -77,6 +86,184 @@ 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, 'another-account')).resolves.toBeNull(); + await expect(prepareCfBuildOutput(root, undefined, '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, 'account-id', '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'); +}); + +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('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) => { From 03e130971334b0cdeff11a9f990e698844fa6dfe Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 12:26:28 +0800 Subject: [PATCH 3/6] fix: keep custom TanStack entries through deploy and settle the build output API A migrated TanStack config keeps `main: "@tanstack/react-start/server-entry"`, which was resolved as a file under the app root and broke dev and build. A package-style `main` is now left for Vite to resolve, and TanStack's own entry keeps the default, matching how Cloudflare reads `main`. `void deploy` wrapped the built Worker again and kept only its default export, so the Durable Object and Workflow classes the custom entry exports never reached Cloudflare. The deployment wrapper now re-exports the original module's named exports. `prepareCfBuildOutput` from `void/deploy` now takes `{ accountId, mode }`, returns only `directory`, `assetsDirectory` and `dispose`, and is documented as experimental because it follows Cloudflare's draft Build Output format. It names the missing input or module instead of failing with ENOENT, and refuses legacy asset symlinks that point outside the asset directory. The TanStack guide says where to bind exported classes, and the reference covers local container images for both output formats. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integrations/frameworks/tanstack-start.md | 4 +- docs/reference/api/deploy.md | 15 +- packages/void/src/cli/cf-build-output.ts | 22 ++ packages/void/src/cli/wrapper.ts | 2 + packages/void/src/deploy.ts | 38 +- packages/void/src/plugin-framework-entry.ts | 16 +- .../void/test/unit/cf-build-output.test.ts | 356 +++++++++++------- .../test/unit/plugin-class-a-wrapper.test.ts | 28 +- packages/void/test/unit/wrapper.test.ts | 14 + 9 files changed, 354 insertions(+), 141 deletions(-) diff --git a/docs/integrations/frameworks/tanstack-start.md b/docs/integrations/frameworks/tanstack-start.md index dc0159518..67a93b653 100644 --- a/docs/integrations/frameworks/tanstack-start.md +++ b/docs/integrations/frameworks/tanstack-start.md @@ -92,7 +92,9 @@ export { Counter } from './counter'; export default handler; ``` -Void preserves these named exports in development and production builds. Without `cloudflare.main`, Void uses TanStack's default server entry. +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 diff --git a/docs/reference/api/deploy.md b/docs/reference/api/deploy.md index 5c7927416..a2d76224d 100644 --- a/docs/reference/api/deploy.md +++ b/docs/reference/api/deploy.md @@ -24,12 +24,16 @@ Run preparation inside the same isolated environment as the app build. App confi ## 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', undefined, 'staging'); +const prepared = await prepareCfBuildOutput('/path/to/app/dist/server', { mode: 'staging' }); if (!prepared) { throw new Error('The Worker build could not be prepared.'); } @@ -41,8 +45,11 @@ try { } ``` -Pass the project root for native Cloudflare Build Output at `.cloudflare/output/v0`, or the Worker output directory containing a generated `wrangler.json`. Preparation copies modules and assets without changing that input. Native output retains its module types, auxiliary Workers, Container declarations, and build metadata. It does not provision resources, upload a Worker, or activate a deployment. Local Dockerfile container images in legacy output require their normal Docker build environment. +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. -The optional second argument pins the target account. A conflicting account in the built configuration returns `null`. When omitted, preparation preserves an account from the build or leaves it unset; credentials are not required. Native output keeps its original build mode; an explicitly different third argument returns `null`. For legacy output, the third argument records the Vite build mode and defaults to `production`. +| 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`; invalid or missing Worker modules throw an error. +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 50151fbb9..8df050f9d 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -65,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 })) { @@ -117,6 +130,11 @@ export async function prepareCfBuildOutput( 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; @@ -201,6 +219,7 @@ export async function prepareCfBuildOutput( await dispose(); return null; } + assertAssetSymlinksInside(assetPath); await writeAssets({ root: directory, sourceDirectory: assetPath }); } await readBuildOutput(directory); @@ -268,6 +287,9 @@ async function prepareNativeBuildOutput( ) { 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); } diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index cb22a7d31..e1ce46703 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";'); diff --git a/packages/void/src/deploy.ts b/packages/void/src/deploy.ts index 9f616befa..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,7 +23,42 @@ import { collectRevalidate } from './isr-config.ts'; import { hasEmailHandlers } from './router/email.ts'; import { validateSsrEntry } from './ssr/entry.ts'; -export { prepareCfBuildOutput, type CfBuildOutput } from './cli/cf-build-output.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; diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index ffb3ce979..cabee037c 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -46,6 +46,11 @@ export function getFrameworkEntry( ); } +const TANSTACK_SERVER_ENTRY = '@tanstack/react-start/server-entry'; +// Cloudflare treats a `main` with a module extension as a file path and anything else as a +// module specifier, so a migrated TanStack config keeps `main: "@tanstack/react-start/server-entry"`. +const WORKER_ENTRY_PATH = /^(?:\.{1,2}[\\/]|[\\/]|[a-z]:[\\/])|\.(?:[cm]?[jt]s|[jt]sx)$/i; + /** The framework's own server entry, before our trigger wrapper. */ export function getOriginalFrameworkEntry( framework: Framework, @@ -54,12 +59,19 @@ export function getOriginalFrameworkEntry( ): string { switch (framework) { case 'tanstack-start': { - if (configuredMain === undefined) { - return '@tanstack/react-start/server-entry'; + 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 (!WORKER_ENTRY_PATH.test(configuredMain)) { + return configuredMain; + } const original = resolve(root, configuredMain); if (original === resolve(root, '.void', 'tanstack-start-trigger-wrapper.ts')) { throw new Error( diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index a7b956736..56f065455 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -180,8 +180,8 @@ it( it('rejects native artifact account and mode mismatches', async () => { const root = await nativeBuild(); - await expect(prepareCfBuildOutput(root, 'another-account')).resolves.toBeNull(); - await expect(prepareCfBuildOutput(root, undefined, 'production')).resolves.toBeNull(); + 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 () => { @@ -210,14 +210,17 @@ it('preserves native auxiliary Workers and already-built Container declarations' image: { reference: 'docker.io/example/native:1' }, }, }); - await withPrepared(prepareCfBuildOutput(root, 'account-id', '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' }, - }); - }); + 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'])( @@ -245,6 +248,61 @@ it('rejects an absent native main module', async () => { 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')); @@ -293,11 +351,14 @@ it('records an explicit build mode and matching target account', async () => { main: 'index.js', account_id: 'account-id', }); - await withPrepared(prepareCfBuildOutput(output, 'account-id', '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' }); - }); + 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 () => { @@ -307,7 +368,9 @@ it('rejects a mismatching explicit target account', async () => { main: 'index.js', account_id: 'built-account', }); - await expect(prepareCfBuildOutput(output, 'different-account')).resolves.toBeNull(); + await expect( + prepareCfBuildOutput(output, { accountId: 'different-account' }), + ).resolves.toBeNull(); }); it( @@ -360,34 +423,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, ); @@ -411,7 +477,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'); @@ -495,14 +561,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, ); @@ -511,7 +580,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', ); }, @@ -523,7 +592,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', ); }); @@ -536,19 +605,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"', ); }); @@ -561,11 +633,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( @@ -580,17 +655,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, ); @@ -614,22 +692,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 () => { @@ -639,13 +720,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 () => { @@ -663,21 +749,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 () => { @@ -765,21 +854,24 @@ 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; + 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); + }, + ); 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 23a5af138..c44c2186c 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -10,8 +10,9 @@ 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'; @@ -124,6 +125,31 @@ describe('Class A framework custom 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('leaves a package entry for Vite to resolve', () => { + expect( + getOriginalFrameworkEntry('tanstack-start', createTanStackRoot(), '@acme/worker/entry'), + ).toBe('@acme/worker/entry'); + }); + + it.each(['src/server.ts', './src/server', '../shared/server.tsx'])( + 'reads the file entry %s from the app root', + (main) => { + const root = createTanStackRoot(); + 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', diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index b67080dfe..11835fc8c 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -85,6 +85,20 @@ 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('fails readiness when the wrapped entry has no fetch handler', async () => { const root = mkdtempSync(join(tmpdir(), 'void-wrapper-no-fetch-')); roots.push(root); From 36e6f64802b0358f7582b3ae44e02070772ee290 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 12:43:49 +0800 Subject: [PATCH 4/6] fix: keep entry handlers through deploy and read cloudflare.main without regex The deployment wrapper rebuilt the default export from fetch and Void's own cron and queue handlers, so a custom entry's scheduled, email or tail handler was dropped on deploy. It now spreads the original default export first; Void's handlers still take precedence. A package subpath such as `@acme/worker/entry.js` was read as a file under the app root because it ends in `.js`. A bare `cloudflare.main` now counts as a file only when it exists in the app; `./`, `../` and absolute paths are always files. The entry, bundle-path and test checks use plain string checks instead of regular expressions. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/cf-build-output.ts | 3 ++- packages/void/src/cli/wrapper.ts | 2 ++ packages/void/src/plugin-framework-entry.ts | 23 ++++++++++++++----- .../void/test/unit/cf-build-output.test.ts | 3 ++- .../test/unit/plugin-class-a-wrapper.test.ts | 13 +++++++---- packages/void/test/unit/wrapper.test.ts | 14 +++++++++++ 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index 8df050f9d..21bef3df0 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -280,8 +280,9 @@ async function prepareNativeBuildOutput( 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('\\') || - /^[a-z]:/i.test(module) || + module.includes(':') || !isInside(bundle, resolve(bundle, module)) || !isInside(worker.bundleDir, resolve(worker.bundleDir, module)) ) { diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index e1ce46703..a302c686b 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -741,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/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index cabee037c..03f584df8 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, resolve } from 'pathslash'; +import { existsSync, mkdirSync } from 'node:fs'; +import { extname, 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'; @@ -47,9 +47,20 @@ export function getFrameworkEntry( } const TANSTACK_SERVER_ENTRY = '@tanstack/react-start/server-entry'; -// Cloudflare treats a `main` with a module extension as a file path and anything else as a -// module specifier, so a migrated TanStack config keeps `main: "@tanstack/react-start/server-entry"`. -const WORKER_ENTRY_PATH = /^(?:\.{1,2}[\\/]|[\\/]|[a-z]:[\\/])|\.(?:[cm]?[jt]s|[jt]sx)$/i; +// The extensions Cloudflare reads as a file in `main`; anything else is a module specifier. +const ENTRY_MODULE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.mts', '.jsx', '.tsx']); + +/** + * 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 ENTRY_MODULE_EXTENSIONS.has(extname(main)) && existsSync(resolve(root, main)); +} /** The framework's own server entry, before our trigger wrapper. */ export function getOriginalFrameworkEntry( @@ -69,7 +80,7 @@ export function getOriginalFrameworkEntry( if (typeof configuredMain !== 'string' || configuredMain.trim() === '') { throw new Error('config: cloudflare.main must be a non-empty Worker entry path.'); } - if (!WORKER_ENTRY_PATH.test(configuredMain)) { + if (!isFileEntry(root, configuredMain)) { return configuredMain; } const original = resolve(root, configuredMain); diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index 56f065455..aba8d0b0a 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -864,10 +864,11 @@ it.skipIf(process.platform === 'win32')( 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'); } + expect(Object.keys(image)).toEqual(['localReference']); + expect(image.localReference.startsWith('void-sandbox-build:')).toBe(true); tag = image.localReference; expectCfDryRun(prepared.directory); }, 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 c44c2186c..53605559c 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -134,16 +134,19 @@ describe('Class A framework custom entry', () => { }, ); - it('leaves a package entry for Vite to resolve', () => { - expect( - getOriginalFrameworkEntry('tanstack-start', createTanStackRoot(), '@acme/worker/entry'), - ).toBe('@acme/worker/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/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'); expect(getOriginalFrameworkEntry('tanstack-start', root, main)).toBe( toSlash(resolve(root, main)), ); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 11835fc8c..af170019d 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -99,6 +99,20 @@ describe('repeated wrapper generation', () => { 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); From 109a01bdcc81983f3289b85ab0380ca97733d66a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 12:53:03 +0800 Subject: [PATCH 5/6] fix: read any existing app file as the custom TanStack entry The extension list that replaced the entry regex left out `.cjs` and `.cts`, so `cloudflare.main: "src/worker.cjs"` was sent to Vite as a package. A bare `main` now counts as a file whenever it is a file in the app, with no extension list to keep in step. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-framework-entry.ts | 8 +++----- packages/void/test/unit/plugin-class-a-wrapper.test.ts | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 03f584df8..7debf81e4 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync } from 'node:fs'; -import { extname, isAbsolute, join, resolve } 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'; @@ -47,8 +47,6 @@ export function getFrameworkEntry( } const TANSTACK_SERVER_ENTRY = '@tanstack/react-start/server-entry'; -// The extensions Cloudflare reads as a file in `main`; anything else is a module specifier. -const ENTRY_MODULE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.mts', '.jsx', '.tsx']); /** * Whether `main` names a file in the app rather than a package. A migrated TanStack config keeps @@ -59,7 +57,7 @@ function isFileEntry(root: string, main: string): boolean { if (main.startsWith('.') || isAbsolute(main)) { return true; } - return ENTRY_MODULE_EXTENSIONS.has(extname(main)) && existsSync(resolve(root, main)); + return statSync(resolve(root, main), { throwIfNoEntry: false })?.isFile() === true; } /** The framework's own server entry, before our trigger wrapper. */ 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 53605559c..ccf37b590 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -141,12 +141,13 @@ describe('Class A framework custom entry', () => { }, ); - it.each(['src/server.ts', './src/server', '../shared/server.tsx'])( + 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)), ); From 499b5a2bc76529148bf4a930076d73e1471b66be Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 13:02:32 +0800 Subject: [PATCH 6/6] test: lock in that native Build Output refuses asset symlinks Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/test/unit/cf-build-output.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index aba8d0b0a..619646022 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -309,6 +309,15 @@ it.skipIf(process.platform === 'win32')('rejects symlinked native modules', asyn 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 });