Compile the API instead of running its TypeScript in production.

The unit's ExecStart named src/index.ts, so every start depended on the host
having Node 22.18 or newer for native type stripping. A deploy onto a host with
Node 20 met ERR_UNKNOWN_FILE_EXTENSION, exited in under a second, and was
restarted 464 times over fifteen hours with nothing anywhere going red.

api/tsconfig.json now emits to api/dist. The source keeps its explicit .ts import
specifiers, which is what makes `node --watch src/index.ts` work in development;
rewriteRelativeImportExtensions turns them into .js on the way out, so what runs
in production is ordinary ESM that any Node from 20.18 up will start.

`pnpm build` builds shared, then api, then web. `pnpm dev` is unchanged.

deploy/ is tracked rather than ignored: the unit files are the thing an operator
copies to /etc/systemd/system, and the alert unit added next has to live
somewhere a deploy can find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
michilis
2026-08-25 15:58:53 +02:00
co-authored by Claude Opus 5
parent 06ba3d35e7
commit 65307ba278
10 changed files with 455 additions and 17 deletions
+43 -12
View File
@@ -52,9 +52,15 @@ rating encoding, and the bugs this rebuild fixes.
## Requirements
- Node 22.18 or newer (native TypeScript type stripping, so no build step for the API)
- Node 20.18 or newer to build and to run what a build produces
- Node 22.18 or newer to *develop*: `pnpm dev`, `pnpm seed` and the `api` test scripts
run `src/*.ts` through node directly, which needs native type stripping
- pnpm 9 or newer
`engines.node` is the first of those, not the second, on purpose: it is the floor a
deployment has to clear, and a production host should never be told it needs a newer
Node than the compiled service actually runs on.
## Setup
```bash
@@ -286,7 +292,11 @@ pnpm typecheck
pnpm build
```
Output lands in `web/dist/`. Every page is prerendered once per language, so ~55 mints
Three packages in order, and the order is a dependency chain rather than a habit:
`shared` emits the types and the warning copy both other packages import, `api` compiles
`api/src` to `api/dist`, and `web` prerenders against a running API.
Output lands in `api/dist/` and `web/dist/`. Every page is prerendered once per language, so ~55 mints
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
@@ -782,6 +792,9 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
## Deployment
The unit files and the nginx block quoted below are checked in under `deploy/`. Those
are the copies to edit; what is quoted here is the same text, for reading in context.
Three units and an nginx block. Everything this project runs listens on loopback and
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
file belonging to the project.
@@ -806,19 +819,34 @@ is the one that built them.
### Node
The API and the site server both run TypeScript and ESM directly, with no build step, so
**systemd's node must be 22.18 or newer** — that is the release where native type
stripping stopped needing a flag. This is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Check the one that matters:
**Node 20.18 or newer is enough.** Nothing systemd starts reads a `.ts` file: `pnpm
build` compiles `api/src` to `api/dist`, the site server is plain `.mjs`, and both units
run `/usr/bin/node` against ordinary JavaScript. 20.18 rather than 20.0 only because
both `ExecStart` lines pass `--env-file-if-exists`, which landed there.
```bash
/usr/bin/node --version
```
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
That is the version that matters, and it is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Install Node system-wide rather than pointing
`ExecStart` at a version manager's path, which breaks at the next upgrade and is
invisible to `ProtectHome`.
**Why this section used to say 22.18.** The API ran `src/index.ts` directly, on native
type stripping, so the host's Node version was a runtime dependency of the service. A
deploy onto a host with Node 20 met `ERR_UNKNOWN_FILE_EXTENSION`, exited in under a
second, and was restarted by systemd 464 times over fifteen hours. Every dashboard was
green throughout, because there was no dashboard: `Restart=on-failure` with no start
limit is an infinite loop that never reports a failure. Two things changed. The service
is compiled, so the host's Node version cannot break it in that way again; and the units
now stop after five failures in two minutes and run an `OnFailure=` alert, so if
something else breaks it in some other way, the machine says so. See "Failing loudly".
The version floor that is still 22.18 is the *development* one — `pnpm dev`, `pnpm seed`,
`pnpm migrate` and the `api` `test:*` scripts all hand `src/*.ts` to node. That is a
laptop requirement, not a server one.
### The API
@@ -849,7 +877,10 @@ Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
# Keep ICON_DIR either way: cached icons are files, not rows.
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
# Compiled JavaScript, run by the distribution's own node. See "Node" above for why
# this is not src/index.ts any more.
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
Restart=on-failure
RestartSec=5s
@@ -1161,7 +1192,7 @@ Order matters once: the site server refuses to start against a root that has no
`index.html`, so the build has to publish before it comes up.
```bash
/usr/bin/node --version # 22.18 or newer, or the API will not run
/usr/bin/node --version # 20.18 or newer
sudo systemctl enable --now cashumints # API first: the build reads from it
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
sudo systemctl enable --now cashumints-site # now it has something to serve