flybrain/infra/build/build-bridge.sh
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

169 lines
7.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# infra/build/build-bridge.sh [OUT_DIR]
#
# Builds services/bridge into the directory shape infra/build/package-release.sh
# expects as its BRIDGE_DIR, i.e. the thing that becomes `bridge/` inside the
# release tarball and `/opt/fly/current/bridge/` on the container:
#
# index.js the whole service as one ES module (esbuild bundle)
# package.json "type": "module", so node treats index.js as ESM
# node_modules/ the runtime dependencies left external by the bundle
#
# Nothing else. In particular no src/, no tests/, no tsconfig.json: a release
# ships what runs.
#
# WHY THIS SCRIPT EXISTS. It did not, and infra/units/flybridge.service's
# ExecStart has always been `node /opt/fly/current/bridge/index.js`, so the
# first release that carried a bridge at all (v0.1.0, 2026-09-16) shipped a
# hand-assembled bridge/ holding src/ + node_modules + package.json and NO
# index.js. The unit's own ConditionPathExists caught it and left the service
# cleanly inactive — which is the condition doing its job, and also why nobody
# noticed until someone went looking for the chat bridge on a live channel.
#
# WHY A BUNDLE AND NOT tsc. services/bridge is ESM TypeScript whose relative
# imports carry no file extension (`from './auth'`) and whose one workspace
# dependency, @flybrain/feed, has a .ts file as its package `main`. `tsc` would
# emit those specifiers unchanged, and Node's ESM resolver rejects both — so
# there is no `tsc` invocation that produces a runnable tree without first
# rewriting every import in the package. esbuild resolves and inlines them
# instead, which is what `npm run build -w @flybrain/bridge` does.
#
# WHAT STAYS EXTERNAL. @twurple/* and ws are left out of the bundle and come
# from node_modules: they are the only runtime deps, they are plain JavaScript
# already, and keeping them external means the bundle is 46 kB of our own code,
# which is reviewable and diffable against a release. bufferutil and
# utf-8-validate are ws's optional native accelerators — never installed here,
# required inside a try/catch by ws itself, and marked external so esbuild does
# not try to resolve them at build time.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
OUT_DIR="${1:-${REPO_DIR}/services/bridge/build}"
log() { echo "build-bridge: $*" >&2; }
die() { echo "build-bridge: FATAL: $*" >&2; exit 1; }
command -v npm >/dev/null 2>&1 || die "npm not found"
[ -d "${REPO_DIR}/services/bridge" ] || die "no services/bridge in ${REPO_DIR}"
# The workspace install has to exist: the bundle resolves @flybrain/feed through
# the root node_modules symlink npm workspaces creates, and the externals have
# to be there to be copied below.
[ -d "${REPO_DIR}/node_modules" ] \
|| die "no ${REPO_DIR}/node_modules — run 'npm ci' at the repo root first"
log "npm run build -w @flybrain/bridge"
npm run build --prefix "$REPO_DIR" -w @flybrain/bridge
BUNDLE="${REPO_DIR}/services/bridge/dist/index.js"
[ -f "$BUNDLE" ] || die "the build produced no ${BUNDLE}"
log "assembling ${OUT_DIR}"
rm -rf "$OUT_DIR"
mkdir -p "${OUT_DIR}/node_modules"
cp "$BUNDLE" "${OUT_DIR}/index.js"
# A package.json with "type": "module" and nothing else that matters. Copying
# services/bridge's own would carry devDependencies and a "main" pointing at
# src/index.ts, neither of which exists in the release.
cat > "${OUT_DIR}/package.json" <<'JSON'
{
"name": "@flybrain/bridge-dist",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./index.js"
}
JSON
# The externals, and their own transitive dependencies. Resolved by asking node
# rather than by listing them by hand, so a twurple upgrade that adds a
# dependency cannot quietly ship a bridge that throws ERR_MODULE_NOT_FOUND on
# its first EventSub reconnect.
log "copying runtime dependencies"
node - "$REPO_DIR" "$OUT_DIR" <<'MJS'
import { createRequire } from 'node:module';
import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
const [repoDir, outDir] = process.argv.slice(2);
const roots = ['@twurple/api', '@twurple/auth', '@twurple/eventsub-ws', 'ws'];
const seen = new Set();
/**
* The directory holding a package's package.json, resolved from `fromDir`, or null when the
* package is not installed at all.
*
* `require.resolve(`${name}/package.json`)` is the obvious way to ask and it is the FIRST thing
* tried, but it cannot be the only one: a package whose `exports` map does not list
* `./package.json` makes Node refuse the subpath with ERR_PACKAGE_PATH_NOT_EXPORTED, and every
* @twurple package is exactly that shape (measured on @twurple/* 8.1.4 with Node 22.22.2). The
* first version of this script had only that call, inside a try/catch meant for genuinely absent
* optional deps, so all four roots but `ws` were silently skipped and the run died on the
* @twurple/api assertion below — i.e. the bridge could not be built at all (found cutting
* v0.1.1, 2026-09-16).
*
* The fallback is Node's own node_modules lookup — walk up from `fromDir` looking for
* `<dir>/node_modules/<name>/package.json` — which is how a bare specifier finds its package
* directory in the first place and which `exports` has no say over. It also keeps nested
* (non-hoisted) copies working, which is what a transitive `@types/node` under
* `@d-fischer/connection` needs.
*/
function packageDir(name, fromDir) {
try {
const req = createRequire(join(fromDir, 'index.js'));
return dirname(req.resolve(`${name}/package.json`));
} catch {
// Either not installed, or an exports map that hides ./package.json. Fall through.
}
let dir = fromDir;
for (;;) {
const candidate = join(dir, 'node_modules', name);
if (existsSync(join(candidate, 'package.json'))) return candidate;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function visit(name, fromDir) {
// null means an optional peer or an unresolvable optional dependency (bufferutil): ws
// requires those inside a try/catch and works without them.
const dir = packageDir(name, fromDir);
if (dir === null) return;
if (seen.has(dir)) return;
seen.add(dir);
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
for (const dep of Object.keys(manifest.dependencies ?? {})) visit(dep, dir);
}
const bridgeDir = join(repoDir, 'services', 'bridge');
for (const root of roots) visit(root, bridgeDir);
const nodeModulesRoot = join(repoDir, 'node_modules');
let copied = 0;
for (const dir of seen) {
const rel = relative(nodeModulesRoot, dir);
if (rel === '' || rel.startsWith('..')) {
throw new Error(`refusing to copy ${dir}: not under ${nodeModulesRoot}`);
}
const dest = join(outDir, 'node_modules', rel);
mkdirSync(dirname(dest), { recursive: true });
cpSync(dir, dest, { recursive: true, dereference: true });
copied += 1;
}
if (!existsSync(join(outDir, 'node_modules', '@twurple', 'api'))) {
throw new Error('@twurple/api did not land in the assembled node_modules');
}
console.error(`build-bridge: copied ${copied} packages`);
MJS
# The assertion that matters, stated here rather than left to the container:
# flybridge.service's ConditionPathExists is on exactly this file, so a release
# without it produces a silently inactive service and no chat bridge.
[ -f "${OUT_DIR}/index.js" ] || die "no index.js in ${OUT_DIR}"
log "done: ${OUT_DIR} ($(du -sh "$OUT_DIR" | cut -f1))"
log "package it with: infra/build/package-release.sh VERSION <flysim> <stage-dir> ${OUT_DIR} <out-dir>"