From f7033f7cfa373fb8deca5e890cfd7d697de771f6 Mon Sep 17 00:00:00 2001 From: Amey Mandalik Date: Mon, 13 Jul 2026 14:58:37 -0700 Subject: [PATCH] fix(heroku): unblock deploys and cache hashed assets immutably Heroku deploys had been failing on every push (43/43 builds). Two independent blockers, plus a caching bug in the static server, are fixed here. 1. engines.node was ">=18.0.0". The Heroku Node buildpack prioritizes engines.node over the NODE_VERSION config var and resolves the open range to the newest release (Node 26), against which the tree-sitter native module fails to compile (node-gyp build error), aborting pnpm install. Pin to 22.x to match mise.toml/CI and the tested toolchain. 2. heroku-postbuild runs pnpm build, which invokes `tree-sitter generate`, but the CLI is not present on the buildpack. Download the 0.25.10 linux-x64 binary into .heroku-bin/ and prepend it to PATH for the build, mirroring what .github/workflows/ci.yml already does. .heroku-bin/ is gitignored. 3. The static server's hash detection used /\.[a-f0-9]{8,}\.\w+$/, which requires a dot before a lowercase-hex hash. Vite emits name-.ext (e.g. index-FMgzpBhJ.js), so every UI asset fell through to max-age=60 instead of the intended 1-year immutable cache. Replace with isContentHashed(), recognizing both Vite (-hash) and Docusaurus (.hash/-hash) content hashes while excluding plain kebab-case names so unhashed files are never frozen in caches; HTML is always revalidated. Verified end-to-end in the real heroku/heroku:24-build image (x86_64, Node 22, NODE_ENV=production): 16/16 build tasks green and apps/ui/dist/{index.html, docs/index.html} produced. Cache logic checked against all 274 built files with 0 false positives and 0 missed hashes; path-traversal regression suite passes. --- .gitignore | 3 +++ package.json | 4 ++-- server.mjs | 21 +++++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index bc69953a..e72f9871 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ CLAUDE.local.md # Dev-time utilities profile-ast.mjs + +# Heroku build-time tree-sitter CLI (fetched during heroku-postbuild) +.heroku-bin/ diff --git a/package.json b/package.json index 67ca04f3..bbe05952 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "changeset": "changeset", "changeset:version": "changeset version", "changeset:status": "changeset status", - "heroku-postbuild": "corepack enable && pnpm install --frozen-lockfile && DOCS_BASE_PATH=/docs/ pnpm run build && cp -r apps/docs/build apps/ui/dist/docs", + "heroku-postbuild": "corepack enable && pnpm install --frozen-lockfile && mkdir -p .heroku-bin && curl -sSL https://github.com/tree-sitter/tree-sitter/releases/download/v0.25.10/tree-sitter-linux-x64.gz | gunzip > .heroku-bin/tree-sitter && chmod +x .heroku-bin/tree-sitter && PATH=\"$PWD/.heroku-bin:$PATH\" DOCS_BASE_PATH=/docs/ pnpm run build && cp -r apps/docs/build apps/ui/dist/docs", "start": "node server.mjs", "test:server-path-traversal": "node scripts/test-path-traversal.mjs", "prepare": "husky" @@ -75,7 +75,7 @@ "yaml": "^2.9.0" }, "engines": { - "node": ">=18.0.0", + "node": "22.x", "pnpm": ">=8.0.0" }, "packageManager": "pnpm@10.28.0", diff --git a/server.mjs b/server.mjs index a658b88c..7e078a80 100644 --- a/server.mjs +++ b/server.mjs @@ -46,14 +46,31 @@ function isInsideStaticRoot(absPath) { return absPath === STATIC_ROOT || absPath.startsWith(STATIC_ROOT + sep); } +// Detect build-time content-hashed assets so they can be cached immutably for a +// year. Vite emits `name-.ext` (e.g. index-FMgzpBhJ.js) and Docusaurus +// emits `name..ext` / `name-.ext`. A genuine hash is either hex or a +// mixed-case/digit-bearing base64url segment of >=8 chars — this deliberately +// excludes plain kebab-case names (e.g. tree-sitter-agentscript.wasm, +// agent-script-recipes.png) so unhashed files are not frozen in caches. HTML is +// always revalidated so new deploys are picked up. +function isContentHashed(filePath) { + if (extname(filePath) === '.html') return false; + const match = filePath.match(/[-.]([A-Za-z0-9_-]{8,})\.\w+$/); + if (!match) return false; + const hash = match[1]; + const isHex = /^[0-9a-fA-F]{8,}$/.test(hash); + const isBase64WithDigit = /[0-9]/.test(hash) && /[a-zA-Z]/.test(hash); + const isMixedCase = /[a-z]/.test(hash) && /[A-Z]/.test(hash); + return isHex || isBase64WithDigit || isMixedCase; +} + async function serveFile(res, filePath) { try { const data = await readFile(filePath); const ext = extname(filePath); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - const isHashed = /\.[a-f0-9]{8,}\.\w+$/.test(filePath); - const cacheControl = isHashed + const cacheControl = isContentHashed(filePath) ? 'public, max-age=31536000, immutable' : 'public, max-age=60';