Skip to content

Commit bd8f4e2

Browse files
Brooooooklynclaude
andauthored
fix(deploy): read the framework Worker config as JSONC (#498)
Fixes #484 ## Problem ``` void domain add → "// void:published-traffic:v1:..." saved in void.lock.json fresh checkout → restored into .void-wrangler.jsonc (valid JSONC) void deploy → prepareCfFrameworkBuildOutput: JSON.parse(...) ✗ Unexpected token '/', "// void:pu"... is not valid JSON ``` It affects framework apps whose Worker output is deployed directly (Astro, SvelteKit, Nuxt, Analog). ## Fix `prepareCfFrameworkBuildOutput` now reads the config with `readGeneratedWranglerConfigFile`. Other code that reads this file already uses that helper. It accepts comments, a BOM and trailing commas, and still rejects bad input. The issue also names two other `JSON.parse` reads in `cf-build-output.ts`. I left them as they are on purpose, because Void always writes those files as plain JSON: | Read | File | Written by | |---|---|---| | `prepareCfStaticBuildOutput` | `.void/deploy/cloudflare/wrangler-static.json` | `JSON.stringify` (deploy-cloudflare-static.ts) | | `prepareCfBuildOutput` | build output `wrangler.json` | `@cloudflare/vite-plugin` or a scratch dir, both `JSON.stringify` | ## Tests `packages/void/test/unit/cf-build-output.test.ts`: - **Real flow:** write the snapshot with Void's own writer, delete the config, restore it from the lock, run `writeCloudflareFrameworkDeployConfig`, follow the deploy pointer, then prepare the build output. - **Parsing:** JSONC with a BOM, a comment and a trailing comma is accepted. A broken config or a non-object config is still rejected. On `main`, both tests fail with the error from the issue. Checks run: - 5 deploy-path unit files pass (645 tests); - lint, format and typecheck are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 293ea34 commit bd8f4e2

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

‎packages/void/src/cli/cf-build-output.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import ignore from 'ignore';
2828
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'pathslash';
2929
import { migrateBuiltWranglerConfig, record } from './cf-config-migrate.ts';
3030
import { buildContainerImage } from './container-image.ts';
31+
import { readGeneratedWranglerConfigFile } from './generated-wrangler-config.ts';
3132

3233
export interface CfBuildOutput {
3334
directory: string;
@@ -388,9 +389,11 @@ export async function prepareCfFrameworkBuildOutput(options: {
388389
configPath: string;
389390
accountId: string;
390391
}): Promise<CfBuildOutput | null> {
391-
const raw = record(JSON.parse(readFileSync(options.configPath, 'utf8')));
392-
const assets = record(raw?.assets);
393-
if (!raw || typeof raw.main !== 'string' || typeof assets?.directory !== 'string') {
392+
// The root config is JSONC: it may be hand-written, or restored from void.lock.json with Void's
393+
// published-traffic comment.
394+
const raw = readGeneratedWranglerConfigFile(options.configPath);
395+
const assets = record(raw.assets);
396+
if (typeof raw.main !== 'string' || typeof assets?.directory !== 'string') {
394397
return null;
395398
}
396399
const configDirectory = dirname(options.configPath);

‎packages/void/test/unit/cf-build-output.test.ts‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
writeContainerConfig,
2525
} from '@cloudflare/build-output-utils';
2626
import { cloudflare } from '@cloudflare/vite-plugin';
27-
import { renderCloudflareStaticEntry } from '@void/deploy-cloudflare';
27+
import {
28+
renderCloudflareStaticEntry,
29+
writeCloudflareFrameworkDeployConfig,
30+
} from '@void/deploy-cloudflare';
2831
import { toSlash } from 'pathslash';
2932
import { createBuilder } from 'vite';
3033
import { afterEach, expect, it, vi } from 'vite-plus/test';
@@ -33,6 +36,10 @@ import {
3336
prepareCfFrameworkBuildOutput,
3437
prepareCfStaticBuildOutput,
3538
} from '../../src/cli/cf-build-output.ts';
39+
import { resolveGeneratedDeployConfigPathDefault } from '../../src/cli/deploy-artifact-validation.ts';
40+
import { deployPointerPath } from '../../src/cli/deploy-binding-snapshot.ts';
41+
import { writeWranglerPublishedTrafficSnapshot } from '../../src/cli/wrangler.ts';
42+
import { materializeCloudflareConfig } from '../../src/cloudflare-config.ts';
3643
import { prepareCfBuildOutput } from '../../src/deploy.ts';
3744

3845
const roots: Array<string> = [];
@@ -828,6 +835,99 @@ it('bundles a generated static Worker and staged assets into prebuilt cf output'
828835
);
829836
});
830837

838+
/** The comment line `void domain add` writes, in the format of writeWranglerPublishedTrafficSnapshot. */
839+
const PUBLISHED_TRAFFIC_COMMENT = `// void:published-traffic:v1:${Buffer.from(
840+
JSON.stringify({ workers_dev: false, preview_urls: false, routes: [] }),
841+
'utf8',
842+
).toString('base64url')}`;
843+
844+
it(
845+
'bundles framework output whose restored generated config keeps the published traffic comment',
846+
async () => {
847+
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-jsonc-test-'));
848+
roots.push(root);
849+
const output = join(root, 'framework');
850+
mkdirSync(output);
851+
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
852+
writeFileSync(
853+
join(root, 'void.config.ts'),
854+
`export default { cloudflare: ${JSON.stringify({
855+
name: 'void-framework-test',
856+
account_id: 'account-id',
857+
compatibility_date: '2026-09-29',
858+
})} };\n`,
859+
);
860+
writeFileSync(
861+
join(output, '_worker.js'),
862+
'export default { fetch() { return new Response("ok") } };\n',
863+
);
864+
writeFileSync(join(output, 'index.html'), '<h1>public</h1>');
865+
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');
866+
867+
// `void domain add` records the traffic snapshot into the lock; a fresh checkout restores it.
868+
const configPath = materializeCloudflareConfig(root);
869+
writeWranglerPublishedTrafficSnapshot(configPath, {
870+
workers_dev: false,
871+
preview_urls: false,
872+
routes: [{ pattern: 'app.example.com', custom_domain: true }],
873+
});
874+
rmSync(configPath);
875+
expect(materializeCloudflareConfig(root)).toBe(configPath);
876+
// `void deploy` then points the restored config at the framework output.
877+
writeCloudflareFrameworkDeployConfig({
878+
configPath,
879+
deployPointerPath: deployPointerPath(root),
880+
name: 'void-framework-test',
881+
accountId: 'account-id',
882+
main: 'framework/_worker.js',
883+
assetsDirectory: 'framework',
884+
compatibilityDate: '2026-09-29',
885+
});
886+
const deployConfigPath = resolveGeneratedDeployConfigPathDefault(root);
887+
expect(readFileSync(deployConfigPath, 'utf8').startsWith('// void:published-traffic:v1:')).toBe(
888+
true,
889+
);
890+
891+
await withPrepared(
892+
prepareCfFrameworkBuildOutput({
893+
projectRoot: root,
894+
configPath: deployConfigPath,
895+
accountId: 'account-id',
896+
}),
897+
async (prepared) => {
898+
const built = await readBuildOutput(prepared.directory);
899+
expect(built.workers.default.config.manifest?.mainModule).toBe('index.js');
900+
const assets = getWorkerAssetsDir(prepared.directory);
901+
expect(readFileSync(join(assets, 'index.html'), 'utf8')).toContain('public');
902+
expect(existsSync(join(assets, '_worker.js'))).toBe(false);
903+
},
904+
);
905+
},
906+
CF_BUILD_TIMEOUT_MS,
907+
);
908+
909+
it('reads a framework config as JSONC and still rejects one that is not an object', async () => {
910+
const output = worker({});
911+
const configPath = join(output, 'wrangler.jsonc');
912+
const prepare = () =>
913+
prepareCfFrameworkBuildOutput({
914+
projectRoot: dirname(output),
915+
configPath,
916+
accountId: 'account-id',
917+
});
918+
// A hand-written root config may have a BOM and trailing commas. It has no Worker entry here.
919+
writeFileSync(configPath, `\uFEFF${PUBLISHED_TRAFFIC_COMMENT}\n{ "name": "void-test", }`);
920+
await expect(prepare()).resolves.toBeNull();
921+
922+
for (const text of [
923+
`${PUBLISHED_TRAFFIC_COMMENT}\n{ "main": `,
924+
`${PUBLISHED_TRAFFIC_COMMENT}\n[]`,
925+
]) {
926+
writeFileSync(configPath, text);
927+
await expect(prepare()).rejects.toThrow('Invalid Wrangler JSON/JSONC config.');
928+
}
929+
});
930+
831931
it.skipIf(process.platform === 'win32')(
832932
'builds a named Sandbox image into cf prebuilt output and cleans up its local tag',
833933
async () => {

0 commit comments

Comments
 (0)