@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
- # Advanced: inline proxy codegen
1
+ # Advanced: proxy code generation
2
2
 
3
- For serverless and edge deployments where you want proxy logic inlined into the function bundle no runtime call to `registerProxyRoutes`, no extra dependencies the framework exposes `generateProxyCode`.
4
-
5
- ## Use case
6
-
7
- You're deploying to Cloudflare Workers / Vercel Edge / AWS Lambda@Edge. Each function has:
8
-
9
- - Tight cold-start budget
10
- - Cold-bundle-size budget (Workers: 1 MB compressed)
11
- - No `process.env` in some runtimes
12
-
13
- Importing the proxy router and its support files (validators, Hono integration) adds bytes. `generateProxyCode` emits **only** the lines you need for the routes you declared. The output is self-contained: a few `app.get(...)` / `app.all(...)` calls plus a single `_sanitizeProxyPath` helper.
14
-
15
- ## Generated output
16
-
17
- Given this input:
3
+ `generateProxyCode` is a build-time extension exported from `@finesoft/front`. Standard adapters already integrate declared proxy routes. Application business modules should use the portable operation/HTTP APIs when they need validation, policies and scoped services.
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
- You get something like:
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
- Everything is inline. No imports from `@finesoft/front` for the proxy path. Drop this into your function bundle alongside the SSR entry.
83
-
84
- ## When to use codegen vs runtime registration
85
-
86
- | Concern | Runtime (`registerProxyRoutes`) | Codegen (`generateProxyCode`) |
87
- | ----------------------------------------- | ------------------------------- | ------------------------------------------- |
88
- | Long-lived server (Node, Workers) | ✅ preferred | ✅ also fine |
89
- | Tiny edge functions (Lambda@Edge) | Heavier import | ✅ minimal |
90
- | Need to update routes without redeploying | ✅ change config, restart | ❌ redeploy required |
91
- | Config from a remote service | ✅ supported | ❌ codegen runs at build time |
92
- | Multiple proxies sharing helpers | ✅ shared at runtime | Code duplication unless you dedupe yourself |
93
-
94
- Use codegen specifically when bundle size matters. For most deployments, the runtime path is fine.
95
-
96
- ## Build-time integration
97
-
98
- A typical setup:
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
- Then import `./proxy.js` from your serverless function entry:
128
-
129
- ```ts
130
- // dist/main.ts (for 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
- ## What the generated code does for you
142
-
143
- The generated handler enforces the same guarantees as the runtime path:
144
-
145
- - **SSRF protection**: path validation rejects encoded chars, `//` prefix, non-allowed characters
146
- - **Open-redirect protection**: `target.origin` must match the configured target's origin
147
- - **10 MB response size limit**: `Content-Length` fast-reject + `byteLength` actual-bytes check
148
- - **Binary integrity**: `arrayBuffer()` forwarding (no UTF-8 decoding)
149
- - **Auth from env**: reads `process.env[envKey]` at request time
150
-
151
- The framework's test suite asserts **parity** between runtime and generated code with these checks:
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
- If you patch the runtime path's size limit, the generated code's limit is updated in lockstep.
165
-
166
- ## Caveats
167
-
168
- ### `process.env` may not exist
169
-
170
- The generated code guards with `typeof process !== "undefined"`. On runtimes without `process` (some edge environments), the auth header is simply not added — the upstream sees no auth.
171
-
172
- For platforms like Cloudflare Workers that inject env via function args instead of `process.env`, you'll need to either:
173
-
174
- - Wrap the generated code to inject the auth header from the worker's env arg
175
- - Replace the auth section after generation with the platform-appropriate access
176
-
177
- ### No retries, no breakers
178
-
179
- The generated handler does one `fetch` and bubbles failures up as `502 Proxy request failed`. For retry / breaker logic, write your own proxy code — `generateProxyCode` is intentionally minimal.
180
-
181
- ### Multiple proxies share helper code
182
-
183
- `_sanitizeProxyPath` is emitted once at the top of the generated string. Multiple `app.all` calls share it. If you `generateProxyCode` separately for each route and concatenate, you'll get the helper repeated — call it once with all routes.
184
-
185
- ### Validation at generation time
186
-
187
- `generateProxyCode` runs the same `validateConfig` as `registerProxyRoutes`. Invalid configs throw at build time:
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
- This catches config errors before the deploy ships.
17
+ The returned source calls `registerProxyRoutes(app, config)`. A custom generated entry must import `registerProxyRoutes` from `@finesoft/front` and provide its Hono `app`; standard adapters do this automatically. Both development and generated hosts use the same path, binary-response and size-limit implementation. Treat the source as generated build output, not an application startup API. Configure secret values on the host and rebuild when declared routes change. `auth.envKey` reads `process.env` when available; hosts without `process` can supply an Authorization header in their runtime registration config. Platform bundle limits and deployment rules belong to that platform's current documentation.
195
18
 
