Skip to content
508 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-ae1b1f625cfdcab1b'1//! A Node.js HTTP server (Express, Koa, Hono, plain `node:http`…) run as a
2//! Worker rather than a container. Workers run Node's HTTP servers with
3//! `nodejs_compat`: `httpServerHandler` from `cloudflare:node` takes a
4//! `node:http` server and answers the Worker's requests with it
5//! (developers.cloudflare.com/workers/runtime-apis/nodejs/http/; from the
6//! compatibility date 2025-09-01, `nodejs_compat` turns on
7//! `enable_nodejs_http_server_modules` by itself). g1t writes a Worker
8//! entry that imports the app's own start file and hands the server it
9//! starts to `httpServerHandler`, or calls its default export's `fetch`
10//! when it has one (a Hono app), and wrangler bundles it as it bundles any
11//! Workers project.
12//!
13//! What cannot run as a Worker is found before anything is built where it
14//! can be (`plan`): a native addon, a server that needs Node's `upgrade`
15//! event or starts itself on timers, a child process, or files read from
16//! disk while it runs. Such a project, or one whose bundle or first start
17//! fails, is deployed as a container instead (deploy.rs).
18
19use std::path::{Path, PathBuf};
20
21use regex::Regex;
22use serde_json::Value;
23
24/// How the project runs as a Worker.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct NodeWorker {
27 /// For the build's log: `Express`, `Hono`, `Node.js`.
28 pub name: &'static str,
29 /// The file that starts it, relative to the project with `/`: what its
30 /// `start` script runs, else its `main`, else `server.js` and the like.
31 /// It may only exist once the project's `build` script has run.
32 pub entry: String,
33 /// The project's own `build` script, run before it is bundled.
34 pub build: Option<String>,
35 /// The folder it serves files from (`app.use(express.static("public"))`),
36 /// relative to the project, served as the Worker's static assets.
37 pub assets: Option<String>,
38}
39
40/// What `plan` found.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub(crate) enum Plan {
43 Worker(NodeWorker),
44 /// Why it cannot run as a Worker, for the build's log.
45 Blocked(String),
46}
47
48/// Server packages, by the name the log gives them.
49const SERVERS: [(&str, &str); 9] = [
50 ("express", "Express"),
51 ("koa", "Koa"),
52 ("hono", "Hono"),
53 ("@hono/node-server", "Hono"),
54 ("polka", "Polka"),
55 ("restify", "Restify"),
56 ("@hapi/hapi", "hapi"),
57 ("@feathersjs/feathers", "Feathers"),
58 ("h3", "h3"),
59];
60
61/// Packages that build native code, which a Worker cannot load.
62const NATIVE_ADDONS: [&str; 20] = [
63 "better-sqlite3",
64 "sqlite3",
65 "bcrypt",
66 "sharp",
67 "canvas",
68 "@napi-rs/canvas",
69 "argon2",
70 "node-sass",
71 "puppeteer",
72 "playwright",
73 "node-pty",
74 "ssh2",
75 "re2",
76 "leveldown",
77 "zeromq",
78 "@tensorflow/tfjs-node",
79 "sodium-native",
80 "isolated-vm",
81 "uWebSockets.js",
82 "libxmljs",
83];
84
85/// Packages that make a server g1t does not run as a Worker, and why.
86const BLOCKING_PACKAGES: [(&str, &str); 7] = [
87 ("fastify", "Fastify starts itself on timers, which a Worker does not run before its first request"),
88 ("@nestjs/core", "NestJS loads its modules while it starts, which a Worker's bundle cannot do"),
89 ("socket.io", "socket.io accepts WebSocket connections through Node's `upgrade` event, which Workers do not support"),
90 ("express-ws", "express-ws accepts WebSocket connections through Node's `upgrade` event, which Workers do not support"),
91 ("@remix-run/serve", "remix-serve serves its build from disk"),
92 ("@react-router/serve", "react-router-serve serves its build from disk"),
93 ("sails", "Sails reads the app from disk while it starts"),
94];
95
96/// What in the app's own code a Worker cannot do, and why, in the order
97/// they are looked for.
98const BLOCKING_CODE: [(&str, &str); 4] = [
99 (
100 r#"\bchild_process\b|["'](?:node:)?cluster["']|\bworker_threads\b"#,
101 "it starts other processes or threads (`child_process`, `cluster`, `worker_threads`), which a Worker cannot",
102 ),
103 (
104 r#"\bWebSocketServer\b|\bWebSocket\.Server\b|\.on\(\s*["']upgrade["']"#,
105 "it accepts WebSocket connections through Node's `upgrade` event, which Workers do not support",
106 ),
107 (
108 r#"\b(?:readFileSync|readFile|createReadStream|writeFileSync|writeFile|appendFile|createWriteStream|mkdirSync|sendFile|diskStorage)\s*\(|\.render\(\s*["'`]|["']view engine["']"#,
109 "it reads or writes files on disk while it runs, and a Worker has no disk with the project's files",
110 ),
111 (
112 r#"\bserveStatic\s*\(|\bexpress\.static\s*\(|["']koa-static["']|["']@fastify/static["']"#,
113 "it serves files from disk in a way g1t cannot serve them for it (only `app.use(express.static(\"<folder>\"))` can be)",
114 ),
115];
116
117/// Folders of a project that hold no code the server runs.
118const SKIPPED_DIRS: [&str; 15] = [
119 "node_modules",
120 ".git",
121 ".g1t",
122 ".wrangler",
123 "dist",
124 "build",
125 "out",
126 "coverage",
127 "test",
128 "tests",
129 "__tests__",
130 "public",
131 "static",
132 "scripts",
133 "docs",
134];
135
136/// Source files read at most, and the largest read.
137const MAX_SOURCES: usize = 2_000;
138const MAX_SOURCE_BYTES: u64 = 1024 * 1024;
139
140fn has_dependency(package: &Value, name: &str) -> bool {
141 ["dependencies", "devDependencies", "optionalDependencies"].iter().any(|field| package[field].get(name).is_some())
142}
143
144/// The name the log gives the server: its framework, else `Node.js`.
145fn server_name(package: &Value) -> &'static str {
146 SERVERS.iter().find(|(name, _)| has_dependency(package, name)).map_or("Node.js", |(_, shown)| shown)
147}
148
149/// The file a `start` script runs: the last command of it, when that is
150/// `node`, `tsx` or `ts-node` with a file. None for anything else
151/// (`nest start`, `npm run serve`, `pm2-runtime …`).
152pub(crate) fn start_file(script: &str) -> Option<String> {
153 let last = script.split("&&").last()?.split(';').next_back()?.trim();
154 let mut words = last.split_whitespace().peekable();
155 // `NODE_ENV=production node …`, `cross-env NODE_ENV=production node …`.
156 while let Some(word) = words.peek() {
157 if *word == "cross-env" || *word == "env" || (word.contains('=') && !word.starts_with('-')) {
158 words.next();
159 } else {
160 break;
161 }
162 }
163 let runner = words.next()?;
164 if !["node", "tsx", "ts-node", "ts-node-esm"].contains(&runner) {
165 return None;
166 }
167 // Flags that take the next word as their value.
168 const WITH_VALUE: [&str; 6] = ["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file"];
169 let mut file = None;
170 while let Some(word) = words.next() {
171 if WITH_VALUE.contains(&word) {
172 words.next();
173 } else if word.starts_with('-') {
174 continue;
175 } else {
176 file = Some(word);
177 break;
178 }
179 }
180 let file = file?.trim_matches(['"', '\'']).trim_start_matches("./");
181 (!file.is_empty() && !file.contains("..") && !file.starts_with('/')).then(|| file.to_owned())
182}
183
184/// `file` as Node finds it from the project: as it is, with an extension,
185/// or a folder's `index`. When nothing is there yet (a build writes it),
186/// the name as given.
187fn resolve(dir: &Path, file: &str) -> String {
188 let file = file.trim_end_matches('/');
189 if dir.join(file).is_file() {
190 return file.to_owned();
191 }
192 for extension in ["js", "mjs", "cjs", "ts", "mts"] {
193 let with = format!("{file}.{extension}");
194 if dir.join(&with).is_file() {
195 return with;
196 }
197 }
198 for index in ["index.js", "index.mjs", "index.cjs", "index.ts"] {
199 let inside = format!("{file}/{index}");
200 if dir.join(&inside).is_file() {
201 return inside;
202 }
203 }
204 file.to_owned()
205}
206
207/// The file that starts the app, relative to the project: what `start`
208/// runs, else `main`, else the usual names; why not, when it cannot be told.
209fn app_entry(dir: &Path, package: &Value) -> Result<String, String> {
210 let has_build = package["scripts"]["build"].is_string();
211 let found = |file: String| {
212 let file = resolve(dir, &file);
213 // A file the build writes is there once it has run.
214 if dir.join(&file).is_file() || has_build { Ok(file) } else { Err(format!("its start file `{file}` does not exist")) }
215 };
216 if let Some(start) = package["scripts"]["start"].as_str() {
217 return match start_file(start) {
218 Some(file) => found(file),
219 None => Err(format!("g1t cannot tell which file its `start` script (`{start}`) runs")),
220 };
221 }
222 if let Some(main) = package["main"].as_str().map(|m| m.trim_start_matches("./")).filter(|m| !m.is_empty()) {
223 return found(main.to_owned());
224 }
225 ["server.js", "index.js", "app.js", "server.mjs", "index.mjs", "src/server.js", "src/index.js", "server.ts", "src/index.ts", "src/server.ts"]
226 .iter()
227 .find(|file| dir.join(file).is_file())
228 .map(|file| (*file).to_owned())
229 .ok_or_else(|| "g1t cannot tell which file starts it: it has no `start` script or `main`".to_owned())
230}
231
232/// The app's own source files: JavaScript and TypeScript outside the
233/// folders that hold no code it runs, and not its tests or tools' configs.
234fn sources(dir: &Path) -> Vec<PathBuf> {
235 fn walk(dir: &Path, top: bool, out: &mut Vec<PathBuf>) {
236 let Ok(entries) = std::fs::read_dir(dir) else { return };
237 for entry in entries.flatten() {
238 if out.len() >= MAX_SOURCES {
239 return;
240 }
241 let name = entry.file_name().to_string_lossy().into_owned();
242 let path = entry.path();
243 let Ok(kind) = entry.file_type() else { continue };
244 if kind.is_dir() {
245 if !name.starts_with('.') && !SKIPPED_DIRS.contains(&name.as_str()) {
246 walk(&path, false, out);
247 }
248 continue;
249 }
250 let code = ["js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx"].iter().any(|e| name.ends_with(&format!(".{e}")));
251 let tooling = [".test.", ".spec.", ".config.", ".d.ts"].iter().any(|part| name.contains(part)) || (top && name.starts_with("eslint"));
252 if kind.is_file() && code && !tooling && entry.metadata().is_ok_and(|m| m.len() <= MAX_SOURCE_BYTES) {
253 out.push(path);
254 }
255 }
256 }
257 let mut out = Vec::new();
258 walk(dir, true, &mut out);
259 out.sort();
260 out
261}
262
263/// `app.use(express.static("public"))`, unmounted, with the folder as a
264/// literal (or `path.join(__dirname, "public")`): the folder, relative to
265/// the file it is in or the project.
266/// Each with what of `text` is left once it is taken out.
267fn static_folders(pattern: &Regex, text: &str) -> (Vec<(bool, String)>, String) {
268 let folders = pattern.captures_iter(text).map(|c| (c.get(1).is_some(), c[2].to_owned())).collect();
269 (folders, pattern.replace_all(text, ".use(").into_owned())
270}
271
272/// `app.use(express.static("<folder>"))`, as `static_folders` reads it.
273fn static_pattern() -> Regex {
274 Regex::new(
275 r#"\.use\(\s*(?:express\.static|serveStatic|serve)\(\s*(path\.(?:join|resolve)\(\s*__dirname\s*,\s*)?["'`]\.?/?([\w./-]*?)/?["'`]\s*[,)]"#,
276 )
277 .expect("a valid pattern")
278}
279
280/// What to make of the project at `dir`, a Node.js server (container.rs's
281/// `node_server` found it): how it runs as a Worker, or why it cannot.
282pub(crate) fn plan(dir: &Path) -> Plan {
283 let Some(package) = std::fs::read_to_string(dir.join("package.json")).ok().and_then(|text| serde_json::from_str::<Value>(&text).ok()) else {
284 return Plan::Blocked("its package.json could not be read".to_owned());
285 };
286 if let Some(addon) = NATIVE_ADDONS.iter().find(|name| has_dependency(&package, name)) {
287 return Plan::Blocked(format!("it depends on `{addon}`, which builds native code a Worker cannot load"));
288 }
289 if let Some((_, why)) = BLOCKING_PACKAGES.iter().find(|(name, _)| has_dependency(&package, name)) {
290 return Plan::Blocked((*why).to_owned());
291 }
292 let entry = match app_entry(dir, &package) {
293 Ok(entry) => entry,
294 Err(why) => return Plan::Blocked(why),
295 };
296 let blocking: Vec<(Regex, &str)> = BLOCKING_CODE.iter().map(|(pattern, why)| (Regex::new(pattern).expect("a valid pattern"), *why)).collect();
297 let served = static_pattern();
298 let mut assets: Option<String> = None;
299 for file in sources(dir) {
300 let Ok(text) = std::fs::read_to_string(&file) else { continue };
301 let relative = file.strip_prefix(dir).unwrap_or(&file).to_string_lossy().replace('\\', "/");
302 // Each folder served the one way g1t can serve, taken out before
303 // the rest is looked at.
304 let (folders, rest) = static_folders(&served, &text);
305 for (from_file, folder) in folders {
306 let base = if from_file { Path::new(&relative).parent().map(Path::to_path_buf).unwrap_or_default() } else { PathBuf::new() };
307 let folder = base.join(folder.trim_end_matches('/')).to_string_lossy().replace('\\', "/");
308 let folder = if folder.is_empty() { ".".to_owned() } else { folder };
309 if folder == "." || !dir.join(&folder).is_dir() {
310 return Plan::Blocked(format!("it serves the folder `{folder}` from disk, which g1t cannot serve for it"));
311 }
312 if assets.as_ref().is_some_and(|served| *served != folder) {
313 return Plan::Blocked("it serves more than one folder from disk".to_owned());
314 }
315 assets = Some(folder);
316 }
317 if let Some((pattern, why)) = blocking.iter().find(|(pattern, _)| pattern.is_match(&rest)) {
318 let found = pattern.find(&rest).map(|m| m.as_str().trim().to_owned()).unwrap_or_default();
319 return Plan::Blocked(format!("{why} (`{found}` in {relative})"));
320 }
321 }
322 Plan::Worker(NodeWorker {
323 name: server_name(&package),
324 entry,
325 build: package["scripts"]["build"].is_string().then(|| "npm run build".to_owned()),
326 assets,
327 })
328}
329
330/// The project as a Worker whatever `plan` found, for **Build as** set to
331/// Workers: none when the file that starts it cannot be told.
332pub(crate) fn forced(dir: &Path) -> Option<NodeWorker> {
333 let package: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("package.json")).ok()?).ok()?;
334 let entry = app_entry(dir, &package).ok()?;
335 Some(NodeWorker {
336 name: server_name(&package),
337 entry,
338 build: package["scripts"]["build"].is_string().then(|| "npm run build".to_owned()),
339 assets: None,
340 })
341}
342
343/// Runs before the app's own code: keeps the servers it starts, has
344/// `listen` take no address (on Workers, `listen` takes a port or nothing,
345/// and the port only names the server), sets `PORT` for apps that read it,
346/// and keeps the runtime's `Request` and `Response`, which some packages
347/// (`@hono/node-server`) replace with their own.
348pub(crate) const PRELUDE: &str = r#"// Written by g1t: runs before the app's own code.
349import http from "node:http";
350globalThis.__g1tServers = [];
351globalThis.__g1tGlobals = { Request: globalThis.Request, Response: globalThis.Response };
352process.env.PORT ??= "8080";
353const keep = (server) => {
354 if (!globalThis.__g1tServers.includes(server)) globalThis.__g1tServers.push(server);
355 return server;
356};
357const createServer = http.createServer;
358http.createServer = function (...args) {
359 return keep(createServer.apply(this, args));
360};
361const listen = http.Server.prototype.listen;
362http.Server.prototype.listen = function (...args) {
363 keep(this);
364 return listen.call(this, args.find((arg) => typeof arg === "function"));
365};
366"#;
367
368/// What the Worker's entry answers with when the app started no server.
369pub(crate) const NO_SERVER: &str = "The app started no HTTP server: it never called listen().";
370
371/// The Worker's entry: the prelude, then the app at `app` (an absolute
372/// path), served by its default export's `fetch` when it has one, else by
373/// `httpServerHandler` with the last server it started.
374pub(crate) fn entry_module(app: &Path) -> String {
375 let app = serde_json::to_string(&app.to_string_lossy().replace('\\', "/")).expect("a string");
376 let no_server = serde_json::to_string(NO_SERVER).expect("a string");
377 format!(
378 r#"// Written by g1t: runs this Node.js app as a Worker.
379import "./g1t-prelude.mjs";
380import {{ httpServerHandler }} from "cloudflare:node";
381import * as app from {app};
382
383const {{ Response: NativeResponse }} = globalThis.__g1tGlobals;
384const own = app.default && typeof app.default.fetch === "function" ? app.default : null;
385let handler;
386
387// A response made with a replaced Response class, as the runtime's own.
388async function native(answer) {{
389 const response = await answer;
390 if (Object.getPrototypeOf(response) === NativeResponse.prototype) return response;
391 return new NativeResponse(response.body, {{ status: response.status, statusText: response.statusText, headers: response.headers }});
392}}
393
394export default {{
395 fetch(request, env, ctx) {{
396 if (own) return native(own.fetch(request, env, ctx));
397 const server = globalThis.__g1tServers.at(-1);
398 if (!server) return new NativeResponse({no_server}, {{ status: 500 }});
399 handler ??= httpServerHandler(server);
400 return handler.fetch(request, env, ctx);
401 }},
402}};
403"#
404 )
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
412 let dir = std::env::temp_dir().join(format!("g1t-node-worker-{name}-{}", std::process::id()));
413 let _ = std::fs::remove_dir_all(&dir);
414 std::fs::create_dir_all(&dir).unwrap();
415 for (path, text) in files {
416 let path = dir.join(path);
417 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
418 std::fs::write(path, text).unwrap();
419 }
420 dir
421 }
422
423 #[test]
424 fn the_start_file_is_what_start_runs() {
425 assert_eq!(start_file("node server.js").as_deref(), Some("server.js"));
426 assert_eq!(start_file("NODE_ENV=production node --enable-source-maps ./dist/index.js").as_deref(), Some("dist/index.js"));
427 assert_eq!(start_file("cross-env NODE_ENV=production node -r dotenv/config src/app.js").as_deref(), Some("src/app.js"));
428 assert_eq!(start_file("npm run build && node build/server.mjs").as_deref(), Some("build/server.mjs"));
429 assert_eq!(start_file("tsx src/index.ts").as_deref(), Some("src/index.ts"));
430 assert_eq!(start_file("nest start"), None);
431 assert_eq!(start_file("pm2-runtime start ecosystem.config.js"), None);
432 assert_eq!(start_file("node ../x.js"), None);
433 }
434
435 #[test]
436 fn an_express_server_runs_as_a_worker() {
437 let dir = project(
438 "express",
439 &[
440 ("package.json", r#"{ "scripts": { "start": "node server" }, "dependencies": { "express": "5" } }"#),
441 ("server.js", "const app = require('express')();\napp.get('/', (req, res) => res.send('hi'));\napp.listen(process.env.PORT || 3000);\n"),
442 ],
443 );
444 assert_eq!(plan(&dir), Plan::Worker(NodeWorker { name: "Express", entry: "server.js".into(), build: None, assets: None }));
445 assert!(entry_module(&dir.join("server.js")).contains("httpServerHandler(server)"));
446 }
447
448 #[test]
449 fn a_folder_it_serves_becomes_the_workers_assets() {
450 let dir = project(
451 "assets",
452 &[
453 ("package.json", r#"{ "main": "src/app.js", "scripts": { "build": "tsc" }, "dependencies": { "express": "5" } }"#),
454 ("src/app.js", "app.use(express.static(path.join(__dirname, 'www')));\napp.listen(8080);\n"),
455 ("src/www/index.html", ""),
456 ],
457 );
458 let Plan::Worker(worker) = plan(&dir) else { panic!("{:?}", plan(&dir)) };
459 assert_eq!((worker.assets.as_deref(), worker.build.as_deref()), (Some("src/www"), Some("npm run build")));
460 // Mounted under a path, it is served from disk.
461 let mounted = project(
462 "mounted",
463 &[
464 ("package.json", r#"{ "main": "app.js", "dependencies": { "express": "5" } }"#),
465 ("app.js", "app.use('/static', express.static('public'));\n"),
466 ("public/a.css", ""),
467 ],
468 );
469 assert!(matches!(plan(&mounted), Plan::Blocked(why) if why.contains("serves files from disk")));
470 // As is a folder named by a variable, beside one g1t can serve.
471 let named = project(
472 "named",
473 &[
474 ("package.json", r#"{ "main": "app.js", "dependencies": { "express": "5" } }"#),
475 ("app.js", "app.use(express.static('public'));\napp.use(express.static(uploads));\n"),
476 ("public/a.css", ""),
477 ],
478 );
479 assert!(matches!(plan(&named), Plan::Blocked(why) if why.contains("`express.static(` in app.js")));
480 }
481
482 #[test]
483 fn what_a_worker_cannot_do_is_found_first() {
484 /// A project, and a phrase why it cannot run as a Worker says.
485 type Case = (&'static str, &'static [(&'static str, &'static str)], &'static str);
486 let cases: [Case; 6] = [
487 ("addon", &[("package.json", r#"{ "main": "a.js", "dependencies": { "express": "5", "better-sqlite3": "12" } }"#), ("a.js", "")], "`better-sqlite3`"),
488 ("fastify", &[("package.json", r#"{ "main": "a.js", "dependencies": { "fastify": "5" } }"#), ("a.js", "")], "Fastify"),
489 ("spawn", &[("package.json", r#"{ "main": "a.js", "dependencies": { "koa": "3" } }"#), ("a.js", "const { spawn } = require('node:child_process');")], "child_process"),
490 ("ws", &[("package.json", r#"{ "main": "a.js", "dependencies": { "express": "5", "ws": "8" } }"#), ("a.js", "new WebSocketServer({ server })")], "`upgrade`"),
491 ("views", &[("package.json", r#"{ "main": "a.js", "dependencies": { "express": "5" } }"#), ("a.js", ""), ("lib/routes.js", "res.render('home')")], "lib/routes.js"),
492 ("start", &[("package.json", r#"{ "scripts": { "start": "nest start" }, "dependencies": { "express": "5" } }"#)], "`nest start`"),
493 ];
494 for (name, files, phrase) in cases {
495 let dir = project(name, files);
496 match plan(&dir) {
497 Plan::Blocked(why) => assert!(why.contains(phrase), "{name}: {why}"),
498 other => panic!("{name}: {other:?}"),
499 }
500 }
501 // Tests and scripts are not the server's code.
502 let tests = project(
503 "tests-only",
504 &[("package.json", r#"{ "main": "a.js", "dependencies": { "express": "5" } }"#), ("a.js", ""), ("test/a.test.js", "readFileSync('x')"), ("scripts/seed.js", "writeFileSync('x')")],
505 );
506 assert!(matches!(plan(&tests), Plan::Worker(_)));
507 }
508}

This file's history is long; its oldest lines are credited to the oldest commit read.