本文说明 apps/server 的运行时边界:项目同时支持 Node process 和 Cloudflare Workers isolate,但业务代码尽量不直接感知平台差异。
| Runtime | 执行模型 | 状态 | 说明 |
|---|---|---|---|
node |
process | 正式支持 | 本地开发、普通 Node 服务、Node build 目标 |
cloudflare |
isolate | 正式支持 | Cloudflare Workers、D1-backed session storage |
vercel-edge |
isolate | 预留 | 只有类型预留,没有完整入口和部署配置 |
项目只让用户配置事实型变量 APP_RUNTIME,不再额外配置 APP_RUNTIME_MODE。执行模型由代码根据 APP_RUNTIME 推导。
| Runtime | 入口 | 作用 |
|---|---|---|
| Node process | src/app/runtime/process/node/index.ts |
注册 Node 能力并启动 server |
| Cloudflare | src/app/runtime/isolate/cloudflare/index.ts |
注册 Cloudflare 能力并导出 fetch handler |
Node entry 可以使用 @hono/node-server、进程信号、Node 文件系统等能力。Cloudflare entry 只导出 Worker module handler,不能静态引入 Node-only 实现。
bootstrapApp() 永远保持 runtime-agnostic。它只创建通用 Hono app、注册 middleware、routes、lifecycle 和可选 OpenAPI,不接收 runtime 参数,也不分支处理平台能力。runtime-specific 行为只允许放在 runtime entry 或 runtime capability 中。
跨 runtime 的平台能力通过显式 RuntimeCapabilities 对象注入。runtime entry 负责创建 scoped capabilities,middleware 把它写入 Hono context,业务模块通过 runtimeCapabilities(c) 读取。
核心能力包括:
runtimeCapabilities.database()runtimeCapabilities.database.repositories.files()runtimeCapabilities.database.repositories.productExports()runtimeCapabilities.database.repositories.references()runtimeCapabilities.bucket()runtimeCapabilities.shopifySessionStorage()runtimeCapabilities.health.disk(c)runtimeCapabilities.health.memory(c)runtimeCapabilities.file.downloadResolver()runtimeCapabilities.queue.producer()
对应文件:
src/app/runtime/runtime-capabilities.tssrc/app/runtime/process/node/runtime-capabilities.tssrc/app/runtime/isolate/cloudflare/runtime-capabilities.tssrc/shared/middlewares/runtime-capabilities.tssrc/infra/databasesrc/infra/bucketsrc/infra/queuesrc/infra/scheduler
共享业务代码只读取 capability,不直接 import Node-only 或 Cloudflare-only 实现。health/disk 与 health/memory 也遵循这条边界:Node process runtime 通过 @unimolecule/utils/node 读取进程磁盘与内存指标,Cloudflare isolate runtime 对这两项返回 unsupported。/healths 聚合 endpoint 复用 disk、memory、network、database 和 reserved redis 的单项检查结果;只有单项返回 error 时才把整体状态标记为 error。这样可以保护 Cloudflare bundle 的 import graph。
database、bucket、queue 和 scheduler 各自还保留自己的 infra index,但 index
只导出共享契约、类型、registry 或 runtime-neutral helper。process/isolate
实现由 src/app/runtime/process/node/runtime-capabilities.ts 与
src/app/runtime/isolate/cloudflare/runtime-capabilities.ts 显式引入。这样
Cloudflare entry 不会因为共享 infra barrel 间接看到 Node-only process adapter。
process 实现可以缓存长生命周期资源,例如 pg.Pool、bucket adapter、pg-boss
producer/consumer 和 schedule worker。Cloudflare isolate 实现以 request/event
binding 为边界,不保留跨 request 的 D1、R2 或 Queue binding 引用。
runtimeCapabilities.database() 是 Shopify session storage 与 health/database 的统一数据库入口;runtimeCapabilities.database.repositories.*() 是同一个 database capability 下的 repository 绑定出口。Node runtime 只 import postgres.ts repository builder,Cloudflare runtime 只 import sqlite.ts repository builder;公共 repositories/database/index.ts 只保留类型出口,不再按 provider 动态分发。health/database 调用 database adapter 的 check(),由具体 runtime 通过 select 1 验证最小 SQL 查询链路。
当前策略:
| Runtime | Provider | 行为 |
|---|---|---|
node |
postgres |
pg.Pool + drizzle-orm/node-postgres |
cloudflare |
d1 |
Cloudflare D1 + drizzle-orm/d1 |
PostgreSQL 使用 drizzle.pg.config.ts 和 drizzle.pg。D1 使用 drizzle.d1.config.ts 和 drizzle.d1,Wrangler binding 的 migrations_dir 指向 drizzle.d1。
Node 只支持 PostgreSQL;Cloudflare 只支持 D1。非法组合会通过 runtime env 解析,但会在 database strategy 边界失败。
runtimeCapabilities.bucket() 是 file module 与 product-export 的统一 object bucket 入口。Node 支持 memory 和 r2,Cloudflare 当前只支持 r2。Node + R2 使用 @aws-sdk/client-s3 的 S3-compatible 实现;Cloudflare + R2 使用 request-bound Worker R2 binding。
queue 与 scheduler 和 bucket/database 一样走 runtime-aware infra:
| 能力 | Node process | Cloudflare isolate |
|---|---|---|
| Queue producer | pg-boss |
Cloudflare Queue binding |
| Queue consumer | pg-boss polling |
Worker queue(batch, env) |
| Scheduler | pg-boss schedule |
Worker scheduled(controller) |
| dispose 行为 | 停止缓存实例 | no-op 预留口子 |
业务模块通过 registerQueueJob(...) 和 registerSchedulerTask(...) 注册工作单元。
Node entry 在启动时创建 consumer/scheduler 并调用 start(...)。Cloudflare entry
在 queue/scheduled export 中为本次 event 创建 context,然后调用对应 adapter。
Cloudflare request-bound binding 不要求在 bootstrap 阶段存在。schema 允许这类字段 optional,runtime capability 在真正使用时负责强校验。
例如 Cloudflare D1 database capability 在 runtimeCapabilityCloudflare(...) 中读取 APP_DATABASE_D1_BINDING 指向的 env[binding],并通过 requireCloudflareBinding(...) 校验:
const binding = runtimeEnv.APP_DATABASE_D1_BINDING;
const d1 = requireCloudflareBinding(
env[binding],
binding,
isCloudflareD1Database,
);这条边界保证:
- Worker 模块 import 阶段可以只依赖
process.env中的字符串配置完成 app/bootstrap。 - request 进入后,
runtimeEnvMiddleware从c.env合并平台 binding。 - 业务代码不需要到处判断平台 binding 是否存在。
业务模块使用通用 AppEnv,让 controller、middleware、provider 不感知具体 runtime。runtime 入口可以使用具体类型收窄:
RuntimeAppEnv<"node">;
RuntimeAppEnv<"cloudflare">;
RuntimeAppEnv<"vercel-edge">;Cloudflare Worker 入口使用 RuntimeAppEnv<"cloudflare"> 作为 ExportedHandler bindings 类型。这个类型从 RuntimeConfig union 推导,不手写重复 env 字段。
Shopify app mode 不是 runtime capability。它和 APP_RUNTIME 正交,具体 env 语义见 env.md。
Shopify mode capability 只负责 app-flow 差异,例如 App Shell、OAuth callback redirect、Admin request session strategy。它位于:
src/app/modules/shopify/mode
runtime capability 只负责平台 port 差异,例如 database、bucket、queue、session storage、health checker 和 file resolver。env 与 logger 的权威入口仍是 provider。
Shopify frontend target 也不是 runtime capability。它和 APP_RUNTIME、SHOPIFY_APP_MODE 都正交,具体 env 语义、shopify.web.toml 生成规则和 app shell route 策略见 env.md。
| 目标 | 构建配置 | 输出目录 |
|---|---|---|
| Node process | build.config.ts |
dist/process/node |
| Cloudflare isolate | build.config.ts |
dist/isolate/cloudflare |
对应脚本:
pnpm --dir apps/server run buildbuild 使用 production env 运行 bundle。bundle 调用 tsdown,同一个配置同时构建 Node process 和 Cloudflare isolate 两个入口;process 目标会清理 dist,isolate 目标使用 clean: false,因此一次构建后会同时保留两套产物。Node Docker runtime 启动 dist/process/node/index.mjs。
根目录部署入口是:
pnpm deploy执行顺序固定为:
deploy:prepare -> deploy:runtime -> app:deploy
deploy:prepare使用.env.production重新生成shopify.app.toml和shopify.web.toml。deploy:runtime只做 runtime 分发。根脚本scripts/deploy/index.ts校验APP_RUNTIME,然后调用 server workspace 的cf:deploy或node:deploy。app:deploy交给 Shopify CLI 同步 app 配置。
pnpm deploy 不会自动执行 database migration。部署前应按当前 APP_DATABASE_PROVIDER 执行对应迁移命令,详见 database.md。
server workspace 拥有具体部署实现:
| Runtime | Server script | 说明 |
|---|---|---|
cloudflare |
cf:deploy |
运行 scripts/deploy/cloudflare.ts 构建 web 产物并写入 Wrangler assets,然后执行 Wrangler secrets/deploy |
node |
node:deploy |
运行 scripts/deploy/node.ts 构建 web/server,生成 Compose/Nginx,并通过 Docker + PM2 runtime 部署 |
Cloudflare 部署脚本会把 apps/server/wrangler.json 的 assets 动态写成:
{
"directory": "../web/dist",
"not_found_handling": "single-page-application",
"binding": "ASSETS",
"run_worker_first": ["/api/*", "/auth", "/auth/*", "/webhooks", "/webhooks/*"]
}这样 Worker 统一托管 SPA 静态资源和 Hono 动态路由。/api/*、/auth*、
/webhooks* 必须先进入 Hono,其他路径才走静态资源或 SPA fallback。
Node 部署脚本会生成这两个本地文件:
apps/server/docker-compose.yml
apps/server/nginx.conf
它们是部署产物,已在 .gitignore 中忽略。Compose 的 image 和
container_name 由根 package.json 的 name 派生,容器内通过
apps/server/Dockerfile 使用 PM2 runtime 启动 Node build。
Nginx 负责同域托管:/assets/* 直接读取 web build,/api/*、/auth*、
/webhooks* 代理到本机 Node 容器,其余路径回落到 index.html。
Node 部署默认把 web 产物同步到 /var/www/<deployment-name>/web,把 Nginx
配置复制到 /etc/nginx/conf.d/<SHOPIFY_APP_URL host>.conf。如需覆盖路径,
使用 env.md 中的部署期 env。
createApp() 只创建 Hono app、注册 middleware、业务路由和 lifecycle,不默认注册 OpenAPI。
OpenAPI 由 bootstrapApp({ registerOpenApi }) 控制:
- Node process: 非 production 注册
/document和/reference。 - Cloudflare isolate: 默认不注册 OpenAPI。
- production: 不注册 OpenAPI 和 Scalar,Scalar 使用动态 import,避免生产环境加载不必要代码。
对应文件:
src/app/bootstrap/create-app.tssrc/app/bootstrap/index.tssrc/app/bootstrap/register-openapi.ts
Cloudflare bindings 类型由 Wrangler 生成:
pnpm --dir apps/server run cf:type输出文件:
typings/cloudflare-worker-configuration.d.ts
这是生成物,不手动维护。提交前的 lint-staged 已过滤该文件,避免 ESLint/Prettier 修改 Wrangler 输出。
如果需要排查 Wrangler CLI 子进程实际拿到的 env,可以临时使用
scripts/print/wrangler-env.ts 作为 NODE_OPTIONS 调试入口,例如在
apps/server 目录下执行 Wrangler 前临时加上
NODE_OPTIONS="--import tsx --import ./scripts/print/wrangler-env.ts"。它只
打印白名单 key,并会对 secret 值做长度级别的脱敏。
bootstrapApp()永远 runtime-agnostic,不接收 runtime 参数。- runtime-specific 行为只放在 runtime entry 或 runtime capability。
- 业务模块只使用通用
AppEnv,不按 runtime 分支。 - 平台 binding 在 schema 中可以 optional,但使用点必须通过 runtime capability 强校验。
- 业务代码通过 provider、middleware、infra adapter 或 capability 获取 runtime 能力。
- Node-only 依赖只出现在 process entry、process capability、process infra adapter 或
.node.ts文件中。 - Cloudflare entry 不静态导入
node:*、@hono/node-server、@logtape/file。 APP_RUNTIME=cloudflare时,request-bound binding 从c.env进入。vercel-edge当前只作为未来扩展预留,不作为可部署目标。- env var 语义和组合规则集中维护在 env.md。
- Env 解析和 provider 缓存见 env.md。
- Logger 在不同 runtime 下的 sink 策略见 logger.md。
- Bucket provider 策略见 bucket.md。
- File module 的下载和元数据策略见 file.md。
- Shopify session storage 的 runtime 差异见 shopify.md。