From d6bd7c6cb79b808db859e140bd30b24e0d29d337 Mon Sep 17 00:00:00 2001 From: Pikachu Ren Date: Sat, 10 Oct 2026 11:31:20 +0800 Subject: [PATCH 1/2] fix(static): repair /manifest.json 401 on cookie-gated deployments (EdgeOne preview) Three stacked issues made /manifest.json the only failing request: 1. Frontend #162 (2025-08-08) repointed index.html from /static/manifest.json to /manifest.json to match the Go backend dynamic route (OpenList server/router.go:36), but the dist still only ships static/manifest.json - no root manifest.json exists. 2. The TS worker never aligned that route, so /manifest.json fell through to the SPA fallback (text/html on Cloudflare). 3. On cookie-gated platforms (EdgeOne Makers preview domains, eo_time), every other request carries the auth cookie while the PWA manifest fetch is made without any credentials by Chromium - so manifest.json alone got 401. Fix: - fetch-frontend.mjs: copy static/manifest.json -> manifest.json at build time (covers static-first platforms like EdgeOne Pages) and add crossorigin="use-credentials" to the manifest so the browser sends cookies; both idempotent, applied to every dist source path. - assets.ts: new ensureManifestCredentials() applied to all HTML served by getIndexHtmlWithCdn (covers CDN-sourced index.html, path B), plus a /manifest.json -> /static/manifest.json 302 route aligned with the Go router for worker-served traffic. - assets.test.ts: 5 new cases for the attribute patch and the CDN path. Verified: server suite 184/184, tsc --noEmit clean, fetch-frontend end-to-end produces dist/manifest.json and a patched index.html. --- scripts/fetch-frontend.mjs | 73 +++++++++++++++++++++++++++++++ src/backend/server/assets.test.ts | 53 ++++++++++++++++++++++ src/backend/server/assets.ts | 53 +++++++++++++++++++--- 3 files changed, 174 insertions(+), 5 deletions(-) diff --git a/scripts/fetch-frontend.mjs b/scripts/fetch-frontend.mjs index f3096115..ba22ecaa 100644 --- a/scripts/fetch-frontend.mjs +++ b/scripts/fetch-frontend.mjs @@ -130,10 +130,83 @@ function replaceDist(src) { console.log(` Copying frontend dist: ${src} -> ${DEST}`) fs.rmSync(DEST, { recursive: true, force: true }) fs.cpSync(src, DEST, { recursive: true }) + copyManifestToRoot() stampFrontendVersion(src) + patchManifestCredentials() console.log(`✓ Frontend dist ready (${DEST})`) } +/** + * 把 dist/static/manifest.json 复制到 dist/manifest.json。 + * + * 为什么:官方前端 #162(2025-08-08)把 index.html 的 manifest 引用从 + * /static/manifest.json 改成 /manifest.json —— 这是配合 Go 版后端的改动 + * (OpenList server/router.go:36 用动态路由 g.GET("/manifest.json", ...) + * 生成 manifest),但 dist 里文件仍停留在 /static/manifest.json。纯静态 + * /Worker 部署没有这条路由,/manifest.json 要么落到 SPA 兜底(返回 text/html, + * PWA 解析失败),要么在 EdgeOne 预览域这类平台鉴权后面直接 401。 + * 运行期 assetsRouter 的 /manifest.json -> /static/manifest.json 302 只是 + * Worker 场景的兜底;静态直出(EdgeOne Pages)根本不经过 Worker,必须在 + * 构建期把文件补到链接指向的位置。 + */ +function copyManifestToRoot() { + const src = path.join(DEST, "static", "manifest.json") + const dest = path.join(DEST, "manifest.json") + try { + if (!fs.existsSync(src) || fs.existsSync(dest)) return + fs.copyFileSync(src, dest) + console.log(" Copied static/manifest.json -> manifest.json (frontend #162)") + } catch (err) { + console.warn( + ` [fetch-frontend] copy manifest.json skipped: ${err?.message || err}`, + ) + } +} + +/** + * 给 dist/index.html 的 补 crossorigin="use-credentials"。 + * + * 为什么:Chromium 对 PWA manifest 的请求默认【不带任何凭证】——同域 Cookie + * 也不带(Google Web Fundamentals 明确记载)。EdgeOne Pages 预览域名 + * (*.edgeone.cool)等平台的访问控制依赖 Cookie 鉴权(eo_time),于是出现 + * 「站点其它资源全部正常,唯独 /manifest.json 401 Authorization Required」。 + * 补上该属性让浏览器以 include 模式携带 Cookie;同源请求没有 CORS 副作用, + * 在无需鉴权的平台上也无害。 + */ +function patchManifestCredentials() { + const idx = path.join(DEST, "index.html") + try { + if (!fs.existsSync(idx)) return + let html = fs.readFileSync(idx, "utf-8") + if (!/rel=["']manifest["']/i.test(html)) return + let changed = false + html = html.replace( + /]*)>/gi, + (full, attrs) => { + if (!/rel=["']manifest["']/i.test(attrs)) return full + if (/\bcrossorigin\b/i.test(attrs)) return full + changed = true + // 自闭合 /> 的斜杠不属于属性,摘出来还原到闭合符 + const selfClose = /\/\s*$/.test(attrs) + const clean = attrs + .replace(/\/\s*$/, "") + .replace(/\s+$/, "") + return `" : ">"}` + }, + ) + if (changed) { + fs.writeFileSync(idx, html) + console.log( + " Patched manifest link with crossorigin=use-credentials in dist/index.html", + ) + } + } catch (err) { + console.warn( + ` [fetch-frontend] patch manifest credentials skipped: ${err?.message || err}`, + ) + } +} + /** * 构建期戳:把前端版本号写入 dist/index.html 的 。 * diff --git a/src/backend/server/assets.test.ts b/src/backend/server/assets.test.ts index ec857b0b..5591aba8 100644 --- a/src/backend/server/assets.test.ts +++ b/src/backend/server/assets.test.ts @@ -5,6 +5,7 @@ import { resolveCdnUrl, injectCdnIntoHtml, getIndexHtmlWithCdn, + ensureManifestCredentials, parseFrontendVersion, extractEntryAsset, cdnAssetRedirect, @@ -245,6 +246,58 @@ test("injectCdnIntoHtml: CDN URL 中的引号 / 反斜杠被转义", () => { assert.equal(decoded, cdn, "字面量必须还原成原始 URL") }) +// --------------------------------------------------- ensureManifestCredentials + +const HTML_WITH_MANIFEST = ` + + + + Loading... + +
+` + +test("ensureManifestCredentials: 给 manifest link 补 crossorigin=use-credentials", () => { + const out = ensureManifestCredentials(HTML_WITH_MANIFEST) + assert.match( + out, + //, + ) +}) + +test("ensureManifestCredentials: 已有 crossorigin 属性时不重复添加", () => { + const html = HTML_WITH_MANIFEST.replace( + 'rel="manifest" />', + 'rel="manifest" crossorigin="anonymous" />', + ) + assert.equal(ensureManifestCredentials(html), html) +}) + +test("ensureManifestCredentials: 无 manifest 引用的 HTML 原样返回", () => { + const plain = "no manifest" + assert.equal(ensureManifestCredentials(plain), plain) +}) + +test("ensureManifestCredentials: 属性顺序在前(rel 后置)也能匹配", () => { + const html = HTML_WITH_MANIFEST.replace( + '', + '', + ) + const out = ensureManifestCredentials(html) + assert.match(out, /crossorigin="use-credentials">/) +}) + +test("getIndexHtmlWithCdn: CDN 的 index.html(路径 B)也被补上 use-credentials", async () => { + // CDN HTML 来自上游产物,构建期补丁覆盖不到,运行期必须兜底 + await withFetch(async () => { + const out = await getIndexHtmlWithCdn( + { ASSET_URLS: "https://cdn-b.example.com/dist" }, + LOCAL_HTML, + ) + assert.match(out, /rel="manifest" crossorigin="use-credentials"/) + }, cdnImpl({ asset: false, html: HTML_WITH_MANIFEST })) +}) + // ------------------------------------------------------------ getIndexHtmlWithCdn test("getIndexHtmlWithCdn: 未配置 ASSET_URLS 时返回本地 HTML", async () => { diff --git a/src/backend/server/assets.ts b/src/backend/server/assets.ts index 1edf29ce..86832d09 100644 --- a/src/backend/server/assets.ts +++ b/src/backend/server/assets.ts @@ -28,6 +28,15 @@ assetsRouter.get("/favicon.svg", redirectToLogo) assetsRouter.get("/favicon.png", redirectToLogo) assetsRouter.get("/favicon.ico", redirectToLogo) +// 对齐 Go 版 server/router.go:36 的 g.GET("/manifest.json", static.ManifestJSON): +// 官方前端 #162(2025-08-08)起 index.html 引用 /manifest.json,但 dist 里文件 +// 仍在 /static/manifest.json(Go 版由该动态路由生成,静态部署没有对应文件)。 +// 不处理时 /manifest.json 会落到 SPA 兜底:CF 上返回 text/html(PWA 解析失败), +// EdgeOne 预览域这类平台鉴权后面则表现为 manifest 请求 401。这里 302 到真实 +// 静态文件;构建期(fetch-frontend.mjs)同时会把文件复制到根,正常情况下 +// 静态层直接命中、根本不会走到这条路由。 +assetsRouter.get("/manifest.json", (c) => c.redirect("/static/manifest.json", 302)) + /** * 前端静态资源 CDN 注入。 * @@ -134,6 +143,39 @@ export async function resolveCdnUrl(env: any, html?: string): Promise { return raw.replace(/\$version/g, version) } +/** + * 确保 index.html 中 带 crossorigin="use-credentials"。 + * + * 为什么:Chromium 对 PWA manifest 的请求默认【不带任何凭证】——同域 Cookie + * 也不带(Google Web Fundamentals 明确记载,需用 crossorigin="use-credentials" + * 才会携带)。在 EdgeOne Pages 预览域名(*.edgeone.cool)这类「平台级 Cookie + * 鉴权」后面,JS/CSS/API 等其它请求都带 Cookie 正常返回,唯独 manifest.json + * 裸奔得到 401 Authorization Required,表现为 PWA 安装失败 / DevTools 里孤立 + * 的 manifest 401。补上该属性让浏览器以 include 模式携带 Cookie;同源请求 + * 没有 CORS 副作用,在无需鉴权的平台上也无害。 + * + * 双保险:scripts/fetch-frontend.mjs 在构建期已对 dist/index.html 打过同样的 + * 补丁(EdgeOne 静态直出走的正是那份文件);这里覆盖运行时从 CDN 拉取 + * index.html(路径 B)等绕过构建补丁的 HTML 来源。 + */ +export function ensureManifestCredentials(html: string): string { + // 没有 manifest 引用时原样返回,避免无谓的字符串拷贝 + if (!/rel=["']manifest["']/i.test(html)) return html + return html.replace( + /]*)>/gi, + (full, attrs: string) => { + if (!/rel=["']manifest["']/i.test(attrs)) return full + if (/\bcrossorigin\b/i.test(attrs)) return full + // 自闭合 /> 的斜杠不属于属性,摘出来还原到闭合符,避免产出 " / crossorigin=..." + const selfClose = /\/\s*$/.test(attrs) + const clean = attrs + .replace(/\/\s*$/, "") + .replace(/\s+$/, "") + return `" : ">"}` + }, + ) +} + /** * 把已解析的 CDN 地址注入 HTML 的 window.OPENLIST_CONFIG.cdn。 * 前端 vite-plugin-dynamic-base 读取 window.__dynamic_base__(= cdn), @@ -235,9 +277,10 @@ export async function getIndexHtmlWithCdn( localHtml: string, ): Promise { const cdn = await resolveCdnUrl(env, localHtml) - if (!cdn) return localHtml + if (!cdn) return ensureManifestCredentials(localHtml) // 仅允许 http(s),防止 ASSET_URLS 被配置成其它 scheme - if (!/^https?:\/\//i.test(cdn)) return localHtml + if (!/^https?:\/\//i.test(cdn)) + return ensureManifestCredentials(localHtml) // 供静态资源缺失时的 302 复用同一地址(见 cdnAssetRedirect) resolvedCdnCache.set(rawAssetUrls(env), cdn) const hit = cdnHtmlCache.get(cdn) @@ -247,7 +290,7 @@ export async function getIndexHtmlWithCdn( // 时把浏览器引到 404 上(那会比不注入更糟)。 const entry = extractEntryAsset(localHtml) if (entry && (await cdnHasAsset(cdn, entry))) { - const html = injectCdnIntoHtml(localHtml, cdn) + const html = ensureManifestCredentials(injectCdnIntoHtml(localHtml, cdn)) cdnHtmlCache.set(cdn, { html, ts: Date.now() }) return html } @@ -262,7 +305,7 @@ export async function getIndexHtmlWithCdn( let html = await res.text() // 校验确实是 HTML,而非 CDN 的 JSON 错误页(如 npmmirror 的 451 blocked) if (/ Date: Sat, 10 Oct 2026 11:31:21 +0800 Subject: [PATCH 2/2] fix(edgeone): restore server-handler detection via committed handler placeholders EdgeOne Pages decides whether a project has a Node backend by scanning the repository content, not the post-build filesystem: with the handler artifact gitignored (PR #93), detection fails ("No server-handler detected, generating routes.json for pure project") and /api/* falls into the static rewrites returning index.html - while functions/kv-* keep working. - scripts/build-edge.mjs: emit the bundled handler to the officially documented node-functions/[[default]].js in addition to the historical cloud-functions/[[default]].js; the deploy-time build overwrites both with the fresh ~2MB bundle, so runtime always executes the latest code. - cloud-functions/[[default]].js + node-functions/[[default]].js: track a tiny, never-changing placeholder (valid onRequest export returning 503). It exists only so platform detection sees a server handler; the deploy build overwrites it with the real bundle on the build filesystem. Merge conflicts on artifacts are structurally impossible and contributors never need to touch these files. This supersedes both PR #93's gitignored-artifact approach and an interim full-artifact-committed one. - middleware.js: export config ({ matcher: [{ source: "/:path*" }] }) as the platform expects, silencing "Could not find config in middleware file, using defaults". - .github/workflows/build-verify.yml: assert the tracked entries are still the placeholder so accidentally committing a full build fails CI with a clear message. - .gitignore: stop ignoring the two handler entry files. Verified on a live EdgeOne deployment: /api/public/settings returns JSON again after deploying this change. --- .github/workflows/build-verify.yml | 33 +++++++++++++++++++----------- .gitignore | 10 ++++++--- cloud-functions/[[default]].js | 20 ++++++++++++++++++ middleware.js | 7 +++++++ node-functions/[[default]].js | 20 ++++++++++++++++++ scripts/build-edge.mjs | 13 ++++++++++++ 6 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 cloud-functions/[[default]].js create mode 100644 node-functions/[[default]].js diff --git a/.github/workflows/build-verify.yml b/.github/workflows/build-verify.yml index bad7efe6..c875d37c 100644 --- a/.github/workflows/build-verify.yml +++ b/.github/workflows/build-verify.yml @@ -1,16 +1,16 @@ name: Build Verify -# dist/、dist-server/、cloud-functions/[[default]].js 全部是构建产物,一律不入库: +# dist/、dist-server/ 是构建产物,一律不入库;cloud-functions/[[default]].js 与 +# node-functions/[[default]].js 是「EdgeOne server-handler 检测占位」(几行的小文件, +# 入库且基本不变),部署构建时由 scripts/build-edge.mjs 在同一路径生成完整产物覆盖: # - EdgeOne Makers:edgeone.json 的 buildCommand(pnpm run build)在部署时生成 -# dist/ 与 cloud-functions/[[default]].js +# dist/ 与两处完整产物 # - Cloudflare Workers:wrangler deploy 从 src/backend/worker.ts 打包,并用 dist/ 作静态资源 # - 自托管 Node:node dist-server/api/[...route].js 由 pnpm run build 产出 # # 因此"部署用的后端代码"完全取决于构建链路能否跑通,本工作流就是这条链路的看门人: -# 按平台一致的顺序安装、拉取前端、构建,并校验产物确实生成。任一步失败即失败。 -# -# 注意:产物按设计不提交(见 .gitignore),所以这里只校验"能否构建出来", -# 不再像已移除的 edgeone-artifact-guard.yml 那样比对已提交产物是否新鲜。 +# 按平台一致的顺序安装、拉取前端、构建,并校验产物确实生成;同时断言入库的 +# 两处入口仍是占位文件(防止误提交 2MB 构建产物)。任一步失败即失败。 on: push: @@ -82,10 +82,19 @@ jobs: test -f "cloud-functions/[[default]].js" echo "OK: dist/index.html、dist-server/api/[...route].js、cloud-functions/[[default]].js 均已生成" - - name: Assert the EdgeOne artifact is not tracked + # 入库的 cloud-functions/[[default]].js 与 node-functions/[[default]].js 是 + # EdgeOne server-handler 检测占位(见文件头注释)——平台按 Git 仓库内容 + # 判断项目是否含 Node 后端,真实产物由部署构建在同一路径生成并覆盖。 + # 这里断言仓库里跟踪的仍是占位文件:若有人把本地构建出的 2MB 完整产物 + # 提交进来,本检查会失败并提示还原为占位内容。 + - name: Assert tracked handler entries are the placeholder run: | - if git ls-files --error-unmatch "cloud-functions/[[default]].js" > /dev/null 2>&1; then - echo "::error::cloud-functions/[[default]].js 是构建产物,不应提交进仓库(见 .gitignore)。" - exit 1 - fi - echo "OK: cloud-functions/[[default]].js 未被 git 跟踪" + fail=0 + for f in "cloud-functions/[[default]].js" "node-functions/[[default]].js"; do + if ! git show "HEAD:$f" | head -1 | grep -q "PLACEHOLDER"; then + echo "::error::$f 在仓库里不是占位文件 —— 疑似误提交了构建产物。请还原为占位内容(产物由部署构建生成,不入库)。" + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then exit 1; fi + echo "OK: 两处 server-handler 入口均为占位文件" diff --git a/.gitignore b/.gitignore index b7c4cb5f..d1d7d199 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,13 @@ pages/dist/ dist-server/ *.tsbuildinfo -# EdgeOne Makers Node 云函数产物:由平台构建命令(pnpm run build -> scripts/build-edge.mjs) -# 在部署时生成,不入库;构建校验见 .github/workflows/build-verify.yml -cloud-functions/ +# EdgeOne server-handler 检测占位:cloud-functions/[[default]].js 与 +# node-functions/[[default]].js 以几行的占位文件入库(平台按 Git 仓库内容判断 +# 项目是否含 Node 后端;PR #93 实测仅靠构建期生成会报「No server-handler +# detected」退化为纯静态,/api/* 全部落静态)。部署构建时 scripts/build-edge.mjs +# 会在同一路径生成完整产物(~2MB,不入库)覆盖占位,EdgeOne 运行的是构建产物。 +# 占位内容基本永不变更 —— PR 中若出现这两个文件的大段变更,说明误提交了 +# 构建产物,请还原为占位内容。 # source maps (optional, can be removed) *.map diff --git a/cloud-functions/[[default]].js b/cloud-functions/[[default]].js new file mode 100644 index 00000000..54cc2909 --- /dev/null +++ b/cloud-functions/[[default]].js @@ -0,0 +1,20 @@ +// [PLACEHOLDER] EdgeOne server-handler 检测占位 —— 真实产物由部署构建生成,勿提交构建产物! +// +// EdgeOne Pages 平台按 Git 仓库内容检测本项目是否含 Node 后端(server-handler); +// 缺少本文件时构建日志会报「No server-handler detected, generating routes.json +// for pure project」,/api/* 会被 edgeone.json 的 rewrites 吞掉返回 index.html。 +// +// 因此这里入库一个几行的小占位(永不变更、永不产生 PR 冲突),部署时 +// edgeone.json -> pnpm run build -> scripts/build-edge.mjs 会在同一路径生成 +// 打包全部后端代码的完整产物(~2MB)并覆盖本文件,EdgeOne 运行的是那份构建产物。 +// +// 注意:本地执行 pnpm run build 后本文件会被覆盖为完整产物 —— 这属于预期行为, +// 不要把覆盖后的版本提交进来;PR 中若出现本文件的大段变更,请还原为占位内容。 +export async function onRequest(context) { + return new Response( + "openlist: backend bundle missing (built at deploy time, this is a placeholder)", + { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } }, + ) +} + +export default onRequest diff --git a/middleware.js b/middleware.js index 901fcb34..1d674d53 100644 --- a/middleware.js +++ b/middleware.js @@ -12,6 +12,13 @@ // request / next / redirect / rewrite / geo / clientIp,**没有 env**, // 因此无法访问 KV 或环境变量。KV 代理请使用 functions/ 目录下的 // Edge Functions(见 functions/kv-get 等),那里才具备 KV 能力。 +// EdgeOne 约定 middleware.js 需显式导出 config 声明匹配范围;缺失时 CLI +// 报「Could not find config in middleware file, using defaults」并回退 +// 默认 matcher。这里显式声明与默认值一致的 /:path*,消除警告并锁定行为。 +export const config = { + matcher: [{ source: "/:path*" }], +} + export function middleware(context) { const { request, next, rewrite } = context const { pathname } = new URL(request.url) diff --git a/node-functions/[[default]].js b/node-functions/[[default]].js new file mode 100644 index 00000000..54cc2909 --- /dev/null +++ b/node-functions/[[default]].js @@ -0,0 +1,20 @@ +// [PLACEHOLDER] EdgeOne server-handler 检测占位 —— 真实产物由部署构建生成,勿提交构建产物! +// +// EdgeOne Pages 平台按 Git 仓库内容检测本项目是否含 Node 后端(server-handler); +// 缺少本文件时构建日志会报「No server-handler detected, generating routes.json +// for pure project」,/api/* 会被 edgeone.json 的 rewrites 吞掉返回 index.html。 +// +// 因此这里入库一个几行的小占位(永不变更、永不产生 PR 冲突),部署时 +// edgeone.json -> pnpm run build -> scripts/build-edge.mjs 会在同一路径生成 +// 打包全部后端代码的完整产物(~2MB)并覆盖本文件,EdgeOne 运行的是那份构建产物。 +// +// 注意:本地执行 pnpm run build 后本文件会被覆盖为完整产物 —— 这属于预期行为, +// 不要把覆盖后的版本提交进来;PR 中若出现本文件的大段变更,请还原为占位内容。 +export async function onRequest(context) { + return new Response( + "openlist: backend bundle missing (built at deploy time, this is a placeholder)", + { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } }, + ) +} + +export default onRequest diff --git a/scripts/build-edge.mjs b/scripts/build-edge.mjs index e64577b7..897d5870 100644 --- a/scripts/build-edge.mjs +++ b/scripts/build-edge.mjs @@ -186,6 +186,19 @@ async function build() { plugins: [emptyNodeDriverPlugin, normalizeHtmlEolPlugin], }) + // EdgeOne Pages 官方约定的 Node Functions 目录是 node-functions/(平台 + // 文档的项目结构:node-functions/[[default]].js → catch-all,Node.js + // 运行时)。cloud-functions/ 是本项目沿用的历史目录名,部分 CLI 版本 + // 不扫描它 —— 日志表现为「No server-handler detected, generating + // routes.json for pure project」,整个项目被当成纯静态:/api/* 被 + // edgeone.json 的 rewrites 吞掉、返回 index.html。这里把产物复制一份 + // 到官方约定目录,两个位置并存,新旧识别逻辑都能命中。 + fs.mkdirSync("node-functions", { recursive: true }) + fs.copyFileSync( + "cloud-functions/[[default]].js", + "node-functions/[[default]].js", + ) + // 阿里云 ESA(边缘安全加速)边缘函数入口(仅在源文件存在时构建) if (fs.existsSync("esa-entry.ts")) { await esbuild.build({