@finesoft/front 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
@@ -1,200 +1,21 @@
1
- # 高阶:内联 proxy 代码生成
1
+ # 进阶:代理代码生成
2
2
 
3
- serverless edge 部署,想把 proxy 逻辑内联到函数 bundle 里 —— 不在运行时调 `registerProxyRoutes`,不带额外依赖 —— 框架暴露 `generateProxyCode`。
4
-
5
- ## 用例
6
-
7
- 部署到 Cloudflare Workers / Vercel Edge / AWS Lambda@Edge。每个函数有:
8
-
9
- - 紧的冷启动预算
10
- - 紧的冷 bundle 体积预算(Workers:1 MB 压缩后)
11
- - 某些运行时没有 `process.env`
12
-
13
- import proxy router 和它的支持文件(validator、Hono 集成)增字节。`generateProxyCode` **只**发出你声明的路由需要的那几行。输出自包含:几个 `app.get(...)` / `app.all(...)` 调用加一个 `_sanitizeProxyPath` helper。
14
-
15
- ## 生成的输出
16
-
17
- 输入:
3
+ `generateProxyCode` `@finesoft/front` 导出的构建期扩展。标准适配器已经集成声明式代理路由。业务模块需要校验、策略和作用域服务时,应使用可移植的操作与 HTTP API。
18
4
 
