Skip to content

Commit 2329258

Browse files
authored
fix(compile): bundle workers separately under --bundle (#34531)
Before this change, `new Worker(new URL("./w.ts", import.meta.url), { type: "module" })` in source compiled with \`--bundle\` failed at runtime: the bundle preserves the URL string but the original worker source isn't in the VFS, so the worker thread can't load. This scans the main bundle output for the \`new Worker(new URL(..., import.meta.url))\` shape, bundles each unique target as its own entrypoint (the existing path-rewriter and CJS-shim detection both run on the worker bundles too), writes each next to the main bundle as \`.deno_compile_worker_*.mjs\`, and rewrites the URL literals in the main bundle to point at those files. The worker bundles are pushed onto \`--include\` so they ship in the VFS; the \`needs_npm_embed\` signal ORs the workers' results so a worker with CJS deps still pulls the npm tree along. Tested with a main + 1 worker exchanging a single message; spec test lives at \`tests/specs/compile/bundle/worker/\`. Known limitation: only the exact \`new Worker(new URL("X", import.meta.url), ...)\` shape is detected. Computed URLs, workers spawned by transitive dependencies, and minified bundles (where the pattern is mangled) will still fail with a path-not-found at runtime.
1 parent 215d1fc commit 2329258

6 files changed

Lines changed: 199 additions & 47 deletions

File tree

cli/tools/compile.rs

Lines changed: 162 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -179,25 +179,26 @@ pub async fn compile(
179179
"{} deno compile --bundle is experimental and may change.",
180180
colors::yellow("Warning")
181181
);
182-
// Write the bundle next to the working directory rather than the system
183-
// temp dir so the embedded VFS path stays relative to the project and
184-
// doesn't bake the build machine's temp path into the binary.
185-
let initial_cwd = flags.initial_cwd.clone().unwrap_or_else(|| {
186-
crate::util::env::resolve_cwd(None).unwrap().to_path_buf()
187-
});
188-
let bundle_path = initial_cwd.join(format!(
189-
".deno_compile_bundle_{:08x}.mjs",
190-
rand::thread_rng().r#gen::<u32>()
191-
));
192-
// Register for cleanup before writing so a partially written file is
193-
// removed even if bundling fails.
194-
let guard = CleanupGuard(vec![bundle_path.clone()]);
195-
let needs_npm_embed =
196-
run_bundle_for_compile(&flags, &compile_flags, &bundle_path).await?;
182+
let BundleForCompileResult {
183+
path: bundle_path,
184+
needs_npm_embed,
185+
extra_cleanup,
186+
} = run_bundle_for_compile(&flags, &compile_flags)
187+
.boxed_local()
188+
.await?;
197189
flags.internal.compile_bundle_embed_node_modules = needs_npm_embed;
198190
compile_flags.source_file = bundle_path.to_string_lossy().into_owned();
191+
// Make sure any worker bundles travel along in the VFS so the runtime
192+
// `new Worker(new URL(..., import.meta.url))` lookup hits them.
193+
for worker_path in &extra_cleanup {
194+
compile_flags
195+
.include
196+
.push(worker_path.display().to_string());
197+
}
199198
flags.subcommand = DenoSubcommand::Compile(compile_flags.clone());
200-
Some(guard)
199+
let mut cleanup = vec![bundle_path];
200+
cleanup.extend(extra_cleanup);
201+
Some(CleanupGuard(cleanup))
201202
} else {
202203
None
203204
};
@@ -211,49 +212,163 @@ pub async fn compile(
211212
}
212213
}
213214

214-
/// Bundles the entrypoint and writes the result to `bundle_path`. Returns
215-
/// `true` when the bundle needs the npm tree embedded in the binary, i.e.
216-
/// esbuild's CJS-from-ESM wrapper appears in the output and runtime
217-
/// require()s against npm package paths will happen.
215+
struct BundleForCompileResult {
216+
path: PathBuf,
217+
/// True when esbuild's CJS-from-ESM wrapper appears in the bundle (in the
218+
/// main entry or any worker), which means runtime require()s against npm
219+
/// package paths will happen. The standalone binary writer reads this to
220+
/// decide whether to embed the npm tree.
221+
needs_npm_embed: bool,
222+
/// Worker bundle files produced alongside the main one; they live next to
223+
/// the main bundle and must be cleaned up too.
224+
extra_cleanup: Vec<PathBuf>,
225+
}
226+
218227
async fn run_bundle_for_compile(
219228
flags: &Flags,
220229
compile_flags: &CompileFlags,
221-
bundle_path: &Path,
222-
) -> Result<bool, AnyError> {
230+
) -> Result<BundleForCompileResult, AnyError> {
223231
let bundle_flags = Arc::new(flags.clone());
232+
let initial_cwd = flags.initial_cwd.clone().unwrap_or_else(|| {
233+
crate::util::env::resolve_cwd(None).unwrap().to_path_buf()
234+
});
224235

236+
let main_bytes = bundle_one_for_compile(
237+
bundle_flags.clone(),
238+
compile_flags.source_file.clone(),
239+
)
240+
.await?;
241+
let main_rewrite = rewrite_absolute_bundle_paths(&main_bytes, &initial_cwd)?;
242+
let mut needs_npm_embed = main_rewrite.rewrote_paths;
243+
244+
// Scan the main bundle for `new Worker(new URL("X", import.meta.url))`
245+
// patterns. For each unique X, bundle the target as a separate entry,
246+
// write it next to the main bundle, and rewrite the URL string in the
247+
// main bundle to point at the worker bundle's file name. The worker
248+
// bundles are then reachable through `new URL(..., import.meta.url)`
249+
// at runtime, just like the main bundle.
250+
let main_src = std::str::from_utf8(&main_rewrite.bytes)
251+
.context("Bundle output is not valid UTF-8")?;
252+
let worker_urls = discover_worker_urls(main_src);
253+
254+
let mut url_replacements: Vec<(String, String)> = Vec::new();
255+
let mut extra_cleanup: Vec<PathBuf> = Vec::new();
256+
for worker_url in &worker_urls {
257+
let worker_abs = if Path::new(worker_url).is_absolute() {
258+
PathBuf::from(worker_url)
259+
} else {
260+
initial_cwd.join(worker_url)
261+
};
262+
if !worker_abs.exists() {
263+
log::warn!(
264+
"{} Worker entrypoint '{}' was not found on disk; leaving it as-is. The compiled binary will fail to start this worker unless the file is provided via --include.",
265+
colors::yellow("Warning"),
266+
worker_url,
267+
);
268+
continue;
269+
}
270+
let worker_bytes = bundle_one_for_compile(
271+
bundle_flags.clone(),
272+
worker_abs.display().to_string(),
273+
)
274+
.await?;
275+
let worker_rewrite =
276+
rewrite_absolute_bundle_paths(&worker_bytes, &initial_cwd)?;
277+
needs_npm_embed |= worker_rewrite.rewrote_paths;
278+
279+
let worker_path = initial_cwd.join(format!(
280+
".deno_compile_worker_{:08x}.mjs",
281+
rand::thread_rng().r#gen::<u32>()
282+
));
283+
std::fs::write(&worker_path, &worker_rewrite.bytes).with_context(|| {
284+
format!(
285+
"Writing bundled worker entrypoint to '{}'",
286+
worker_path.display()
287+
)
288+
})?;
289+
let worker_file_name = worker_path
290+
.file_name()
291+
.unwrap()
292+
.to_string_lossy()
293+
.into_owned();
294+
url_replacements
295+
.push((worker_url.clone(), format!("./{worker_file_name}")));
296+
extra_cleanup.push(worker_path);
297+
}
298+
299+
// Apply URL replacements to the main bundle source.
300+
let final_main_src = if url_replacements.is_empty() {
301+
main_src.to_string()
302+
} else {
303+
rewrite_worker_urls(main_src, &url_replacements)
304+
};
305+
306+
let bundle_path = initial_cwd.join(format!(
307+
".deno_compile_bundle_{:08x}.mjs",
308+
rand::thread_rng().r#gen::<u32>()
309+
));
310+
std::fs::write(&bundle_path, final_main_src.as_bytes()).with_context(
311+
|| format!("Writing bundled entrypoint to '{}'", bundle_path.display()),
312+
)?;
313+
314+
Ok(BundleForCompileResult {
315+
path: bundle_path,
316+
needs_npm_embed,
317+
extra_cleanup,
318+
})
319+
}
320+
321+
async fn bundle_one_for_compile(
322+
flags: Arc<Flags>,
323+
entrypoint: String,
324+
) -> Result<Vec<u8>, AnyError> {
225325
// Always leave `.node` files external. esbuild has no loader for them
226326
// and would error if it tried to inline a native binary; with this
227327
// pattern the require() calls are emitted verbatim and resolved at
228328
// runtime against the embedded VFS by the native addon loader.
229329
let external = vec!["*.node".to_string()];
330+
super::bundle::bundle_for_compile(flags, entrypoint, external)
331+
.boxed_local()
332+
.await
333+
}
230334

231-
let bytes = super::bundle::bundle_for_compile(
232-
bundle_flags,
233-
compile_flags.source_file.clone(),
234-
external,
235-
)
236-
.boxed_local()
237-
.await?;
335+
/// Pull the relative path argument out of every
336+
/// `new Worker(new URL("X", import.meta.url), …)` in the bundle source.
337+
/// Returns unique paths in source order.
338+
fn discover_worker_urls(bundle_src: &str) -> Vec<String> {
339+
let pattern = lazy_regex::regex!(
340+
r#"new\s+Worker\s*\(\s*new\s+URL\s*\(\s*"([^"]+)"\s*,\s*import\.meta\.url"#
341+
);
342+
let mut seen = std::collections::HashSet::new();
343+
let mut out = Vec::new();
344+
for caps in pattern.captures_iter(bundle_src) {
345+
let url = caps.get(1).unwrap().as_str().to_string();
346+
if seen.insert(url.clone()) {
347+
out.push(url);
348+
}
349+
}
350+
out
351+
}
238352

239-
// The bundle is written next to the working directory, so rewrite paths
240-
// relative to that directory.
241-
let bundle_dir = bundle_path.parent().unwrap_or_else(|| Path::new("."));
242-
243-
// esbuild's CJS-from-ESM wrapper hardcodes the absolute on-disk path of
244-
// each CJS module it imports. At runtime the embedded VFS is mounted
245-
// somewhere else (a temp dir under the user's $TMPDIR), so those paths
246-
// miss. Rewrite each such literal to a runtime-relative resolution
247-
// against `import.meta.url` so it survives the relocation.
248-
let RewriteResult {
249-
bytes: rewritten,
250-
rewrote_paths,
251-
} = rewrite_absolute_bundle_paths(&bytes, bundle_dir)?;
252-
253-
std::fs::write(bundle_path, &rewritten).with_context(|| {
254-
format!("Writing bundled entrypoint to '{}'", bundle_path.display())
255-
})?;
256-
Ok(rewrote_paths)
353+
fn rewrite_worker_urls(
354+
bundle_src: &str,
355+
replacements: &[(String, String)],
356+
) -> String {
357+
let pattern = lazy_regex::regex!(
358+
r#"(new\s+Worker\s*\(\s*new\s+URL\s*\(\s*")([^"]+)("\s*,\s*import\.meta\.url)"#
359+
);
360+
pattern
361+
.replace_all(bundle_src, |caps: &regex::Captures<'_>| {
362+
let original = caps.get(2).unwrap().as_str();
363+
if let Some((_, replacement)) =
364+
replacements.iter().find(|(orig, _)| orig == original)
365+
{
366+
format!("{}{}{}", &caps[1], replacement, &caps[3])
367+
} else {
368+
caps[0].to_string()
369+
}
370+
})
371+
.into_owned()
257372
}
258373

259374
struct RewriteResult {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"tempDir": true,
3+
"steps": [{
4+
"if": "unix",
5+
"args": "compile --bundle --no-check --output app main.ts",
6+
"output": "compile.out"
7+
}, {
8+
"if": "unix",
9+
"commandName": "./app",
10+
"args": [],
11+
"output": "run.out",
12+
"exitCode": 0
13+
}, {
14+
"if": "windows",
15+
"args": "compile --bundle --no-check --output app.exe main.ts",
16+
"output": "compile.out"
17+
}, {
18+
"if": "windows",
19+
"commandName": "./app.exe",
20+
"args": [],
21+
"output": "run.out",
22+
"exitCode": 0
23+
}]
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[WILDCARD]deno compile --bundle is experimental[WILDCARD]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const w = new Worker(new URL("./worker.ts", import.meta.url), {
2+
type: "module",
3+
});
4+
w.onmessage = (e) => {
5+
console.log("main got:", e.data);
6+
w.terminate();
7+
};
8+
w.postMessage("ping");
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
main got: pong-ping
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
self.onmessage = (e) => {
2+
(self as unknown as Worker).postMessage("pong-" + e.data);
3+
};

0 commit comments

Comments
 (0)