Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

A deployment is a site or a Worker whenever it can be, and a container only as the fallback. With Build as on Auto, a Wrangler config or a framework's Cloudflare adapter is a Workers project; a framework's site, a build script's or an index.html is that site even beside a Dockerfile, and the log says "Built as a Vite site; Dockerfile is not used (a container is only the fallback; choose **Build as**: Container to use it)", where a Dockerfile that ran a server used to win; a Node.js server with no Dockerfile (Express, Koa, Hono, Polka, Restify, hapi, node:http) runs as a Worker; any other server g1t recognises, a Node.js server with its own Dockerfile, a framework set up to need a server, or a Dockerfile with nothing else to serve is a container.

A Node.js server runs as a Worker through httpServerHandler from cloudflare:node with nodejs_compat (developers.cloudflare.com/workers/runtime-apis/nodejs/http: from the compatibility date 2025-09-01 nodejs_compat turns on the HTTP server modules, and g1t's date is 2026-09-26). deploy/node_worker.rs finds the file that starts it (what its start script runs with node, tsx or ts-node, else main, else server.js and the like), and the build installs and builds it, writes a prelude that keeps every server the app creates or listens on and lets listen take any port and address, and an entry that imports the app and answers with httpServerHandler, or with the app's default export's fetch when it has one (a Hono app, whose Response @hono/node-server replaces is turned back into the runtime's). wrangler deploy --dry-run bundles it, and wrangler dev starts it once with the project's variables and asks it for /: a module that cannot load, a top-level await that never settles, or a server that never listens fails there, not in production. A folder served with app.use(express.static("public")) is uploaded as the Worker's static files. What cannot run as a Worker is found before anything is built, and the app is a container from the Dockerfile g1t writes, the log saying "Could not run as a Worker: <reason>; deploying as a container.": a native addon (better-sqlite3, bcrypt, sharp and others), Fastify, NestJS, Sails, remix-serve or react-router-serve, a WebSocket server (socket.io, express-ws, WebSocketServer, an upgrade handler), child_process, cluster or worker_threads, files read or written while it runs, files served from disk under a path, or a start script g1t cannot read. A Worker that does not bundle or start falls back the same way. Build as Workers runs a Node.js server as one whatever is found, and Build as Container (shown as Container now, still `docker` as stored) runs its container. g1t-runner detect and g1t/detect print entry for such a Worker.

Checked:
cargo test -p g1t-runner (the precedence table: Vite with a Dockerfile is a site, Express without one a Worker, Express with better-sqlite3 a container, a Go server with its Dockerfile a container, a Wrangler config with a Dockerfile Workers, index.html with an nginx Dockerfile a site), and on Linux the ignored end-to-end tests in tests/deploy_cli.rs, which install Express, bundle and start its Worker and deploy it to a fake API with public/ as its assets, and fall back to a container when the bundle fails or a top-level await never settles. Express, Hono on @hono/node-server and Fastify were run by hand with wrangler dev: Express and Hono answered, and Fastify, which boots on timers, did not, so it is a container. The deployments service's tests and typecheck, the web typecheck, and the docs build pass. The Deployments guide has the rule, Node.js servers as Workers, and how to force a container; the architecture page, the Build as hint and g1t/detect's outputs follow it.
syntaqxcommitted Parent1d1a13eBrowse files
10 files+1365−2980/10 viewed
+4−3
2424 | `root-dir` | The checkout | Where the project is in the checkout. |
2525 | `build-command` | None | A build command of your own, as `g1t/deploy` takes it. |
2626 | `output-dir` | None | An output folder of your own, as `g1t/deploy` takes it. |
27−| `build-as` | `auto` | What it is built as: `auto`, `static`, `workers` or `docker`, as `g1t/deploy` takes it. |
27+| `build-as` | `auto` | What it is built as: `auto`, `static`, `workers` or `docker` (Container), as `g1t/deploy` takes it. |
2828 | `dockerfile` | None | A Dockerfile's path of your own, as `g1t/deploy` takes it. |
2929
3030 ## Outputs
3131
3232 | Output | |
3333 | --- | --- |
34−| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, or a framework's Cloudflare adapter), `static` (a site a build writes), `html` (the files as they are), `container` (a `Dockerfile`, a framework set up to need a server, or a server g1t writes a Dockerfile for) or `none` (nothing to serve). |
35−| `framework` | The framework it is built with, from the table below; for a container without one, `dockerfile`, or the server g1t writes a Dockerfile for (`node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, `rack`, `laravel`, `php`). Empty when there is none. |
34+| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, a framework's Cloudflare adapter, or a Node.js server run as a Worker), `static` (a site a build writes), `html` (the files as they are), `container` (a server that is not run as a Worker, a framework set up to need a server, or a `Dockerfile` with nothing else to serve) or `none` (nothing to serve). |
35+| `framework` | The framework it is built with, from the table below; `node` for a Node.js server run as a Worker; for a container without one, `dockerfile`, or the server g1t writes a Dockerfile for (`node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, `rack`, `laravel`, `php`). Empty when there is none. |
3636 | `dockerfile` | A container's own Dockerfile, relative to the project. Empty when g1t writes one, and for anything that is not a container. |
3737 | `port` | The port a container listens on: its Dockerfile's `EXPOSE`, else `8080`. |
38+| `entry` | For a Node.js server run as a Worker, the file that starts it, relative to the project. Empty for anything else. |
3839 | `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on, and `bundle install` for Jekyll. Empty when there is nothing to install. |
3940 | `build` | What builds it: your command, the package's `build` script (`npm run build`), the framework's own command, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
4041 | `output-dir` | The folder served, relative to the project (`.` for the project itself): yours, a Workers project's assets directory, the folder the framework writes, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it, as for a Cloudflare adapter's Worker. |
+5−2
2626
2727 outputs:
2828 kind:
29− description: workers (a Wrangler config, or a framework's Cloudflare adapter), static (a site a build writes), html (the files as they are), container (a Dockerfile, a framework set up to need a server, or a server g1t writes a Dockerfile for) or none (nothing to serve).
29+ description: workers (a Wrangler config, a framework's Cloudflare adapter, or a Node.js server run as a Worker), static (a site a build writes), html (the files as they are), container (a server that is not run as a Worker, a framework set up to need a server, or a Dockerfile with nothing else to serve) or none (nothing to serve).
3030 value: ${{ steps.detect.outputs.kind }}
3131 install:
3232 description: How its dependencies are installed, such as npm ci. Empty without a package.json.
3838 description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it.
3939 value: ${{ steps.detect.outputs.output-dir }}
4040 framework:
41− description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo; for a container without one, dockerfile or the server g1t writes a Dockerfile for (node, flask, rails…). Empty when there is none.
41+ description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo; node for a Node.js server run as a Worker; for a container without one, dockerfile or the server g1t writes a Dockerfile for (node, flask, rails…). Empty when there is none.
4242 value: ${{ steps.detect.outputs.framework }}
4343 dockerfile:
4444 description: A container's own Dockerfile, relative to the project. Empty when g1t writes one, or for anything else.
4646 port:
4747 description: The port a container listens on, its Dockerfile's EXPOSE or 8080. Empty for anything else.
4848 value: ${{ steps.detect.outputs.port }}
49+ entry:
50+ description: For a Node.js server run as a Worker, the file that starts it, relative to the project. Empty for anything else.
51+ value: ${{ steps.detect.outputs.entry }}
4952
5053 runs:
5154 using: composite
+19−6
2121 open pull request gets a preview. `deployments` reads pushes from its
2222 event queue and asks `runner` for a sandbox in `deploy` mode
2323 ([Actions, runners and sandboxes](/architecture/compute/#other-work-in-sandboxes)).
24−The sandbox works out what the app is, in this order:
24+The sandbox works out what the app is, preferring what runs nothing while
25+no one visits, in this order
26+([What a project is built as](/guides/deployments/#what-a-project-is-built-as)):
2527
2628 1. a Workers project, from `wrangler deploy --dry-run`;
27−2. a framework it knows, such as Vite, Astro, Next.js or SvelteKit;
28−3. a container: a `Dockerfile` that runs a server, even beside a site, or a
29− server it recognises, for which it writes one
30− ([Docker deployments](/guides/deployments/#docker-deployments));
31−4. a static site.
29+2. a framework it knows, such as Vite, Astro, Next.js or SvelteKit, as
30+ the site it builds, even beside a `Dockerfile`;
31+3. a Node.js server with no `Dockerfile` (Express, Koa, Hono…), as a
32+ Worker: the sandbox writes an entry that serves the app's own server
33+ with `httpServerHandler` from `cloudflare:node`, bundles it with
34+ wrangler and starts it once with `wrangler dev` before deploying it
35+ ([Node.js servers as Workers](/guides/deployments/#nodejs-servers-as-workers));
36+4. any other server it recognises, as a container, from its `Dockerfile`
37+ or one it writes ([Docker deployments](/guides/deployments/#docker-deployments));
38+5. a `build` script's site or an `index.html`, even beside a
39+ `Dockerfile`;
40+6. a `Dockerfile` with nothing else to serve, as a container.
41+
42+A container is the fallback: a Node.js server that cannot run as a Worker
43+(a native addon, say), or whose Worker does not bundle or start, is
44+deployed as a container instead.
3245
3346 A container's image is pushed to Cloudflare's registry with a push-only
3447 login `deployments` mints for the build, and runs on Cloudflare
+127−46
99 and the default branch goes to production on every push. Reviewers
1010 click through the change instead of reading a diff.
1111
12−Apps run on Cloudflare Workers, on `g1t.page`, and a project with a
13−`Dockerfile` (or a server g1t can write one for) runs as a container on
14−Cloudflare Containers behind one; see [Docker deployments](#docker-deployments):
12+Apps run on Cloudflare Workers, on `g1t.page`: sites, Workers projects,
13+and Node.js servers such as Express apps. A server that cannot run as a
14+Worker runs as a container on Cloudflare Containers behind one; see
15+[What a project is built as](#what-a-project-is-built-as) and
16+[Docker deployments](#docker-deployments):
1517
1618 | | Address |
1719 | --- | --- |
9799 | The project has | g1t |
98100 | --- | --- |
99101 | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. |
100−| A framework from the table below | Installs dependencies, builds it with the framework's command, and serves the folder it writes as a **static site**, or deploys the Worker its Cloudflare adapter writes as a **Workers project**. |
102+| A framework from the table below, even beside a `Dockerfile` | Installs dependencies, builds it with the framework's command, and serves the folder it writes as a **static site**, or deploys the Worker its Cloudflare adapter writes as a **Workers project**. |
101103 | A framework set up to need a server (Next.js without a static export, Astro's `@astrojs/node`, SvelteKit's `adapter-node`, a server-rendered React Router or Remix app) | Builds it as a **container**: from its `Dockerfile`, or one g1t writes for it. |
102−| A `Dockerfile` or `Containerfile` that runs a server (`CMD`, `ENTRYPOINT` or `EXPOSE`), even beside a site or a framework above | Builds the image and runs it as a **container**. |
104+| A Node.js server (Express, Koa, Hono, `node:http` and others) with no `Dockerfile` | Runs it as a **Workers project**: wraps its server in a Worker, bundles it, and checks that it starts. One that cannot run as a Worker runs as a **container** instead, and the build's log says why. See [Node.js servers as Workers](#nodejs-servers-as-workers). |
105+| A Python, Go, Ruby, PHP or Rust server, or a Node.js server with a `Dockerfile` | Builds it as a **container**: from its `Dockerfile`, or one g1t writes for it and shows in the build's log. See [Docker deployments](#docker-deployments). |
106+| A `build` script in `package.json`, even beside a `Dockerfile` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
107+| An `index.html` and nothing to build, even beside a `Dockerfile` | Serves it as it is. |
103108 | A `Dockerfile` and nothing else to serve | Builds the image and runs it as a **container**. |
104−| A Node.js, Python, Go, Ruby, PHP or Rust server, with no `Dockerfile` | Writes a Dockerfile for it, shows it in the build's log, and runs it as a **container**. See [Docker deployments](#docker-deployments). |
105−| A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
106−| An `index.html` and nothing to build | Serves it as it is. |
107−| Nothing g1t recognises | Fails, saying to add a `Dockerfile`. |
109+| Nothing g1t recognises | Fails, saying to add a `Dockerfile`, a `build` script or an `index.html`. |
110+
111+### What a project is built as
112+
113+A site or a Worker is always preferred: it runs nothing while no one
114+visits, so it costs nothing then, and it wakes in milliseconds. A
115+container is the fallback. With **Build as** on **Auto** (the default),
116+the first of these that fits is what the project is built as:
108117
118+1. **A Workers project**: a Wrangler config (`wrangler.jsonc`,
119+ `wrangler.json` or `wrangler.toml`), or a framework's Cloudflare
120+ adapter, even beside a `Dockerfile`.
121+2. **A framework's site**: a framework from the [table below](#frameworks)
122+ that builds a site, even beside a `Dockerfile`. A framework set up to
123+ need a server is a container, from its `Dockerfile` or one g1t writes.
124+3. **A Node.js server as a Worker**: a Node.js server with no
125+ `Dockerfile` ([Node.js servers as Workers](#nodejs-servers-as-workers)).
126+ One that cannot run as a Worker is a container, from a Dockerfile g1t
127+ writes.
128+4. **A container for any other server g1t recognises**: Python, Go, Ruby,
129+ PHP or Rust, or a Node.js server with a `Dockerfile` of its own; from
130+ that `Dockerfile`, or one g1t writes.
131+5. **A site**: a `build` script's output, or an `index.html`, even beside
132+ a `Dockerfile`.
133+6. **A container from its `Dockerfile`** (or `Containerfile`, or the path
134+ set as its **Dockerfile**), when there is nothing else to serve.
135+7. Otherwise the build fails, saying what to add.
136+
137+When a site is built beside a `Dockerfile`, the build's log says the
138+`Dockerfile` is not used, near its top: "Built as a Vite site; Dockerfile
139+is not used (a container is only the fallback; choose **Build as**:
140+Container to use it)." To run the project's `Dockerfile` instead, set
141+**Build as** to **Container** under **Settings → Deployments**. **Build
142+as** set to **Static site**, **Workers** or **Container** is what the
143+project is built as, whatever else it has.
144+
109145 **Settings → Deployments** shows what g1t detected at the project's last
110146 finished build and what it was built with, such as **Static site ·
111147 Astro**, **Workers project**, **Container · Dockerfile** or **Container ·
204240 lists each one it left out. Code that needs them should check that the
205241 binding is there.
206242
243+### Node.js servers as Workers
244+
245+A Node.js server with no `Dockerfile` runs as a Worker, not a container:
246+an Express, Koa, Hono, Polka, Restify or hapi app, or one on `node:http`
247+alone. Workers run Node.js HTTP servers with the `nodejs_compat`
248+compatibility flag, through
249+[`httpServerHandler`](https://developers.cloudflare.com/workers/runtime-apis/nodejs/http/)
250+from `cloudflare:node`, so the app's code runs as written. g1t knows a
251+Node.js server by a server package in `package.json`, or by a `start`
252+script with no `build` script or `index.html` beside it.
253+
254+1. **It finds the file that starts the app**: the file its `start` script
255+ runs with `node`, `tsx` or `ts-node` (`node server.js`,
256+ `NODE_ENV=production node dist/index.js`), else `main` in
257+ `package.json`, else `server.js`, `index.js`, `app.js` or the like.
258+2. **It installs and builds** as for any project: the lockfile's install,
259+ then `npm run build` when there is a `build` script (a TypeScript app
260+ compiled to `dist`, say).
261+3. **It writes a Worker entry** that imports that file, keeps the server
262+ it starts, and answers each request with `httpServerHandler`. An app
263+ whose default export has a `fetch` method (a Hono app) is answered by
264+ that `fetch` directly. `listen` may name any port and address: on
265+ Workers the port only names the server. `PORT` is `8080` unless the
266+ project's variables set it. The entry is printed whole in the build's
267+ log.
268+4. **It bundles the Worker** with `wrangler deploy --dry-run`, with the
269+ compatibility date `2026-09-26` and `nodejs_compat`.
270+5. **It starts the Worker once** with `wrangler dev`, with the project's
271+ [variables and secrets](#secrets-and-variables), and asks it for `/`. A
272+ Worker that fails as it starts (a top-level `await` that never settles,
273+ a module that cannot load, a server that never calls `listen`) is not
274+ deployed.
275+
276+A folder the app serves with `app.use(express.static("public"))`, with
277+no path before it, is uploaded as the Worker's static files: they are
278+served without running the app, and count toward none of its requests or
279+CPU time.
280+
281+What is known not to run as a Worker is found before anything is built,
282+and the app runs as a container instead, from a Dockerfile g1t writes,
283+with the reason in the build's log:
284+
285+| The app | Why |
286+| --- | --- |
287+| Depends on a native addon: `better-sqlite3`, `sqlite3`, `bcrypt`, `sharp`, `canvas`, `argon2`, `puppeteer`, `playwright`, `node-pty`, `ssh2` and others | A Worker cannot load native code. |
288+| Is a Fastify, NestJS or Sails app | They start on timers or read their modules from disk as they start, which a Worker cannot do before its first request. |
289+| Accepts WebSocket connections with `socket.io`, `express-ws`, `WebSocketServer` or an `upgrade` handler | Workers do not support Node's `upgrade` event. |
290+| Uses `child_process`, `cluster` or `worker_threads` | A Worker cannot start processes or threads. |
291+| Reads or writes files while it runs: `readFile`, `writeFile`, `createReadStream`, `sendFile`, `res.render` and a view engine, `multer`'s disk storage | A Worker has no disk with the project's files. |
292+| Serves files from disk under a path (`app.use("/static", express.static("public"))`), from a folder that does not exist, or from more than one folder | g1t can serve one folder, at the top, for it. |
293+| Has a `start` script g1t cannot read (`nest start`, `pm2-runtime …`) | g1t cannot tell which file starts it. |
294+
295+g1t looks for these in the app's own JavaScript and TypeScript, not in
296+`node_modules`, tests, `scripts`, `public` or a build's output. A Worker
297+that does not bundle, or does not start, also runs as a container, saying
298+"Could not run as a Worker: …; deploying as a container." With a
299+`Dockerfile` of its own, a Node.js server is built from that `Dockerfile`
300+as a container. To run one as a Worker whatever g1t finds, set **Build
301+as** to **Workers**; to run it as a container, **Container**.
302+
207303 ### Docker deployments
208304
209305 Any app that runs in a container deploys as one, whatever its framework or
212308 behind the app's Worker, at the same addresses, custom domains and
213309 previews as any other app.
214310
215−There is nothing to set: push a project with a `Dockerfile`, or a server
216−g1t recognises, and it deploys as a container. What a project is built as
217−is decided in this order:
311+A container is the fallback, for what cannot be served as a site or run
312+as a Worker ([What a project is built as](#what-a-project-is-built-as)):
313+a Python, Go, Ruby, PHP or Rust server, a Node.js server with a
314+`Dockerfile` of its own or one that cannot run as a Worker, a framework
315+set up to need a server, or a `Dockerfile` with nothing else to serve.
316+There is nothing to set for those. A site or a Node.js server is built
317+without its `Dockerfile`; to deploy its container instead, set **Build
318+as** to **Container** under **Settings → Deployments**.
218319
219−1. **Build as**, under **Settings → Deployments**: **Static site**,
220− **Workers** or **Docker** is what it is built as, whatever else it has.
221−2. **Auto** (the default) finds it from its files:
222− 1. A Wrangler config, or a framework's Cloudflare adapter, is a
223− Workers project.
224− 2. A site (a framework that builds one, a `build` script, or an
225− `index.html`) beside a `Dockerfile` is a container when the
226− Dockerfile runs a server: its last stage has `CMD` or `ENTRYPOINT`,
227− or `EXPOSE` on an image that is not only a file server. A
228− Dockerfile that only copies the site into `nginx`, `httpd`, `caddy`,
229− `lighttpd`, `busybox` or another file server, or says none of those,
230− leaves it a site, which g1t serves itself for nothing while no one
231− visits.
232− 3. A framework that builds a static site, with no such Dockerfile, is
233− that site.
234− 4. A framework set up to need a server is a container, from its
235− `Dockerfile`, or one g1t writes for it.
236− 5. A project with no framework g1t knows is a container when it has a
237− `Dockerfile` (or `Containerfile`, or the path set as its
238− **Dockerfile**), or is a server g1t recognises.
239− 6. Otherwise a static site or plain HTML, as above; with nothing to
240− serve, the build fails, saying to add a `Dockerfile`.
320+The build's log says which it chose and why, near its top: for example,
321+"Built as a static site; Dockerfile is not used (a container is only the
322+fallback; choose **Build as**: Container to use it)", or "Could not run as
323+a Worker: it depends on `better-sqlite3`, which builds native code a
324+Worker cannot load; deploying as a container."
241325
242−When a project could be either, the build's log says which it chose and
243−why, near its top: for example, "Building it as a container from
244−Dockerfile, not as the static site it also has: its Dockerfile starts a
245−server of its own (`CMD`). Set **Build as** to Static site to serve the
246−site instead."
247−
248326 g1t writes a Dockerfile for these, when there is none:
249327
250328 | The project has | Runs with |
451529 runs on Linux and macOS, with `curl` and `jq` or `python3`.
452530
453531 `g1t/detect` says what a build of the checkout would find, without
454−building anything: its outputs are `kind` (`workers`, `static`, `html` or
455−`none`), `install`, `build`, `output-dir` and `framework` (such as
456−`astro` or `nextjs`, empty when there is none). A framework set up to
532+building anything: its outputs are `kind` (`workers`, `static`, `html`, `container` or
533+`none`), `install`, `build`, `output-dir`, `framework` (such as
534+`astro`, `nextjs` or `node`, empty when there is none), `dockerfile` and
535+`port` for a container, and `entry` for a Node.js server run as a Worker. A framework set up to
457536 need a server is `none`, and the step's log says what to change. Use
458537 them to cache, or to fail early:
459538
635714 | --- | --- | --- |
636715 | Production | On | Deploy the default branch on every push. |
637716 | Previews | On | A preview for every branch with an open pull request. |
638−| Build as | Auto | **Auto** (found from its files), **Static site**, **Workers** or **Docker**; see [Docker deployments](#docker-deployments). |
717+| Build as | Auto | **Auto** (found from its files, a site or a Worker before a container), **Static site**, **Workers** or **Container**; see [What a project is built as](#what-a-project-is-built-as). |
639718 | Dockerfile | `Dockerfile` or `Containerfile` | For a container: its Dockerfile's path, inside the project. |
640719 | Container size | `lite` | For a container: the instance it runs at, with its price an hour awake. |
641720 | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. Not used for a container. |
878957 | "Deployments come with the g1t plan … and `<workspace>` does not have it" | An owner starts the g1t plan under Billing. |
879958 | A deployment **Skipped**, saying it reached its spend limit or that new compute is paused | The build was refused before it started. Follow the link in the message: raise the spend limit, prepay, or answer the spike, on Billing. |
880959 | "Stopped: unusual CPU use" | The build looked like it was mining. Contact support if it was a real build; see [abuse and mining](/guides/guardrails/#abuse-and-mining). |
881−| "found nothing to serve" | Add a `Dockerfile`, a `build` script, an `index.html`, or a Workers config; or set **Output directory**. |
960+| "found nothing to serve" | Add a `build` script, an `index.html`, a Workers config or a `Dockerfile`; or set **Output directory**. |
882961 | "Docker deployments are switched off on this g1t" | The project is a container, and this g1t (a self-hosted one) runs none. Set **Build as** to Static site or Workers if it can be built as one. See [Docker deployments](#docker-deployments). |
883−| "**Build as** is Docker, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. |
962+| "**Build as** is Container, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. |
963+| "Could not run as a Worker: …; deploying as a container" | The Node.js server cannot run as a Worker, for the reason given; it deploys as a container. See [Node.js servers as Workers](#nodejs-servers-as-workers) for what to change, or set **Build as** to **Container** to skip the attempt. |
964+| "Built as a … site; Dockerfile is not used" | A site is preferred to a container. Set **Build as** to **Container** to deploy the `Dockerfile` instead. |
884965 | "The image is 2.4 GB, more than the 2.0 GB an app of its size (lite) can hold" | Make the image smaller (a slim base image, a multi-stage build), or choose a larger **Container size**. |
885966 | A container app says "The app did not start" | It did not answer on its port within 90 seconds. Make it listen on `0.0.0.0` at `PORT`, or `EXPOSE` the port it uses in its Dockerfile. Its output is in the build's log only while it builds; run the image locally with `docker run -e PORT=8080 -p 8080:8080` to see it start. |
886967 | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. |
+1−1
3636 { value: "auto", label: "Auto: found from its files" },
3737 { value: "static", label: "Static site" },
3838 { value: "workers", label: "Workers" },
39− { value: "docker", label: "Docker" },
39+ { value: "docker", label: "Container" },
4040 ];
4141
4242 /** `1/16 vCPU, 256 MiB, 2 GB disk`. */
+1−1
172172 Build through a workflow g1t manages for this project, listed under Actions with its runs and logs. It runs on g1t's machines like any workflow, and a workflow named g1t in .g1t/workflows takes its place.
173173 </Check>
174174 <div className="grid gap-4 md:grid-cols-2">
175− <Field label="Build as" hint="Auto finds it from the project's files: a Wrangler config is Workers; a Dockerfile that runs a server, or a server g1t recognises, is a container; anything else that builds a site stays one. The build's log says which it chose and why.">
175+ <Field label="Build as" hint="Auto finds it from the project's files, preferring a site or a Worker to a container: a Wrangler config is Workers; a framework, a build script or an index.html is a site, even beside a Dockerfile; a Node.js server runs as a Worker; other servers, and Node.js servers that cannot run as a Worker, are containers. Choose Container to use the Dockerfile. The build's log says which it chose and why.">
176176 <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} />
177177 </Field>
178178 <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty.">
+595−147
77 //! the sandbox uploads them with it, and sends the Worker's code to the
88 //! service, which puts the app in place.
99 //!
10−//! What gets built:
10+//! What gets built, a site or a Worker before a container (`detect_as`
11+//! says the order):
1112 //!
1213 //! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or
1314 //! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its
2223 //! bundled as a Workers project's is, without a Wrangler config of its
2324 //! own. A framework set up to need a server fails before anything is
2425 //! installed, saying what to change.
25−//! - A container (deploy/container.rs): a project with a `Dockerfile` (or
26−//! `Containerfile`, or the path it chose) and no framework that builds a
27−//! static site or a Worker, a framework set up to need a server, or a
28−//! Node.js, Python, Go, Ruby or PHP server, for which g1t writes a
29−//! Dockerfile and shows it in the log. The image is built with Docker
26+//! - A Node.js server with no Dockerfile (Express, Koa, Hono…), as a
27+//! Worker (deploy/node_worker.rs): g1t writes an entry that serves the
28+//! app's own server with `httpServerHandler` from `cloudflare:node`,
29+//! bundles it with wrangler and starts it once locally. One that cannot
30+//! run as a Worker is a container instead, saying why.
31+//! - A container (deploy/container.rs): a server g1t recognises (Node.js
32+//! with a Dockerfile, Python, Go, Ruby, PHP, Rust), a framework set up to
33+//! need a server, or a project with a `Dockerfile` (or `Containerfile`,
34+//! or the path it chose) and nothing else to serve. Without a
35+//! Dockerfile, g1t writes one and shows it in the log. The image is built with Docker
3036 //! and pushed to Cloudflare's registry with a push-only login the
3137 //! deployments service gives this build alone, for minutes (the
3238 //! `registry` step); the service runs it.
3541 //! exists is served, or the repository itself if it has an `index.html`.
3642 //!
3743 //! The project's own **Build as** (`BUILD_AS`: auto, static, workers or
38−//! docker), **Dockerfile** (`DOCKERFILE`), **Build command** and **Output
44+//! docker, shown as Container), **Dockerfile** (`DOCKERFILE`), **Build command** and **Output
3945 //! directory** win over what is detected.
4046 //!
4147 //! Configuration comes from the environment:
6571 use anyhow::{Context, Result, bail};
6672 use base64::Engine;
6773 use base64::engine::general_purpose::STANDARD;
74+use regex::Regex;
6875 use serde::{Deserialize, Serialize};
6976 use serde_json::{Value, json};
7077 use sha2::{Digest, Sha256};
7481
7582 mod container;
7683 mod frameworks;
84+mod node_worker;
7785
7886 use container::BuildAs;
7987 use frameworks::{Adapter, Framework};
88+use node_worker::NodeWorker;
8089
8190 /// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
8291 const BUNDLE_DIR: &str = "/work/g1t-bundle";
654663 pre_build: Option<String>,
655664 /// For a container: its Dockerfile, or the one g1t writes.
656665 pub container: Option<ContainerBuild>,
657− /// What it would be served as without its Dockerfile, when it is a
658− /// container only for having one: built so where Docker deployments
659− /// are switched off (`not_enabled` from the registry step).
666+ /// For a Node.js server run as a Worker: the file that starts it, and
667+ /// what it serves (deploy/node_worker.rs).
668+ pub node: Option<NodeWorker>,
669+ /// What it is built as when this cannot be: for a container beside a
670+ /// site, the site, where Docker deployments are switched off
671+ /// (`not_enabled` from the registry step); for a Node.js server run as
672+ /// a Worker, the container it runs as when its Worker does not bundle
673+ /// or start.
660674 fallback: Option<Box<Detected>>,
661675 /// Why it is built as this and not as the other thing it could be (a
662676 /// site beside a Dockerfile), for the build's log.
687701 problem: Some(problem),
688702 pre_build: None,
689703 container: None,
704+ node: None,
690705 fallback: None,
691706 reason: None,
692707 }
696711 /// a container the server g1t wrote a Dockerfile for (`flask`…) or
697712 /// `dockerfile`.
698713 pub(crate) fn framework_id(&self) -> Option<&'static str> {
699− self.framework.as_ref().map(|f| f.id).or_else(|| {
714+ self.framework.as_ref().map(|f| f.id).or_else(|| self.node.as_ref().map(|_| "node")).or_else(|| {
700715 self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id))
701716 })
702717 }
703718 }
704719
705720 /// Why nothing could be found to serve, and what to add.
706−const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no Dockerfile, no index.html, and none of dist, build, out, \
707− public, _site or .output/public. Add a `Dockerfile` to deploy it as a container, or set **Build command** and \
708− **Output directory** for a static site.";
721+const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no server g1t recognises, no index.html, none of dist, build, \
722+ out, public, _site or .output/public, and no Dockerfile. Set **Build command** and **Output directory** for a \
723+ static site, or add a `Dockerfile` to deploy it as a container.";
709724
710725 /// The project as a container: its own Dockerfile, else one g1t writes
711726 /// for `framework` or for a server it recognises. None when it has neither.
736751 problem: None,
737752 pre_build: None,
738753 container: Some(build),
754+ node: None,
739755 fallback: None,
740756 reason: None,
741757 })
747763 }
748764
749765 /// What the project is, by its **Build as** setting (`build_as`) and its
750−/// Dockerfile's path (`dockerfile`, when it chose one):
766+/// Dockerfile's path (`dockerfile`, when it chose one). A site or a Worker
767+/// is always preferred, as it runs nothing while no one visits; a
768+/// container is the fallback.
751769 ///
752−/// - **Docker**: a container, from its Dockerfile or one g1t writes.
770+/// - **Container**: a container, from its Dockerfile or one g1t writes.
753771 /// - **Static site**: built and served as files, whatever else it has.
754−/// - **Workers**: its Wrangler config or a framework adapter's Worker.
755−/// - **Auto**, in order: a Wrangler config (or a framework's Workers
756−/// adapter) is a Workers project; a site beside a Dockerfile of the
757−/// project's own is a container when the Dockerfile runs a server
758−/// (`container::runs_server`: `CMD`, `ENTRYPOINT`, or `EXPOSE` on an
759−/// image that is not only a file server), else the site, which costs
760−/// nothing while no one visits; any other framework that builds a site
761−/// is that site; a framework set up to need a server is a container
762−/// when it has a Dockerfile, or when g1t can write one; a project with
763−/// no framework g1t knows is a container when it has a Dockerfile or is
764−/// a server g1t recognises (a Node.js, Python, Go, Ruby, PHP or Rust
765−/// app), else a static site, else nothing, saying to add a Dockerfile.
766−/// Each choice between a site and a container says why (`reason`).
772+/// - **Workers**: its Wrangler config, a framework adapter's Worker, or a
773+/// Node.js server run as a Worker.
774+/// - **Auto**, in order:
775+/// 1. A Wrangler config (or a framework's Workers adapter) is a Workers
776+/// project.
777+/// 2. A framework that builds a site is that site, even beside a
778+/// Dockerfile, which is then not used (`reason` says so); one set up
779+/// to need a server is a container, from its Dockerfile or one g1t
780+/// writes.
781+/// 3. A Node.js server (Express, Koa, Hono, `node:http`…) with no
782+/// Dockerfile runs as a Worker (deploy/node_worker.rs), with a
783+/// container from a Dockerfile g1t writes as its fallback; one that
784+/// cannot run as a Worker is that container from the start.
785+/// 4. Any other server g1t recognises (Python, Go, Ruby, PHP, Rust, or
786+/// Node.js with a Dockerfile) is a container, from its Dockerfile or
787+/// one g1t writes.
788+/// 5. A `build` script's site or an `index.html` is that site, even
789+/// beside a Dockerfile; else a Dockerfile is a container; else
790+/// nothing, saying what to add.
767791 pub(crate) fn detect_as(
768792 dir: &Path,
769793 custom_build: Option<&str>,
779803 Detected::nothing(
780804 framework,
781805 None,
782− "**Build as** is Docker, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
806+ "**Build as** is Container, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
783807 one for. Add a `Dockerfile` (or set its path under Settings, Deployments)."
784808 .to_owned(),
785809 )
791815 if site.kind == "workers" {
792816 return Ok(site);
793817 }
818+ // A Node.js server, as a Worker whatever g1t thinks of it: the
819+ // project said so.
820+ if container::node_server(dir).is_some() {
821+ let mut found = match node_worker::plan(dir) {
822+ node_worker::Plan::Worker(node) => as_node_worker(dir, node, custom_build),
823+ node_worker::Plan::Blocked(why) => match node_worker::forced(dir) {
824+ Some(node) => {
825+ let mut found = as_node_worker(dir, node, custom_build);
826+ found.reason = Some(format!("**Build as** is Workers, so it is built as one, though it may not run as one: {why}."));
827+ found
828+ }
829+ None => return Ok(Detected::nothing(None, site.install, format!("**Build as** is Workers, but {why}."))),
830+ },
831+ };
832+ found.fallback = None;
833+ return Ok(found);
834+ }
794835 Ok(Detected::nothing(
795836 site.framework,
796837 site.install,
797838 "**Build as** is Workers, but this project has no Wrangler config (`wrangler.jsonc`, `wrangler.json` or \
798− `wrangler.toml`) and no framework adapter that writes a Worker."
839+ `wrangler.toml`), no framework adapter that writes a Worker, and no Node.js server g1t can run as one."
799840 .to_owned(),
800841 ))
801842 }
802843 BuildAs::Auto => {
803844 let site = detect_site(dir, custom_build, chosen_output, false)?;
845+ // 1. A Workers project.
804846 if site.kind == "workers" {
805847 return Ok(site);
806848 }
807− // A site beside a Dockerfile of the project's own: the
808− // Dockerfile, when it runs a server; else the site, which g1t
809− // serves for nothing while no one visits.
810− let serves = site.problem.is_none() && (site.kind == "static" || site.kind == "html");
811− if serves && let Some(path) = &own {
812− let text = std::fs::read_to_string(dir.join(path)).unwrap_or_default();
813− let (server, why) = container::runs_server(&text);
814− let what = if site.kind == "html" { "plain HTML site" } else { "static site" };
815− if !server {
816− let mut site = site;
817− site.reason = Some(format!(
818− "Building it as a {what}, not from {path}: {why}, and g1t serves the site itself. \
819− Set **Build as** to Docker to run the container instead."
820− ));
821− return Ok(site);
849+ // 2. A framework: its site, or a container for its server.
850+ if site.framework.is_some() {
851+ if let Some(problem) = site.problem.clone() {
852+ let found = as_container(dir, site.framework.clone(), own);
853+ return Ok(found.unwrap_or_else(|| {
854+ Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
855+ }));
822856 }
823− let mut found = as_container(dir, site.framework.clone(), own.clone()).context("its Dockerfile was not read")?;
824− found.reason = Some(format!(
825− "Building it as a container from {path}, not as the {what} it also has: {why}. \
826− Set **Build as** to Static site to serve the site instead."
827− ));
828− found.fallback = Some(Box::new(site));
829− return Ok(found);
830− }
831− if site.framework.is_some() && site.problem.is_none() {
832− return Ok(site);
857+ return Ok(unused_dockerfile(site, own.as_deref()));
833858 }
834− if let Some(problem) = site.problem.clone() {
835− // A framework set up to need a server.
836− let found = as_container(dir, site.framework.clone(), own);
837− return Ok(found.unwrap_or_else(|| {
838− Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
839− }));
859+ // 3. A Node.js server with no Dockerfile: a Worker.
860+ if own.is_none() && container::node_server(dir).is_some() {
861+ return Ok(match node_worker::plan(dir) {
862+ node_worker::Plan::Worker(node) => as_node_worker(dir, node, custom_build),
863+ node_worker::Plan::Blocked(why) => {
864+ let mut found = as_container(dir, None, None).context("g1t could not write its Dockerfile")?;
865+ found.reason = Some(format!("Could not run as a Worker: {why}; deploying as a container."));
866+ found
867+ }
868+ });
840869 }
841− // A Dockerfile with nothing else to serve, or a server g1t
842− // recognises, which it writes one for.
843− if let Some(mut found) = as_container(dir, None, own) {
870+ // 4. Any other server g1t recognises.
871+ if container::server(dir).is_some()
872+ && let Some(mut found) = as_container(dir, None, own.clone())
873+ {
844874 if let (Some(server), Some(folder)) = (found.container.as_ref().and_then(|c| c.generated.as_ref()), &site.output_dir) {
845875 found.reason = Some(format!(
846876 "Building it as a {} server, not as a site of `{folder}`. Set **Build as** to Static site to serve only `{folder}` instead.",
847877 server.name
848878 ));
849879 }
880+ if site.problem.is_none() && (site.kind == "static" || site.kind == "html") {
881+ found.fallback = Some(Box::new(site));
882+ }
850883 return Ok(found);
851884 }
852− if site.kind == "none" {
853− return Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()));
885+ // 5. A site of its own, else its Dockerfile, else nothing.
886+ if site.problem.is_none() && (site.kind == "static" || site.kind == "html") {
887+ return Ok(unused_dockerfile(site, own.as_deref()));
888+ }
889+ if let Some(found) = as_container(dir, None, own) {
890+ return Ok(found);
854891 }
855− Ok(site)
892+ Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()))
856893 }
857894 }
858895 }
859896
897+/// A site built as one beside a Dockerfile of the project's own, saying in
898+/// its `reason` that the Dockerfile is not used.
899+fn unused_dockerfile(mut site: Detected, own: Option<&str>) -> Detected {
900+ if let Some(path) = own {
901+ let what = match (&site.framework, site.kind) {
902+ (Some(framework), _) => format!("{} site", framework.name),
903+ (None, "html") => "plain HTML site".to_owned(),
904+ _ => "static site".to_owned(),
905+ };
906+ site.reason = Some(format!(
907+ "Built as a {what}; {path} is not used (a container is only the fallback; choose **Build as**: Container to use it)."
908+ ));
909+ }
910+ site
911+}
912+
913+/// A Node.js server as a Worker: installed and built as the project is,
914+/// then bundled with the entry g1t writes; a container from a Dockerfile
915+/// g1t writes is its fallback. The project's own **Build command**, when
916+/// it has one, runs in place of its `build` script.
917+fn as_node_worker(dir: &Path, mut node: NodeWorker, custom_build: Option<&str>) -> Detected {
918+ if let Some(command) = custom_build.map(str::trim).filter(|c| !c.is_empty()) {
919+ node.build = Some(command.to_owned());
920+ }
921+ let mut steps: Vec<String> = node.build.clone().into_iter().collect();
922+ steps.push(wrangler_build(None, None));
923+ Detected {
924+ kind: "workers",
925+ framework: None,
926+ install: install_command(dir).map(str::to_owned),
927+ build: Some(steps.join(" && ")),
928+ output_dir: node.assets.clone(),
929+ problem: None,
930+ pre_build: node.build.clone(),
931+ container: None,
932+ fallback: as_container(dir, None, None).map(Box::new),
933+ reason: Some(format!(
934+ "Running this {} server as a Worker, from `{}`; it is deployed as a container instead if it cannot run as one.",
935+ node.name, node.entry
936+ )),
937+ node: Some(node),
938+ }
939+}
940+
860941 /// What the project is as a static site or Workers project: `as_static`
861942 /// ignores its Wrangler config and any framework adapter, and builds it
862943 /// even when its framework is set up to need a server.
887968 problem: None,
888969 pre_build,
889970 container: None,
971+ node: None,
890972 fallback: None,
891973 reason: None,
892974 });
908990 build,
909991 problem: None,
910992 container: None,
993+ node: None,
911994 fallback: None,
912995 reason: None,
913996 });
9311014 (None, None) => "none",
9321015 _ => "static",
9331016 };
934− Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None, reason: None })
1017+ Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, node: None, fallback: None, reason: None })
9351018 }
9361019
9371020 /// The compatibility date a Worker gets when its config names none.
11441227 pushed.map(Some)
11451228 }
11461229
1230+/// Where the Worker entry g1t writes for a Node.js server goes, with its
1231+/// prelude and config: beside the bundle, never in the checkout.
1232+fn node_worker_dir() -> PathBuf {
1233+ bundle_dir().with_file_name(format!("{}.node", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
1234+}
1235+
1236+/// How long a Node.js server's Worker may take to start and answer, in the
1237+/// check before it is deployed.
1238+const START_SECONDS: u64 = 60;
1239+
1240+/// What the check of a Node.js server's Worker found.
1241+#[derive(Debug, PartialEq, Eq)]
1242+enum Started {
1243+ /// It started and answered a request.
1244+ Answered,
1245+ /// It did not start, or failed while it answered, and why.
1246+ Failed(String),
1247+ /// The check itself could not run, and why: the Worker is deployed.
1248+ Unchecked(String),
1249+}
1250+
1251+/// The lines of a Worker's local run that say it failed (`Uncaught …`),
1252+/// without the terminal's colours.
1253+fn uncaught(log: &str) -> Option<String> {
1254+ let plain = Regex::new(r"\x1b\[[0-9;]*m").expect("a valid pattern").replace_all(log, "").into_owned();
1255+ let line = plain.lines().find(|line| line.contains("Uncaught"))?;
1256+ let line = line.trim().trim_start_matches(['✘', ' ']).trim_start_matches("[ERROR]").trim();
1257+ let line = line.strip_prefix("service core:user:g1t-app:").unwrap_or(line).trim();
1258+ Some(line.chars().take(300).collect())
1259+}
1260+
1261+/// What a request to the Worker, and its log, say about how it started.
1262+fn judge_start(body: &str, log: &str) -> Started {
1263+ if let Some(line) = uncaught(log) {
1264+ return Started::Failed(format!("it failed as it started: {line}"));
1265+ }
1266+ if body.contains(node_worker::NO_SERVER) {
1267+ return Started::Failed("it started no HTTP server: it never called `listen()` while it started".to_owned());
1268+ }
1269+ Started::Answered
1270+}
1271+
1272+/// Starts the Worker at `config` locally with `wrangler dev`, as Cloudflare
1273+/// would run it, asks it for `/`, and stops it: what fails as it starts
1274+/// (a module that cannot load, a top-level `await` that never settles, a
1275+/// timer at the top level) fails here, before anything is deployed. The
1276+/// build's variables and secrets are its `.dev.vars`.
1277+fn start_check(dir: &Path, config: &Path) -> Started {
1278+ use std::process::{Command, Stdio};
1279+ use std::time::Duration;
1280+ let folder = config.parent().unwrap_or(dir);
1281+ let mut vars = String::new();
1282+ for source in ["BUILD_ENV", "BUILD_SECRETS"] {
1283+ if let Ok(Value::Object(values)) = serde_json::from_str::<Value>(&std::env::var(source).unwrap_or_default()) {
1284+ for (name, value) in values {
1285+ if let Some(value) = value.as_str() {
1286+ vars.push_str(&format!("{name}={}\n", serde_json::to_string(value).unwrap_or_default()));
1287+ }
1288+ }
1289+ }
1290+ }
1291+ if !vars.is_empty() && std::fs::write(folder.join(".dev.vars"), vars).is_err() {
1292+ return Started::Unchecked("its variables could not be written".to_owned());
1293+ }
1294+ let port = match std::net::TcpListener::bind("127.0.0.1:0").and_then(|listener| listener.local_addr()) {
1295+ Ok(address) => address.port(),
1296+ Err(error) => return Started::Unchecked(format!("no port to run it on: {error}")),
1297+ };
1298+ let log_path = folder.join("start.log");
1299+ let (Ok(out), Ok(err)) = (std::fs::File::create(&log_path), std::fs::File::create(folder.join("start.err.log"))) else {
1300+ return Started::Unchecked("its log could not be written".to_owned());
1301+ };
1302+ let mut command = Command::new(if cfg!(windows) { "npx.cmd" } else { "npx" });
1303+ command
1304+ .args(["--yes", "wrangler@4", "dev", "--local", "--ip", "127.0.0.1", "--show-interactive-dev-session=false"])
1305+ .arg("--port")
1306+ .arg(port.to_string())
1307+ .arg("--config")
1308+ .arg(config)
1309+ .current_dir(dir)
1310+ .env("WRANGLER_SEND_METRICS", "false")
1311+ .stdin(Stdio::null())
1312+ .stdout(out)
1313+ .stderr(err);
1314+ #[cfg(unix)]
1315+ {
1316+ use std::os::unix::process::CommandExt;
1317+ // Its own group, so wrangler and workerd stop with it.
1318+ command.process_group(0);
1319+ }
1320+ let mut child = match command.spawn() {
1321+ Ok(child) => child,
1322+ Err(error) => return Started::Unchecked(format!("wrangler could not be run: {error}")),
1323+ };
1324+ let read_log = || {
1325+ let mut text = std::fs::read_to_string(&log_path).unwrap_or_default();
1326+ text.push_str(&std::fs::read_to_string(folder.join("start.err.log")).unwrap_or_default());
1327+ text
1328+ };
1329+ let url = format!("http://127.0.0.1:{port}/");
1330+ let started = Instant::now();
1331+ let found = loop {
1332+ if let Ok(Some(_)) = child.try_wait() {
1333+ let log = read_log();
1334+ break match uncaught(&log) {
1335+ Some(line) => Started::Failed(format!("it failed as it started: {line}")),
1336+ None => Started::Unchecked(format!(
1337+ "wrangler dev stopped before it answered: {}",
1338+ log.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("").trim()
1339+ )),
1340+ };
1341+ }
1342+ let answer = match ureq::get(&url).timeout(Duration::from_secs(30)).call() {
1343+ Ok(response) => Some(response.into_string().unwrap_or_default()),
1344+ Err(ureq::Error::Status(_, response)) => Some(response.into_string().unwrap_or_default()),
1345+ Err(_) => None,
1346+ };
1347+ if let Some(body) = answer {
1348+ // What it logged as it answered is written a moment after.
1349+ std::thread::sleep(Duration::from_millis(500));
1350+ break judge_start(&body, &read_log());
1351+ }
1352+ if started.elapsed() > Duration::from_secs(START_SECONDS) {
1353+ let log = read_log();
1354+ break match uncaught(&log) {
1355+ Some(line) => Started::Failed(format!("it failed as it started: {line}")),
1356+ None => Started::Unchecked(format!("it did not answer within {START_SECONDS} seconds")),
1357+ };
1358+ }
1359+ std::thread::sleep(Duration::from_millis(500));
1360+ };
1361+ stop(&mut child);
1362+ let _ = std::fs::remove_file(folder.join(".dev.vars"));
1363+ found
1364+}
1365+
1366+/// Stops a process started in its own group, and everything it started.
1367+fn stop(child: &mut std::process::Child) {
1368+ #[cfg(unix)]
1369+ {
1370+ let group = -(child.id() as i32);
1371+ // SAFETY: signals the child's own process group, which it leads.
1372+ unsafe { libc::kill(group, libc::SIGTERM) };
1373+ for _ in 0..30 {
1374+ if let Ok(Some(_)) = child.try_wait() {
1375+ unsafe { libc::kill(group, libc::SIGKILL) };
1376+ return;
1377+ }
1378+ std::thread::sleep(std::time::Duration::from_millis(100));
1379+ }
1380+ unsafe { libc::kill(group, libc::SIGKILL) };
1381+ }
1382+ let _ = child.kill();
1383+ let _ = child.wait();
1384+}
1385+
1386+/// A Node.js server built as a Worker: installed and built as the project
1387+/// is, wrapped in the entry g1t writes (deploy/node_worker.rs), bundled by
1388+/// wrangler, and started once locally to see that it runs. Any failure is
1389+/// the caller's to fall back from.
1390+fn build_node_worker(log: &mut Log, secrets: &[String], dir: &Path, plan: &Detected) -> Result<Built> {
1391+ let node = plan.node.as_ref().context("not a Node.js server")?;
1392+ if let Some(install) = &plan.install {
1393+ step(log, install, secrets)?;
1394+ }
1395+ if let Some(command) = &plan.pre_build {
1396+ step(log, command, secrets)?;
1397+ }
1398+ let app = dir.join(&node.entry);
1399+ if !app.is_file() {
1400+ bail!("its start file `{}` does not exist after the build", node.entry);
1401+ }
1402+ let folder = node_worker_dir();
1403+ let _ = std::fs::remove_dir_all(&folder);
1404+ std::fs::create_dir_all(&folder)?;
1405+ let built = (|| -> Result<Built> {
1406+ std::fs::write(folder.join("g1t-prelude.mjs"), node_worker::PRELUDE)?;
1407+ let entry = folder.join("g1t-entry.mjs");
1408+ let source = node_worker::entry_module(&canonical(&app).unwrap_or(app.clone()));
1409+ std::fs::write(&entry, &source)?;
1410+ log.line(&format!("g1t wrote this Worker entry for `{}` (`cloudflare:node`'s httpServerHandler serves its server):", node.entry));
1411+ for line in source.lines() {
1412+ log.line(&format!(" {line}"));
1413+ }
1414+ let file = folder.join("wrangler.json");
1415+ let flags = vec!["nodejs_compat".to_owned()];
1416+ std::fs::write(
1417+ &file,
1418+ serde_json::to_string_pretty(&json!({
1419+ "name": "g1t-app",
1420+ "main": entry,
1421+ "compatibility_date": COMPATIBILITY_DATE,
1422+ "compatibility_flags": flags,
1423+ }))?,
1424+ )?;
1425+ let config = WranglerConfig {
1426+ main: Some(entry.to_string_lossy().into_owned()),
1427+ compatibility_flags: flags,
1428+ assets: node.assets.as_ref().map(|folder| AssetsConfig { directory: Some(folder.clone()), ..AssetsConfig::default() }),
1429+ ..WranglerConfig::default()
1430+ };
1431+ let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, Some(&file))?;
1432+ if let Some(folder) = &node.assets {
1433+ log.line(&format!("Serving `{folder}` as the Worker's static files, as the app serves it."));
1434+ }
1435+ match start_check(dir, &file) {
1436+ Started::Answered => log.line("It started and answered a request, as a Worker."),
1437+ Started::Unchecked(why) => log.line(&format!("Could not check that it starts ({why}); deploying it as a Worker.")),
1438+ Started::Failed(why) => bail!("{why}"),
1439+ }
1440+ Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: Some("node"), container: None })
1441+ })();
1442+ let _ = std::fs::remove_dir_all(&folder);
1443+ built
1444+}
1445+
11471446 fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> {
11481447 let project = project_dir();
11491448 let dir = project.as_path();
11621461 if let Some(problem) = &plan.problem {
11631462 bail!("{problem}");
11641463 }
1464+ // A Node.js server as a Worker, else the container it falls back to.
1465+ if plan.node.is_some() {
1466+ match build_node_worker(log, secrets, dir, &plan) {
1467+ Ok(built) => return Ok(built),
1468+ Err(error) => match plan.fallback.take() {
1469+ Some(container) => {
1470+ log.line(&format!("Could not run as a Worker: {error:#}; deploying as a container."));
1471+ plan = *container;
1472+ }
1473+ None => return Err(error.context("it could not be built as a Worker")),
1474+ },
1475+ }
1476+ }
11651477 if plan.container.is_some() {
11661478 match container_build(reporter, log, secrets, dir, &plan)? {
11671479 Some(image) => {
14501762
14511763 Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
14521764 G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
1453−BUILD_AS (auto, static, workers or docker), DOCKERFILE, and BUILD_ENV and
1765+BUILD_AS (auto, static, workers or container), DOCKERFILE, and BUILD_ENV and
14541766 BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
14551767 is built with the docker on PATH.
14561768
14881800 let _ = std::fs::remove_dir_all(bundle_dir());
14891801 let _ = std::fs::remove_file(adapter_config_path());
14901802 let _ = std::fs::remove_dir_all(docker_config_dir());
1803+ let _ = std::fs::remove_dir_all(node_worker_dir());
14911804 }
14921805 let (sent, failed) = match outcome {
14931806 Ok(mut finish) => {
15351848 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
15361849 "--help" | "-h" => {
15371850 println!(
1538− "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found), and for a container dockerfile and port, as key=value lines. Reads ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, BUILD_AS (auto, static, workers or docker) and DOCKERFILE as a build does."
1851+ "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found), for a container dockerfile and port, and for a Node.js server run as a Worker entry, as key=value lines. Reads ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, BUILD_AS (auto, static, workers or container) and DOCKERFILE as a build does."
15391852 );
15401853 return 0;
15411854 }
15841897 if let Some(container) = &found.container {
15851898 lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port));
15861899 }
1900+ // A Node.js server run as a Worker: the file that starts it.
1901+ if let Some(node) = &found.node {
1902+ lines.push_str(&format!("entry={}\n", one_line(Some(&node.entry))));
1903+ }
15871904 lines
15881905 }
15891906
17782095 problem: None,
17792096 pre_build: None,
17802097 container: None,
2098+ node: None,
17812099 fallback: None,
17822100 reason: None,
17832101 };
21422460 }
21432461 }
21442462
2463+ /// One project of the precedence table: its files, and what Auto makes
2464+ /// of it.
2465+ struct Precedence {
2466+ name: &'static str,
2467+ files: &'static [(&'static str, &'static str)],
2468+ kind: &'static str,
2469+ framework: Option<&'static str>,
2470+ /// For a container: the Dockerfile it is built from, `None` when
2471+ /// g1t writes it.
2472+ dockerfile: Option<&'static str>,
2473+ /// What it falls back to: `container` for a Node.js server's
2474+ /// Worker, the site for a container beside one.
2475+ fallback: Option<&'static str>,
2476+ /// A phrase its `reason` says, if it says one.
2477+ reason: Option<&'static str>,
2478+ }
2479+
2480+ const PRECEDENCE: Precedence = Precedence { name: "", files: &[], kind: "static", framework: None, dockerfile: None, fallback: None, reason: None };
2481+
2482+ const EXPRESS: &str = r#"{ "scripts": { "start": "node server.js" }, "dependencies": { "express": "5" } }"#;
2483+ const EXPRESS_SERVER: &str = "const app = require('express')();\napp.get('/', (req, res) => res.send('hi'));\napp.listen(process.env.PORT || 3000);\n";
2484+ const GO_DOCKERFILE: &str = "FROM golang:1.25 AS build\nCOPY . .\nRUN go build -o /app .\nFROM scratch\nCOPY --from=build /app /app\nEXPOSE 9000\nENTRYPOINT [\"/app\"]\n";
2485+ const NODE_DOCKERFILE: &str = "FROM node:22-slim\nCOPY . .\nRUN npm ci\nENV PORT=3000\nCMD [\"node\", \"server.js\"]\n";
2486+
21452487 #[test]
2146− fn build_as_and_dockerfiles_decide_in_order() {
2488+ fn a_site_or_a_worker_is_preferred_and_a_container_is_the_fallback() {
2489+ let cases = [
2490+ // A framework's site, even beside a Dockerfile that runs a server.
2491+ Precedence {
2492+ name: "vite-dockerfile",
2493+ files: &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", NODE_DOCKERFILE)],
2494+ framework: Some("vite"),
2495+ reason: Some("Built as a Vite site; Dockerfile is not used (a container is only the fallback; choose **Build as**: Container to use it)."),
2496+ ..PRECEDENCE
2497+ },
2498+ // A Wrangler config wins over a Dockerfile.
2499+ Precedence {
2500+ name: "wrangler-dockerfile",
2501+ files: &[("wrangler.jsonc", r#"{ "main": "src/index.ts" }"#), ("Dockerfile", NODE_DOCKERFILE)],
2502+ kind: "workers",
2503+ ..PRECEDENCE
2504+ },
2505+ // An index.html beside an nginx Dockerfile: the site.
2506+ Precedence {
2507+ name: "html-nginx",
2508+ files: &[("index.html", "<h1>hi</h1>"), ("Dockerfile", "FROM nginx:alpine\nCOPY . /usr/share/nginx/html\nEXPOSE 80\n")],
2509+ kind: "html",
2510+ reason: Some("Built as a plain HTML site; Dockerfile is not used"),
2511+ ..PRECEDENCE
2512+ },
2513+ // An index.html beside a Dockerfile that runs a server it does
2514+ // not recognise: still the site.
2515+ Precedence {
2516+ name: "html-python-dockerfile",
2517+ files: &[("index.html", ""), ("Containerfile", "FROM python:3.12-slim\nEXPOSE 5000\nCMD [\"python\", \"app.py\"]\n")],
2518+ kind: "html",
2519+ reason: Some("Containerfile is not used"),
2520+ ..PRECEDENCE
2521+ },
2522+ // A build script's site beside a Dockerfile.
2523+ Precedence {
2524+ name: "build-script-dockerfile",
2525+ files: &[("package.json", r#"{ "scripts": { "build": "make" } }"#), ("Dockerfile", NODE_DOCKERFILE)],
2526+ reason: Some("Built as a static site; Dockerfile is not used"),
2527+ ..PRECEDENCE
2528+ },
2529+ // An Express server with no Dockerfile: a Worker, with a
2530+ // container as its fallback.
2531+ Precedence {
2532+ name: "express",
2533+ files: &[("package.json", EXPRESS), ("server.js", EXPRESS_SERVER)],
2534+ kind: "workers",
2535+ framework: Some("node"),
2536+ fallback: Some("container"),
2537+ reason: Some("Running this Express server as a Worker, from `server.js`"),
2538+ ..PRECEDENCE
2539+ },
2540+ // With a folder it could be served as, still the server.
2541+ Precedence {
2542+ name: "express-public",
2543+ files: &[("package.json", EXPRESS), ("server.js", EXPRESS_SERVER), ("public/index.html", "")],
2544+ kind: "workers",
2545+ framework: Some("node"),
2546+ fallback: Some("container"),
2547+ ..PRECEDENCE
2548+ },
2549+ // A native addon: a container from the start, saying why.
2550+ Precedence {
2551+ name: "express-addon",
2552+ files: &[
2553+ ("package.json", r#"{ "scripts": { "start": "node server.js" }, "dependencies": { "express": "5", "better-sqlite3": "12" } }"#),
2554+ ("server.js", EXPRESS_SERVER),
2555+ ],
2556+ kind: "container",
2557+ framework: Some("node"),
2558+ reason: Some("Could not run as a Worker: it depends on `better-sqlite3`, which builds native code a Worker cannot load; deploying as a container."),
2559+ ..PRECEDENCE
2560+ },
2561+ // An Express server with its own Dockerfile: the container.
2562+ Precedence {
2563+ name: "express-dockerfile",
2564+ files: &[("package.json", EXPRESS), ("server.js", EXPRESS_SERVER), ("Dockerfile", NODE_DOCKERFILE)],
2565+ kind: "container",
2566+ framework: Some("dockerfile"),
2567+ dockerfile: Some("Dockerfile"),
2568+ ..PRECEDENCE
2569+ },
2570+ // A Go server with its Dockerfile and nothing else.
2571+ Precedence {
2572+ name: "go-dockerfile",
2573+ files: &[("go.mod", "module x\n"), ("main.go", "package main\n"), ("Dockerfile", GO_DOCKERFILE)],
2574+ kind: "container",
2575+ framework: Some("dockerfile"),
2576+ dockerfile: Some("Dockerfile"),
2577+ ..PRECEDENCE
2578+ },
2579+ // A Go server beside a folder of files: the server, the files
2580+ // its fallback where containers are switched off.
2581+ Precedence {
2582+ name: "go-public",
2583+ files: &[("go.mod", "module x\n"), ("main.go", "package main\n"), ("public/index.html", "")],
2584+ kind: "container",
2585+ framework: Some("go"),
2586+ fallback: Some("static"),
2587+ reason: Some("Building it as a Go server, not as a site of `public`"),
2588+ ..PRECEDENCE
2589+ },
2590+ // A Dockerfile and nothing else.
2591+ Precedence {
2592+ name: "dockerfile-alone",
2593+ files: &[("Dockerfile", "FROM nginx")],
2594+ kind: "container",
2595+ framework: Some("dockerfile"),
2596+ dockerfile: Some("Dockerfile"),
2597+ ..PRECEDENCE
2598+ },
2599+ // A framework set up to need a server, with its Dockerfile.
2600+ Precedence {
2601+ name: "next-dockerfile",
2602+ files: &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")],
2603+ kind: "container",
2604+ framework: Some("nextjs"),
2605+ dockerfile: Some("Dockerfile"),
2606+ ..PRECEDENCE
2607+ },
2608+ ];
2609+ for case in cases {
2610+ let dir = project(&format!("p-{}", case.name), case.files);
2611+ let found = detect_as(&dir, None, None, BuildAs::Auto, None).unwrap();
2612+ let name = case.name;
2613+ assert_eq!(found.kind, case.kind, "{name}: kind");
2614+ assert_eq!(found.framework_id(), case.framework, "{name}: framework");
2615+ assert_eq!(found.problem, None, "{name}: problem");
2616+ assert_eq!(found.container.as_ref().and_then(|c| c.dockerfile.as_deref()), case.dockerfile, "{name}: dockerfile");
2617+ assert_eq!(found.fallback.as_ref().map(|f| f.kind), case.fallback, "{name}: fallback");
2618+ match case.reason {
2619+ Some(phrase) => assert!(found.reason.as_deref().is_some_and(|r| r.contains(phrase)), "{name}: {:?}", found.reason),
2620+ None if case.kind != "workers" || case.framework != Some("node") => assert_eq!(found.reason, None, "{name}: reason"),
2621+ None => {}
2622+ }
2623+ let _ = std::fs::remove_dir_all(&dir);
2624+ }
2625+ }
2626+
2627+ #[test]
2628+ fn build_as_overrides_what_is_found() {
21472629 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
2148− // A framework that builds a static site stays one beside a
2149− // Dockerfile that only serves its files: it costs nothing while no
2150− // one visits.
2151− let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2152− let found = detect_with(&vite, BuildAs::Auto, None);
2153− assert_eq!(found.kind, "static");
2154− assert!(found.reason.unwrap().contains("only serves files with `nginx`"));
2155− // Beside a Dockerfile that runs a server of its own, the server:
2156− // the site alone would leave its back end out.
2157− let full = project(
2158− "p-vite-server",
2159− &[
2160− ("package.json", r#"{ "devDependencies": { "vite": "7" } }"#),
2161− (
2162− "Dockerfile",
2163− "FROM node:22-slim AS build\nRUN npm ci && npm run build\nFROM node:22-slim\nCOPY --from=build /app /app\nENV PORT=3000\nCMD [\"node\", \"server.js\"]\n",
2164− ),
2165− ],
2166− );
2167− let found = detect_with(&full, BuildAs::Auto, None);
2168− assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2169− assert_eq!(found.container.as_ref().unwrap().port, 3000);
2170− assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("static"));
2171− assert!(found.reason.unwrap().contains("not as the static site it also has: its Dockerfile starts a server of its own (`CMD`)"));
2172− // Unless the project says Docker.
2630+ // Container uses the Dockerfile a site has beside it.
2631+ let vite = project("o-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
21732632 let found = detect_with(&vite, BuildAs::Docker, None);
21742633 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
21752634 let container = found.container.unwrap();
21762635 assert_eq!((container.dockerfile.as_deref(), container.port), (Some("Dockerfile"), 80));
2177− // A Wrangler config wins in Auto, and not when it says Static site.
2178− let workers = project("p-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
2179− assert_eq!(detect_with(&workers, BuildAs::Auto, None).kind, "workers");
2636+ // Container makes an Express server one, with the Dockerfile g1t writes.
2637+ let express = project("o-express", &[("package.json", EXPRESS), ("server.js", EXPRESS_SERVER)]);
2638+ let found = detect_with(&express, BuildAs::Docker, None);
2639+ assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2640+ assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2641+ // Auto prints the Worker's entry.
2642+ let found = detect_with(&express, BuildAs::Auto, None);
2643+ assert_eq!(
2644+ output_lines(&found),
2645+ "kind=workers\ninstall=npm install\nbuild=npx --yes wrangler@4 deploy --dry-run\noutput-dir=\nframework=node\nentry=server.js\n"
2646+ );
2647+ // Static site ignores a Wrangler config.
2648+ let workers = project("o-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
21802649 assert_eq!(detect_with(&workers, BuildAs::Static, None).kind, "html");
2181− // Workers, without a config or adapter, says what is missing.
2182− let html = project("p-html", &[("index.html", "<h1>hi</h1>")]);
2650+ // Workers, without a config, an adapter or a Node.js server, says what is missing.
2651+ let html = project("o-html", &[("index.html", "<h1>hi</h1>")]);
21832652 let found = detect_with(&html, BuildAs::Workers, None);
21842653 assert_eq!(found.kind, "none");
21852654 assert!(found.problem.unwrap().contains("no Wrangler config"));
2186− // No framework, a site and a Dockerfile that runs a server: a
2187− // container, built as the site it is without one where Docker
2188− // deployments are switched off.
2189− let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM python:3.12-slim\nEXPOSE 5000\n")]);
2190− let found = detect_with(&both, BuildAs::Auto, None);
2191− assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2192− assert_eq!(found.container.as_ref().unwrap().port, 5000);
2193− assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2194− // A site and a Dockerfile that says neither how it starts nor
2195− // where it listens: the site.
2196− let bare = project("p-bare", &[("index.html", ""), ("Dockerfile", "FROM nginx")]);
2197− let found = detect_with(&bare, BuildAs::Auto, None);
2198− assert_eq!(found.kind, "html");
2199− assert!(found.reason.unwrap().contains("neither starts a server"));
2200− // A Dockerfile and nothing else is a container, whatever it says.
2201− let alone = project("p-alone", &[("Dockerfile", "FROM nginx")]);
2202− assert_eq!(detect_with(&alone, BuildAs::Auto, None).kind, "container");
2655+ // Workers runs a Node.js server as a Worker, with no fallback,
2656+ // even one g1t thinks cannot run as one.
2657+ let addon = project(
2658+ "o-addon",
2659+ &[("package.json", r#"{ "scripts": { "start": "node server.js" }, "dependencies": { "express": "5", "sharp": "0.34" } }"#), ("server.js", "")],
2660+ );
2661+ let found = detect_with(&addon, BuildAs::Workers, None);
2662+ assert_eq!((found.kind, found.framework_id()), ("workers", Some("node")));
2663+ assert!(found.fallback.is_none());
2664+ assert!(found.reason.unwrap().contains("may not run as one: it depends on `sharp`"));
22032665 // A Dockerfile at a path of the project's choosing.
2204− let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
2666+ let chosen = project("o-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
22052667 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
22062668 let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile"));
22072669 assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000));
22082670 assert!(detect_as(&chosen, None, None, BuildAs::Auto, Some("nope")).is_err());
2209− // A server g1t recognises gets a Dockerfile written for it.
2210− let express = project("p-express", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
2211− let found = detect_with(&express, BuildAs::Auto, None);
2212− assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2213− assert!(found.fallback.is_none());
2214− assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2215− // Nothing at all says to add a Dockerfile.
2216− let nothing = project("p-nothing", &[("README.md", "")]);
2671+ // Nothing at all says what to add.
2672+ let nothing = project("o-nothing", &[("README.md", "")]);
22172673 let found = detect_with(&nothing, BuildAs::Auto, None);
22182674 assert_eq!(found.kind, "none");
22192675 assert!(found.problem.as_deref().unwrap().starts_with("found nothing to serve"));
2220− assert!(found.problem.unwrap().contains("Add a `Dockerfile`"));
2221− let found = detect_with(&nothing, BuildAs::Docker, None);
2222− assert!(found.problem.unwrap().contains("**Build as** is Docker"));
2223− // A framework set up for a server, with a Dockerfile of its own.
2224− let next = project("p-next", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")]);
2225− let found = detect_with(&next, BuildAs::Auto, None);
2226− assert_eq!((found.kind, found.framework_id()), ("container", Some("nextjs")));
2227− assert_eq!(found.container.unwrap().dockerfile.as_deref(), Some("Dockerfile"));
2676+ assert!(detect_with(&nothing, BuildAs::Docker, None).problem.unwrap().contains("**Build as** is Container"));
22282677 // OpenNext's adapter means Workers: it is not run as a server.
2229− let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
2678+ let opennext = project("o-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
22302679 assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2231− // A server g1t recognises beside a folder it could serve: the
2232− // server, saying so.
2233− let public = project(
2234− "p-public",
2235− &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#), ("public/index.html", "")],
2236− );
2237− let found = detect_with(&public, BuildAs::Auto, None);
2238− assert_eq!(found.kind, "container");
2239− assert!(found.reason.unwrap().contains("as a Node.js server, not as a site of `public`"));
2240− for dir in [vite, full, workers, html, both, bare, alone, chosen, express, nothing, next, opennext, public] {
2680+ for dir in [vite, express, workers, html, addon, chosen, nothing, opennext] {
22412681 let _ = std::fs::remove_dir_all(dir);
22422682 }
22432683 }
22442684
22452685 #[test]
2686+ fn a_workers_start_is_judged_by_its_log_and_answer() {
2687+ assert_eq!(judge_start("hello", "[wrangler:info] Ready on http://127.0.0.1:1\n"), Started::Answered);
2688+ let log = "\u{1b}[31m✘ \u{1b}[41;31m[\u{1b}[41;97mERROR\u{1b}[41;31m]\u{1b}[0m \u{1b}[1mservice core:user:g1t-app: Uncaught Error: Top-level await in module is unsettled.\u{1b}[0m\n";
2689+ assert_eq!(judge_start("", log), Started::Failed("it failed as it started: Uncaught Error: Top-level await in module is unsettled.".to_owned()));
2690+ assert!(matches!(judge_start(node_worker::NO_SERVER, ""), Started::Failed(why) if why.contains("never called `listen()`")));
2691+ }
2692+
2693+ #[test]
22462694 fn the_pushed_digest_is_the_one_in_the_apps_repository() {
22472695 let digest = "a".repeat(64);
22482696 let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n");
+4−78
115115 })
116116 }
117117
118−/// Images that serve a folder of files and nothing else, as the base of a
119−/// Dockerfile that only copies a site into them.
120−const FILE_SERVERS: [&str; 8] = [
121− "nginx",
122− "nginxinc/nginx-unprivileged",
123− "httpd",
124− "caddy",
125− "lighttpd",
126− "joseluisq/static-web-server",
127− "pierrezemb/gostatic",
128− "busybox",
129−];
130−
131−/// What a Dockerfile's last stage is: whether it runs a server of the
132−/// project's own, and in words, why, for the build's log.
133−///
134−/// It does when that stage says how it starts (`CMD` or `ENTRYPOINT`), or
135−/// says it listens (`EXPOSE`) on an image that is not only a file server
136−/// (nginx, httpd, Caddy…). A Dockerfile that only copies a built site into
137−/// such an image, or says neither, is taken to serve files, which g1t
138−/// serves itself, for nothing while no one visits.
139−pub(crate) fn runs_server(dockerfile: &str) -> (bool, String) {
140− let stage = last_stage(dockerfile);
141− let has = |word: &str| stage.iter().any(|(w, _)| w == word);
142− // `FROM [--platform=…] image[:tag|@digest] [AS name]`
143− let base = stage
144− .first()
145− .filter(|(word, _)| word == "FROM")
146− .and_then(|(_, rest)| rest.split_whitespace().find(|w| !w.starts_with("--")))
147− .map(|image| {
148− let image = image.split('@').next().unwrap_or(image);
149− // A tag is after the last `/`, so a registry's port is kept.
150− let name = match image.rsplit_once(':') {
151− Some((name, tag)) if !tag.contains('/') => name,
152− _ => image,
153− };
154− name.trim_start_matches("docker.io/").trim_start_matches("library/").to_ascii_lowercase()
155− })
156− .unwrap_or_default();
157− let starts = ["CMD", "ENTRYPOINT"].into_iter().find(|word| has(word));
158− if let Some(word) = starts {
159− return (true, format!("its Dockerfile starts a server of its own (`{word}`)"));
160− }
161− if has("EXPOSE") {
162− if FILE_SERVERS.contains(&base.as_str()) {
163− return (false, format!("its Dockerfile only serves files with `{base}`"));
164− }
165− return (true, "its Dockerfile runs a server (`EXPOSE`)".to_owned());
166− }
167− (false, "its Dockerfile neither starts a server (`CMD`, `ENTRYPOINT`) nor listens on a port (`EXPOSE`)".to_owned())
168−}
169−
170118 /// A server g1t can write a Dockerfile for.
171119 #[derive(Debug, Clone, PartialEq, Eq)]
172120 pub(crate) struct Server {
200148 }
201149
202150 /// Packages that make a Node.js project a server of its own.
203−const NODE_SERVERS: [&str; 14] = [
151+const NODE_SERVERS: [&str; 15] = [
204152 "express",
205153 "fastify",
206154 "koa",
207155 "@hapi/hapi",
208156 "@nestjs/core",
209157 "@hono/node-server",
158+ "hono",
210159 "restify",
211160 "polka",
212161 "@adonisjs/core",
383332
384333 /// A Node.js server: a `start` script, with a server package or nothing
385334 /// that looks like a static site (no `build` script and no `index.html`).
386−fn node_server(dir: &Path) -> Option<Server> {
335+pub(crate) fn node_server(dir: &Path) -> Option<Server> {
387336 let package = package(dir)?;
388337 let scripts = &package["scripts"];
389338 let server_package = NODE_SERVERS.iter().any(|name| has_dependency(&package, name));
632581 }
633582
634583 #[test]
635− fn a_dockerfile_runs_a_server_unless_it_only_serves_files() {
636− let (server, why) = runs_server("FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n");
637− assert!(server);
638− assert_eq!(why, "its Dockerfile starts a server of its own (`CMD`)");
639− assert!(runs_server("FROM golang AS build\nRUN go build\nFROM scratch\nENTRYPOINT [\"/app\"]").0);
640− assert!(runs_server("FROM python:3.12\nEXPOSE 5000").0);
641− // A site copied into a file server, by any of its names.
642− for base in ["nginx", "nginx:1.27-alpine", "docker.io/library/nginx:latest", "httpd@sha256:abc", "--platform=linux/amd64 caddy:2"] {
643− let (server, why) = runs_server(&format!("FROM node AS build\nCMD x\nFROM {base}\nCOPY --from=build /app/dist /srv\nEXPOSE 80\n"));
644− assert!(!server, "{base}");
645− assert!(why.contains("only serves files"), "{why}");
646− }
647− // nginx with a start of its own is the project's to say.
648− assert!(runs_server("FROM nginx\nCOPY . .\nCMD [\"nginx\", \"-g\", \"daemon off;\"]").0);
649− assert!(!runs_server("FROM node\nCOPY . .").0);
650− // A registry with a port is not a tag.
651− assert!(runs_server("FROM registry.local:5000/nginx\nEXPOSE 80").0);
652− }
653−
654− #[test]
655584 fn rust_servers_are_recognised() {
656585 let axum = project("axum", &[("Cargo.toml", "[package]\nname = \"todo-api\"\n\n[dependencies]\naxum = \"0.8\"\n"), ("src/main.rs", "fn main() {}")]);
657586 let found = server(&axum).unwrap();
658587 assert_eq!((found.id, found.name), ("rust", "Rust"));
659588 assert!(found.dockerfile.contains("--bin todo-api"));
660589 assert!(found.dockerfile.contains("ENTRYPOINT [\"/app\"]"));
661− assert!(runs_server(&found.dockerfile).0);
662590 let lib = project("rust-lib", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n"), ("src/main.rs", "")]);
663591 assert_eq!(server(&lib), None, "not a web server");
664592 let two = project("rust-two", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\naxum = \"0.8\"\n[[bin]]\nname = \"a\"\n[[bin]]\nname = \"b\"\n")]);
666594 }
667595
668596 #[test]
669− fn every_dockerfile_g1t_writes_runs_a_server_on_its_port() {
597+ fn every_dockerfile_g1t_writes_listens_on_its_port() {
670598 let projects = [
671599 project("w-node", &[("package.json", r#"{ "scripts": { "start": "node i.js" }, "dependencies": { "express": "5" } }"#)]),
672600 project("w-flask", &[("requirements.txt", "flask\n"), ("app.py", "")]),
677605 for dir in &projects {
678606 let found = server(dir).unwrap();
679607 assert_eq!(exposed_port(&found.dockerfile), Some(DEFAULT_PORT), "{}", found.id);
680− // PHP's Apache image starts itself: it has no `CMD` of the file's own.
681− assert!(runs_server(&found.dockerfile).0, "{}", found.id);
682608 }
683609 }
684610
+508−0
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+
19+use std::path::{Path, PathBuf};
20+
21+use regex::Regex;
22+use serde_json::Value;
23+
24+/// How the project runs as a Worker.
25+#[derive(Debug, Clone, PartialEq, Eq)]
26+pub(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)]
42+pub(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.
49+const 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.
62+const 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.
86+const 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.
98+const 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.
118+const 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.
137+const MAX_SOURCES: usize = 2_000;
138+const MAX_SOURCE_BYTES: u64 = 1024 * 1024;
139+
140+fn 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`.
145+fn 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 …`).
152+pub(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.
187+fn 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.
209+fn 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.
234+fn 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.
267+fn 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.
273+fn 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.
282+pub(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.
332+pub(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.
348+pub(crate) const PRELUDE: &str = r#"// Written by g1t: runs before the app's own code.
349+import http from "node:http";
350+globalThis.__g1tServers = [];
351+globalThis.__g1tGlobals = { Request: globalThis.Request, Response: globalThis.Response };
352+process.env.PORT ??= "8080";
353+const keep = (server) => {
354+ if (!globalThis.__g1tServers.includes(server)) globalThis.__g1tServers.push(server);
355+ return server;
356+};
357+const createServer = http.createServer;
358+http.createServer = function (...args) {
359+ return keep(createServer.apply(this, args));
360+};
361+const listen = http.Server.prototype.listen;
362+http.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.
369+pub(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.
374+pub(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.
379+import "./g1t-prelude.mjs";
380+import {{ httpServerHandler }} from "cloudflare:node";
381+import * as app from {app};
382+
383+const {{ Response: NativeResponse }} = globalThis.__g1tGlobals;
384+const own = app.default && typeof app.default.fetch === "function" ? app.default : null;
385+let handler;
386+
387+// A response made with a replaced Response class, as the runtime's own.
388+async 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+
394+export 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)]
408+mod 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+}
+101−14
Loading the diff…

Comments

Sign in to comment.