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({ 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 (/