@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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -1,200 +1,21 @@
|
|
|
1
|
-
# Advanced:
|
|
1
|
+
# Advanced: proxy code generation
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1
|
+
# Request isolation
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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.
|