Skip to content

Commit 270670d

Browse files
brainkimclaude
andcommitted
fix: prevent platform export from leaking when user overrides module
When a user overrides a directory/cache module in shovel.json but omits export, the platform default's named export was leaking through the shallow merge, producing broken imports like `import { CloudflareAssetsDirectory } from "@b9g/filesystem/node-fs"`. Also adds a platform export compatibility test that verifies platform packages only import symbols that @b9g/platform/runtime actually exports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb88050 commit 270670d

3 files changed

Lines changed: 171 additions & 0 deletions

File tree

src/utils/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,9 @@ export function generateConfigModule(
13941394
const platformConfig = platformCaches[name] || {};
13951395
const userConfig = userCaches[name] || {};
13961396
const mergedConfig = {...platformConfig, ...userConfig};
1397+
if (userConfig.module && !userConfig.export) {
1398+
delete mergedConfig.export;
1399+
}
13971400
caches[name] = reifyModule(mergedConfig, "cache", name);
13981401
}
13991402
config.caches = caches;
@@ -1415,6 +1418,9 @@ export function generateConfigModule(
14151418
const platformConfig = platformDirectories[name] || {};
14161419
const userConfig = userDirectories[name] || {};
14171420
const mergedConfig = {...platformConfig, ...userConfig};
1421+
if (userConfig.module && !userConfig.export) {
1422+
delete mergedConfig.export;
1423+
}
14181424
directories[name] = reifyModule(mergedConfig, "directory", name);
14191425
}
14201426
config.directories = directories;

test/config-module.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,61 @@ describe("generateConfigModule", () => {
418418
expect(module).toContain('from "@b9g/filesystem/memory"');
419419
expect(module).not.toContain('from "@b9g/filesystem/node-fs"');
420420
});
421+
422+
it("clears platform export when user overrides directory module without export", () => {
423+
// Bug: user overrides module but not export → platform's export leaks through
424+
const module = generateConfigModule(
425+
{
426+
directories: {
427+
public: {
428+
module: "@b9g/filesystem/node-fs",
429+
path: "./public",
430+
},
431+
},
432+
},
433+
{
434+
platformDefaults: {
435+
directories: {
436+
public: {
437+
module: "@b9g/platform-cloudflare/directories",
438+
export: "CloudflareAssetsDirectory",
439+
},
440+
},
441+
},
442+
},
443+
);
444+
445+
// Should use user's module with default import, NOT platform's named export
446+
expect(module).toContain('from "@b9g/filesystem/node-fs"');
447+
expect(module).not.toContain("CloudflareAssetsDirectory");
448+
});
449+
450+
it("clears platform export when user overrides cache module without export", () => {
451+
// Same bug for caches: user overrides module but not export
452+
const module = generateConfigModule(
453+
{
454+
caches: {
455+
sessions: {
456+
module: "@b9g/cache/memory",
457+
},
458+
},
459+
},
460+
{
461+
platformDefaults: {
462+
caches: {
463+
sessions: {
464+
module: "@b9g/platform-cloudflare/caches",
465+
export: "CloudflareKVCache",
466+
},
467+
},
468+
},
469+
},
470+
);
471+
472+
// Should use user's module with default import, NOT platform's named export
473+
expect(module).toContain('from "@b9g/cache/memory"');
474+
expect(module).not.toContain("CloudflareKVCache");
475+
});
421476
});
422477

423478
describe("secrets handling", () => {

test/platform-exports.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Tests that platform packages only import symbols that @b9g/platform/runtime
3+
* actually exports. Catches publish regressions like the missing
4+
* setBroadcastChannelBackend in @b9g/platform@0.1.18.
5+
*/
6+
7+
import {describe, it, expect} from "bun:test";
8+
import {readFileSync, existsSync} from "fs";
9+
import {join} from "path";
10+
11+
const packagesDir = join(import.meta.dir, "..", "packages");
12+
13+
/** Extract named imports from `import { A, B } from "@b9g/platform/runtime"` */
14+
function extractImportsFrom(
15+
code: string,
16+
fromModule: string,
17+
): Set<string> {
18+
const imports = new Set<string>();
19+
// Match: import { A, B, C } from "module"
20+
// Handles multi-line imports
21+
const escaped = fromModule.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22+
const re = new RegExp(
23+
`import\\s*\\{([^}]+)\\}\\s*from\\s*["']${escaped}["']`,
24+
"g",
25+
);
26+
for (const match of code.matchAll(re)) {
27+
for (const name of match[1].split(",")) {
28+
const trimmed = name.trim();
29+
if (trimmed) {
30+
imports.add(trimmed);
31+
}
32+
}
33+
}
34+
return imports;
35+
}
36+
37+
/** Extract the final `export { ... }` block from a bundled ESM file */
38+
function extractExports(code: string): Set<string> {
39+
const exports = new Set<string>();
40+
// esbuild emits a single `export { ... }` block at the end
41+
const re = /export\s*\{([^}]+)\}/g;
42+
for (const match of code.matchAll(re)) {
43+
for (const name of match[1].split(",")) {
44+
// Handle `foo as bar` — the exported name is `bar`
45+
const parts = name.trim().split(/\s+as\s+/);
46+
const exported = (parts[1] || parts[0]).trim();
47+
if (exported) {
48+
exports.add(exported);
49+
}
50+
}
51+
}
52+
return exports;
53+
}
54+
55+
describe("platform export compatibility", () => {
56+
const baseRuntimePath = join(
57+
packagesDir,
58+
"platform",
59+
"dist",
60+
"src",
61+
"runtime.js",
62+
);
63+
64+
// Platform packages that have a runtime.js importing from @b9g/platform/runtime
65+
const platformPackages = ["platform-cloudflare"];
66+
67+
it("@b9g/platform/runtime dist exists", () => {
68+
expect(existsSync(baseRuntimePath)).toBe(true);
69+
});
70+
71+
for (const pkg of platformPackages) {
72+
const runtimePath = join(
73+
packagesDir,
74+
pkg,
75+
"dist",
76+
"src",
77+
"runtime.js",
78+
);
79+
80+
it(`@b9g/${pkg}/runtime dist exists`, () => {
81+
expect(existsSync(runtimePath)).toBe(true);
82+
});
83+
84+
it(`@b9g/${pkg}/runtime only imports symbols exported by @b9g/platform/runtime`, () => {
85+
const baseCode = readFileSync(baseRuntimePath, "utf-8");
86+
const pkgCode = readFileSync(runtimePath, "utf-8");
87+
88+
const baseExports = extractExports(baseCode);
89+
const pkgImports = extractImportsFrom(
90+
pkgCode,
91+
"@b9g/platform/runtime",
92+
);
93+
94+
expect(pkgImports.size).toBeGreaterThan(0);
95+
96+
const missing = new Set<string>();
97+
for (const name of pkgImports) {
98+
if (!baseExports.has(name)) {
99+
missing.add(name);
100+
}
101+
}
102+
103+
if (missing.size > 0) {
104+
throw new Error(
105+
`@b9g/${pkg}/runtime imports symbols not exported by @b9g/platform/runtime: ${[...missing].join(", ")}`,
106+
);
107+
}
108+
});
109+
}
110+
});

0 commit comments

Comments
 (0)