From d0c101f013ca84dc1bc7a889c7bb006511e68e5a Mon Sep 17 00:00:00 2001 From: Alumos <62974788+Alumos@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:12 +0800 Subject: [PATCH] fix(edgeone): preserve a discoverable Node function entry Keep a tracked root handler so EdgeOne enables its Node function builder before running the project build. Generate the backend bundle in dist-server and keep it out of function discovery and version control. Verify the entry before building and exercise diagnostics and SPA responses after bundling. Remove a duplicate DbCipher type import that blocked lint. Co-authored-by: Codex --- .github/workflows/build-verify.yml | 33 +++++--- .gitignore | 5 +- .prettierignore | 1 - cloud-functions/[[default]].js | 10 +++ middleware.js | 6 +- scripts/build-edge.mjs | 56 +++++++------- src/backend/internal/model/db_cipher.test.ts | 1 - tests/edgeone-entry.test.mjs | 79 ++++++++++++++++++++ 8 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 cloud-functions/[[default]].js create mode 100644 tests/edgeone-entry.test.mjs diff --git a/.github/workflows/build-verify.yml b/.github/workflows/build-verify.yml index bad7efe6..ef455993 100644 --- a/.github/workflows/build-verify.yml +++ b/.github/workflows/build-verify.yml @@ -1,8 +1,8 @@ name: Build Verify -# dist/、dist-server/、cloud-functions/[[default]].js 全部是构建产物,一律不入库: -# - EdgeOne Makers:edgeone.json 的 buildCommand(pnpm run build)在部署时生成 -# dist/ 与 cloud-functions/[[default]].js +# dist/、dist-server/ 是构建产物,不入库;cloud-functions/[[default]].js 是入库的 +# 薄入口,让 EdgeOne 在项目构建前发现 Node 云函数。实际后端包在构建时生成: +# - EdgeOne Makers:pnpm run build 生成 dist/ 与 dist-server/edgeone-entry.js # - Cloudflare Workers:wrangler deploy 从 src/backend/worker.ts 打包,并用 dist/ 作静态资源 # - 自托管 Node:node dist-server/api/[...route].js 由 pnpm run build 产出 # @@ -20,7 +20,10 @@ on: - "src/**" - "api/**" - "functions/**" + - "cloud-functions/**" - "scripts/**" + - "tests/edgeone-entry.test.mjs" + - ".gitignore" - "middleware.js" - "edgeone.json" - "wrangler.jsonc" @@ -32,7 +35,10 @@ on: - "src/**" - "api/**" - "functions/**" + - "cloud-functions/**" - "scripts/**" + - "tests/edgeone-entry.test.mjs" + - ".gitignore" - "middleware.js" - "edgeone.json" - "wrangler.jsonc" @@ -68,6 +74,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify EdgeOne entry is discoverable before building + run: | + git ls-files --error-unmatch "cloud-functions/[[default]].js" + node --check "cloud-functions/[[default]].js" + test ! -f dist-server/edgeone-entry.js + - name: Fetch official frontend dist run: node scripts/fetch-frontend.mjs @@ -79,13 +91,16 @@ jobs: set -e test -f dist/index.html test -f "dist-server/api/[...route].js" - test -f "cloud-functions/[[default]].js" - echo "OK: dist/index.html、dist-server/api/[...route].js、cloud-functions/[[default]].js 均已生成" + test -f dist-server/edgeone-entry.js + echo "OK: dist/index.html、dist-server/api/[...route].js、dist-server/edgeone-entry.js 均已生成" - - name: Assert the EdgeOne artifact is not tracked + - name: Assert the EdgeOne bundle is not tracked run: | - if git ls-files --error-unmatch "cloud-functions/[[default]].js" > /dev/null 2>&1; then - echo "::error::cloud-functions/[[default]].js 是构建产物,不应提交进仓库(见 .gitignore)。" + if git ls-files --error-unmatch dist-server/edgeone-entry.js > /dev/null 2>&1; then + echo "::error::dist-server/edgeone-entry.js 是构建产物,不应提交进仓库(见 .gitignore)。" exit 1 fi - echo "OK: cloud-functions/[[default]].js 未被 git 跟踪" + echo "OK: dist-server/edgeone-entry.js 未被 git 跟踪" + + - name: Test EdgeOne entry and API responses + run: node --test tests/edgeone-entry.test.mjs diff --git a/.gitignore b/.gitignore index b7c4cb5f..ada4fe1b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,8 @@ pages/dist/ dist-server/ *.tsbuildinfo -# EdgeOne Makers Node 云函数产物:由平台构建命令(pnpm run build -> scripts/build-edge.mjs) -# 在部署时生成,不入库;构建校验见 .github/workflows/build-verify.yml -cloud-functions/ +# EdgeOne 的 cloud-functions/ 入口源码需入库,供平台在构建前发现。 +# 实际后端包由 scripts/build-edge.mjs 生成到上方忽略的 dist-server/。 # source maps (optional, can be removed) *.map diff --git a/.prettierignore b/.prettierignore index b2f695bd..fbf23130 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,5 @@ dist/ dist-server/ -cloud-functions/ solid-router/ pnpm-lock.yaml .husky diff --git a/cloud-functions/[[default]].js b/cloud-functions/[[default]].js new file mode 100644 index 00000000..271e6272 --- /dev/null +++ b/cloud-functions/[[default]].js @@ -0,0 +1,10 @@ +// Keep this entry in Git so EdgeOne can discover Node functions before running +// the project build. The backend bundle is generated outside cloud-functions/ +// to avoid registering it as another route. +import { onRequest as handleRequest } from "../dist-server/edgeone-entry.js" + +export function onRequest(context) { + return handleRequest(context) +} + +export default onRequest diff --git a/middleware.js b/middleware.js index 901fcb34..6904865f 100644 --- a/middleware.js +++ b/middleware.js @@ -1,6 +1,7 @@ // EdgeOne Makers 边缘中间件:SPA 路由回退 // -// cloud-functions/[[default]].js(构建产物,见 scripts/build-edge.mjs)是根级 +// cloud-functions/[[default]].js 是入库的根级入口,调用构建生成的 +// dist-server/edgeone-entry.js(见 scripts/build-edge.mjs)。此根级 // catch-all,会接管所有未命中静态文件的 // 请求(包括 /add、/@manage/* 等前端路由),而 Node 函数内没有 ASSETS 绑定, // Hono 兜底只能返回 404 —— 这就是「访问 /add 404 后整站打不开」的原因。 @@ -18,7 +19,8 @@ export function middleware(context) { const accept = request.headers.get("accept") || "" const isBackend = - pathname === "/health" || /^\/(api|d|p|sd|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(pathname) + pathname === "/health" || + /^\/(api|d|p|sd|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(pathname) if ( !isBackend && diff --git a/scripts/build-edge.mjs b/scripts/build-edge.mjs index e64577b7..052057ad 100644 --- a/scripts/build-edge.mjs +++ b/scripts/build-edge.mjs @@ -74,17 +74,18 @@ const emptyNodeDriverPlugin = { return { path: args.path, namespace: "empty-node-driver" } }) // 拦截直接引用 ssh2 / cpu-features / iconv-lite / mysql2 - build.onResolve({ filter: /^(ssh2|cpu-features|iconv-lite)(\/.*)?$/ }, (args) => { - return { path: args.path, namespace: "empty-node-driver" } - }) + build.onResolve( + { filter: /^(ssh2|cpu-features|iconv-lite)(\/.*)?$/ }, + (args) => { + return { path: args.path, namespace: "empty-node-driver" } + }, + ) build.onResolve({ filter: /^mysql2(\/.*)?$/ }, (args) => { return { path: args.path, namespace: "empty-node-driver" } }) - build.onLoad( - { filter: /.*/, namespace: "empty-node-driver" }, - () => { - return { - contents: ` + build.onLoad({ filter: /.*/, namespace: "empty-node-driver" }, () => { + return { + contents: ` // Empty stub for Edge/CloudFunction build — Node-only drivers (sftp/ftp/ssh2/mysql2) are not available in edge/serverless isolates. export const SFTPDriver = class { constructor() { throw new Error("[Edge/Serverless] SFTP driver requires full Node.js runtime"); } }; export const normalizeSFTPAddition = (v) => v; @@ -95,10 +96,9 @@ export const Client = class { constructor() { throw new Error("[Edge/Serverless] export const createPool = () => { throw new Error("[Edge/Serverless] mysql2 is not available in edge/serverless runtime"); }; export default {}; `, - loader: "js", - } - }, - ) + loader: "js", + } + }) }, } @@ -124,15 +124,24 @@ const normalizeHtmlEolPlugin = { const nodeShimPlugin = { name: "node-shim", setup(build) { - const nodeModules = ["crypto", "buffer", "util", "stream", "zlib", "module", "fs", "path"] + const nodeModules = [ + "crypto", + "buffer", + "util", + "stream", + "zlib", + "module", + "fs", + "path", + ] const shimPath = path.resolve(__dirname, "node-shim.mjs") - + // 匹配裸模块名(如 "crypto") const bareFilter = new RegExp(`^(${nodeModules.join("|")})$`) build.onResolve({ filter: bareFilter }, (args) => { return { path: shimPath, external: false } }) - + // 匹配 node: 前缀(如 "node:crypto") const nodeFilter = new RegExp(`^node:(${nodeModules.join("|")})$`) build.onResolve({ filter: nodeFilter }, (args) => { @@ -169,15 +178,15 @@ async function build() { plugins: [emptyNodeDriverPlugin], }) - // EdgeOne Makers 的 Node 云函数入口。产物按平台约定落在项目根 - // cloud-functions/[[default]].js,但它**不入库**:EdgeOne 的构建命令 - // (edgeone.json -> pnpm run build)会在部署时执行本脚本重新生成。 + // EdgeOne 在执行项目构建前发现 Node 云函数,因此 cloud-functions/[[default]].js + // 必须作为源码入库。实际后端包写入 dist-server,由该入口调用;不要写入 + // cloud-functions,避免覆盖入口或被平台发现为额外路由。 await esbuild.build({ entryPoints: ["api/_makers.ts"], bundle: true, platform: "node", target: "node22", - outfile: "cloud-functions/[[default]].js", + outfile: "dist-server/edgeone-entry.js", minify: true, format: "esm", external: ["ssh2", "cpu-features", "iconv-lite", "mysql2"], @@ -197,19 +206,14 @@ async function build() { format: "esm", mainFields: ["browser", "module", "main"], // 优先选择浏览器版本依赖 conditions: ["browser"], // 强制浏览器条件导出 - external: [ - "ssh2", - "cpu-features", - "iconv-lite", - "mysql2", - ], + external: ["ssh2", "cpu-features", "iconv-lite", "mysql2"], loader: { ".html": "text", ".node": "empty" }, plugins: [emptyNodeDriverPlugin, normalizeHtmlEolPlugin, nodeShimPlugin], }) } console.log( - "✓ Edge build complete -> dist-server/api/[...route].js & cloud-functions/[[default]].js", + "✓ Edge build complete -> dist-server/api/[...route].js & dist-server/edgeone-entry.js", ) } diff --git a/src/backend/internal/model/db_cipher.test.ts b/src/backend/internal/model/db_cipher.test.ts index 196c2115..c8c8d6e9 100644 --- a/src/backend/internal/model/db_cipher.test.ts +++ b/src/backend/internal/model/db_cipher.test.ts @@ -11,7 +11,6 @@ import { encryptConfigValue, isSealedCiphertext, resolveDbCipher, - type DbCipher, } from "../../pkg/crypto" import { readCipher } from "./store/backend" import { memoryDriver } from "./store/driver/memory" diff --git a/tests/edgeone-entry.test.mjs b/tests/edgeone-entry.test.mjs new file mode 100644 index 00000000..9917b766 --- /dev/null +++ b/tests/edgeone-entry.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { createRequire } from "node:module" +import os from "node:os" +import path from "node:path" +import { after, test } from "node:test" +import { fileURLToPath, pathToFileURL } from "node:url" +import { build } from "esbuild" + +const root = fileURLToPath(new URL("../", import.meta.url)) +const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "edgeone-entry-"), +) +after(() => rm(temporaryDirectory, { recursive: true, force: true })) + +// EdgeOne's Node launcher provides global require for bundled CommonJS +// dependencies. Match that runtime when importing the generated ESM backend. +globalThis.require = createRequire(import.meta.url) + +// EdgeOne bundles the committed entry after the project build has generated its +// imported backend. Exercise that second bundle as well as the original entry. +const output = path.join(temporaryDirectory, "entry.mjs") +await build({ + entryPoints: [path.join(root, "cloud-functions/[[default]].js")], + outfile: output, + bundle: true, + platform: "node", + target: "node22", + format: "esm", +}) + +const entries = [ + ["committed entry", await import("../cloud-functions/[[default]].js")], + ["rebundled entry", await import(pathToFileURL(output).href)], +] + +for (const [name, entry] of entries) { + test(`${name}: diagnostics return JSON through onRequest`, async () => { + const values = new Map() + const env = { + DB_DRIVER: "kv", + DB_FORMAT: "map", + JWT_SECRET: "edgeone-entry-test-secret-32-characters", + KV: { + get: async (key) => values.get(key) ?? null, + put: async (key, value) => values.set(key, value), + }, + } + assert.equal(entry.default, entry.onRequest) + for (const route of ["env_check", "init_status"]) { + const response = await entry.onRequest({ + request: new Request(`http://localhost/api/public/${route}`), + env, + }) + assert.equal(response.status, 200) + assert.match(response.headers.get("content-type"), /application\/json/) + const body = await response.json() + assert.equal(body.code, 200) + if (route === "env_check") { + assert.equal(body.data.config.db_driver, "kv") + assert.equal(body.data.storage.available, true) + } else { + assert.equal(body.data.initialized, false) + } + } + }) + + test(`${name}: frontend navigation keeps its SPA fallback`, async () => { + const response = await entry.onRequest({ + request: new Request("http://localhost/@manage", { + headers: { accept: "text/html" }, + }), + env: {}, + }) + assert.equal(response.status, 200) + assert.match(response.headers.get("content-type"), /text\/html/) + assert.match(await response.text(), /]/i) + }) +}