From a66f50ad20c37f1ef04b0918280761a9a7d0de04 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Mon, 17 Aug 2026 23:43:37 +0200 Subject: [PATCH] fix(build): restore @swc/helpers in the standalone bundle, health-check the deploy next 16.3.1 traces only the cjs half of @swc/helpers into output: standalone, while its own require-hook loads the ESM entry point. The bundle starts and then dies on the first request needing a helper: Cannot find module .../@swc/helpers/esm/_interop_require_default.js raised as an unhandledRejection. The process stays alive without ever binding the port, so PM2 reports it online while every request 503s. That took forms.msk-scripts.de down after the 16.2.12 -> 16.3.1 bump in #240. copy-standalone-assets.mjs already exists for this exact class of problem, it puts sharp and the @img closure back after Next declines to trace them. Add @swc/helpers to it, copying the whole package per store entry rather than the one missing directory, so a change to which half gets traced cannot bring this back. Version-matched: grafting the esm of one version onto another happens to work but is not something to ship. The deploy also gets a health check. It ran green through this outage because it only asked PM2, which answers about the process, not about whether anything is listening. Curl the port instead, retry for a minute, and dump the error log before failing. A deploy that cannot serve should not report success. --- .github/workflows/deploy.yml | 18 +++++++++ apps/web/scripts/copy-standalone-assets.mjs | 41 +++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0cb2254..8b5d6de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,6 +58,24 @@ jobs: echo "-> Save PM2 process list" pm2 save + # PM2 reporting "online" only means the process exists, which is not + # the same as it serving. On 2026-08-17 a bundle missing part of + # @swc/helpers died on startup without ever binding the port: PM2 + # showed online, this workflow reported success, and the site + # returned 503 for 40 minutes. Ask the port, not the supervisor. + echo "-> Health check" + code="" + for i in $(seq 1 20); do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:3008/ || true) + [ "$code" = "200" ] && { echo "Serving after ${i} attempt(s)."; break; } + sleep 3 + done + if [ "$code" != "200" ]; then + echo "::error::Web process is not serving on :3008 (last status: ${code:-none})" + pm2 logs msk-forms-web --lines 40 --nostream --err || true + exit 1 + fi + echo "Deploy finished" - name: Notify Discord (optional) diff --git a/apps/web/scripts/copy-standalone-assets.mjs b/apps/web/scripts/copy-standalone-assets.mjs index 930d753..e126c21 100644 --- a/apps/web/scripts/copy-standalone-assets.mjs +++ b/apps/web/scripts/copy-standalone-assets.mjs @@ -7,6 +7,11 @@ // into the bundle under Turbopack — at runtime sharp then fails with // "Could not load the sharp module … libvips-cpp.so… cannot open shared object // file". So we copy sharp + its platform `@img/*` binaries in explicitly. +// +// Same story for `@swc/helpers`, whose ESM half 16.3.1 leaves behind. Both are +// the same lesson: a green build says nothing about whether the bundle it +// produced can actually boot, so anything Next declines to trace has to be put +// back here by hand. import { cpSync, existsSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -89,11 +94,47 @@ function copySharp() { } } +/** + * Restore `@swc/helpers` in the bundle's pnpm store. + * + * Next 16.3.1 traces only the `cjs/` half of the package into + * `output: standalone`, but its own `require-hook` loads the ESM entry point. + * The bundle therefore starts and then dies on the first request that needs a + * helper: "Cannot find module …/@swc/helpers/esm/_interop_require_default.js", + * raised as an unhandledRejection. The process stays alive without ever binding + * the port, so PM2 reports it as online while every request 503s. This took the + * site down on 2026-08-17 (next 16.2.12 -> 16.3.1). + * + * Copy the whole package rather than just the missing directory, so a future + * change to which half gets traced cannot reintroduce this. Version-matched per + * store entry: mixing the `esm/` of one version into another happens to work + * but is not something to ship. + */ +function copySwcHelpers() { + const standalonePnpm = join(webRoot, ".next", "standalone", "node_modules", ".pnpm"); + const sourcePnpm = join(webRoot, "..", "..", "node_modules", ".pnpm"); + if (!existsSync(standalonePnpm) || !existsSync(sourcePnpm)) return; + + for (const entry of readdirSync(standalonePnpm)) { + if (!entry.startsWith("@swc+helpers@")) continue; + const src = join(sourcePnpm, entry, "node_modules", "@swc", "helpers"); + const dst = join(standalonePnpm, entry, "node_modules", "@swc", "helpers"); + if (!existsSync(src)) { + console.log(`${entry}: no source copy found, leaving the traced one alone.`); + continue; + } + rmSync(dst, { recursive: true, force: true }); + cpSync(src, dst, { recursive: true, dereference: true }); + console.log(`restored ${entry} -> ${dst}`); + } +} + if (!existsSync(standaloneWeb)) { console.log("No standalone output found; skipping standalone asset copy."); } else { mirror(join(".next", "static"), join(standaloneWeb, ".next", "static")); mirror("public", join(standaloneWeb, "public")); copySharp(); + copySwcHelpers(); console.log("Standalone assets ready."); }