196
- ## Related
19
+ For ordinary applications, configure the Vite plugin and use the standard adapter rather than copying generated handler source into application files. See [HTTP and deployment](../09-server-and-deployment.md).
197
20
 
198
- - [Chapter 9: Server & deployment proxy routes](../09-server-and-deployment.md#proxy-routes)
199
- - The implementation: `packages/server/src/proxy.ts`
200
- - The parity test: `packages/server/test/proxy.test.ts`
21
+ Redirects are manual by default; `followRedirects: true` follows at most 20 hops within the configured origin. Responses are counted as chunks arrive and cancelled above 10 MiB with a 502, even when Content-Length is missing or false.
@@ -1,330 +1,17 @@
1
- # Advanced: multi-tenant scopes
1
+ # Request isolation
2
2
 
3
- A multi-tenant app serves multiple customers from one deployment, but each customer's request must see their own:
3
+ Runtime execution opens and closes an invocation scope automatically. Trusted host bindings carry tenant/identity; nested operations inherit the current context.
4
4
 
5
- - Database connection / API client
6
- - Logger / metrics tagged with the tenant
7
- - Feature flags / pricing / branding
8
- - Cached translations / content
9
-
10
- The DI container's child scopes are the right primitive. This recipe shows how to wire per-tenant isolation through a `beforeLoad` guard.
11
-
12
- ## Mental model
13
-
14
- ```
15
- Framework
16
- └── parent Container ← shared services (HTTP pool, base recorder, ...)
17
- └── per-request scope ← framework creates this for every SSR request
18
-
19
- └── tenant overrides registered by a beforeLoad guard
20
- ```
21
-
22
- The per-request scope is created by the framework automatically. Your guard registers tenant-specific overrides on top. Anything you don't override falls through to the parent.
23
-
24
- ## Step 1: identify the tenant
25
-
26
- This is your business logic. Common sources:
27
-
28
- - **Subdomain**: `acme.myapp.com` → `acme`
29
- - **Path prefix**: `/t/acme/...` → `acme`
30
- - **Header**: `X-Tenant-Id: acme`
31
- - **Authenticated user**: 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
- ## Step 2: load tenant config
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
- // Load from your config store — file, 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
- A real implementation would have cache invalidation on config updates. For most apps, refresh on a timer or via a webhook is enough.
73
-
74
- ## Step 3: register tenant services in a guard
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
- // Register tenant-specific services on the request 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
- // Decorate the parent's recorder with tenant context
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
- Key insights:
120
-
121
- - **The scope is already created by the framework.** You register on `ctx.container` — that's the request scope.
122
- - **Fall-through is automatic.** Anything not registered here resolves from the parent container.
123
- - **Decorate, don't replace.** The recorder is wrapped with tenant fields rather than replaced — base behavior (HTTP transmission, batching) stays in place.
124
-
125
- ## Step 4: install the guard globally
126
-
127
- ```ts
128
- // src/bootstrap.ts
129
- import { type Framework, defineRoutes } from "@finesoft/front";
130
- import { tenantGuard } from "./lib/guards/tenant";
131
- // ... other imports
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` runs before any route-specific guard, so by the time any controller executes, the tenant scope is set up.
145
-
146
- ## Step 5: controllers transparently get the right services
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"); // tenant-specific client
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
- The controller doesn't know about tenants — it just resolves `userApi` and gets the right one for this request.
171
-
172
- ## Cross-tenant prohibitions
173
-
174
- To prevent a user authenticated for tenant A from accessing tenant B's data:
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
- // Apply after tenantGuard:
188
- framework.middleware.use("beforeLoad", tenantGuard);
189
- framework.middleware.use("beforeLoad", sameTenantGuard);
190
- ```
191
-
192
- Guard order matters — `tenantGuard` must register `tenantConfig` before `sameTenantGuard` reads it.
193
-
194
- ## Hydration considerations
195
-
196
- Tenant configs include `featureFlags`, which are read on both server and client. The framework's `PrefetchedIntents` serialization handles this — the browser receives the same flag values the server saw, so client-side reads stay consistent.
197
-
198
- Tenant config itself is **not** automatically serialized. If your view needs to display `tenantConfig.displayName`, the controller should include it in the `Page` object:
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
- The `Page` is serialized, so it survives the SSR → CSR boundary. The full `TenantConfig` (with secrets) should never end up in a `Page`.
215
-
216
- ## Browser-side considerations
217
-
218
- `tenantGuard` runs on the browser too — on initial navigation and on every subsequent navigation. For browser-only apps (`renderMode: "csr"`), this is the only time it runs.
219
-
220
- But the browser can't safely resolve secrets like `apiToken`. Two approaches:
221
-
222
- **Approach 1: server proxies all API calls.** The browser hits `/api/users` (your proxy), which forwards to `${upstreamUrl}/users` with the auth header injected from `process.env[apiTokenEnvKey]`. The browser never sees the token.
223
-
224
- **Approach 2: short-lived session token.** The server issues a JWT scoped to the tenant; the browser uses it for direct upstream calls. Token rotation handled by your auth layer.
225
-
226
- Most apps go with approach 1. The framework's proxy router is designed for it.
227
-
228
- ## Caveats
229
-
230
- ### Don't cache tenant scopes across requests
5
+ ## Invocation / 调用
231
6
 
232
7
  ```ts
233
- // BAD
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
- // Use scope...
243
- }
244
- ```
245
-
246
- Each request needs its own scope — even for the same tenant — because:
247
-
248
- - Other guards add request-specific overrides (auth, trace id) that shouldn't leak across requests
249
- - The scope holds resolved instances of stateful services; sharing them across requests breaks isolation
250
-
251
- Tenant **config** can be cached. Tenant **scope** cannot.
252
-
253
- ### Be careful with shared service instances
254
-
255
- If you cache the `UserApi` instance at module level instead of registering a factory, all requests share state:
256
-
257
- ```ts
258
- // BAD
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
- If `UserApi` has any request-scoped state (interceptors that capture closures over request-specific values), they'll bleed across tenants. Register a fresh factory; let the container cache it per scope.
271
-
272
- ## Testing
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
- ## Related
327
-
328
- - [Chapter 7: DI container](../07-di-container.md) — scopes and fallback resolution
329
- - [Chapter 3: Middleware](../03-middleware.md) — global guards
330
- - [Pitfall: container scope leak](../pitfalls/container-scope-leak.md) — what goes wrong if you cache scopes
17
+ Use `provide({ token, lifetime: "scope", create, dispose })` for per-request resources. Concurrent initialization deduplicates and a failed initialization may retry. Runtime-scoped providers cannot depend on request-scoped values. Externally supplied values remain external unless owned explicitly; created values are released in dependency order. A command is not retried or cached automatically; cancellation does not roll back an already applied mutation.