Skip to content

Commit

Merge branch 'worktree-agent-ae1b1f625cfdcab1b'

syntaqxcommitted Parentsd87be0cba2f3ceBrowse 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…