Skip to content

Commit e17f70f

Browse files
Brooooooklynclaude
andcommitted
fix(cli): read Wrangler configs that start with a UTF-8 BOM
Bug: a root wrangler.jsonc/wrangler.json saved with a UTF-8 BOM (common for files written by Windows editors) is valid for Wrangler, but Void treats it as unreadable. `vite dev` / `vite build` stop with "db: cannot read malformed Wrangler configuration". `void deploy --platform cloudflare` on an app with a durable-objects/ file treats the file as missing and overwrites it with only `durable_objects` and `migrations`, so `name`, `main`, `routes` and every other key are lost. For typed projects the truncated config is then saved as the lock's resolved state. The managed deploy live-binding sync reaches the same reader. Plain `void deploy` (no --platform, no saved destination), which the deployment guide names as the way to link an existing Worker, stops with "Set the existing Worker's name in a valid wrangler.jsonc before linking to Cloudflare." even though the name is set. Repro: write wrangler.jsonc as U+FEFF + `{ // app, "name": "existing", "main": ..., "routes": [...] }`, then call syncWranglerDurableObjects() with one Durable Object module (or readRootWranglerConfig()). The file ends up as `{ durable_objects, migrations }` (or the reader throws "Failed to parse JSONC"). With the same BOM file and no saved destination, maybeLinkCloudflareDeploy() throws instead of offering to link the Worker. Cause: two JSONC readers pass the text straight to jsonc-parser: the private parseJsonc() in packages/void/src/cli/wrangler.ts and the onboarding check in maybeLinkCloudflareDeploy() (packages/void/src/cli/cloudflare-link.ts). Node's utf-8 read keeps the BOM. jsonc-parser still returns the object, but also reports the BOM as a syntax error at offset 0, and both readers treat any error as a bad file. readOrCreateProjectWranglerConfig() swallows that error and returns an empty config with created: true, so its callers write their keys over the user's file. Void's other JSONC readers (generated-wrangler-config, migrate) and the comment-preserving writers in wrangler.ts already strip the BOM. Fix: strip one leading BOM before parsing in both readers. In wrangler.ts, parseJsonc() uses the file's existing UTF8_BOM constant, which fixes every reader in that file at once (readOrCreateProjectWranglerConfig, readRootWranglerConfig, the Durable Object, compat and WebSocket readers). In cloudflare-link.ts the onboarding read uses the same `.replace(/^\uFEFF/, '')` as generated-wrangler-config; its checks and error messages are unchanged, and it still never writes the file. No other behaviour changes. Test: - packages/void/test/unit/wrangler.test.ts, "keeps existing keys of a BOM-prefixed config when adding Durable Objects". Without the fix readRootWranglerConfig() throws "Failed to parse JSONC", and the sync replaces the file with only durable_objects and migrations. - packages/void/test/unit/cloudflare-link.test.ts, "offers to link a Worker whose config starts with a UTF-8 BOM". Without the fix maybeLinkCloudflareDeploy() throws "Set the existing Worker's name in a valid wrangler.jsonc"; with it, it returns 'existing' and the file bytes, BOM included, are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH
1 parent bd55219 commit e17f70f

4 files changed

Lines changed: 49 additions & 2 deletions

File tree

‎packages/void/src/cli/cloudflare-link.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ export async function maybeLinkCloudflareDeploy(
225225
}
226226
const errors: Array<ParseError> = [];
227227
const configPath = files.length ? join(root, name) : materializeCloudflareConfig(root);
228-
const config = parse(readFileSync(configPath, 'utf8'), errors, {
228+
const config = parse(readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''), errors, {
229229
allowTrailingComma: true,
230230
});
231231
if (!files.length && (!config || typeof config.name !== 'string' || !config.name.trim())) {

‎packages/void/src/cli/wrangler.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1771,7 +1771,8 @@ function detectFormattingOptions(text: string): FormattingOptions {
17711771

17721772
function parseJsonc(text: string): Record<string, unknown> {
17731773
const errors: Array<ParseError> = [];
1774-
const result = parse(text, errors, { allowTrailingComma: true });
1774+
const source = text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text;
1775+
const result = parse(source, errors, { allowTrailingComma: true });
17751776
if (errors.length > 0) {
17761777
throw new SyntaxError('Failed to parse JSONC');
17771778
}

‎packages/void/test/unit/cloudflare-link.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ describe('Cloudflare deployment onboarding', () => {
3535
expect(readDeploymentPlatform(root)).toBeNull();
3636
expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(config);
3737
});
38+
it('offers to link a Worker whose config starts with a UTF-8 BOM', async () => {
39+
const bomConfig = `\uFEFF${config}`;
40+
writeFileSync(join(root, 'wrangler.jsonc'), bomConfig);
41+
expect(
42+
await maybeLinkCloudflareDeploy(
43+
root,
44+
{},
45+
{ interactive: true, confirm: vi.fn().mockResolvedValue(true) },
46+
),
47+
).toBe('existing');
48+
expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(bomConfig);
49+
});
3850
it('offers to link a Worker configured through a custom Astro JSONC path', async () => {
3951
rmSync(join(root, 'wrangler.jsonc'));
4052
writeFileSync(join(root, 'wrangler.build.jsonc'), config);

‎packages/void/test/unit/wrangler.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
LATEST_KNOWN_COMPAT_DATE,
1414
readExistingLiveBindingConfig,
1515
readProvisionedBindingEntry,
16+
readRootWranglerConfig,
1617
readWranglerCompat,
1718
readWranglerManagedQueueCleanupCandidates,
1819
readWranglerWebSocketTombstones,
@@ -549,6 +550,39 @@ describe('syncWranglerBindings', () => {
549550
]);
550551
});
551552

553+
it('keeps existing keys of a BOM-prefixed config when adding Durable Objects', () => {
554+
setup(
555+
'wrangler.jsonc',
556+
'' +
557+
`{
558+
// app
559+
"name": "existing",
560+
"main": "src/index.ts",
561+
"routes": [{ "pattern": "example.com", "custom_domain": true }]
562+
}`,
563+
);
564+
expect(readRootWranglerConfig(tmpDir)?.config.name).toBe('existing');
565+
566+
const counter = {
567+
filePath: join(tmpDir, 'durable-objects', 'counter.ts'),
568+
bindingName: 'COUNTER',
569+
className: 'CounterDurableObject',
570+
migrationTag: 'void-durable-counter-v1',
571+
};
572+
expect(syncWranglerDurableObjects(tmpDir, [counter])).toBe(true);
573+
const parsed = JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf-8'));
574+
575+
expect(parsed).toMatchObject({
576+
name: 'existing',
577+
main: 'src/index.ts',
578+
routes: [{ pattern: 'example.com', custom_domain: true }],
579+
durable_objects: { bindings: [{ name: 'COUNTER', class_name: 'CounterDurableObject' }] },
580+
migrations: [
581+
{ tag: 'void-durable-counter-v1', new_sqlite_classes: ['CounterDurableObject'] },
582+
],
583+
});
584+
});
585+
552586
it('adds live durable object and SQLite migration metadata', () => {
553587
setup('wrangler.jsonc', '{ "name": "test" }');
554588
const result = syncWranglerBindings(tmpDir, { ...noBindings, needsLive: true });

0 commit comments

Comments
 (0)