|
8 | 8 | * - src/ path aliases |
9 | 9 | */ |
10 | 10 |
|
11 | | -import { readFileSync, readdirSync, writeFileSync } from 'fs' |
12 | | -import { join } from 'path' |
| 11 | +import { readFileSync } from 'fs' |
13 | 12 | import { noTelemetryPlugin } from './no-telemetry-plugin' |
14 | 13 | import { CLI_EXTERNALS, SDK_EXTERNALS } from './externals.js' |
15 | 14 |
|
@@ -69,53 +68,42 @@ const featureFlags: Record<string, boolean> = { |
69 | 68 | // so the previous onResolve/onLoad shim was silently ineffective — ALL |
70 | 69 | // feature() calls evaluated to false regardless of the featureFlags map. |
71 | 70 | // |
72 | | -// Fix: pre-process source files to strip the bun:bundle import and |
73 | | -// replace feature('FLAG') calls with their boolean literal. Files are |
74 | | -// modified in-place before Bun.build() and restored in a finally block. |
| 71 | +// Fix: transform source as Bun loads each module, stripping the bun:bundle |
| 72 | +// import and replacing feature('FLAG') calls with their boolean literal. |
| 73 | +// The working tree stays immutable while smoke/build runs. |
75 | 74 |
|
76 | 75 | // Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n) |
77 | 76 | const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs |
78 | 77 | const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g |
79 | | -const modifiedFiles = new Map<string, string>() // path → original content |
80 | | - |
81 | | -function preProcessFeatureFlags(dir: string) { |
82 | | - for (const ent of readdirSync(dir, { withFileTypes: true })) { |
83 | | - const full = join(dir, ent.name) |
84 | | - if (ent.isDirectory()) { preProcessFeatureFlags(full); continue } |
85 | | - if (!/\.(ts|tsx)$/.test(ent.name)) continue |
86 | | - |
87 | | - const raw = readFileSync(full, 'utf-8') |
88 | | - if (!raw.includes('feature(')) continue |
89 | | - |
90 | | - let contents = raw |
91 | | - contents = contents.replace(featureImportRe, '') |
92 | | - contents = contents.replace(featureCallRe, (_match, name) => |
93 | | - String((featureFlags as Record<string, boolean>)[name] ?? false), |
94 | | - ) |
95 | | - |
96 | | - if (contents !== raw) { |
97 | | - modifiedFiles.set(full, raw) |
98 | | - writeFileSync(full, contents) |
99 | | - } |
100 | | - } |
101 | | -} |
102 | | - |
103 | | -function restoreModifiedFiles() { |
104 | | - for (const [path, original] of modifiedFiles) { |
105 | | - writeFileSync(path, original) |
106 | | - } |
107 | | - modifiedFiles.clear() |
108 | | -} |
109 | | - |
110 | | -preProcessFeatureFlags(join(import.meta.dir, '..', 'src')) |
111 | | -const numModified = modifiedFiles.size |
112 | | - |
113 | | -// Restore source files on abrupt termination (Ctrl+C, kill, etc.) |
114 | | -for (const signal of ['SIGINT', 'SIGTERM'] as const) { |
115 | | - process.on(signal, () => { |
116 | | - restoreModifiedFiles() |
117 | | - process.exit(signal === 'SIGINT' ? 130 : 143) |
118 | | - }) |
| 78 | +const featureFlagTransformedFiles = new Set<string>() |
| 79 | + |
| 80 | +const featureFlagPreprocessPlugin = { |
| 81 | + name: 'feature-flag-preprocess', |
| 82 | + setup(build) { |
| 83 | + build.onLoad({ filter: /\.[cm]?tsx?$/ }, args => { |
| 84 | + const normalizedPath = args.path.replace(/\\/g, '/') |
| 85 | + if (!normalizedPath.includes('/src/')) return null |
| 86 | + |
| 87 | + const raw = readFileSync(args.path, 'utf-8') |
| 88 | + if (!raw.includes('feature(')) return null |
| 89 | + |
| 90 | + let contents = raw |
| 91 | + contents = contents.replace(featureImportRe, '') |
| 92 | + contents = contents.replace(featureCallRe, (_match, name) => |
| 93 | + String((featureFlags as Record<string, boolean>)[name] ?? false), |
| 94 | + ) |
| 95 | + |
| 96 | + if (contents === raw) return null |
| 97 | + |
| 98 | + featureFlagTransformedFiles.add(args.path) |
| 99 | + return { |
| 100 | + contents, |
| 101 | + loader: args.path.endsWith('.tsx') || args.path.endsWith('.jsx') |
| 102 | + ? 'tsx' |
| 103 | + : 'ts', |
| 104 | + } |
| 105 | + }) |
| 106 | + }, |
119 | 107 | } |
120 | 108 |
|
121 | 109 | let result: Awaited<ReturnType<typeof Bun.build>> | undefined |
@@ -149,6 +137,7 @@ result = await Bun.build({ |
149 | 137 | }, |
150 | 138 | plugins: [ |
151 | 139 | noTelemetryPlugin, |
| 140 | + featureFlagPreprocessPlugin, |
152 | 141 | { |
153 | 142 | name: 'bun-bundle-shim', |
154 | 143 | setup(build) { |
@@ -185,8 +174,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are |
185 | 174 | ], |
186 | 175 | ] as const) |
187 | 176 |
|
188 | | - // bun:bundle feature() replacement is handled by the source |
189 | | - // pre-processing step above (see preProcessFeatureFlags). |
| 177 | + // bun:bundle feature() replacement is handled by featureFlagPreprocessPlugin. |
190 | 178 | // The previous onResolve/onLoad shim was ineffective in Bun |
191 | 179 | // v1.3.9+ because the bun: namespace is resolved natively |
192 | 180 | // before the JS plugin phase runs. |
@@ -456,6 +444,7 @@ sdkResult = await Bun.build({ |
456 | 444 | external: SDK_EXTERNALS, |
457 | 445 | plugins: [ |
458 | 446 | noTelemetryPlugin, |
| 447 | + featureFlagPreprocessPlugin, |
459 | 448 | // Stub missing internal/optional modules (same pattern as CLI build) |
460 | 449 | { |
461 | 450 | name: 'sdk-missing-stub', |
@@ -860,9 +849,7 @@ if (!sdkResult.success) { |
860 | 849 | } |
861 | 850 |
|
862 | 851 | } finally { |
863 | | - // Always restore source files, even if Bun.build() throws |
864 | | - restoreModifiedFiles() |
865 | | - console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`) |
| 852 | + console.log(` 🔄 feature-flags: transformed ${featureFlagTransformedFiles.size} files during bundling`) |
866 | 853 | } |
867 | 854 |
|
868 | 855 | // ── Validate SDK bundle for React/Ink leakage ────────────────────────────── |
|
0 commit comments