A deployment is a site or a Worker whenever it can be, and a container only as the fallback. With Build as on Auto, a Wrangler config or a framework's Cloudflare adapter is a Workers project; a framework's site, a build script's or an index.html is that site even beside a Dockerfile, and the log says "Built as a Vite site; Dockerfile is not used (a container is only the fallback; choose **Build as**: Container to use it)", where a Dockerfile that ran a server used to win; a Node.js server with no Dockerfile (Express, Koa, Hono, Polka, Restify, hapi, node:http) runs as a Worker; any other server g1t recognises, a Node.js server with its own Dockerfile, a framework set up to need a server, or a Dockerfile with nothing else to serve is a container.
A Node.js server runs as a Worker through httpServerHandler from cloudflare:node with nodejs_compat (developers.cloudflare.com/workers/runtime-apis/nodejs/http: from the compatibility date 2025-09-01 nodejs_compat turns on the HTTP server modules, and g1t's date is 2026-09-26). deploy/node_worker.rs finds the file that starts it (what its start script runs with node, tsx or ts-node, else main, else server.js and the like), and the build installs and builds it, writes a prelude that keeps every server the app creates or listens on and lets listen take any port and address, and an entry that imports the app and answers with httpServerHandler, or with the app's default export's fetch when it has one (a Hono app, whose Response @hono/node-server replaces is turned back into the runtime's). wrangler deploy --dry-run bundles it, and wrangler dev starts it once with the project's variables and asks it for /: a module that cannot load, a top-level await that never settles, or a server that never listens fails there, not in production. A folder served with app.use(express.static("public")) is uploaded as the Worker's static files. What cannot run as a Worker is found before anything is built, and the app is a container from the Dockerfile g1t writes, the log saying "Could not run as a Worker: <reason>; deploying as a container.": a native addon (better-sqlite3, bcrypt, sharp and others), Fastify, NestJS, Sails, remix-serve or react-router-serve, a WebSocket server (socket.io, express-ws, WebSocketServer, an upgrade handler), child_process, cluster or worker_threads, files read or written while it runs, files served from disk under a path, or a start script g1t cannot read. A Worker that does not bundle or start falls back the same way. Build as Workers runs a Node.js server as one whatever is found, and Build as Container (shown as Container now, still `docker` as stored) runs its container. g1t-runner detect and g1t/detect print entry for such a Worker.
- Checked:
- cargo test -p g1t-runner (the precedence table: Vite with a Dockerfile is a site, Express without one a Worker, Express with better-sqlite3 a container, a Go server with its Dockerfile a container, a Wrangler config with a Dockerfile Workers, index.html with an nginx Dockerfile a site), and on Linux the ignored end-to-end tests in tests/deploy_cli.rs, which install Express, bundle and start its Worker and deploy it to a fake API with public/ as its assets, and fall back to a container when the bundle fails or a top-level await never settles. Express, Hono on @hono/node-server and Fastify were run by hand with wrangler dev: Express and Hono answered, and Fastify, which boots on timers, did not, so it is a container. The deployments service's tests and typecheck, the web typecheck, and the docs build pass. The Deployments guide has the rule, Node.js servers as Workers, and how to force a container; the architecture page, the Build as hint and g1t/detect's outputs follow it.
| 24 | 24 | | `root-dir` | The checkout | Where the project is in the checkout. | | |
| 25 | 25 | | `build-command` | None | A build command of your own, as `g1t/deploy` takes it. | | |
| 26 | 26 | | `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. | | |
| 28 | 28 | | `dockerfile` | None | A Dockerfile's path of your own, as `g1t/deploy` takes it. | | |
| 29 | 29 | ||
| 30 | 30 | ## Outputs | |
| 31 | 31 | ||
| 32 | 32 | | Output | | | |
| 33 | 33 | | --- | --- | | |
| 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. | | |
| 36 | 36 | | `dockerfile` | A container's own Dockerfile, relative to the project. Empty when g1t writes one, and for anything that is not a container. | | |
| 37 | 37 | | `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. | | |
| 38 | 39 | | `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. | | |
| 39 | 40 | | `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. | | |
| 40 | 41 | | `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. | |
| 26 | 26 | ||
| 27 | 27 | outputs: | |
| 28 | 28 | 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). | |
| 30 | 30 | value: ${{ steps.detect.outputs.kind }} | |
| 31 | 31 | install: | |
| 32 | 32 | description: How its dependencies are installed, such as npm ci. Empty without a package.json. | |
| ⋯ | |||
| 38 | 38 | description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it. | |
| 39 | 39 | value: ${{ steps.detect.outputs.output-dir }} | |
| 40 | 40 | 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. | |
| 42 | 42 | value: ${{ steps.detect.outputs.framework }} | |
| 43 | 43 | dockerfile: | |
| 44 | 44 | description: A container's own Dockerfile, relative to the project. Empty when g1t writes one, or for anything else. | |
| ⋯ | |||
| 46 | 46 | port: | |
| 47 | 47 | description: The port a container listens on, its Dockerfile's EXPOSE or 8080. Empty for anything else. | |
| 48 | 48 | 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 }} | |
| 49 | 52 | ||
| 50 | 53 | runs: | |
| 51 | 54 | using: composite | |
| 21 | 21 | open pull request gets a preview. `deployments` reads pushes from its | |
| 22 | 22 | event queue and asks `runner` for a sandbox in `deploy` mode | |
| 23 | 23 | ([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)): | |
| 25 | 27 | ||
| 26 | 28 | 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. | |
| 32 | 45 | ||
| 33 | 46 | A container's image is pushed to Cloudflare's registry with a push-only | |
| 34 | 47 | login `deployments` mints for the build, and runs on Cloudflare |
| 9 | 9 | and the default branch goes to production on every push. Reviewers | |
| 10 | 10 | click through the change instead of reading a diff. | |
| 11 | 11 | ||
| 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): | |
| 15 | 17 | ||
| 16 | 18 | | | Address | | |
| 17 | 19 | | --- | --- | | |
| ⋯ | |||
| 97 | 99 | | The project has | g1t | | |
| 98 | 100 | | --- | --- | | |
| 99 | 101 | | `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**. | | |
| 101 | 103 | | 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. | | |
| 103 | 108 | | 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: | |
| 108 | 117 | ||
| 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 | + | ||
| 109 | 145 | **Settings → Deployments** shows what g1t detected at the project's last | |
| 110 | 146 | finished build and what it was built with, such as **Static site · | |
| 111 | 147 | Astro**, **Workers project**, **Container · Dockerfile** or **Container · | |
| ⋯ | |||
| 204 | 240 | lists each one it left out. Code that needs them should check that the | |
| 205 | 241 | binding is there. | |
| 206 | 242 | ||
| 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 | + | ||
| 207 | 303 | ### Docker deployments | |
| 208 | 304 | ||
| 209 | 305 | Any app that runs in a container deploys as one, whatever its framework or | |
| ⋯ | |||
| 212 | 308 | behind the app's Worker, at the same addresses, custom domains and | |
| 213 | 309 | previews as any other app. | |
| 214 | 310 | ||
| 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**. | |
| 218 | 319 | ||
| 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." | |
| 241 | 325 | ||
| 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 | − | ||
| 248 | 326 | g1t writes a Dockerfile for these, when there is none: | |
| 249 | 327 | ||
| 250 | 328 | | The project has | Runs with | | |
| ⋯ | |||
| 451 | 529 | runs on Linux and macOS, with `curl` and `jq` or `python3`. | |
| 452 | 530 | ||
| 453 | 531 | `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 | |
| 457 | 536 | need a server is `none`, and the step's log says what to change. Use | |
| 458 | 537 | them to cache, or to fail early: | |
| 459 | 538 | ||
| ⋯ | |||
| 635 | 714 | | --- | --- | --- | | |
| 636 | 715 | | Production | On | Deploy the default branch on every push. | | |
| 637 | 716 | | 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). | | |
| 639 | 718 | | Dockerfile | `Dockerfile` or `Containerfile` | For a container: its Dockerfile's path, inside the project. | | |
| 640 | 719 | | Container size | `lite` | For a container: the instance it runs at, with its price an hour awake. | | |
| 641 | 720 | | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. Not used for a container. | | |
| ⋯ | |||
| 878 | 957 | | "Deployments come with the g1t plan … and `<workspace>` does not have it" | An owner starts the g1t plan under Billing. | | |
| 879 | 958 | | 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. | | |
| 880 | 959 | | "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**. | | |
| 882 | 961 | | "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. | | |
| 884 | 965 | | "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**. | | |
| 885 | 966 | | 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. | | |
| 886 | 967 | | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. | | |
| 36 | 36 | { value: "auto", label: "Auto: found from its files" }, | |
| 37 | 37 | { value: "static", label: "Static site" }, | |
| 38 | 38 | { value: "workers", label: "Workers" }, | |
| 39 | − | { value: "docker", label: "Docker" }, | |
| 39 | + | { value: "docker", label: "Container" }, | |
| 40 | 40 | ]; | |
| 41 | 41 | ||
| 42 | 42 | /** `1/16 vCPU, 256 MiB, 2 GB disk`. */ |
| 172 | 172 | 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. | |
| 173 | 173 | </Check> | |
| 174 | 174 | <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."> | |
| 176 | 176 | <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} /> | |
| 177 | 177 | </Field> | |
| 178 | 178 | <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty."> |
| 7 | 7 | //! the sandbox uploads them with it, and sends the Worker's code to the | |
| 8 | 8 | //! service, which puts the app in place. | |
| 9 | 9 | //! | |
| 10 | − | //! What gets built: | |
| 10 | + | //! What gets built, a site or a Worker before a container (`detect_as` | |
| 11 | + | //! says the order): | |
| 11 | 12 | //! | |
| 12 | 13 | //! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or | |
| 13 | 14 | //! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its | |
| ⋯ | |||
| 22 | 23 | //! bundled as a Workers project's is, without a Wrangler config of its | |
| 23 | 24 | //! own. A framework set up to need a server fails before anything is | |
| 24 | 25 | //! 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 | |
| 30 | 36 | //! and pushed to Cloudflare's registry with a push-only login the | |
| 31 | 37 | //! deployments service gives this build alone, for minutes (the | |
| 32 | 38 | //! `registry` step); the service runs it. | |
| ⋯ | |||
| 35 | 41 | //! exists is served, or the repository itself if it has an `index.html`. | |
| 36 | 42 | //! | |
| 37 | 43 | //! 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 | |
| 39 | 45 | //! directory** win over what is detected. | |
| 40 | 46 | //! | |
| 41 | 47 | //! Configuration comes from the environment: | |
| ⋯ | |||
| 65 | 71 | use anyhow::{Context, Result, bail}; | |
| 66 | 72 | use base64::Engine; | |
| 67 | 73 | use base64::engine::general_purpose::STANDARD; | |
| 74 | + | use regex::Regex; | |
| 68 | 75 | use serde::{Deserialize, Serialize}; | |
| 69 | 76 | use serde_json::{Value, json}; | |
| 70 | 77 | use sha2::{Digest, Sha256}; | |
| ⋯ | |||
| 74 | 81 | ||
| 75 | 82 | mod container; | |
| 76 | 83 | mod frameworks; | |
| 84 | + | mod node_worker; | |
| 77 | 85 | ||
| 78 | 86 | use container::BuildAs; | |
| 79 | 87 | use frameworks::{Adapter, Framework}; | |
| 88 | + | use node_worker::NodeWorker; | |
| 80 | 89 | ||
| 81 | 90 | /// Where `wrangler deploy --dry-run` writes the bundle in a sandbox. | |
| 82 | 91 | const BUNDLE_DIR: &str = "/work/g1t-bundle"; | |
| ⋯ | |||
| 654 | 663 | pre_build: Option<String>, | |
| 655 | 664 | /// For a container: its Dockerfile, or the one g1t writes. | |
| 656 | 665 | 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. | |
| 660 | 674 | fallback: Option<Box<Detected>>, | |
| 661 | 675 | /// Why it is built as this and not as the other thing it could be (a | |
| 662 | 676 | /// site beside a Dockerfile), for the build's log. | |
| ⋯ | |||
| 687 | 701 | problem: Some(problem), | |
| 688 | 702 | pre_build: None, | |
| 689 | 703 | container: None, | |
| 704 | + | node: None, | |
| 690 | 705 | fallback: None, | |
| 691 | 706 | reason: None, | |
| 692 | 707 | } | |
| ⋯ | |||
| 696 | 711 | /// a container the server g1t wrote a Dockerfile for (`flask`…) or | |
| 697 | 712 | /// `dockerfile`. | |
| 698 | 713 | 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(|| { | |
| 700 | 715 | self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id)) | |
| 701 | 716 | }) | |
| 702 | 717 | } | |
| 703 | 718 | } | |
| 704 | 719 | ||
| 705 | 720 | /// 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."; | |
| 709 | 724 | ||
| 710 | 725 | /// The project as a container: its own Dockerfile, else one g1t writes | |
| 711 | 726 | /// for `framework` or for a server it recognises. None when it has neither. | |
| ⋯ | |||
| 736 | 751 | problem: None, | |
| 737 | 752 | pre_build: None, | |
| 738 | 753 | container: Some(build), | |
| 754 | + | node: None, | |
| 739 | 755 | fallback: None, | |
| 740 | 756 | reason: None, | |
| 741 | 757 | }) | |
| ⋯ | |||
| 747 | 763 | } | |
| 748 | 764 | ||
| 749 | 765 | /// 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. | |
| 751 | 769 | /// | |
| 752 | − | /// - **Docker**: a container, from its Dockerfile or one g1t writes. | |
| 770 | + | /// - **Container**: a container, from its Dockerfile or one g1t writes. | |
| 753 | 771 | /// - **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. | |
| 767 | 791 | pub(crate) fn detect_as( | |
| 768 | 792 | dir: &Path, | |
| 769 | 793 | custom_build: Option<&str>, | |
| ⋯ | |||
| 779 | 803 | Detected::nothing( | |
| 780 | 804 | framework, | |
| 781 | 805 | 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 \ | |
| 783 | 807 | one for. Add a `Dockerfile` (or set its path under Settings, Deployments)." | |
| 784 | 808 | .to_owned(), | |
| 785 | 809 | ) | |
| ⋯ | |||
| 791 | 815 | if site.kind == "workers" { | |
| 792 | 816 | return Ok(site); | |
| 793 | 817 | } | |
| 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 | + | } | |
| 794 | 835 | Ok(Detected::nothing( | |
| 795 | 836 | site.framework, | |
| 796 | 837 | site.install, | |
| 797 | 838 | "**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." | |
| 799 | 840 | .to_owned(), | |
| 800 | 841 | )) | |
| 801 | 842 | } | |
| 802 | 843 | BuildAs::Auto => { | |
| 803 | 844 | let site = detect_site(dir, custom_build, chosen_output, false)?; | |
| 845 | + | // 1. A Workers project. | |
| 804 | 846 | if site.kind == "workers" { | |
| 805 | 847 | return Ok(site); | |
| 806 | 848 | } | |
| 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 | + | })); | |
| 822 | 856 | } | |
| 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())); | |
| 833 | 858 | } | |
| 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 | + | }); | |
| 840 | 869 | } | |
| 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 | + | { | |
| 844 | 874 | if let (Some(server), Some(folder)) = (found.container.as_ref().and_then(|c| c.generated.as_ref()), &site.output_dir) { | |
| 845 | 875 | found.reason = Some(format!( | |
| 846 | 876 | "Building it as a {} server, not as a site of `{folder}`. Set **Build as** to Static site to serve only `{folder}` instead.", | |
| 847 | 877 | server.name | |
| 848 | 878 | )); | |
| 849 | 879 | } | |
| 880 | + | if site.problem.is_none() && (site.kind == "static" || site.kind == "html") { | |
| 881 | + | found.fallback = Some(Box::new(site)); | |
| 882 | + | } | |
| 850 | 883 | return Ok(found); | |
| 851 | 884 | } | |
| 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); | |
| 854 | 891 | } | |
| 855 | − | Ok(site) | |
| 892 | + | Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned())) | |
| 856 | 893 | } | |
| 857 | 894 | } | |
| 858 | 895 | } | |
| 859 | 896 | ||
| 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 | + | ||
| 860 | 941 | /// What the project is as a static site or Workers project: `as_static` | |
| 861 | 942 | /// ignores its Wrangler config and any framework adapter, and builds it | |
| 862 | 943 | /// even when its framework is set up to need a server. | |
| ⋯ | |||
| 887 | 968 | problem: None, | |
| 888 | 969 | pre_build, | |
| 889 | 970 | container: None, | |
| 971 | + | node: None, | |
| 890 | 972 | fallback: None, | |
| 891 | 973 | reason: None, | |
| 892 | 974 | }); | |
| ⋯ | |||
| 908 | 990 | build, | |
| 909 | 991 | problem: None, | |
| 910 | 992 | container: None, | |
| 993 | + | node: None, | |
| 911 | 994 | fallback: None, | |
| 912 | 995 | reason: None, | |
| 913 | 996 | }); | |
| ⋯ | |||
| 931 | 1014 | (None, None) => "none", | |
| 932 | 1015 | _ => "static", | |
| 933 | 1016 | }; | |
| 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 }) | |
| 935 | 1018 | } | |
| 936 | 1019 | ||
| 937 | 1020 | /// The compatibility date a Worker gets when its config names none. | |
| ⋯ | |||
| 1144 | 1227 | pushed.map(Some) | |
| 1145 | 1228 | } | |
| 1146 | 1229 | ||
| 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 | + | ||
| 1147 | 1446 | fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> { | |
| 1148 | 1447 | let project = project_dir(); | |
| 1149 | 1448 | let dir = project.as_path(); | |
| ⋯ | |||
| 1162 | 1461 | if let Some(problem) = &plan.problem { | |
| 1163 | 1462 | bail!("{problem}"); | |
| 1164 | 1463 | } | |
| 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 | + | } | |
| 1165 | 1477 | if plan.container.is_some() { | |
| 1166 | 1478 | match container_build(reporter, log, secrets, dir, &plan)? { | |
| 1167 | 1479 | Some(image) => { | |
| ⋯ | |||
| 1450 | 1762 | ||
| 1451 | 1763 | Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds), | |
| 1452 | 1764 | 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 | |
| 1454 | 1766 | BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container | |
| 1455 | 1767 | is built with the docker on PATH. | |
| 1456 | 1768 | ||
| ⋯ | |||
| 1488 | 1800 | let _ = std::fs::remove_dir_all(bundle_dir()); | |
| 1489 | 1801 | let _ = std::fs::remove_file(adapter_config_path()); | |
| 1490 | 1802 | let _ = std::fs::remove_dir_all(docker_config_dir()); | |
| 1803 | + | let _ = std::fs::remove_dir_all(node_worker_dir()); | |
| 1491 | 1804 | } | |
| 1492 | 1805 | let (sent, failed) = match outcome { | |
| 1493 | 1806 | Ok(mut finish) => { | |
| ⋯ | |||
| 1535 | 1848 | "--root-dir" => root = rest.next().cloned().unwrap_or_default(), | |
| 1536 | 1849 | "--help" | "-h" => { | |
| 1537 | 1850 | 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." | |
| 1539 | 1852 | ); | |
| 1540 | 1853 | return 0; | |
| 1541 | 1854 | } | |
| ⋯ | |||
| 1584 | 1897 | if let Some(container) = &found.container { | |
| 1585 | 1898 | lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port)); | |
| 1586 | 1899 | } | |
| 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 | + | } | |
| 1587 | 1904 | lines | |
| 1588 | 1905 | } | |
| 1589 | 1906 | ||
| ⋯ | |||
| 1778 | 2095 | problem: None, | |
| 1779 | 2096 | pre_build: None, | |
| 1780 | 2097 | container: None, | |
| 2098 | + | node: None, | |
| 1781 | 2099 | fallback: None, | |
| 1782 | 2100 | reason: None, | |
| 1783 | 2101 | }; | |
| ⋯ | |||
| 2142 | 2460 | } | |
| 2143 | 2461 | } | |
| 2144 | 2462 | ||
| 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 | + | ||
| 2145 | 2487 | #[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() { | |
| 2147 | 2629 | 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")]); | |
| 2173 | 2632 | let found = detect_with(&vite, BuildAs::Docker, None); | |
| 2174 | 2633 | assert_eq!((found.kind, found.framework_id()), ("container", Some("vite"))); | |
| 2175 | 2634 | let container = found.container.unwrap(); | |
| 2176 | 2635 | 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", "")]); | |
| 2180 | 2649 | 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>")]); | |
| 2183 | 2652 | let found = detect_with(&html, BuildAs::Workers, None); | |
| 2184 | 2653 | assert_eq!(found.kind, "none"); | |
| 2185 | 2654 | 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`")); | |
| 2203 | 2665 | // 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")]); | |
| 2205 | 2667 | assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none"); | |
| 2206 | 2668 | let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile")); | |
| 2207 | 2669 | assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000)); | |
| 2208 | 2670 | 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", "")]); | |
| 2217 | 2673 | let found = detect_with(¬hing, BuildAs::Auto, None); | |
| 2218 | 2674 | assert_eq!(found.kind, "none"); | |
| 2219 | 2675 | 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(¬hing, 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(¬hing, BuildAs::Docker, None).problem.unwrap().contains("**Build as** is Container")); | |
| 2228 | 2677 | // 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" } }"#)]); | |
| 2230 | 2679 | 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] { | |
| 2241 | 2681 | let _ = std::fs::remove_dir_all(dir); | |
| 2242 | 2682 | } | |
| 2243 | 2683 | } | |
| 2244 | 2684 | ||
| 2245 | 2685 | #[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] | |
| 2246 | 2694 | fn the_pushed_digest_is_the_one_in_the_apps_repository() { | |
| 2247 | 2695 | let digest = "a".repeat(64); | |
| 2248 | 2696 | let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n"); | |
| 115 | 115 | }) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 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 | − | ||
| 170 | 118 | /// A server g1t can write a Dockerfile for. | |
| 171 | 119 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 172 | 120 | pub(crate) struct Server { | |
| ⋯ | |||
| 200 | 148 | } | |
| 201 | 149 | ||
| 202 | 150 | /// Packages that make a Node.js project a server of its own. | |
| 203 | − | const NODE_SERVERS: [&str; 14] = [ | |
| 151 | + | const NODE_SERVERS: [&str; 15] = [ | |
| 204 | 152 | "express", | |
| 205 | 153 | "fastify", | |
| 206 | 154 | "koa", | |
| 207 | 155 | "@hapi/hapi", | |
| 208 | 156 | "@nestjs/core", | |
| 209 | 157 | "@hono/node-server", | |
| 158 | + | "hono", | |
| 210 | 159 | "restify", | |
| 211 | 160 | "polka", | |
| 212 | 161 | "@adonisjs/core", | |
| ⋯ | |||
| 383 | 332 | ||
| 384 | 333 | /// A Node.js server: a `start` script, with a server package or nothing | |
| 385 | 334 | /// 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> { | |
| 387 | 336 | let package = package(dir)?; | |
| 388 | 337 | let scripts = &package["scripts"]; | |
| 389 | 338 | let server_package = NODE_SERVERS.iter().any(|name| has_dependency(&package, name)); | |
| ⋯ | |||
| 632 | 581 | } | |
| 633 | 582 | ||
| 634 | 583 | #[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] | |
| 655 | 584 | fn rust_servers_are_recognised() { | |
| 656 | 585 | let axum = project("axum", &[("Cargo.toml", "[package]\nname = \"todo-api\"\n\n[dependencies]\naxum = \"0.8\"\n"), ("src/main.rs", "fn main() {}")]); | |
| 657 | 586 | let found = server(&axum).unwrap(); | |
| 658 | 587 | assert_eq!((found.id, found.name), ("rust", "Rust")); | |
| 659 | 588 | assert!(found.dockerfile.contains("--bin todo-api")); | |
| 660 | 589 | assert!(found.dockerfile.contains("ENTRYPOINT [\"/app\"]")); | |
| 661 | − | assert!(runs_server(&found.dockerfile).0); | |
| 662 | 590 | let lib = project("rust-lib", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n"), ("src/main.rs", "")]); | |
| 663 | 591 | assert_eq!(server(&lib), None, "not a web server"); | |
| 664 | 592 | let two = project("rust-two", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\naxum = \"0.8\"\n[[bin]]\nname = \"a\"\n[[bin]]\nname = \"b\"\n")]); | |
| ⋯ | |||
| 666 | 594 | } | |
| 667 | 595 | ||
| 668 | 596 | #[test] | |
| 669 | − | fn every_dockerfile_g1t_writes_runs_a_server_on_its_port() { | |
| 597 | + | fn every_dockerfile_g1t_writes_listens_on_its_port() { | |
| 670 | 598 | let projects = [ | |
| 671 | 599 | project("w-node", &[("package.json", r#"{ "scripts": { "start": "node i.js" }, "dependencies": { "express": "5" } }"#)]), | |
| 672 | 600 | project("w-flask", &[("requirements.txt", "flask\n"), ("app.py", "")]), | |
| ⋯ | |||
| 677 | 605 | for dir in &projects { | |
| 678 | 606 | let found = server(dir).unwrap(); | |
| 679 | 607 | 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); | |
| 682 | 608 | } | |
| 683 | 609 | } | |
| 684 | 610 | ||
| 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 | + | } |
Comments
Sign in to comment.