diff --git a/.gitignore b/.gitignore index 906baba9..2fb4cd52 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,13 @@ logs *.log -# Local documentation artifacts (except committed issue-triage outputs and scripts) +# Local documentation artifacts. Only docs/superpowers/ (design + recovery +# runbooks) and hand-written docs/*.mjs tooling are tracked; the 2026-07 +# docs/issue-triage/ workbench was discarded once the g0v/moedict-webkit +# backlog it triaged was closed, so its negations are gone too. docs/* !docs/ !docs/*.mjs -!docs/issue-triage/ -!docs/issue-triage/** !docs/superpowers/ !docs/superpowers/** npm-debug.log* @@ -95,4 +96,8 @@ tests/e2e/**/*-firefox-win32.png # TW-Kai R2-hosted Worker fallback font shards — hosted in R2 moedict-assets bucket # (fonts/TW-Kai-shard-*.ttf). Never commit untracked font binaries into git history. -data/assets/fonts/TW-Kai-shard-*.ttf \ No newline at end of file +# Both landing spots are covered: data/assets/fonts/ (the --overwrite target that +# scripts/build-tw-kai-shards.mjs guards) and a repo-root build dir, which is what +# `--out-dir=fonts` produces (76MB of regenerable, manifest-checksummed TTFs). +data/assets/fonts/TW-Kai-shard-*.ttf +/fonts/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ddbbb6a1..fa143712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,8 @@ memory/MEMORY.md # 跨 session 的架構筆記(與本檔互補) ```bash vp install # 依 devEngines.packageManager 安裝相依 -vp run dev # predev 重建索引,再啟動 Vite + Miniflare +vp run dev # predev 重建索引,再啟動 Vite(無 Worker,見下) +bun run dev:remote # 同上但改跑真 Worker + 遠端 R2(需 wrangler 登入) vp run build # prebuild 重建索引、tsc -b,再執行 vp build vp check # Oxfmt + type-aware Oxlint + TS diagnostics vp run typecheck # canonical tsc -b --noEmit project build @@ -87,6 +88,32 @@ vp run test:coverage # 三層 coverage 合併至 coverage/combined/ `vp run test:unit` / `vp run test:integration`。不要用裸 `bun test`——它不讀 `vite.config.ts` 的 happy-dom、setup、alias 與 project 設定。 +### 本機 dev 的兩種模式(`vite.config.ts` 的 `plugins` 三元式) + +`command === "serve"` 時,只有 `VITE_CLOUDFLARE_REMOTE_DEV=1` 才會掛 +`cloudflare()`;預設掛的是 `localDataAssetsPlugin()`,**dev server 裡沒有 +Worker**(`worker/index.ts` 完全不執行)。 + +- **預設 `vp run dev`(無需 Cloudflare 憑證)**:`/api/*` 與 `/assets/*` 由 + `server.proxy` 轉發到 `VITE_DEV_API_ORIGIN`(預設 `https://www.moedict.tw`, + 與既有的 `/lookup/trs` dev proxy 同一條慣例)。`public/` 底下真的存在的檔案 + (如 `/assets/images/icon.png`)由 `bypass` 攔下走本機,不會被 proxy 蓋掉。 + **沒有這層 proxy 時,這些路徑會落到 Vite 的 SPA fallback 回 `index.html`**, + 瀏覽器就會出現 `取得 ASSET_BASE_URL 失敗: SyntaxError: JSON.parse ...` + 與 `MIME 型態「text/html」不是「text/css」`——那是「沒有 Worker」的症狀, + 不是 AssetLoader 的 bug。 + ⚠️ 字典內容來自**正式站**,不是本機 `data/dictionary`;改資料要驗證請用 + `dev:remote`(讀 R2)或 `vp run build && bun run serve:e2e`(Miniflare + + `tests/helpers/fixtures.ts` 播種的本機資料)。 + ⚠️ 用 5173/3000/8787 以外的埠時,`r2-assets.moedict.tw` 的字型會被 CORS + 擋(allowlist 見 `commands/r2-assets-cors.json`),CSS 仍會套用。 +- **`bun run dev:remote`**:掛 `cloudflare()`,跑真 Worker(Miniflare)+ + `wrangler.jsonc` 裡 `remote: true` 的 R2 綁定 → **需要 wrangler 登入** + (`~/Library/Preferences/.wrangler/config/default.toml`),否則以 + `Failed to start the remote proxy session` 開不起來。三個 R2 綁定都是 + `remote: true`,所以沒有「本機空桶」模式可用;要離線跑真 Worker 只有 + `serve:e2e` 那條(curated fixtures)。 + ## 部署(零停機兩階段 rollout,這是規範不是建議) **沒有裸 `wrangler deploy` 這條路。** 標準指令一律走安全 orchestrator: @@ -598,6 +625,19 @@ moedict-data(MOE 原始 dump)→ moedict-process(pack 產生器) 依 legacy `view.ls:132-158`):由 `src/components/TitlePronunciation.tsx` 單點持有(ruby 以 children slot 傳入),順序由 `tests/unit/title-pronunciation.test.tsx` 鎖定。改順序=改該元件+測試。 +- **詞條標題列的控制項版型**(`.entry-heading` / `.entry-control-stack` / + `.radical` / `.entry-actions`,`src/index.css`):桌機(≥768px)維持 + `float: right` 的兩列堆疊(部首+筆順一列、複製/異體字/星號一列); + 行動版(`@media (max-width: 767px)`)是 **grid 窄欄** + (`minmax(0, 1fr) max-content`):部首列維持一行不換行,星號/異體字/ + 複製由上而下疊成 44px 單欄靠右;≤3 字短標題(`.entry-compact-title`, + 見 `DictionaryPage.tsx` 可見字數判斷)改為橫向一簇,避免短標題旁留空。 + `.entry-copy-status` 保留 8rem 預留(行動版 out-of-flow 向標題區左延, + 不撐寬窄欄)——R8 要求量到它,**不可**拿掉。守門測試在 + `tests/e2e/dictionary.spec.ts`(`mobile: action row …`、 + `mobile: extra action buttons …`、`mobile: enlarged long title …`,以及 + `copy button activates with Space …` 的零位移契約)與 + `tests/e2e/visual-invariants.spec.ts` 的 R8(320/390px:預留盒幾何+無水平溢出)。 - **URL 前綴文法**(`'`=t、`:`=h、`~`=c、`@`/`=`/`=*` 家族、`/<數字>` idx): 唯一定義在 `src/utils/dictionary-route.ts` 的 `classifyRoute`(頁面/head 分類) 與 `stripLangPrefix`(語言前綴,API 端加 `{'!': 't'}` legacy 別名)。 diff --git a/tests/e2e/dark-mode.spec.ts b/tests/e2e/dark-mode.spec.ts index eed8ee85..61dc390a 100644 --- a/tests/e2e/dark-mode.spec.ts +++ b/tests/e2e/dark-mode.spec.ts @@ -944,7 +944,11 @@ test.describe("mobile dark-mode search results & controls (490×1376, real mobil const HAKKA_PATH = "/%3A%E5%AD%97"; // :字 const CN_XREF_PATH = "/~%E4%B8%8A%E8%A8%B4"; // ~上訴 (seeded lang-c fixture) -const CN_API_PATTERN = /\/api\/~[^?]*\.json/; +// Match only the primary fixture request. The rendered cross-reference labels +// trigger follow-up `/api/~*.json` fetches; intercepting those too leaves +// route.fetch() callbacks in flight when the test page closes and turns normal +// teardown into "Target page, context or browser has been closed" failures. +const CN_API_PATTERN = /\/api\/~%E4%B8%8A%E8%A8%B4\.json(?:\?.*)?$/i; /** Intercept the ~上訴 JSON API to inject a synthetic `alt` field — no seeded * lang-c fixture carries `A`/`alt` (簡體字), the source field diff --git a/tests/unit/dev-proxy-public-bypass.test.ts b/tests/unit/dev-proxy-public-bypass.test.ts new file mode 100644 index 00000000..a5dfcc28 --- /dev/null +++ b/tests/unit/dev-proxy-public-bypass.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +// (vite.config.ts resolves `projectRoot` from `import.meta.url`, which is not +// a file: URL under the project-wide happy-dom environment.) +/** + * `servedFromPublicDir` decides whether plain `vp run dev` serves a request + * locally instead of proxying it to the live Worker (vite.config.ts + * `workerProxyConfig`). It runs inside Vite's proxy `bypass` hook, so a throw + * here fails the request rather than the file lookup — every failure mode must + * answer "not a public file". + * + * Guards the review finding on PR #170: a bare `decodeURIComponent` on a + * request path raises URIError for `/assets/%`, and the repo rule + * (src/utils/dictionary-route.ts) is that request paths decode only through + * `tryDecodeURIComponent`. + */ + +import { describe, expect, it } from "vite-plus/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { servedFromPublicDir } from "../../vite.config"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("dev proxy public/ bypass", () => { + it("serves a real file under public/ locally", () => { + // Committed fixture: public/assets/images/icon.png is why the /assets + // proxy needs a bypass at all. + expect(servedFromPublicDir("/assets/images/icon.png")).toBe(true); + expect(servedFromPublicDir("/assets/images/icon.png?v=1")).toBe(true); + expect(servedFromPublicDir("/manifest.json")).toBe(true); + }); + + it("proxies anything that is not a file in public/", () => { + expect(servedFromPublicDir("/assets/styles.css")).toBe(false); + expect(servedFromPublicDir("/api/%E8%90%8C.json")).toBe(false); + // A directory is not a file, so it must not shadow the Worker. + expect(servedFromPublicDir("/assets/images")).toBe(false); + expect(servedFromPublicDir("/assets/images/")).toBe(false); + expect(servedFromPublicDir(undefined)).toBe(false); + expect(servedFromPublicDir("")).toBe(false); + }); + + it("answers false instead of throwing on malformed percent-encoding", () => { + // Bare decodeURIComponent raises URIError on each of these. + for (const url of ["/assets/%", "/assets/%zz", "/api/%E0%A4%A", "/%C0%80"]) { + expect(servedFromPublicDir(url)).toBe(false); + } + }); + + it("refuses traversal and NUL bytes rather than resolving outside public/", () => { + // package.json exists at the repo root but is not public/ content. + expect(servedFromPublicDir("/../package.json")).toBe(false); + expect(servedFromPublicDir("/assets/../../package.json")).toBe(false); + expect(servedFromPublicDir("/%2e%2e/package.json")).toBe(false); + expect(servedFromPublicDir("/assets/icon%00.png")).toBe(false); + + // Encoded separators are decoded only after new URL(), so these cases + // exercise the explicit pre-normalization traversal guard. Without it, + // normalize() erases `..` and the encoded-slash requests map to the real + // public/manifest.json. Encoded backslash covers Windows path semantics. + expect(servedFromPublicDir("/api/%2e%2e%2fmanifest.json")).toBe(false); + expect(servedFromPublicDir("/assets/%2e%2e%2fmanifest.json")).toBe(false); + expect(servedFromPublicDir("/assets/%2e%2e%5cmanifest.json")).toBe(false); + }); + + it("keeps real traversal targets as positive controls", () => { + // Prove both targets used above exist. package.json checks attempted + // escape from public/; manifest.json catches traversal that normalizes + // back onto a real public file and could otherwise trigger the bypass. + expect(fs.statSync(path.resolve(REPO_ROOT, "package.json")).isFile()).toBe(true); + expect(fs.statSync(path.resolve(REPO_ROOT, "public", "manifest.json")).isFile()).toBe(true); + expect(servedFromPublicDir("/package.json")).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index f6d5d311..52f67bef 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,10 +3,11 @@ import path from "node:path"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { defineConfig, type Plugin, lazyPlugins } from "vite-plus"; +import { defineConfig, type Plugin, type ProxyOptions, lazyPlugins } from "vite-plus"; import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; +import { tryDecodeURIComponent } from "./src/utils/dictionary-route"; import { STROKE_JSON_BASE_URL } from "./src/utils/media-cdn"; interface LocalStaticMount { @@ -87,6 +88,80 @@ async function proxyStrokeJson(cp: string, res: import("http").ServerResponse): } } +/** + * Dev-only proxy for every path the Cloudflare Worker owns in production. + * + * WHY: `vp dev` (without VITE_CLOUDFLARE_REMOTE_DEV=1) runs plain Vite with no + * Worker at all, so `/api/*` and `/assets/*` used to fall through to Vite's SPA + * fallback and return `index.html` — the browser then reported + * `JSON.parse: unexpected character` for /api/config and refused + * `/assets/styles.css` because its MIME type was `text/html`. + * + * Forwarding those prefixes to a live Worker origin (default: production + * www.moedict.tw, override with VITE_DEV_API_ORIGIN) keeps plain `vp dev` + * fully functional without Cloudflare credentials, mirroring the existing + * `/lookup/trs` dev proxy. Files that genuinely live in `public/` + * (e.g. /assets/images/icon.png) are served locally and never proxied. + */ +const WORKER_PROXY_PREFIXES = ["/api", "/assets"] as const; +const publicDir = path.resolve(projectRoot, "public"); + +/** + * True when the request maps to a real file under `public/`, which must win + * over the remote Worker. + * + * Exported for tests. Every failure mode answers "not a public file" rather + * than throwing: this runs inside Vite's proxy `bypass` hook, so an exception + * fails the request instead of the lookup. Percent-decoding goes through + * `tryDecodeURIComponent` because AGENTS.md makes that the single decode path + * for request paths (a bare `decodeURIComponent` raises URIError on + * `/assets/%`), and one `statSync` in a try/catch replaces existsSync+statSync, + * which had a TOCTOU window and could still throw (EACCES, ELOOP). + */ +export function servedFromPublicDir(requestUrl: string | undefined): boolean { + if (!requestUrl) return false; + let pathname: string; + try { + pathname = new URL(requestUrl, "http://localhost").pathname; + } catch { + return false; + } + const decoded = tryDecodeURIComponent(pathname); + if (decoded === null) return false; + + // Reject traversal before normalization. Encoded separators survive the + // WHATWG URL parser (for example `%2f`), then decoding can turn + // `/api/%2e%2e%2fmanifest.json` into `/api/../manifest.json`. Normalizing + // first would erase the `..` evidence and could incorrectly classify the + // request as public/manifest.json. + if (decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..")) return false; + + const relative = path.posix.normalize(decoded).replace(/^\/+/, ""); + if (relative.length === 0 || relative.startsWith("..")) return false; + const resolved = path.resolve(publicDir, relative); + if (path.relative(publicDir, resolved).startsWith("..")) return false; + try { + return fs.statSync(resolved).isFile(); + } catch { + return false; + } +} + +function workerProxyConfig(origin: string): Record { + return Object.fromEntries( + WORKER_PROXY_PREFIXES.map((prefix): [string, ProxyOptions] => [ + prefix, + { + target: origin, + changeOrigin: true, + // `public/` wins over the remote Worker: returning the request URL + // tells Vite's proxy to skip this request and continue the chain. + bypass: (req) => (servedFromPublicDir(req.url) ? req.url : undefined), + }, + ]), + ); +} + function localDataAssetsPlugin(): Plugin { return { name: "moedict-local-data-assets", @@ -171,6 +246,8 @@ function getDictionaryDataVersion(): string { // https://vite.dev/config/ export default defineConfig(({ command }) => { const remoteDev = process.env.VITE_CLOUDFLARE_REMOTE_DEV === "1"; + // Live Worker origin backing plain `vp dev` (no Cloudflare credentials needed). + const devWorkerOrigin = process.env.VITE_DEV_API_ORIGIN ?? "https://www.moedict.tw"; // Emit source maps only when explicitly building for coverage — the // coverage merge script (scripts/merge-coverage.mjs) reads them to map // bundled Chromium V8 coverage back to src/**/*.ts. Regular `npm run @@ -312,9 +389,12 @@ export default defineConfig(({ command }) => { server: { proxy: { "/lookup/trs": { - target: "https://www.moedict.tw", + target: devWorkerOrigin, changeOrigin: true, }, + // Only when the Cloudflare plugin is not mounted (plain `vp dev`); + // with VITE_CLOUDFLARE_REMOTE_DEV=1 the real Worker serves these. + ...(command === "serve" && !remoteDev ? workerProxyConfig(devWorkerOrigin) : {}), }, }, plugins: lazyPlugins(() => [