diff --git a/.gitignore b/.gitignore index d55a740b..46e8b7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,9 @@ CLAUDE.local.md profile-ast.mjs scripts/sync-vscode-theme.ts +# Heroku build-time tree-sitter CLI (fetched during heroku-postbuild) +.heroku-bin/ + # ── GSD baseline (auto-generated) ── .gsd .gsd-id @@ -97,4 +100,3 @@ os-sync/copybara_deploy.jar # AI Readiness audit artifacts (intermediate; AI_READINESS_REPORT.md is committed) AI_READINESS_PRECHECK*.json AI_READINESS_JUDGMENT.json - diff --git a/package.json b/package.json index 914be454..68e9213b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,12 @@ "docs:build": "pnpm --filter @agentscript/docs build", "docs:serve": "pnpm --filter @agentscript/docs serve", "docs:typedoc": "typedoc", + "changeset": "changeset", + "changeset:version": "changeset version", + "changeset:status": "changeset status", + "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", "ui:dev": "turbo run dev --filter=@agentscript/ui", "release:dry": "multi-semantic-release --dry-run", "prepare": "husky" @@ -68,7 +74,7 @@ "vitest": "^3.2.4" }, "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';