Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions .github/workflows/build-verify.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 入口均为占位文件"
10 changes: 7 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions cloud-functions/[[default]].js
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions node-functions/[[default]].js
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions scripts/build-edge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
73 changes: 73 additions & 0 deletions scripts/fetch-frontend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 的 <link rel="manifest"> 补 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(
/<link\b([^>]*)>/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 `<link${clean} crossorigin="use-credentials"${selfClose ? " />" : ">"}`
},
)
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 的 <meta name="frontend-version">。
*
Expand Down
53 changes: 53 additions & 0 deletions src/backend/server/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveCdnUrl,
injectCdnIntoHtml,
getIndexHtmlWithCdn,
ensureManifestCredentials,
parseFrontendVersion,
extractEntryAsset,
cdnAssetRedirect,
Expand Down Expand Up @@ -245,6 +246,58 @@ test("injectCdnIntoHtml: CDN URL 中的引号 / 反斜杠被转义", () => {
assert.equal(decoded, cdn, "字面量必须还原成原始 URL")
})

// --------------------------------------------------- ensureManifestCredentials

const HTML_WITH_MANIFEST = `<!doctype html>
<html>
<head>
<link href="/manifest.json" rel="manifest" />
<title>Loading...</title>
</head>
<body><div id="root"></div></body>
</html>`

test("ensureManifestCredentials: 给 manifest link 补 crossorigin=use-credentials", () => {
const out = ensureManifestCredentials(HTML_WITH_MANIFEST)
assert.match(
out,
/<link href="\/manifest\.json" rel="manifest" crossorigin="use-credentials" \/>/,
)
})

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 = "<html><body>no manifest</body></html>"
assert.equal(ensureManifestCredentials(plain), plain)
})

test("ensureManifestCredentials: 属性顺序在前(rel 后置)也能匹配", () => {
const html = HTML_WITH_MANIFEST.replace(
'<link href="/manifest.json" rel="manifest" />',
'<link rel="manifest" href="/manifest.json">',
)
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 () => {
Expand Down
53 changes: 48 additions & 5 deletions src/backend/server/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 注入。
*
Expand Down Expand Up @@ -134,6 +143,39 @@ export async function resolveCdnUrl(env: any, html?: string): Promise<string> {
return raw.replace(/\$version/g, version)
}

/**
* 确保 index.html 中 <link rel="manifest"> 带 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(
/<link\b([^>]*)>/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 `<link${clean} crossorigin="use-credentials"${selfClose ? " />" : ">"}`
},
)
}

/**
* 把已解析的 CDN 地址注入 HTML 的 window.OPENLIST_CONFIG.cdn。
* 前端 vite-plugin-dynamic-base 读取 window.__dynamic_base__(= cdn),
Expand Down Expand Up @@ -235,9 +277,10 @@ export async function getIndexHtmlWithCdn(
localHtml: string,
): Promise<string> {
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)
Expand All @@ -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
}
Expand All @@ -262,7 +305,7 @@ export async function getIndexHtmlWithCdn(
let html = await res.text()
// 校验确实是 HTML,而非 CDN 的 JSON 错误页(如 npmmirror 的 451 blocked)
if (/<html/i.test(html)) {
html = injectCdnIntoHtml(html, cdn)
html = ensureManifestCredentials(injectCdnIntoHtml(html, cdn))
cdnHtmlCache.set(cdn, { html, ts: Date.now() })
return html
}
Expand All @@ -272,7 +315,7 @@ export async function getIndexHtmlWithCdn(
}

// C. 降级:不注入
return localHtml
return ensureManifestCredentials(localHtml)
}

for (const folder of CDN_REDIRECT_FOLDERS) {
Expand Down
Loading