Skip to content
Open
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: 24 additions & 9 deletions .github/workflows/build-verify.yml
Original file line number Diff line number Diff line change
@@ -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 产出
#
Expand All @@ -20,7 +20,10 @@ on:
- "src/**"
- "api/**"
- "functions/**"
- "cloud-functions/**"
- "scripts/**"
- "tests/edgeone-entry.test.mjs"
- ".gitignore"
- "middleware.js"
- "edgeone.json"
- "wrangler.jsonc"
Expand All @@ -32,7 +35,10 @@ on:
- "src/**"
- "api/**"
- "functions/**"
- "cloud-functions/**"
- "scripts/**"
- "tests/edgeone-entry.test.mjs"
- ".gitignore"
- "middleware.js"
- "edgeone.json"
- "wrangler.jsonc"
Expand Down Expand Up @@ -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

Expand All @@ -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
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
dist/
dist-server/
cloud-functions/
solid-router/
pnpm-lock.yaml
.husky
Expand Down
10 changes: 10 additions & 0 deletions cloud-functions/[[default]].js
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -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 后整站打不开」的原因。
Expand All @@ -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 &&
Expand Down
56 changes: 30 additions & 26 deletions scripts/build-edge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
}
})
},
}

Expand All @@ -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) => {
Expand Down Expand Up @@ -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"],
Expand All @@ -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",
)
}

Expand Down
1 change: 0 additions & 1 deletion src/backend/internal/model/db_cipher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
encryptConfigValue,
isSealedCiphertext,
resolveDbCipher,
type DbCipher,
} from "../../pkg/crypto"
import { readCipher } from "./store/backend"
import { memoryDriver } from "./store/driver/memory"
Expand Down
79 changes: 79 additions & 0 deletions tests/edgeone-entry.test.mjs
Original file line number Diff line number Diff line change
@@ -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(), /<html[\s>]/i)
})
}