Merge branch 'worktree-agent-ae1b1f625cfdcab1b'
10 files+1365−2980/10 viewed
| 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 | + | } |
Loading the diff…