Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/integrations/frameworks/tanstack-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions docs/reference/api/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
127 changes: 123 additions & 4 deletions packages/void/src/cli/cf-build-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
import { tmpdir } from 'node:os';
import {
getWorkerBundleDir,
getWorkerAssetsDir,
getWorkerConfigPath,
getRootConfigPath,
normalizeDirectoryName,
readBuildOutput,
writeAssets,
Expand Down Expand Up @@ -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<string, { type: ModuleType }> {
const modules: Record<string, { type: ModuleType }> = {};
for (const entry of readdirSync(source, { recursive: true, withFileTypes: true })) {
Expand Down Expand Up @@ -104,17 +120,30 @@ function copyBundle(source: string, destination: string): Record<string, { type:
return modules;
}

/** Create a private cf Build Output tree from Void's verified Vite artifact. */
/** Create a disposable cf Build Output tree from an existing Cloudflare Worker build. */
export async function prepareCfBuildOutput(
workerDirectory: string,
accountId: string,
accountId?: string,
mode?: string,
): Promise<CfBuildOutput | null> {
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -185,6 +219,7 @@ export async function prepareCfBuildOutput(
await dispose();
return null;
}
assertAssetSymlinksInside(assetPath);
await writeAssets({ root: directory, sourceDirectory: assetPath });
}
await readBuildOutput(directory);
Expand All @@ -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<CfBuildOutput | null> {
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject escaping symlinks in native asset trees

When native .cloudflare/output/v0 assets contain a symlink, this cpSync neither calls assertAssetSymlinksInside nor dereferences the link; Node's default copy preserves it and resolves its target back into the original source tree. The returned artifact can therefore stop being self-contained when the source moves, and an escaping link can cause a later asset upload to read a file outside the asset directory, even though the public API promises that such links throw. Validate and materialize native asset links just as the legacy branch does.

Useful? React with 👍 / 👎.

@Brooooooklyn Brooooooklyn Oct 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, but this cannot happen: prepareNativeBuildOutput first calls readBuildOutput(source), which refuses any symlink in the native output, assets included, before cpSync runs ("Build Output Specification: symlink found at …/assets/…. Symlinks are not permitted because the build output must be portable and self-contained."). A link inside the asset directory is refused too, so there is nothing to validate or materialize at the copy. 499b5a2 adds a test that locks this in: rejects symlinked native assets in cf-build-output.test.ts. Leaving this thread open for the maintainer.

}
}
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;
Expand Down
4 changes: 4 additions & 0 deletions packages/void/src/cli/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,8 @@ function generateWrapperSource(
const lines: Array<string> = [];
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}";`);
Comment thread
Brooooooklyn marked this conversation as resolved.

if (features.isr) {
lines.push('import { handleFrameworkIsr } from "./__void_isr.mjs";');
Expand Down Expand Up @@ -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,');
Expand Down
38 changes: 38 additions & 0 deletions packages/void/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<void>;
};

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<CfBuildOutput | null> {
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. */
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading