@finesoft/front 0.5.0 → 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 -698
- 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-BYZq9Jp7.mjs +0 -2
- package/dist/browser-JTs2jqVY.d.mts +0 -2811
|
@@ -1,264 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 自定义部署适配器
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
本配方端到端写一个。模式:构建期发出平台专属的入口文件,让该入口指向框架的 SSR + proxy 管线。
|
|
6
|
-
|
|
7
|
-
## adapter 做什么
|
|
8
|
-
|
|
9
|
-
构建期:
|
|
10
|
-
|
|
11
|
-
1. 把 SSR 入口(`src/ssr.ts`)打包成单 JS 文件,依赖内联。
|
|
12
|
-
2. 把客户端入口打成平台预期的形状(多数是 `dist/client/`)。
|
|
13
|
-
3. 发出**平台专属入口**,做以下事:
|
|
14
|
-
- import SSR bundle
|
|
15
|
-
- 以平台原生形状(Request、Lambda event 等)接收请求
|
|
16
|
-
- 调 `createServer({ ssrEntry, proxies })` 并 serve 响应
|
|
17
|
-
|
|
18
|
-
框架在 `packages/server/src/adapters/shared.ts` 提供 `buildBundle`、`generateSSREntry`、`copyStaticAssets`、`prerenderRoutes`。用它们 —— 它们一致地处理了所有 adapter 的重活。
|
|
19
|
-
|
|
20
|
-
## 示例:Deno Deploy adapter
|
|
21
|
-
|
|
22
|
-
Deno Deploy 跑 ES 模块,Web 标准 Request/Response。工作流类似 Cloudflare Workers 但带原生 Deno API。
|
|
23
|
-
|
|
24
|
-
### adapter 接口
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
// src/lib/adapters/deno-deploy.ts
|
|
28
|
-
import type { AdapterDefinition, AdapterContext } from "@finesoft/front";
|
|
29
|
-
import { buildBundle, copyStaticAssets, generateSSREntry, prerenderRoutes } from "@finesoft/front";
|
|
30
|
-
|
|
31
|
-
export const denoDeployAdapter: AdapterDefinition = {
|
|
32
|
-
name: "deno-deploy",
|
|
33
|
-
|
|
34
|
-
async build(ctx: AdapterContext): Promise<void> {
|
|
35
|
-
// 1. 打包 SSR
|
|
36
|
-
const ssrEntry = generateSSREntry(ctx, {
|
|
37
|
-
// Deno 支持原生 fetch / URL / Response,无需 shim
|
|
38
|
-
external: [],
|
|
39
|
-
});
|
|
40
|
-
await buildBundle(ctx, {
|
|
41
|
-
entry: ssrEntry,
|
|
42
|
-
outFile: "dist/server.js",
|
|
43
|
-
format: "esm",
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// 2. 拷静态资源
|
|
47
|
-
copyStaticAssets(ctx, "dist/client", "dist/static");
|
|
48
|
-
|
|
49
|
-
// 3. 预渲染 prerender 路由
|
|
50
|
-
await prerenderRoutes(ctx);
|
|
51
|
-
|
|
52
|
-
// 4. 发出 Deno 入口
|
|
53
|
-
writeEntryFile(
|
|
54
|
-
ctx,
|
|
55
|
-
"dist/main.ts",
|
|
56
|
-
`
|
|
57
|
-
import { createServer } from "./server.js";
|
|
58
|
-
const app = createServer({
|
|
59
|
-
ssrEntry: "./server.js",
|
|
60
|
-
staticDir: "./static",
|
|
61
|
-
});
|
|
62
|
-
Deno.serve(app.fetch);
|
|
63
|
-
`,
|
|
64
|
-
);
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### 注册
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
// vite.config.ts
|
|
73
|
-
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
74
|
-
import { denoDeployAdapter } from "./src/lib/adapters/deno-deploy";
|
|
75
|
-
|
|
76
|
-
export default {
|
|
77
|
-
plugins: [
|
|
78
|
-
finesoftFrontViteConfig({
|
|
79
|
-
ssr: { entry: "src/ssr.ts" },
|
|
80
|
-
adapter: denoDeployAdapter,
|
|
81
|
-
}),
|
|
82
|
-
],
|
|
83
|
-
};
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`adapter` 选项接受字符串(内置)或 `AdapterDefinition`(自定义)。
|
|
87
|
-
|
|
88
|
-
## adapter context
|
|
89
|
-
|
|
90
|
-
`AdapterContext` 传给 `build()`,暴露:
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
interface AdapterContext {
|
|
94
|
-
root: string; // 项目根的绝对路径
|
|
95
|
-
outDir: string; // dist 目录的绝对路径
|
|
96
|
-
ssrEntryPath: string; // src/ssr.ts 的解析后路径
|
|
97
|
-
routes: RouteDefinition[]; // 来自 bootstrap 的路由(用于 prerender)
|
|
98
|
-
proxies: ProxyRouteConfig[]; // 来自 finesoftFrontViteConfig 的 proxy 配置
|
|
99
|
-
isr: IsrConfig | null; // 启用时的 ISR 配置
|
|
100
|
-
env: Record<string, string>; // 构建期环境变量
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
通常不会用全部 —— `buildBundle` 和 `generateSSREntry` 拿它们需要的。
|
|
105
|
-
|
|
106
|
-
## 常见模式
|
|
107
|
-
|
|
108
|
-
### Edge 运行时(Workers / Deno / Bun)
|
|
109
|
-
|
|
110
|
-
标准 Web API(Request、Response、fetch)。打 ESM,target `webworker`。大多数 edge 运行时接受 default-exported handler:
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
export default {
|
|
114
|
-
async fetch(request, env) {
|
|
115
|
-
return app.fetch(request, env);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
`packages/server/src/adapters/cloudflare.ts` 是 Cloudflare 的标准参考。
|
|
121
|
-
|
|
122
|
-
### Lambda 风格(AWS Lambda、GCF、Azure Functions)
|
|
123
|
-
|
|
124
|
-
平台专属 event 形状。在 `Request` 之间转换:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
import { app } from "./server.js";
|
|
128
|
-
|
|
129
|
-
export const handler = async (event: APIGatewayProxyEventV2) => {
|
|
130
|
-
const request = lambdaEventToRequest(event);
|
|
131
|
-
const response = await app.fetch(request);
|
|
132
|
-
return responseToLambdaResult(response);
|
|
133
|
-
};
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
每个云的 SDK 都自带 event-to-request 转换的类型和 helper。直接复用,别重造。
|
|
137
|
-
|
|
138
|
-
### 多进程服务器(Bun cluster、PM2)
|
|
139
|
-
|
|
140
|
-
Bun 和现代 Node 支持 `cluster` 风格多进程 serve 利用 CPU 并行:
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
import { app } from "./server.js";
|
|
144
|
-
import { serve } from "@hono/node-server";
|
|
145
|
-
|
|
146
|
-
const port = parseInt(process.env.PORT ?? "3000", 10);
|
|
147
|
-
serve({ fetch: app.fetch, port });
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
每个进程独立。ISR 缓存按进程 —— 真正共享缓存要前置 CDN。
|
|
151
|
-
|
|
152
|
-
## 静态(无服务器)
|
|
153
|
-
|
|
154
|
-
`adapter: "static"` 是最简单目标 —— 一切预渲染,请求时啥都不跑。
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
export const staticAdapter: AdapterDefinition = {
|
|
158
|
-
name: "static",
|
|
159
|
-
async build(ctx) {
|
|
160
|
-
// 完全跳过 SSR bundle
|
|
161
|
-
await prerenderRoutes(ctx); // 每个路由都必须是 renderMode: "prerender"
|
|
162
|
-
copyStaticAssets(ctx, "dist/client", "dist/static");
|
|
163
|
-
// 无服务器入口 —— 只有静态文件
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
验证每个路由都能预渲染:
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
if (!ctx.routes.every((r) => r.renderMode === "prerender")) {
|
|
172
|
-
throw new Error("Static adapter requires every route to be renderMode: 'prerender'");
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## 自动检测扩展
|
|
177
|
-
|
|
178
|
-
内置 `"auto"` adapter 按顺序查环境变量:
|
|
3
|
+
构建适配器实现 Vite 入口的 Adapter,负责生成部署文件、绑定平台 Request/Response 和清理能力。运行时 SSR 使用 SSR 入口的 createSSRHandler。保留唯一响应组装器,生成器必须传递请求上下文、状态、响应头、Cookie、语言和重定向。内部 buildBundle/generateSSREntry 不是公开 API。仓库 Node/Cloudflare/Netlify/Vercel 实现可作参考,宣称支持前需在目标运行时执行产物。
|
|
179
4
|
|
|
180
5
|
```ts
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return "node";
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
自定义 adapter 有已知环境标签的话,在项目 `vite.config.ts` 自己包一层自动检测:
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
function pickAdapter() {
|
|
193
|
-
if (process.env.DENO_DEPLOYMENT_ID) return denoDeployAdapter;
|
|
194
|
-
return "node";
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
finesoftFrontViteConfig({
|
|
198
|
-
adapter: pickAdapter(),
|
|
199
|
-
});
|
|
6
|
+
import type { Adapter, AdapterContext } from "@finesoft/front";
|
|
7
|
+
// Supply { name, async build(context: AdapterContext) { ... } } as the Vite adapter.
|
|
8
|
+
// Runtime module:
|
|
9
|
+
import { createSSRHandler } from "@finesoft/front";
|
|
200
10
|
```
|
|
201
11
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
集成测试:跑构建,验证发出的入口:
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
import { describe, test, expect } from "vite-plus/test";
|
|
208
|
-
import { build } from "vite";
|
|
209
|
-
import { denoDeployAdapter } from "./deno-deploy";
|
|
210
|
-
|
|
211
|
-
describe("denoDeployAdapter", () => {
|
|
212
|
-
test("emits a Deno-compatible entry", async () => {
|
|
213
|
-
await build({
|
|
214
|
-
root: "test/fixtures/basic",
|
|
215
|
-
plugins: [
|
|
216
|
-
finesoftFrontViteConfig({
|
|
217
|
-
ssr: { entry: "src/ssr.ts" },
|
|
218
|
-
adapter: denoDeployAdapter,
|
|
219
|
-
}),
|
|
220
|
-
],
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const entry = await readFile("test/fixtures/basic/dist/main.ts", "utf-8");
|
|
224
|
-
expect(entry).toContain("Deno.serve");
|
|
225
|
-
expect(entry).toContain("./server.js");
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
冒烟测试运行时:本地起平台真打 `/`。这能抓单元测试抓不到的平台怪癖(CORS、头归一化、body 解码)。
|
|
231
|
-
|
|
232
|
-
## 坑
|
|
233
|
-
|
|
234
|
-
### 别把 Node 内置模块打进 edge 运行时
|
|
235
|
-
|
|
236
|
-
`fs`、`path`、`http` 等在 Workers / Deno 不存在。`generateSSREntry` 接 `external` 列表 —— 设成平台不兼容的模块,让打包器在构建期出错而不是部署时请求崩。
|
|
237
|
-
|
|
238
|
-
### `process.env` 每个平台不同
|
|
239
|
-
|
|
240
|
-
- Node、Vercel:`process.env.FOO`
|
|
241
|
-
- Cloudflare Workers:通过 `fetch()` 的 `env` 参注入秘密
|
|
242
|
-
- Deno:`Deno.env.get("FOO")`
|
|
243
|
-
|
|
244
|
-
框架对声明的 proxy auth key 处理 `process.env`,但你自己的运行时 env 读取要包成平台感知的 helper。
|
|
245
|
-
|
|
246
|
-
### 资源的文件系统访问
|
|
247
|
-
|
|
248
|
-
你在请求时依赖读文件(罕见;多数通过 `staticDir` serve),只有 Node 类 adapter 有原生 fs 访问。edge 运行时要把资源嵌入 bundle 或通过 KV 存代理。
|
|
249
|
-
|
|
250
|
-
## 上游贡献
|
|
251
|
-
|
|
252
|
-
自定义 adapter 针对的是流行平台但框架未内置,考虑开 PR。adapter 住 `packages/server/src/adapters/`,结构一致 —— `cloudflare.ts` 是最干净参考。
|
|
253
|
-
|
|
254
|
-
框架 adapter API 有意保持小。贡献保持最小:
|
|
255
|
-
|
|
256
|
-
- `adapters/` 里一个文件
|
|
257
|
-
- `auto.ts` 里一个条目用于自动检测(若适用)
|
|
258
|
-
- 本文档里一段
|
|
259
|
-
|
|
260
|
-
## 参考
|
|
261
|
-
|
|
262
|
-
- 内置 adapter:`packages/server/src/adapters/`
|
|
263
|
-
- 你会用的共享 helper:`packages/server/src/adapters/shared.ts`
|
|
264
|
-
- [第 9 章:服务器与部署](../09-server-and-deployment.md) —— adapter 包的是什么
|
|
12
|
+
`createSSRHandler` 返回直接拥有 `fetch(request, bindings)` 与 `dispose()` 的执行对象,替代原 `createSSRHost(...).handle(...)`。标准宿主设置 `ownRenderers: true`,关闭时先等待全部响应组装结束,再按身份去重释放 renderer;未设置时 renderer 仍由调用者拥有。`createSSRRender` 自身管理原生渲染的等待与 runtime 释放。
|
|
@@ -1,318 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 事件记录
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
可移植操作通过执行上下文发送结构化事件,runtime 接收 recorder。
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
好 recorder 的特性:
|
|
8
|
-
|
|
9
|
-
- **不阻塞。** `record()` 同步返回;传输后台进行。
|
|
10
|
-
- **批处理。** 每 N 个事件或 T 秒一个 HTTP 请求,不是每个事件一个。
|
|
11
|
-
- **跨导航存活。** unload 时通过 `sendBeacon` flush 待发事件。
|
|
12
|
-
- **优雅降级。** 网络失败不让应用崩;发不出去的事件不无限堆积。
|
|
13
|
-
- **生命周期感知。** `destroy()` flush 所有待发事件后销毁。
|
|
14
|
-
|
|
15
|
-
## 骨架
|
|
5
|
+
## Recorder / 记录器
|
|
16
6
|
|
|
17
7
|
```ts
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export interface HttpRecorderOptions {
|
|
22
|
-
endpoint: string;
|
|
23
|
-
batchSize?: number;
|
|
24
|
-
flushIntervalMs?: number;
|
|
25
|
-
maxQueueSize?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class HttpEventRecorder implements EventRecorder {
|
|
29
|
-
private queue: EventRecord[] = [];
|
|
30
|
-
private timer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
-
private flushing = false;
|
|
32
|
-
private readonly opts: Required<HttpRecorderOptions>;
|
|
33
|
-
|
|
34
|
-
constructor(options: HttpRecorderOptions) {
|
|
35
|
-
this.opts = {
|
|
36
|
-
batchSize: 50,
|
|
37
|
-
flushIntervalMs: 5000,
|
|
38
|
-
maxQueueSize: 1000,
|
|
39
|
-
...options,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
if (typeof window !== "undefined") {
|
|
43
|
-
this.timer = setInterval(() => this.flush(), this.opts.flushIntervalMs);
|
|
44
|
-
window.addEventListener("pagehide", this.beaconFlush);
|
|
45
|
-
window.addEventListener("beforeunload", this.beaconFlush);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
record(event: EventRecord): void {
|
|
50
|
-
if (this.queue.length >= this.opts.maxQueueSize) {
|
|
51
|
-
// 溢出保护 —— 丢最旧,控内存
|
|
52
|
-
this.queue.shift();
|
|
53
|
-
}
|
|
54
|
-
this.queue.push(event);
|
|
55
|
-
if (this.queue.length >= this.opts.batchSize) {
|
|
56
|
-
void this.flush();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
destroy(): void {
|
|
61
|
-
if (this.timer) clearInterval(this.timer);
|
|
62
|
-
if (typeof window !== "undefined") {
|
|
63
|
-
window.removeEventListener("pagehide", this.beaconFlush);
|
|
64
|
-
window.removeEventListener("beforeunload", this.beaconFlush);
|
|
65
|
-
}
|
|
66
|
-
this.beaconFlush();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private async flush(): Promise<void> {
|
|
70
|
-
if (this.flushing || this.queue.length === 0) return;
|
|
71
|
-
this.flushing = true;
|
|
72
|
-
const batch = this.queue.splice(0, this.opts.batchSize);
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const resp = await fetch(this.opts.endpoint, {
|
|
76
|
-
method: "POST",
|
|
77
|
-
headers: { "Content-Type": "application/json" },
|
|
78
|
-
body: JSON.stringify(batch),
|
|
79
|
-
keepalive: true,
|
|
80
|
-
});
|
|
81
|
-
if (!resp.ok) {
|
|
82
|
-
// 4xx —— 丢。5xx —— 放回队头。
|
|
83
|
-
if (resp.status >= 500) this.queue.unshift(...batch);
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
// 网络失败 —— 放回队头
|
|
87
|
-
this.queue.unshift(...batch);
|
|
88
|
-
} finally {
|
|
89
|
-
this.flushing = false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private beaconFlush = (): void => {
|
|
94
|
-
if (this.queue.length === 0) return;
|
|
95
|
-
if (typeof navigator === "undefined" || !navigator.sendBeacon) return;
|
|
96
|
-
const batch = this.queue.splice(0, this.queue.length);
|
|
97
|
-
navigator.sendBeacon(this.opts.endpoint, JSON.stringify(batch));
|
|
98
|
-
};
|
|
99
|
-
}
|
|
8
|
+
import { ConsoleEventRecorder, createRuntime } from "@finesoft/front";
|
|
9
|
+
const runtime = createRuntime({ app, recorder: new ConsoleEventRecorder() });
|
|
10
|
+
// In a handler: context.record("cart.updated", { itemCount: 3 });
|
|
100
11
|
```
|
|
101
12
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### `keepalive: true`
|
|
105
|
-
|
|
106
|
-
告诉浏览器即使页面正在导航离开也要完成请求。有 body 大小上限(~64 KB)但能跨导航。配 `sendBeacon` 处理 unload —— beacon 更小更可靠。
|
|
107
|
-
|
|
108
|
-
### `pagehide` 和 `beforeunload`
|
|
109
|
-
|
|
110
|
-
`pagehide` 在页面进 bfcache(前进/后退)时触发。`beforeunload` 在常规导航/关闭时触发。两者都该 flush 待发事件。有些浏览器只触发一个,所以两个都监听。
|
|
111
|
-
|
|
112
|
-
### `keepalive` vs `sendBeacon`
|
|
113
|
-
|
|
114
|
-
| 方法 | body 上限 | 返回响应? | 时机 |
|
|
115
|
-
| ---------------------- | --------- | ---------- | ---------------------- |
|
|
116
|
-
| `fetch(..keepalive)` | ~64KB | 是 | 页面生命中的周期 flush |
|
|
117
|
-
| `navigator.sendBeacon` | ~64KB | 否 | unload 时最终 flush |
|
|
118
|
-
|
|
119
|
-
两个都用:周期 `fetch` 看成功/失败,`sendBeacon` 作为最后逃生口。
|
|
120
|
-
|
|
121
|
-
### 5xx 放回,4xx 丢
|
|
122
|
-
|
|
123
|
-
5xx 是服务端错 —— 之后重试。4xx 是你错 —— 重试无用,无限重试会冲垮服务器。丢 batch 继续。
|
|
124
|
-
|
|
125
|
-
### 溢出保护
|
|
126
|
-
|
|
127
|
-
网络挂几小时,应用还在发事件,队列无界增长。`maxQueueSize` 限制;新事件来时最旧的掉。这是用完整性换内存安全 —— 按你能丢多少 vs 能用多少内存挑大小。
|
|
128
|
-
|
|
129
|
-
## 接上
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
// src/main.ts
|
|
133
|
-
import { startBrowserApp, CompositeEventRecorder, ConsoleEventRecorder } from "@finesoft/front/browser";
|
|
134
|
-
import { bootstrap } from "./bootstrap";
|
|
135
|
-
import { HttpEventRecorder } from "./lib/recorders/http-recorder";
|
|
136
|
-
|
|
137
|
-
startBrowserApp({
|
|
138
|
-
bootstrap,
|
|
139
|
-
frameworkConfig: {
|
|
140
|
-
eventRecorder: new CompositeEventRecorder([
|
|
141
|
-
new ConsoleEventRecorder(),
|
|
142
|
-
new HttpEventRecorder({
|
|
143
|
-
endpoint: "/api/events",
|
|
144
|
-
batchSize: 50,
|
|
145
|
-
flushIntervalMs: 5000,
|
|
146
|
-
}),
|
|
147
|
-
]),
|
|
148
|
-
},
|
|
149
|
-
mount: /* ... */,
|
|
150
|
-
});
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
`CompositeEventRecorder` 包两个,事件**同时**送到 console(dev 可见)**和**后端。
|
|
154
|
-
|
|
155
|
-
## 加横切字段
|
|
156
|
-
|
|
157
|
-
用 `WithFieldsRecorder` 装饰附加 session 级字段:
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
import { WithFieldsRecorder, type FieldProvider } from "@finesoft/front";
|
|
161
|
-
|
|
162
|
-
const sessionFields: FieldProvider = {
|
|
163
|
-
getFields: () => ({
|
|
164
|
-
sessionId: getSessionId(),
|
|
165
|
-
appVersion: __APP_VERSION__,
|
|
166
|
-
userAgent: navigator.userAgent,
|
|
167
|
-
}),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const userFields: FieldProvider = {
|
|
171
|
-
getFields: () => {
|
|
172
|
-
const user = getCurrentUser();
|
|
173
|
-
return user ? { userId: user.id, role: user.role } : {};
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
new WithFieldsRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), [
|
|
178
|
-
sessionFields,
|
|
179
|
-
userFields,
|
|
180
|
-
]);
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
`getFields` **每个事件**跑一次,所以事件之间的用户状态变化能正确反映。
|
|
184
|
-
|
|
185
|
-
## 服务端记录
|
|
186
|
-
|
|
187
|
-
框架对每个 SSR 请求记录 `PageView`。要在服务端捕获:
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
// src/ssr.ts
|
|
191
|
-
import { createSSRRender } from "@finesoft/front";
|
|
192
|
-
import { HttpEventRecorder } from "./lib/recorders/http-recorder";
|
|
193
|
-
|
|
194
|
-
// 跨所有 SSR 请求共享的单实例 recorder
|
|
195
|
-
const serverRecorder = new HttpEventRecorder({
|
|
196
|
-
endpoint: "https://internal-events.example/v1/events",
|
|
197
|
-
batchSize: 100, // 服务端更激进的批量
|
|
198
|
-
flushIntervalMs: 1000,
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
export const render = createSSRRender({
|
|
202
|
-
bootstrap,
|
|
203
|
-
frameworkConfig: {
|
|
204
|
-
eventRecorder: serverRecorder,
|
|
205
|
-
},
|
|
206
|
-
/* ... */
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
process.on("SIGTERM", () => serverRecorder.destroy());
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
服务端 recorder 应该:
|
|
213
|
-
|
|
214
|
-
- 跨请求共享单实例(别按请求构造)
|
|
215
|
-
- 用大 batch / 长 flush 间隔(没 UI 要阻塞)
|
|
216
|
-
- 把 `destroy()` 接进优雅关闭,在途事件能 flush
|
|
217
|
-
|
|
218
|
-
## 与 Sentry / Datadog 集成
|
|
219
|
-
|
|
220
|
-
同时用 `ReportCallback`(送 `warn`/`error` 到 Sentry)和 `EventRecorder`(送结构化事件到后端)的话,分开:
|
|
221
|
-
|
|
222
|
-
```ts
|
|
223
|
-
Framework.create({
|
|
224
|
-
reportCallback: (level, category, args) => {
|
|
225
|
-
Sentry.captureMessage(`[${category}] ${args.join(" ")}`, level);
|
|
226
|
-
},
|
|
227
|
-
eventRecorder: new CompositeEventRecorder([
|
|
228
|
-
new HttpEventRecorder({ endpoint: "/api/events" }),
|
|
229
|
-
// 可选:也转给 Datadog
|
|
230
|
-
new DatadogEventRecorder({ apiKey: env.DD_API_KEY }),
|
|
231
|
-
]),
|
|
232
|
-
});
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
不同 sink 不同用途 —— 错误送 Sentry triage,结构化事件送数据仓库做分析。别想着一个 recorder 包办两件。
|
|
236
|
-
|
|
237
|
-
## 采样
|
|
238
|
-
|
|
239
|
-
高流量应用要采样事件:
|
|
240
|
-
|
|
241
|
-
```ts
|
|
242
|
-
class SamplingRecorder implements EventRecorder {
|
|
243
|
-
constructor(
|
|
244
|
-
private inner: EventRecorder,
|
|
245
|
-
private rate: number,
|
|
246
|
-
) {}
|
|
247
|
-
record(event: EventRecord): void {
|
|
248
|
-
if (Math.random() < this.rate) this.inner.record(event);
|
|
249
|
-
}
|
|
250
|
-
destroy(): void {
|
|
251
|
-
this.inner.destroy?.();
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
new SamplingRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), 0.1);
|
|
256
|
-
// 记 10% 的事件
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
在 recorder 层采样,不在调用点 —— 调用点不该知道是否被采样。
|
|
260
|
-
|
|
261
|
-
## 测试
|
|
262
|
-
|
|
263
|
-
```ts
|
|
264
|
-
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
265
|
-
import { HttpEventRecorder } from "./http-recorder";
|
|
266
|
-
|
|
267
|
-
afterEach(() => {
|
|
268
|
-
vi.useRealTimers();
|
|
269
|
-
vi.unstubAllGlobals();
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe("HttpEventRecorder", () => {
|
|
273
|
-
test("flushes when batch fills", async () => {
|
|
274
|
-
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
275
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
276
|
-
|
|
277
|
-
const recorder = new HttpEventRecorder({
|
|
278
|
-
endpoint: "/api/events",
|
|
279
|
-
batchSize: 3,
|
|
280
|
-
flushIntervalMs: 60_000,
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
recorder.record({ name: "A", fields: {} });
|
|
284
|
-
recorder.record({ name: "B", fields: {} });
|
|
285
|
-
recorder.record({ name: "C", fields: {} });
|
|
286
|
-
|
|
287
|
-
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
288
|
-
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toHaveLength(3);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("re-queues batch on 5xx", async () => {
|
|
292
|
-
const fetchMock = vi
|
|
293
|
-
.fn()
|
|
294
|
-
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
|
295
|
-
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
296
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
297
|
-
|
|
298
|
-
const recorder = new HttpEventRecorder({
|
|
299
|
-
endpoint: "/api/events",
|
|
300
|
-
batchSize: 1,
|
|
301
|
-
flushIntervalMs: 60_000,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
recorder.record({ name: "A", fields: {} });
|
|
305
|
-
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
306
|
-
|
|
307
|
-
// 模拟下一次 flush
|
|
308
|
-
await (recorder as any).flush();
|
|
309
|
-
|
|
310
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
## 参考
|
|
316
|
-
|
|
317
|
-
- [第 8 章:可观测性](../08-observability.md) —— 基础原语和内置事件
|
|
318
|
-
- 框架自己的 composite / with-fields recorder:`packages/core/src/metrics/`
|
|
13
|
+
记录业务结果时不要复制凭据、请求原文或私有页面数据。记录器故障与执行隔离。浏览器曝光使用显式浏览器 observer,可移植 core 只定义事件契约。
|