19
5
  ```ts
20
6
  import { generateProxyCode } from "@finesoft/front";
21
-
22
- const code = generateProxyCode([
7
+ const source = generateProxyCode([
23
8
  {
24
9
  prefix: "/api",
25
10
  target: "https://upstream.example",
26
11
  headers: { "X-App": "myapp" },
27
12
  auth: { type: "bearer", envKey: "API_TOKEN" },
28
- cache: "max-age=60",
29
- },
30
- ]);
31
-
32
- console.log(code);
33
- ```
34
-
35
- 得到大概这样的:
36
-
37
- ```js
38
- // ─── 框架声明式代理路由 ───
39
- function _sanitizeProxyPath(raw) {
40
- if (raw.length > 2048) return null;
41
- try {
42
- if (decodeURIComponent(raw) !== raw) return null;
43
- } catch {
44
- return null;
45
- }
46
- if (raw.startsWith("//")) return null;
47
- if (!/^[/\w.\-~%:@!$&'()*+,;=]*$/.test(raw)) return null;
48
- return raw.startsWith("/") ? raw : "/" + raw;
49
- }
50
-
51
- app.all("/api/*", async (c) => {
52
- const _sub = _sanitizeProxyPath(c.req.path.replace("/api", ""));
53
- if (!_sub) return c.text("Invalid path", 400);
54
- const _target = new URL(_sub, "https://upstream.example");
55
- if (_target.origin !== "https://upstream.example") return c.text("Invalid proxy target", 400);
56
- const _reqUrl = new URL(c.req.url);
57
- _reqUrl.searchParams.forEach((v, k) => _target.searchParams.set(k, v));
58
- const _headers = { "X-App": "myapp" };
59
- const _token =
60
- (typeof process !== "undefined" && process.env && process.env["API_TOKEN"]) || "";
61
- if (_token) _headers.Authorization = "Bearer " + _token;
62
- try {
63
- const _resp = await fetch(_target.toString(), { headers: _headers, redirect: "manual" });
64
- const _cl = _resp.headers.get("Content-Length");
65
- if (_cl && parseInt(_cl, 10) > 10485760) {
66
- return c.text("Proxy response too large", 502);
67
- }
68
- const _body = await _resp.arrayBuffer();
69
- if (_body.byteLength > 10485760) {
70
- return c.text("Proxy response too large", 502);
71
- }
72
- const _rh = { "Content-Type": _resp.headers.get("Content-Type") || "application/json" };
73
- if ("max-age=60") _rh["Cache-Control"] = "max-age=60";
74
- return c.newResponse(_body, _resp.status, _rh);
75
- } catch (_e) {
76
- console.error("[Proxy /api]", _e);
77
- return c.json({ error: "Proxy request failed" }, 502);
78
- }
79
- });
80
- ```
81
-
82
- 所有都内联。proxy 路径不从 `@finesoft/front` import 任何东西。把这放进函数 bundle 里,跟 SSR 入口一起。
83
-
84
- ## 代码生成 vs 运行时注册 怎么选
85
-
86
- | 关注点 | 运行时(`registerProxyRoutes`) | 代码生成(`generateProxyCode`) |
87
- | ----------------------------- | ------------------------------- | ------------------------------- |
88
- | 长跑服务器(Node、Workers) | ✅ 优先 | ✅ 也行 |
89
- | 微小 edge 函数(Lambda@Edge) | import 更重 | ✅ 最小 |
90
- | 不重新部署就更新路由 | ✅ 改配置,重启 | ❌ 需重新部署 |
91
- | 配置来自远端服务 | ✅ 支持 | ❌ 代码生成在构建期跑 |
92
- | 多个 proxy 共享 helper | ✅ 运行时共享 | 自己去重否则代码重复 |
93
-
94
- bundle 体积重要时用代码生成。多数部署运行时路径就好。
95
-
96
- ## 构建期集成
97
-
98
- 典型设置:
99
-
100
- ```ts
101
- // scripts/build-proxy.mjs
102
- import { generateProxyCode } from "@finesoft/front";
103
- import { writeFile } from "node:fs/promises";
104
-
105
- const code = generateProxyCode([
106
- { prefix: "/api/users", target: "https://users.internal" },
107
- { prefix: "/api/products", target: "https://products.internal", cache: "max-age=30" },
108
- {
109
- prefix: "/api/orders",
110
- target: "https://orders.internal",
111
- auth: { type: "bearer", envKey: "ORDERS_TOKEN" },
112
13
  },
113
14
  ]);
114
-
115
- const wrapper = `
116
- import { Hono } from "hono";
117
- const app = new Hono();
118
-
119
- ${code}
120
-
121
- export default app;
122
- `;
123
-
124
- await writeFile("dist/proxy.js", wrapper, "utf8");
125
- ```
126
-
127
- 然后在 serverless 函数入口 import `./proxy.js`:
128
-
129
- ```ts
130
- // dist/main.ts(Cloudflare Worker)
131
- import proxyApp from "./proxy.js";
132
- import ssrApp from "./ssr-bundle.js";
133
-
134
- const app = new Hono();
135
- app.route("/", proxyApp);
136
- app.route("/", ssrApp);
137
-
138
- export default app;
139
- ```
140
-
141
- ## 生成代码替你做了什么
142
-
143
- 生成的 handler 强制和运行时路径同样的保证:
144
-
145
- - **SSRF 保护**:path 校验拒绝编码字符、`//` 前缀、不允许字符集外字符
146
- - **开放重定向保护**:`target.origin` 必须与配置的 target origin 一致
147
- - **10 MB 响应大小限制**:`Content-Length` 快速拒绝 + `byteLength` 实际字节检查
148
- - **二进制完整性**:`arrayBuffer()` 转发(不 UTF-8 解码)
149
- - **从环境读 auth**:请求时读 `process.env[envKey]`
150
-
151
- 框架测试套件断言运行时和生成代码的**parity**:
152
-
153
- ```ts
154
- // packages/server/test/proxy.test.ts
155
- test("generated proxy code embeds the same response size limit as runtime (parity)", () => {
156
- const code = generateProxyCode([{ prefix: "/api", target: "https://upstream.example" }]);
157
-
158
- const MAX = String(10 * 1024 * 1024);
159
- expect(code).toContain(`parseInt(_cl, 10) > ${MAX}`);
160
- expect(code).toContain(`_body.byteLength > ${MAX}`);
161
- });
162
- ```
163
-
164
- 你改运行时路径的大小限制,生成代码的限制锁步更新。
165
-
166
- ## 注意事项
167
-
168
- ### `process.env` 可能不存在
169
-
170
- 生成的代码用 `typeof process !== "undefined"` 守卫。没有 `process` 的运行时(某些 edge 环境),auth 头根本不会加 —— 上游看不到 auth。
171
-
172
- 像 Cloudflare Workers 用函数 arg 注入 env 而不是 `process.env` 的平台,你要:
173
-
174
- - 包一层生成代码,从 worker 的 env arg 注入 auth 头
175
- - 或生成后替换 auth 那段,改成平台对应的访问方式
176
-
177
- ### 无重试、无熔断
178
-
179
- 生成的 handler 一次 `fetch`,失败冒成 `502 Proxy request failed`。要重试 / 熔断逻辑,自己写 proxy 代码 —— `generateProxyCode` 有意最小。
180
-
181
- ### 多个 proxy 共享 helper 代码
182
-
183
- `_sanitizeProxyPath` 在生成字符串顶部发一次。多个 `app.all` 共用。每个路由单独 `generateProxyCode` 然后拼接,helper 会重复 —— 一次传所有路由调用。
184
-
185
- ### 生成时校验
186
-
187
- `generateProxyCode` 跑和 `registerProxyRoutes` 一样的 `validateConfig`。无效配置构建期就抛:
188
-
189
- ```ts
190
- generateProxyCode([{ prefix: "/api", target: "file:///etc/passwd" }]);
191
- // Error: [proxy] target must start with "https://" or "http://": "file:///etc/passwd"
192
15
  ```
