From 8b39ea70c3a8ebf048e18169c986d2e761c85c00 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Fri, 23 Oct 2026 00:15:26 +0800 Subject: [PATCH] feat(03-04): Worker [assets] binding + one-command deploy + D-14 build-artifact gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wrangler.jsonc assets block serves ../web/dist; run_worker_first keeps /api/* + /telegram/* on the Worker (D-13) - root npm run deploy = build theme → web → bot wrangler deploy + set-webhook (D-15); dev = build + wrangler dev --port 8787 --local (D-12) - vite.config.ts dual-build: __ARAHARAH_HARNESS__ define keyed on --mode harness, separate dist/dist-harness outDirs - main.ts guard unchanged; ambient constant declared once in web/src/vite-env.d.ts - build-artifact.test.mjs BA1..BA6: zero CDN refs, self-hosted fonts, MapLibre worker, inlined style, no mock installer in ANY emitted file (HTML included) --- .gitignore | 5 +- bot/package.json | 2 +- bot/wrangler.jsonc | 9 +++ package.json | 7 +- web/package.json | 1 + web/src/main.ts | 10 +-- web/src/vite-env.d.ts | 10 +++ web/test/build-artifact.test.mjs | 135 +++++++++++++++++++++++++++++++ web/vite.config.ts | 55 ++++++++----- 9 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 web/src/vite-env.d.ts create mode 100644 web/test/build-artifact.test.mjs diff --git a/.gitignore b/.gitignore index f4d7154..c7e7fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,12 @@ # Dependencies node_modules/ -# Build output +# Build output (any depth covers theme/dist and web/dist — regenerated +# output must NEVER be committed, including theme/dist which the web +# build consumes at build time without vendoring) dist/ theme/dist/ +web/dist-harness/ # Secrets / local env .env diff --git a/bot/package.json b/bot/package.json index 8124490..fc060f3 100644 --- a/bot/package.json +++ b/bot/package.json @@ -5,7 +5,7 @@ "scripts": { "test": "node test/runner.mjs", "typecheck": "tsc --noEmit", - "dev": "wrangler dev", + "dev": "wrangler dev --port 8787 --local", "deploy": "wrangler deploy && node scripts/set-webhook.mjs", "commands": "node scripts/register-commands.mjs" }, diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index aefa0e5..9952933 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -6,5 +6,14 @@ // workers_dev: serve on the ..workers.dev URL — the WEBHOOK_URL // target for this phase (Phase 3's custom route/domain may supersede) "workers_dev": true, + // Workers static assets binding (D-13, D-14): serves ../web/dist. The + // Worker fetch still owns /api/* and /telegram/* first via + // run_worker_first (a GLOB ARRAY, not a boolean). + "assets": { + "directory": "../web/dist", + "binding": "ASSETS", + "not_found_handling": "none" + }, + "run_worker_first": ["/api/*", "/telegram/*"], "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } diff --git a/package.json b/package.json index 09b9243..de5c8bf 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "web" ], "scripts": { - "build": "npm run build --workspace theme", + "build": "npm run build -w theme && npm run build -w web", + "deploy": "npm run build && npm run deploy -w bot", + "dev": "npm run build && npm run dev -w bot", "sheet": "npm run sheet --workspace theme", "gallery": "npm run gallery --workspace theme", "audit": "npm run audit --workspace theme", @@ -21,7 +23,8 @@ "web:test": "npm test -w web", "web:typecheck": "npm run typecheck -w web", "web:dev": "npm run dev -w web", - "web:build": "npm run build -w web", + "web:build": "npm run build -w theme && npm run build -w web", + "web:build:harness": "npm run build:harness -w web", "web:capture": "npm run capture -w web" } } diff --git a/web/package.json b/web/package.json index 5d8f052..b792ac2 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "typecheck": "tsc --noEmit", "dev": "vite", "build": "vite build", + "build:harness": "vite build --mode harness", "capture": "node scripts/capture.mjs" }, "dependencies": { diff --git a/web/src/main.ts b/web/src/main.ts index 1facfd2..52e9fea 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -17,12 +17,10 @@ * storage API anywhere on the path. */ -/* Ambient harness constant. Its canonical declaration home is - web/src/vite-env.d.ts (plan 03-04 task 2); until that file exists it is - declared here — single `declare const`, never re-typed later. Vite's - `define` (vite.config.ts) supplies true ONLY in --mode harness builds - and false everywhere else, so a plain dev run cannot hit a ReferenceError. */ -declare const __ARAHARAH_HARNESS__: boolean; +/* Ambient harness constant `__ARAHARAH_HARNESS__` is declared in + web/src/vite-env.d.ts (its canonical home, plan 03-04) and supplied by + Vite's `define` (vite.config.ts): true ONLY in --mode harness builds, + false everywhere else, so plain dev runs cannot hit a ReferenceError. */ import "./shell.css"; import { bootMap } from "./map/map.ts"; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..cc282dd --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/* + * vite-env.d.ts — ambient declarations for the build pipeline. + * + * __ARAHARAH_HARNESS__ is injected by vite.config.ts's `define` (03-04 + * dual-build): true only in `vite build --mode harness` / --mode harness + * dev, statically false in every production build. Declared here ONCE so + * typecheck passes in both modes; main.ts references it without a local + * re-declaration. + */ +declare const __ARAHARAH_HARNESS__: boolean; diff --git a/web/test/build-artifact.test.mjs b/web/test/build-artifact.test.mjs new file mode 100644 index 0000000..985c21b --- /dev/null +++ b/web/test/build-artifact.test.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/* + * build-artifact.test.mjs — the D-14 build-artifact gate (T-03-20, T-03-21). + * ------------------------------------------------------------------------------ + * Reads web/dist (the PRODUCTION build only — the [assets] binding serves + * exactly this directory) and proves the shipped bundle is fully + * self-contained and free of the Mock-Telegram installer: + * + * BA1 no emitted .js/.css file references a third-party CDN + * (cdn.tailwindcss.com, fonts.googleapis.com, fonts.gstatic.com, + * unpkg.com, cdn.jsdelivr.net) — T-03-20: a CDN slip would leak + * user navigation requests to third parties + * BA2 at least one self-hosted .woff2 font was copied through by Vite's + * url() resolution of @araharah/theme/dist/rams.css + * BA3 a hashed MapLibre worker chunk exists under web/dist/assets + * BA4 the built JS carries the inlined Rams style ("liberty-rams" or + * "ne2_shaded" — build-time import, never a runtime fetch) + * BA5 the PRODUCTION bundle contains "installMockWebApp"/"__ARAHARAH_MOCK__" + * in NO emitted file — .js AND .html (web/index.html is the ONE entry + * shared by both builds, so an unguarded runner-page profile block + * would ship the mock and the fetch stubs as inline HTML that a + * JS-only scan would miss entirely). Scans web/dist ONLY: the harness + * build emits to web/dist-harness/ and is deliberately NOT clean — + * BA5 pointed there would invert the gate it exists to enforce. + * BA6 no emitted HTML carries the runner page's /api/session or + * /api/location-fallback fetch stubs (same dead-code-elimination proof) + * + * If web/dist does not exist, every case fails with a build-first message. + */ +import { strict as assert } from "node:assert"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { test } from "./harness.mjs"; + +const WEB = path.join(fileURLToPath(new URL(import.meta.url)), "..", ".."); +const DIST = join(WEB, "dist"); +const DIST_ASSETS = join(DIST, "assets"); + +const CDN_HOSTS = [ + "cdn.tailwindcss.com", + "fonts.googleapis.com", + "fonts.gstatic.com", + "unpkg.com", + "cdn.jsdelivr.net", +]; + +function claimBuildFirst() { + return new Error( + "web/dist does not exist — run `npm run build -w theme && npm run build -w web` before this suite", + ); +} + +/* walk web/dist recursively — DirEnt-based, no dependency */ +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else out.push(full); + } + return out; +} + +function readIfExists(file) { + return existsSync(file) ? readFileSync(file, "utf8") : null; +} + +const allFiles = existsSync(DIST) ? walk(DIST) : []; +const jsFiles = allFiles.filter((f) => f.endsWith(".js")); +const cssFiles = allFiles.filter((f) => f.endsWith(".css")); +const htmlFiles = allFiles.filter((f) => f.endsWith(".html")); + +test("BA1 no emitted .js/.css file references a third-party CDN (D-14)", () => { + assert.ok(existsSync(DIST), claimBuildFirst().message); + for (const file of [...jsFiles, ...cssFiles]) { + const content = readFileSync(file, "utf8"); + for (const host of CDN_HOSTS) { + assert.ok( + !content.includes(host), + `${file} references CDN host ${host} (D-14 forbids CDN fallbacks)`, + ); + } + } +}); + +test("BA2 web/dist carries self-hosted woff2 fonts copied at build time", () => { + assert.ok(existsSync(DIST), claimBuildFirst().message); + const woff2 = allFiles.filter((f) => f.endsWith(".woff2")); + assert.ok(woff2.length >= 1, `no .woff2 under web/dist — apply BA2's contingency (public/fonts copy or postbuild script), never a CDN link`); +}); + +test("BA3 web/dist/assets contains a hashed MapLibre worker chunk", () => { + assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message); + const worker = readdirSync(DIST_ASSETS).filter((f) => f.endsWith(".js") && /worker/i.test(f)); + assert.ok(worker.length >= 1, `no MapLibre worker chunk in web/dist/assets (found: ${readdirSync(DIST_ASSETS).join(", ")})`); +}); + +test("BA4 built JS carries the inlined Rams style JSON (liberty-rams/ne2_shaded)", () => { + assert.ok(existsSync(DIST_ASSETS), claimBuildFirst().message); + const bundle = jsFiles + .filter((f) => f.startsWith(DIST_ASSETS)) + .map((f) => readFileSync(f, "utf8")) + .join(""); + const hasStyle = bundle.includes("liberty-rams") || bundle.includes("ne2_shaded"); + assert.ok(hasStyle, "inlined Rams style marker absent from the JS bundle — style JSON was not inlined at build time"); +}); + +test("BA5 production bundle carries NO Mock-Telegram installer in ANY emitted file (.js + .html)", () => { + assert.ok(existsSync(DIST), claimBuildFirst().message); + const jsAssets = jsFiles.map((f) => readFileSync(f, "utf8")).join(""); + const allHtml = htmlFiles.map((f) => readFileSync(f, "utf8")).join(""); + for (const token of ["installMockWebApp", "__ARAHARAH_MOCK__"]) { + assert.ok( + !jsAssets.includes(token), + `installMockWebApp gate: "${token}" found in a web/dist JS asset — the mock survived tree-shaking`, + ); + assert.ok( + !allHtml.includes(token), + `installMockWebApp gate: "${token}" found in an emitted web/dist HTML (${htmlFiles.join(", ")}) — the runner page's profile block shipped`, + ); + } +}); + +test("BA6 production HTML carries NO runner-page fetch stub endpoints", () => { + assert.ok(existsSync(DIST), claimBuildFirst().message); + const allHtml = htmlFiles.map((f) => readFileSync(f, "utf8")).join(""); + for (const endpoint of ["/api/session", "/api/location-fallback"]) { + assert.ok( + !allHtml.includes(endpoint), + `runner-page fetch stub "${endpoint}" present in emitted web/dist HTML — an unguarded profile block shipped`, + ); + } +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index cee87ea..af3e0a6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,23 +1,36 @@ import { defineConfig } from "vite"; -export default defineConfig(({ mode }) => ({ - base: "/", - // 03-03: the harness constant (declared ambient in main.ts; canonical home - // is web/src/vite-env.d.ts under plan 03-04) is REAL only in --mode harness - // builds and statically false everywhere else — with a bare ambient global - // referenced in main.ts, this define keeps plain `vite dev` from throwing a - // ReferenceError while the fallback mock install there still fires via - // import.meta.env.DEV. 03-04's dual-build config keys on the same mode. - define: { - __ARAHARAH_HARNESS__: JSON.stringify(mode === "harness"), - }, - build: { - outDir: "dist", - emptyOutDir: true, - target: "es2022", - }, - server: { - port: 5173, - strictPort: true, - }, -})); +export default defineConfig(({ mode }) => { + const isHarness = mode === "harness"; + return { + base: "/", + // 03-04 dual-build contract (the capture-harness fix, T-03-21): + // `vite build --mode harness` emits to dist-harness/ WITH the mock + // installer + fetch stubs; every other build emits to dist/ WITHOUT + // them. The constant is REAL only in the harness mode and statically + // false everywhere else — including plain `vite dev`, where this + // define keeps main.ts's ambient reference from throwing a + // ReferenceError while the fallback mock still fires via + // import.meta.env.DEV. + // + // Vite replaces the IDENTIFIER textually at build time, so the + // harness bundle compiles `if (VITE_HARNESS) installMockWebApp()` + // down to `if (true) …`. The production build never defines it, + // so the identifier is unreferenced and the branch is tree-shaken. + // + // The two builds must NEVER share an outDir: sharing one would make + // them clobber each other and make the BA5 gate meaningless. + define: { + __ARAHARAH_HARNESS__: JSON.stringify(isHarness), + }, + build: { + outDir: isHarness ? "dist-harness" : "dist", + emptyOutDir: true, + target: "es2022", + }, + server: { + port: 5173, + strictPort: true, + }, + }; +});