@finesoft/front 0.1.76 → 0.1.78
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +203 -0
- package/docs/03-middleware.md +220 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +203 -0
- package/docs/zh/03-middleware.md +220 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# 8. Observability
|
|
2
|
+
|
|
3
|
+
Three primitives, each composable, each replaceable:
|
|
4
|
+
|
|
5
|
+
- **`Logger`** — line-oriented (debug/info/warn/error)
|
|
6
|
+
- **`EventRecorder`** — structured event records (`{ name, fields }`)
|
|
7
|
+
- **`ReportCallback`** — fan-out of `warn`/`error` logs to external monitoring
|
|
8
|
+
|
|
9
|
+
Plus impression tracking via `IntersectionImpressionObserver`.
|
|
10
|
+
|
|
11
|
+
## Logger
|
|
12
|
+
|
|
13
|
+
The base `ConsoleLogger` writes to `console.{debug,info,warn,error}` with a category prefix. Scoped loggers add nested prefixes.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { ConsoleLogger } from "@finesoft/front";
|
|
17
|
+
|
|
18
|
+
const logger = new ConsoleLogger("app");
|
|
19
|
+
logger.info("hello"); // [app] hello
|
|
20
|
+
|
|
21
|
+
const auth = logger.scope("auth");
|
|
22
|
+
auth.warn("token expired"); // [app:auth] token expired
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Levels: `debug`, `info`, `warn`, `error`. The framework calls `warn`/`error` on its own logger for internal failures (failed guards, dispatch errors).
|
|
26
|
+
|
|
27
|
+
### Replacing the framework logger
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
31
|
+
|
|
32
|
+
framework.container.register(DEP_KEYS.LOGGER, () => myLogger);
|
|
33
|
+
framework.container.register(DEP_KEYS.LOGGER_FACTORY, () => ({
|
|
34
|
+
create(category) {
|
|
35
|
+
return myLoggerImpl.scope(category);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## `ReportCallback` — forwarding logs to monitoring
|
|
41
|
+
|
|
42
|
+
The cleanest way to ship `warn`/`error` logs to Sentry / Datadog / your own collector:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Framework, type ReportCallback } from "@finesoft/front";
|
|
46
|
+
|
|
47
|
+
const reportCallback: ReportCallback = (level, category, args) => {
|
|
48
|
+
Sentry.captureMessage(`[${category}] ${args.join(" ")}`, level);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const framework = Framework.create({
|
|
52
|
+
reportCallback,
|
|
53
|
+
// ...
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
How it works:
|
|
58
|
+
|
|
59
|
+
- `Framework.create({ reportCallback })` installs `ReportingLoggerFactory` over `ConsoleLogger`
|
|
60
|
+
- All `logger.warn(...)` and `logger.error(...)` calls fire the callback **and** continue to the console
|
|
61
|
+
- `debug` and `info` are not forwarded (avoid burying signal in volume)
|
|
62
|
+
- The callback runs inside `try/catch` — a failure in the reporter cannot crash the framework
|
|
63
|
+
|
|
64
|
+
### Categories
|
|
65
|
+
|
|
66
|
+
Each `logger.scope("auth")` becomes a category passed to the callback. Use them for routing in your monitoring system:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
reportCallback(level, category, args) {
|
|
70
|
+
if (category.startsWith("auth")) {
|
|
71
|
+
sentry.captureMessage(/* go to auth project */);
|
|
72
|
+
} else {
|
|
73
|
+
sentry.captureMessage(/* default project */);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Anti-spam
|
|
79
|
+
|
|
80
|
+
`ReportCallback` runs every time. If you have a hot loop logging the same warning a thousand times, you'll send a thousand events. Add deduplication in the callback itself:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const seen = new Map<string, number>();
|
|
84
|
+
const reportCallback: ReportCallback = (level, category, args) => {
|
|
85
|
+
const key = `${category}:${args[0]}`;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const last = seen.get(key) ?? 0;
|
|
88
|
+
if (now - last < 60_000) return; // 1 per minute per (category, message)
|
|
89
|
+
seen.set(key, now);
|
|
90
|
+
Sentry.captureMessage(/* ... */);
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## `EventRecorder` — structured events
|
|
95
|
+
|
|
96
|
+
When you want events with fields, not free-form log lines, use `EventRecorder`.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { Framework, ConsoleEventRecorder, type EventRecorder } from "@finesoft/front";
|
|
100
|
+
|
|
101
|
+
const recorder: EventRecorder = new ConsoleEventRecorder();
|
|
102
|
+
const framework = Framework.create({ eventRecorder: recorder });
|
|
103
|
+
|
|
104
|
+
// Anywhere:
|
|
105
|
+
recorder.record({
|
|
106
|
+
name: "PageView",
|
|
107
|
+
fields: { url: "/products/42", referrer: "/search?q=widget" },
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Built-in events
|
|
112
|
+
|
|
113
|
+
The framework records `PageView` automatically via `framework.didEnterPage(page)`. This fires:
|
|
114
|
+
|
|
115
|
+
- After every successful navigation (SSR + CSR)
|
|
116
|
+
- With fields: `{ intentId, url, renderMode }`
|
|
117
|
+
|
|
118
|
+
### Composing recorders
|
|
119
|
+
|
|
120
|
+
`CompositeEventRecorder` fans events to multiple sinks:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { ConsoleEventRecorder, CompositeEventRecorder } from "@finesoft/front";
|
|
124
|
+
|
|
125
|
+
const recorder = new CompositeEventRecorder([
|
|
126
|
+
new ConsoleEventRecorder(), // dev visibility
|
|
127
|
+
productionAnalyticsRecorder, // ship to backend
|
|
128
|
+
]);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If one recorder throws, the others still receive the event.
|
|
132
|
+
|
|
133
|
+
### Adding common fields
|
|
134
|
+
|
|
135
|
+
`WithFieldsRecorder` decorates another recorder, prepending fields to every event:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { WithFieldsRecorder, ConsoleEventRecorder } from "@finesoft/front";
|
|
139
|
+
|
|
140
|
+
const recorder = new WithFieldsRecorder(new ConsoleEventRecorder(), [
|
|
141
|
+
{ getFields: () => ({ app: "myApp", version: "1.0.0" }) },
|
|
142
|
+
{ getFields: () => ({ userId: getCurrentUserId() }) },
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
recorder.record({ name: "Click", fields: { id: "buy" } });
|
|
146
|
+
// → { app, version, userId, id }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use this for session-scoped fields you don't want to repeat in every `record()` call.
|
|
150
|
+
|
|
151
|
+
### Building your own recorder
|
|
152
|
+
|
|
153
|
+
Implement the `EventRecorder` interface:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import type { EventRecorder, EventRecord } from "@finesoft/front";
|
|
157
|
+
|
|
158
|
+
class HttpEventRecorder implements EventRecorder {
|
|
159
|
+
constructor(private endpoint: string) {}
|
|
160
|
+
record(event: EventRecord): void {
|
|
161
|
+
// fire-and-forget; do NOT await — record() shouldn't block UI
|
|
162
|
+
navigator.sendBeacon(this.endpoint, JSON.stringify(event));
|
|
163
|
+
}
|
|
164
|
+
destroy(): void {
|
|
165
|
+
// flush, close connections, etc.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
See [advanced/custom-event-recorder](./advanced/custom-event-recorder.md) for batching, retries, and lifecycle handling.
|
|
171
|
+
|
|
172
|
+
## Impression tracking
|
|
173
|
+
|
|
174
|
+
Track when elements enter the viewport using `IntersectionObserver` plumbing:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { IntersectionImpressionObserver, type EventRecorder } from "@finesoft/front";
|
|
178
|
+
|
|
179
|
+
const observer = new IntersectionImpressionObserver((entries) => {
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
recorder.record({
|
|
182
|
+
name: "Impression",
|
|
183
|
+
fields: { id: entry.id, ...entry.metadata },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Attach to an element when it mounts
|
|
189
|
+
observer.observe(productCardElement, "product-card-123", {
|
|
190
|
+
category: "featured",
|
|
191
|
+
position: 3,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Detach when it unmounts
|
|
195
|
+
observer.unobserve(productCardElement);
|
|
196
|
+
|
|
197
|
+
// On app teardown
|
|
198
|
+
observer.destroy();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The observer fires the callback once per element per visibility transition. It does not deduplicate across observe/unobserve cycles — your callback should decide whether to record again.
|
|
202
|
+
|
|
203
|
+
## Error handling via `fallback`
|
|
204
|
+
|
|
205
|
+
A controller error becomes a recorded event automatically:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
class HomeController extends BaseController<{}, HomePage> {
|
|
209
|
+
readonly intentId = "home";
|
|
210
|
+
|
|
211
|
+
async execute(_params, container) {
|
|
212
|
+
const api = container.resolve<UserApi>("userApi");
|
|
213
|
+
return { kind: "home", users: await api.list() };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fallback(_params, error) {
|
|
217
|
+
// 1. framework logs the error via its logger (→ reportCallback)
|
|
218
|
+
// 2. you return a degraded page
|
|
219
|
+
return { kind: "home", users: [], degraded: true };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Order of operations on error:
|
|
225
|
+
|
|
226
|
+
1. `execute()` throws.
|
|
227
|
+
2. `BaseController` catches, calls `framework.logger.error("[controller:home]", error)`.
|
|
228
|
+
3. Logger fires `reportCallback("error", "controller:home", [error])` → Sentry.
|
|
229
|
+
4. `fallback()` returns a `Page` of your design.
|
|
230
|
+
5. Render proceeds with the fallback page (HTTP 200 by default).
|
|
231
|
+
|
|
232
|
+
To set a non-200 status, return a page with an "error" marker and check it in an `afterLoad` guard:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
afterLoad: [
|
|
236
|
+
(ctx) => {
|
|
237
|
+
if ("degraded" in ctx.page && ctx.page.degraded) {
|
|
238
|
+
return deny(503, "Service degraded");
|
|
239
|
+
}
|
|
240
|
+
return next();
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Metrics (counters / gauges / timing)
|
|
246
|
+
|
|
247
|
+
The framework's `MetricsClient` is minimal:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
251
|
+
|
|
252
|
+
const metrics = framework.container.resolve(DEP_KEYS.METRICS);
|
|
253
|
+
|
|
254
|
+
metrics.increment("requests.total");
|
|
255
|
+
metrics.increment("login.failed", { reason: "wrong_password" });
|
|
256
|
+
metrics.gauge("queue.depth", 17);
|
|
257
|
+
metrics.timing("render.duration", 142);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The default implementation is a no-op. Register your own:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
framework.container.register(DEP_KEYS.METRICS, () => new StatsdMetrics(/* ... */));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
For most apps, `EventRecorder` is enough — your analytics backend already aggregates events into metrics.
|
|
267
|
+
|
|
268
|
+
## Trace IDs and request correlation
|
|
269
|
+
|
|
270
|
+
A common pattern: tag every log and event with a per-request trace id.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// beforeLoad guard
|
|
274
|
+
function traceIdGuard(ctx) {
|
|
275
|
+
const traceId = ctx.getHeader("x-request-id") ?? crypto.randomUUID();
|
|
276
|
+
ctx.container.register("traceId", () => traceId);
|
|
277
|
+
ctx.container.register(
|
|
278
|
+
DEP_KEYS.EVENT_RECORDER,
|
|
279
|
+
() => new WithFieldsRecorder(baseRecorder, [{ getFields: () => ({ traceId }) }]),
|
|
280
|
+
);
|
|
281
|
+
return next();
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Register the scoped recorder via `WithFieldsRecorder` so every event in this request automatically carries the trace id.
|
|
286
|
+
|
|
287
|
+
## Next
|
|
288
|
+
|
|
289
|
+
- [Server & deployment](./09-server-and-deployment.md) — where these primitives meet HTTP
|
|
290
|
+
- [Advanced: custom event recorder](./advanced/custom-event-recorder.md) — production-grade implementation
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# 9. Server & deployment
|
|
2
|
+
|
|
3
|
+
The server side of the framework. This chapter covers:
|
|
4
|
+
|
|
5
|
+
- The Vite plugin (`finesoftFrontViteConfig`) — dev server, build config, code generation
|
|
6
|
+
- `createServer` — the standalone Hono server
|
|
7
|
+
- The proxy router — declarative API forwarding with SSRF / binary integrity guards
|
|
8
|
+
- Adapters — Node, Vercel, Cloudflare, Netlify, static
|
|
9
|
+
|
|
10
|
+
## The Vite plugin
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// vite.config.ts
|
|
14
|
+
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
15
|
+
import { defineConfig } from "vite";
|
|
16
|
+
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
plugins: [
|
|
19
|
+
// ... view layer plugin (Vue/React/Svelte)
|
|
20
|
+
finesoftFrontViteConfig({
|
|
21
|
+
ssr: { entry: "src/ssr.ts" },
|
|
22
|
+
i18n: { messagesDir: "src/locales" },
|
|
23
|
+
proxies: [{ prefix: "/api", target: "https://upstream.example" }],
|
|
24
|
+
adapter: "auto",
|
|
25
|
+
isr: { routes: ["/blog/*"], ttl: 300 },
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
|
|
33
|
+
| Option | Type | Notes |
|
|
34
|
+
| ------------------ | ------------------------- | ------------------------------------------------------- |
|
|
35
|
+
| `ssr.entry` | `string` | Path to your SSR entry (default `src/ssr.ts`). |
|
|
36
|
+
| `i18n.messagesDir` | `string` | Folder with `{locale}.json` files (default off). |
|
|
37
|
+
| `proxies` | `ProxyRouteConfig[]` | Declarative API forwarding. See below. |
|
|
38
|
+
| `adapter` | `"auto" \| "node" \| ...` | Target platform. `"auto"` detects from env vars. |
|
|
39
|
+
| `isr` | `{ routes, ttl }` | Incremental Static Regeneration for prerendered routes. |
|
|
40
|
+
|
|
41
|
+
### What it does
|
|
42
|
+
|
|
43
|
+
In dev:
|
|
44
|
+
|
|
45
|
+
- Starts a Hono server that runs your SSR entry on every request
|
|
46
|
+
- Hot-reloads SSR code via Vite's module graph
|
|
47
|
+
- Serves the proxy routes locally so client-side `fetch("/api/...")` works
|
|
48
|
+
|
|
49
|
+
In build:
|
|
50
|
+
|
|
51
|
+
- Bundles the client bundle with Vite's standard pipeline
|
|
52
|
+
- Bundles the SSR entry as a separate module
|
|
53
|
+
- Generates an adapter-specific entry file (`vercel.func`, `_worker.js`, `node-server.js`, etc.)
|
|
54
|
+
- Prerenders any `renderMode: "prerender"` routes to static HTML
|
|
55
|
+
|
|
56
|
+
## `createServer` — the standalone Hono server
|
|
57
|
+
|
|
58
|
+
For Node deployments and tests, the framework exports a function that gives you a ready-to-run Hono app:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createServer } from "@finesoft/front";
|
|
62
|
+
|
|
63
|
+
const app = createServer({
|
|
64
|
+
ssrEntry: "./dist/server/ssr.js",
|
|
65
|
+
proxies: [{ prefix: "/api", target: "https://upstream.example" }],
|
|
66
|
+
staticDir: "./dist/client",
|
|
67
|
+
isr: { routes: ["/blog/*"], ttl: 300 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// app is a Hono instance — mount it however your runtime expects
|
|
71
|
+
import { serve } from "@hono/node-server";
|
|
72
|
+
serve({ fetch: app.fetch, port: 3000 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### What it includes
|
|
76
|
+
|
|
77
|
+
- Static file serving for the client bundle
|
|
78
|
+
- All your proxy routes (registered via `registerProxyRoutes`)
|
|
79
|
+
- SSR rendering with full middleware pipeline
|
|
80
|
+
- ISR cache for prerendered routes
|
|
81
|
+
- Locale resolution from `Accept-Language`
|
|
82
|
+
|
|
83
|
+
## Proxy routes
|
|
84
|
+
|
|
85
|
+
Declarative API forwarding with built-in SSRF protection, binary-safe forwarding, and configurable auth/cache.
|
|
86
|
+
|
|
87
|
+
### Basic config
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
proxies: [
|
|
91
|
+
{
|
|
92
|
+
prefix: "/api", // must start with /
|
|
93
|
+
target: "https://api.example.com", // must be https:// or http://
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Now `GET /api/users/42` → `GET https://api.example.com/users/42`. Query params and request headers are forwarded.
|
|
99
|
+
|
|
100
|
+
### Full options
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
{
|
|
104
|
+
prefix: "/api/apple",
|
|
105
|
+
target: "https://api.music.apple.com",
|
|
106
|
+
methods: ["get", "post"], // default ["all"]
|
|
107
|
+
headers: { "X-App": "finesoft" }, // injected per request
|
|
108
|
+
auth: { type: "bearer", envKey: "APPLE_TOKEN" }, // reads process.env.APPLE_TOKEN
|
|
109
|
+
cache: "public, max-age=60", // Cache-Control on response
|
|
110
|
+
followRedirects: false, // default false (redirect: "manual")
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`auth.type`: `"bearer"` → `Authorization: Bearer <token>`. `"basic"` → `Authorization: Basic <token>`. The `envKey` is read at request time, so changing it (or unsetting it) does not require a restart.
|
|
115
|
+
|
|
116
|
+
### What the framework enforces
|
|
117
|
+
|
|
118
|
+
- **SSRF protection**: path is rejected if URL-encoded (any `%`-encoded char), starts with `//`, or contains characters outside the allowed set (`[/\w.\-~%:@!$&'()*+,;=]`). Decoded ≠ raw also rejected (prevents `%2F` smuggling).
|
|
119
|
+
- **Open-redirect protection**: the constructed target URL must have the same `origin` as the configured `target`. Different origin → `400 Invalid proxy target`.
|
|
120
|
+
- **Binary integrity**: response body forwarded via `arrayBuffer()`, not `text()` — preserves bytes exactly. PDF, image, protobuf responses are byte-identical to the upstream response.
|
|
121
|
+
- **Size limit**: 10 MB. `Content-Length` header is checked first for fast rejection; actual body byte length is checked after fetch.
|
|
122
|
+
- **HTTP warning**: any `http://` target logs a warning at startup. Use HTTPS in production.
|
|
123
|
+
|
|
124
|
+
### Generated proxy code (serverless / edge)
|
|
125
|
+
|
|
126
|
+
For serverless functions, the proxy logic is inlined into the deployed function bundle instead of relying on `registerProxyRoutes` at runtime. See [advanced/inline-proxy-codegen](./advanced/inline-proxy-codegen.md).
|
|
127
|
+
|
|
128
|
+
## Adapters
|
|
129
|
+
|
|
130
|
+
| Adapter | Target | Build output |
|
|
131
|
+
| -------------- | -------------------------- | ------------------------------------------------------ |
|
|
132
|
+
| `"node"` | Standalone Node.js server | `dist/server/index.js` — `serve({ fetch: app.fetch })` |
|
|
133
|
+
| `"vercel"` | Vercel Build Output API v3 | `.vercel/output/` with `functions/` and `static/` |
|
|
134
|
+
| `"cloudflare"` | Cloudflare Workers | `dist/_worker.js` + `dist/_routes.json` |
|
|
135
|
+
| `"netlify"` | Netlify Functions v2 | `netlify/functions/` + `_redirects` |
|
|
136
|
+
| `"static"` | Pre-rendered static files | `dist/client/` only (no server) |
|
|
137
|
+
| `"auto"` | Auto-detect at build time | Picks one of the above by environment variable |
|
|
138
|
+
|
|
139
|
+
### Auto-detection
|
|
140
|
+
|
|
141
|
+
`adapter: "auto"` checks (in order):
|
|
142
|
+
|
|
143
|
+
1. `VERCEL=1` → vercel
|
|
144
|
+
2. `CF_PAGES=1` → cloudflare
|
|
145
|
+
3. `NETLIFY=1` → netlify
|
|
146
|
+
4. otherwise → node
|
|
147
|
+
|
|
148
|
+
This works for most CI environments — Vercel / Cloudflare / Netlify all set these automatically during their build.
|
|
149
|
+
|
|
150
|
+
## ISR (Incremental Static Regeneration)
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
isr: {
|
|
154
|
+
routes: ["/blog/*", "/products/*"],
|
|
155
|
+
ttl: 300, // seconds
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
How it works:
|
|
160
|
+
|
|
161
|
+
1. First request to `/blog/hello-world`: render fully, cache the HTML, set expiry to now + 300s
|
|
162
|
+
2. Subsequent requests within TTL: serve cached HTML directly
|
|
163
|
+
3. After expiry: next request triggers re-render; concurrent requests get stale HTML until re-render finishes
|
|
164
|
+
|
|
165
|
+
The cache is in-memory per server instance. For multi-instance deployments where consistency matters, put a CDN in front and use HTTP `Cache-Control` headers instead.
|
|
166
|
+
|
|
167
|
+
Routes not matched by `isr.routes` always render fresh.
|
|
168
|
+
|
|
169
|
+
### Cache invalidation
|
|
170
|
+
|
|
171
|
+
Programmatic invalidation is not exposed in the public API. To force a refresh:
|
|
172
|
+
|
|
173
|
+
- Restart the server (loses entire cache)
|
|
174
|
+
- Wait for TTL
|
|
175
|
+
- Add a cache-busting query param the controller can ignore but that bypasses the cache key
|
|
176
|
+
|
|
177
|
+
For production, push invalidation up to CDN level — the framework's in-memory cache is for single-instance serving.
|
|
178
|
+
|
|
179
|
+
## Custom Hono middleware
|
|
180
|
+
|
|
181
|
+
If you need server logic outside the proxy and SSR (e.g., a webhook endpoint, a health check), mount it on the same Hono app:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const app = createServer({ ssrEntry: "./dist/server/ssr.js" });
|
|
185
|
+
|
|
186
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
187
|
+
app.post("/webhook", async (c) => {
|
|
188
|
+
const body = await c.req.json();
|
|
189
|
+
await handleWebhook(body);
|
|
190
|
+
return c.json({ ok: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// SSR catch-all is registered last by createServer — your routes win.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Environment variables
|
|
197
|
+
|
|
198
|
+
The framework reads:
|
|
199
|
+
|
|
200
|
+
- `NODE_ENV` — `"production"` enables prod-only optimizations
|
|
201
|
+
- `PROXY_TOKEN` / `BASIC_TOKEN` / any `auth.envKey` — proxy auth secrets
|
|
202
|
+
- `VERCEL`, `CF_PAGES`, `NETLIFY` — adapter auto-detection
|
|
203
|
+
|
|
204
|
+
Anything else is yours. Access via `process.env` directly or by registering a config object in the DI container:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
framework.container.register("config", () => ({
|
|
208
|
+
upstreamUrl: process.env.UPSTREAM_URL ?? "https://api.example.com",
|
|
209
|
+
sessionSecret: requireEnv("SESSION_SECRET"),
|
|
210
|
+
}));
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Health checks and graceful shutdown
|
|
214
|
+
|
|
215
|
+
For Node deployments behind a load balancer:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { serve } from "@hono/node-server";
|
|
219
|
+
|
|
220
|
+
const app = createServer({
|
|
221
|
+
/* ... */
|
|
222
|
+
});
|
|
223
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
224
|
+
|
|
225
|
+
const server = serve({ fetch: app.fetch, port: 3000 });
|
|
226
|
+
|
|
227
|
+
process.on("SIGTERM", () => {
|
|
228
|
+
server.close(() => {
|
|
229
|
+
// dispose Framework if you held a reference
|
|
230
|
+
framework.dispose();
|
|
231
|
+
process.exit(0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`framework.dispose()` recursively disposes the container, calls `destroy()` on registered recorders/loggers, and unregisters all routes.
|
|
237
|
+
|
|
238
|
+
## Next
|
|
239
|
+
|
|
240
|
+
- [Features, platform, PWA](./10-features-platform-pwa.md) — feature flags, platform detection
|
|
241
|
+
- [Engineering: CI & release flow](./engineering/ci-release-flow.md) — automating releases
|
|
242
|
+
- [Pitfalls: proxy binary payloads](./pitfalls/proxy-binary-payloads.md) — why `arrayBuffer` matters
|