193
16
 
194
- 抓配置错误在部署发出之前。
17
+ 返回的源码调用 `registerProxyRoutes(app, config)`。自定义生成入口需要从 `@finesoft/front` 导入 `registerProxyRoutes` 并提供 Hono `app`;标准适配器自动完成这些绑定。开发环境和生成主机共用路径校验、二进制响应与大小限制实现。它属于构建产物,不是应用启动 API。密钥值配置在主机上,路由声明变化后重新构建。`auth.envKey` 在可用时读取 `process.env`;没有 `process` 的主机可在运行时注册配置中提供 Authorization 请求头。平台包体限制及部署规则以该平台当前文档为准。
195
18
 
196
- ## 参考
19
+ 普通应用配置 Vite 插件并使用标准适配器即可,无需把生成的 handler 源码复制到业务文件。见 [HTTP 与部署](../09-server-and-deployment.md)。
197
20
 
198
- - [第 9 章:服务器与部署 · proxy 路由](../09-server-and-deployment.md#proxy-路由)
199
- - 实现:`packages/server/src/proxy.ts`
200
- - parity 测试:`packages/server/test/proxy.test.ts`
21
+ 默认不跟随重定向;`followRedirects: true` 只允许目标 origin 内的跳转,最多 20 次。响应按块计数,超过 10 MiB 立即取消读取并返回 502,即使 Content-Length 缺失或不实也执行限制。
@@ -1,330 +1,17 @@
1
- # 高阶:多租户 scope
1
+ # 请求隔离
2
2
 
3
- 多租户应用一份部署服务多个客户,每个客户的请求要看到自己的:
3
+ 运行时自动打开、关闭调用 scope。可信主机绑定传递租户和身份,嵌套操作继承当前上下文。
4
4
 
5
- - 数据库连接 / API 客户端
6
- - 带租户 tag 的 logger / 指标
7
- - Feature flag / 价格 / 品牌
8
- - 缓存的翻译 / 内容
9
-
10
- DI 容器的子 scope 是正确的原语。本配方展示通过 `beforeLoad` 守卫接通按租户隔离。
11
-
12
- ## 心智模型
13
-
14
- ```
15
- Framework
16
- └── 父 Container ← 共享服务(HTTP 池、基础 recorder、...)
17
- └── 每个请求 scope ← 框架为每个 SSR 请求创建
18
-
19
- └── 一个 beforeLoad 守卫注册的租户覆盖
20
- ```
21
-
22
- 每请求 scope 由框架自动创建。你的守卫在它之上注册租户级覆盖。你没覆盖的东西都回退到父。
23
-
24
- ## 步骤 1:识别租户
25
-
26
- 这是你的业务逻辑。常见来源:
27
-
28
- - **子域**:`acme.myapp.com` → `acme`
29
- - **路径前缀**:`/t/acme/...` → `acme`
30
- - **头**:`X-Tenant-Id: acme`
31
- - **鉴权用户**:cookie → session → tenant
32
-
33
- ```ts
34
- // src/lib/tenants/resolve.ts
35
- import type { NavigationContext } from "@finesoft/front";
36
-
37
- export function resolveTenant(ctx: NavigationContext): string | null {
38
- const host = ctx.url.hostname;
39
- const sub = host.split(".")[0];
40
- if (sub && sub !== "www" && sub !== "myapp") return sub;
41
- return null;
42
- }
43
- ```
44
-
45
- ## 步骤 2:加载租户配置
46
-
47
- ```ts
48
- // src/lib/tenants/config.ts
49
- export interface TenantConfig {
50
- tenantId: string;
51
- displayName: string;
52
- upstreamUrl: string;
53
- apiToken: string;
54
- featureFlags: Record<string, unknown>;
55
- locale: string;
56
- }
57
-
58
- const cache = new Map<string, TenantConfig>();
59
-
60
- export async function getTenantConfig(tenantId: string): Promise<TenantConfig | null> {
61
- if (cache.has(tenantId)) return cache.get(tenantId)!;
62
-
63
- // 从配置存储读 —— 文件、DB、KV
64
- const config = await loadFromStore(tenantId);
65
- if (!config) return null;
66
-
67
- cache.set(tenantId, config);
68
- return config;
69
- }
70
- ```
71
-
72
- 真实实现要在配置更新时让缓存失效。多数应用按定时器刷或通过 webhook 即可。
73
-
74
- ## 步骤 3:在守卫里注册租户服务
75
-
76
- ```ts
77
- // src/lib/guards/tenant.ts
78
- import { deny, next, type NavigationContext, DEP_KEYS } from "@finesoft/front";
79
- import { resolveTenant } from "../tenants/resolve";
80
- import { getTenantConfig } from "../tenants/config";
81
- import { UserApi } from "../api/user";
82
- import { WithFieldsRecorder } from "@finesoft/front";
83
-
84
- export async function tenantGuard(ctx: NavigationContext) {
85
- const tenantId = resolveTenant(ctx);
86
- if (!tenantId) return deny(404, "Unknown tenant");
87
-
88
- const config = await getTenantConfig(tenantId);
89
- if (!config) return deny(404, "Tenant not found");
90
-
91
- // 在请求 scope 上注册租户专属服务
92
- const scope = ctx.container;
93
- scope.register("tenantConfig", () => config);
94
-
95
- scope.register(
96
- "userApi",
97
- () =>
98
- new UserApi({
99
- baseUrl: config.upstreamUrl,
100
- defaultHeaders: { Authorization: `Bearer ${config.apiToken}` },
101
- }),
102
- );
103
-
104
- scope.register(DEP_KEYS.FEATURE_FLAGS, () => ({
105
- get: (key, fallback) => config.featureFlags[key] ?? fallback,
106
- }));
107
-
108
- // 用租户上下文装饰父的 recorder
109
- const baseRecorder = scope.parent!.resolve(DEP_KEYS.EVENT_RECORDER);
110
- scope.register(
111
- DEP_KEYS.EVENT_RECORDER,
112
- () => new WithFieldsRecorder(baseRecorder, [{ getFields: () => ({ tenantId }) }]),
113
- );
114
-
115
- return next();
116
- }
117
- ```
118
-
119
- 要点:
120
-
121
- - **scope 已经由框架创建。** 你在 `ctx.container` 上注册 —— 那就是请求 scope。
122
- - **回退自动。** 这里没注册的会从父容器 resolve。
123
- - **装饰而不替换。** recorder 用租户字段包起来而不是替换 —— 基础行为(HTTP 传输、批处理)保持原样。
124
-
125
- ## 步骤 4:全局安装守卫
126
-
127
- ```ts
128
- // src/bootstrap.ts
129
- import { type Framework, defineRoutes } from "@finesoft/front";
130
- import { tenantGuard } from "./lib/guards/tenant";
131
- // ... 其他 import
132
-
133
- export function bootstrap(framework: Framework): void {
134
- framework.middleware.use("beforeLoad", tenantGuard);
135
-
136
- defineRoutes(framework, [
137
- { path: "/", intentId: "home", controller: new HomeController() },
138
- { path: "/billing", intentId: "billing", controller: new BillingController() },
139
- // ...
140
- ]);
141
- }
142
- ```
143
-
144
- `tenantGuard` 在任何路由级守卫之前跑,所以任何 Controller 跑时租户 scope 都已经设好。
145
-
146
- ## 步骤 5:Controller 透明地拿到正确的服务
147
-
148
- ```ts
149
- // src/controllers/billing.ts
150
- export class BillingController extends BaseController<{}, BillingPage> {
151
- readonly intentId = "billing";
152
-
153
- async execute(_params, container) {
154
- const config = container.resolve<TenantConfig>("tenantConfig");
155
- const api = container.resolve<UserApi>("userApi"); // 租户专属客户端
156
-
157
- const usage = await api.getUsage();
158
- const invoices = await api.getInvoices();
159
-
160
- return {
161
- kind: "billing",
162
- tenantName: config.displayName,
163
- usage,
164
- invoices,
165
- };
166
- }
167
- }
168
- ```
169
-
170
- Controller 不知道租户存在 —— 它只 resolve `userApi`,拿到本请求对应的那个。
171
-
172
- ## 跨租户禁止
173
-
174
- 防 A 租户认证的用户访问 B 租户数据:
175
-
176
- ```ts
177
- async function sameTenantGuard(ctx: NavigationContext) {
178
- const session = await ctx.container.resolve<SessionService>("session").current();
179
- const requestedTenant = ctx.container.resolve<TenantConfig>("tenantConfig").tenantId;
180
-
181
- if (!session) return redirect("/login");
182
- if (session.tenantId !== requestedTenant) return deny(403, "Cross-tenant access forbidden");
183
-
184
- return next();
185
- }
186
-
187
- // 在 tenantGuard 之后应用:
188
- framework.middleware.use("beforeLoad", tenantGuard);
189
- framework.middleware.use("beforeLoad", sameTenantGuard);
190
- ```
191
-
192
- 守卫顺序重要 —— `tenantGuard` 必须先注册 `tenantConfig`,`sameTenantGuard` 才能读到。
193
-
194
- ## Hydration 考虑
195
-
196
- 租户配置含 `featureFlags`,两端都读。框架的 `PrefetchedIntents` 序列化处理这个 —— 浏览器拿到服务端看到的同样 flag 值,所以客户端读保持一致。
197
-
198
- 租户配置本身**不**自动序列化。view 要显示 `tenantConfig.displayName` 的话,Controller 应该把它放进 `Page`:
199
-
200
- ```ts
201
- async execute(_params, container) {
202
- const config = container.resolve<TenantConfig>("tenantConfig");
203
- return {
204
- kind: "home",
205
- tenant: {
206
- id: config.tenantId,
207
- displayName: config.displayName,
208
- },
209
- // ...
210
- };
211
- }
212
- ```
213
-
214
- `Page` 被序列化所以能跨 SSR → CSR 边界。完整 `TenantConfig`(含秘密)永不应该出现在 `Page` 里。
215
-
216
- ## 浏览器端考虑
217
-
218
- `tenantGuard` 在浏览器也跑 —— 首次导航和每次后续导航。仅浏览器应用(`renderMode: "csr"`)只在这跑。
219
-
220
- 但浏览器不能安全地 resolve `apiToken` 这种秘密。两种方法:
221
-
222
- **方法 1:服务器代理所有 API 调用。** 浏览器打 `/api/users`(你的 proxy),它转发到 `${upstreamUrl}/users` 并从 `process.env[apiTokenEnvKey]` 注入 auth 头。浏览器从不见 token。
223
-
224
- **方法 2:短期 session token。** 服务器颁发 scope 到租户的 JWT;浏览器用它直接调上游。token 轮换由你的 auth 层处理。
225
-
226
- 多数应用走方法 1。框架的 proxy router 就是为这个设计的。
227
-
228
- ## 注意事项
229
-
230
- ### 别跨请求缓存租户 scope
5
+ ## Invocation / 调用
231
6
 
232
7
  ```ts
233
- // 不好
234
- const tenantScopeCache = new Map<string, Container>();
235
-
236
- async function tenantGuard(ctx) {
237
- let scope = tenantScopeCache.get(tenantId);
238
- if (!scope) {
239
- scope = framework.container.createScope();
240
- tenantScopeCache.set(tenantId, scope);
241
- }
242
- // 用 scope...
243
- }
244
- ```
245
-
246
- 每个请求需要自己的 scope —— 即使同租户 —— 因为:
247
-
248
- - 其他守卫加请求专属覆盖(auth、trace id),不该跨请求泄漏
249
- - scope 持有有状态服务的 resolve 实例;跨请求共享破坏隔离
250
-
251
- 租户**配置**可以缓存。租户**scope**不行。
252
-
253
- ### 共享服务实例要小心
254
-
255
- 把 `UserApi` 实例缓存到模块级而不是注册工厂,所有请求共享状态:
256
-
257
- ```ts
258
- // 不好
259
- const apiByTenant = new Map<string, UserApi>();
260
- scope.register("userApi", () => {
261
- let api = apiByTenant.get(tenantId);
262
- if (!api) {
263
- api = new UserApi({ baseUrl: config.upstreamUrl });
264
- apiByTenant.set(tenantId, api);
265
- }
266
- return api;
267
- });
268
- ```
269
-
270
- `UserApi` 有任何请求级状态(捕获请求专属值的拦截器闭包)就跨租户泄。注册新鲜工厂;让容器按 scope 缓存。
271
-
272
- ## 测试
273
-
274
- ```ts
275
- import { describe, test, expect, vi, afterEach } from "vite-plus/test";
276
- import { Container } from "@finesoft/front";
277
- import { tenantGuard } from "./tenant";
278
-
279
- afterEach(() => vi.restoreAllMocks());
280
-
281
- describe("tenantGuard", () => {
282
- test("registers tenant services for known tenant", async () => {
283
- const parent = new Container();
284
- const scope = parent.createScope();
285
-
286
- vi.spyOn(await import("../tenants/config"), "getTenantConfig").mockResolvedValue({
287
- tenantId: "acme",
288
- displayName: "Acme Co",
289
- upstreamUrl: "https://acme.example",
290
- apiToken: "token-123",
291
- featureFlags: { darkMode: true },
292
- locale: "en-US",
293
- });
294
-
295
- const ctx = {
296
- url: new URL("https://acme.myapp.com/"),
297
- container: scope,
298
- intent: { intentId: "home", params: {} },
299
- getCookie: () => null,
300
- getHeader: () => null,
301
- isSsr: true,
302
- };
303
-
304
- const result = await tenantGuard(ctx as any);
305
-
306
- expect(result).toEqual({ kind: "next" });
307
- expect(scope.resolve("tenantConfig")).toMatchObject({ tenantId: "acme" });
308
- });
309
-
310
- test("denies unknown tenant", async () => {
311
- const ctx = {
312
- url: new URL("https://unknown.myapp.com/"),
313
- container: new Container(),
314
- intent: { intentId: "home", params: {} },
315
- getCookie: () => null,
316
- getHeader: () => null,
317
- isSsr: true,
318
- };
319
-
320
- const result = await tenantGuard(ctx as any);
321
- expect(result).toMatchObject({ kind: "deny", status: 404 });
322
- });
8
+ // Derive these values from trusted authentication at the host boundary.
9
+ await runtime.execute(operation, input, {
10
+ identity: authenticatedUser,
11
+ bindings: { tenant: tenantId },
12
+ signal: request.signal,
323
13
  });
14
+ // Inside an operation: await context.execute(otherOperation, input);
324
15
  ```
325
16
 
326
- ## 参考
327
-
328
- - [第 7 章:DI 容器](../07-di-container.md) —— scope 和回退 resolve
329
- - [第 3 章:中间件](../03-middleware.md) —— 全局守卫
330
- - [陷阱:container scope 泄漏](../pitfalls/container-scope-leak.md) —— 缓存 scope 会出什么问题
17
+ 每请求资源使用 `provide({ token, lifetime: "scope", create, dispose })`。并发初始化去重,初始化失败可重试。runtime 生命周期不能依赖请求生命周期值。外部传入值默认由外部管理,创建值按依赖顺序释放。command 不自动重试或缓存,取消也不会回滚已生效修改。