Skip to content

Commit e5009c9

Browse files
committed
fix: replace broken bun:bundle shim with source pre-processing
The `onResolve`/`onLoad` plugin shim for `bun:bundle` was silently ineffective in Bun v1.3.9+ — the `bun:` namespace is resolved by Bun's native C++ resolver before the JS plugin phase runs. This meant ALL `feature()` flags evaluated to `false` regardless of the `featureFlags` map in build.ts (including `MONITOR_TOOL: true`). Replace the shim with a source pre-processing step that: 1. Strips `import { feature } from 'bun:bundle'` from .ts/.tsx files 2. Replaces `feature('FLAG')` calls with boolean literals 3. Restores original files in a `finally` block after Bun.build() Also extend the missing-module scanner to detect `require()` and dynamic `import()` calls — not just static `import ... from` — since modules behind feature() gates become resolvable when flags are enabled.
1 parent f6a4455 commit e5009c9

1 file changed

Lines changed: 89 additions & 25 deletions

File tree

scripts/build.ts

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* - src/ path aliases
99
*/
1010

11-
import { readFileSync } from 'fs'
11+
import { readFileSync, readdirSync, writeFileSync } from 'fs'
12+
import { join } from 'path'
1213
import { noTelemetryPlugin } from './no-telemetry-plugin'
1314

1415
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -43,6 +44,56 @@ const featureFlags: Record<string, boolean> = {
4344
COWORKER_TYPE_TELEMETRY: false,
4445
}
4546

47+
// ── Pre-process: replace feature() calls with boolean literals ──────
48+
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
49+
// before plugins can intercept it via onResolve. The bun: namespace is
50+
// handled by Bun's C++ resolver which runs before the JS plugin phase,
51+
// so the previous onResolve/onLoad shim was silently ineffective — ALL
52+
// feature() calls evaluated to false regardless of the featureFlags map.
53+
//
54+
// Fix: pre-process source files to strip the bun:bundle import and
55+
// replace feature('FLAG') calls with their boolean literal. Files are
56+
// modified in-place before Bun.build() and restored in a finally block.
57+
58+
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
59+
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
60+
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
61+
const modifiedFiles = new Map<string, string>() // path → original content
62+
63+
function preProcessFeatureFlags(dir: string) {
64+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
65+
const full = join(dir, ent.name)
66+
if (ent.isDirectory()) { preProcessFeatureFlags(full); continue }
67+
if (!/\.(ts|tsx)$/.test(ent.name)) continue
68+
69+
const raw = readFileSync(full, 'utf-8')
70+
if (!raw.includes('feature(')) continue
71+
72+
let contents = raw
73+
contents = contents.replace(featureImportRe, '')
74+
contents = contents.replace(featureCallRe, (_match, name) =>
75+
String((featureFlags as Record<string, boolean>)[name] ?? false),
76+
)
77+
78+
if (contents !== raw) {
79+
modifiedFiles.set(full, raw)
80+
writeFileSync(full, contents)
81+
}
82+
}
83+
}
84+
85+
function restoreModifiedFiles() {
86+
for (const [path, original] of modifiedFiles) {
87+
writeFileSync(path, original)
88+
}
89+
modifiedFiles.clear()
90+
}
91+
92+
preProcessFeatureFlags(join(import.meta.dir, '..', 'src'))
93+
const numModified = modifiedFiles.size
94+
95+
try {
96+
4697
const result = await Bun.build({
4798
entrypoints: ['./src/entrypoints/cli.tsx'],
4899
outdir: './dist',
@@ -103,18 +154,11 @@ export async function handleBgFlag() { throw new Error("Background sessions are
103154
],
104155
] as const)
105156

106-
// Resolve `import { feature } from 'bun:bundle'` to a shim
107-
build.onResolve({ filter: /^bun:bundle$/ }, () => ({
108-
path: 'bun:bundle',
109-
namespace: 'bun-bundle-shim',
110-
}))
111-
build.onLoad(
112-
{ filter: /.*/, namespace: 'bun-bundle-shim' },
113-
() => ({
114-
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
115-
loader: 'js',
116-
}),
117-
)
157+
// bun:bundle feature() replacement is handled by the source
158+
// pre-processing step above (see preProcessFeatureFlags).
159+
// The previous onResolve/onLoad shim was ineffective in Bun
160+
// v1.3.9+ because the bun: namespace is resolved natively
161+
// before the JS plugin phase runs.
118162

119163
build.onResolve(
120164
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
@@ -274,16 +318,7 @@ export const SeverityNumber = {};
274318

275319
// Scan source to find imports that can't resolve
276320
function scanForMissingImports() {
277-
function walk(dir: string) {
278-
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
279-
const full = pathMod.join(dir, ent.name)
280-
if (ent.isDirectory()) { walk(full); continue }
281-
if (!/\.(ts|tsx)$/.test(ent.name)) continue
282-
const code: string = fs.readFileSync(full, 'utf-8')
283-
// Collect all imports
284-
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
285-
const specifier = m[4]
286-
const namedPart = m[1] || m[3] || ''
321+
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
287322
const names = namedPart.split(',')
288323
.map((s: string) => s.trim().replace(/^type\s+/, ''))
289324
.filter((s: string) => s && !s.startsWith('type '))
@@ -303,8 +338,7 @@ export const SeverityNumber = {};
303338
}
304339
// Check relative .js imports
305340
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
306-
const dir2 = pathMod.dirname(full)
307-
const resolved = pathMod.resolve(dir2, specifier)
341+
const resolved = pathMod.resolve(fileDir, specifier)
308342
const tsVariant = resolved.replace(/\.js$/, '.ts')
309343
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
310344
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
@@ -317,6 +351,30 @@ export const SeverityNumber = {};
317351
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
318352
for (const n of names) missingModuleExports.get(specifier)!.add(n)
319353
}
354+
}
355+
356+
function walk(dir: string) {
357+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
358+
const full = pathMod.join(dir, ent.name)
359+
if (ent.isDirectory()) { walk(full); continue }
360+
if (!/\.(ts|tsx)$/.test(ent.name)) continue
361+
const code: string = fs.readFileSync(full, 'utf-8')
362+
const fileDir = pathMod.dirname(full)
363+
364+
// Collect static imports: import { X } from '...'
365+
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
366+
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
367+
}
368+
369+
// Collect dynamic requires: require('...') — these are used
370+
// behind feature() gates and become live when flags are enabled.
371+
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
372+
checkAndRegister(m[1], fileDir, '')
373+
}
374+
375+
// Collect dynamic imports: import('...')
376+
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
377+
checkAndRegister(m[1], fileDir, '')
320378
}
321379
}
322380
}
@@ -393,3 +451,9 @@ if (!result.success) {
393451
}
394452

395453
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
454+
455+
} finally {
456+
// Always restore source files, even if Bun.build() throws
457+
restoreModifiedFiles()
458+
console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`)
459+
}

0 commit comments

Comments
 (0)