@ilha/router 0.9.2 → 0.10.1
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 +44 -27
- package/dist/head.d.ts +67 -0
- package/dist/http.d.ts +28 -0
- package/dist/index.d.ts +43 -97
- package/dist/index.js +2 -1
- package/dist/{plugin-BHuojFhQ.js → plugin-Bh0y5kkI.js} +102 -115
- package/dist/plugin.d.ts +1 -1
- package/dist/route-match.d.ts +1 -1
- package/dist/{rspack.d.ts → rsbuild.d.ts} +2 -2
- package/dist/rsbuild.js +10 -0
- package/dist/server-island.d.ts +17 -2
- package/dist/server-island.js +13 -25
- package/dist/server-islands.d.ts +16 -2
- package/dist/server.d.ts +8 -0
- package/dist/server.js +14 -0
- package/dist/snapshot-CsEaY6h_.js +337 -0
- package/dist/snapshot.d.ts +1 -0
- package/dist/{src-BBsbD5vU.js → src-B5dHU24f.js} +157 -361
- package/dist/ssr-CGcMUP1G.js +475 -0
- package/dist/ssr.d.ts +169 -0
- package/dist/ssr.js +2 -200
- package/dist/vite.js +1 -1
- package/package.json +11 -14
- package/dist/public-types.d.ts +0 -7
- package/dist/request-scope-C4reU4v0.js +0 -34
- package/dist/rolldown.d.ts +0 -6
- package/dist/rolldown.js +0 -10
- package/dist/rspack.js +0 -10
- package/dist/server-island-registry.d.ts +0 -122
- package/dist/server-island-registry.js +0 -189
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { M as parsePattern, N as safeDecode, b as resolveRedirectTarget, j as matchSegments } from "./src-B5dHU24f.js";
|
|
2
|
+
import "ilha";
|
|
3
|
+
import { bindServerAction, setServerManifestSerializer } from "ilha/internal";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
|
|
6
|
+
//#region src/request-scope.ts
|
|
7
|
+
/**
|
|
8
|
+
* Request scope for server-owned island rendering.
|
|
9
|
+
*
|
|
10
|
+
* A `.server.tsx` island's render function always executes on the server —
|
|
11
|
+
* page SSR through the router, or streamed frames through the plugin's
|
|
12
|
+
* `/__ilha/frame` endpoint. Both seed this scope with the originating
|
|
13
|
+
* `Request`, so render functions can read request data (URL, headers,
|
|
14
|
+
* cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
|
|
15
|
+
*
|
|
16
|
+
* The storage lives on `globalThis` under `ilha.requestAls` so every module
|
|
17
|
+
* copy (plugin bundle, SSR graph) shares one instance. The public accessor
|
|
18
|
+
* is `useContext()` from the main `@ilha/router` entry, which reads the
|
|
19
|
+
* storage without importing `node:async_hooks`; this node-only module is the
|
|
20
|
+
* sole place that constructs it.
|
|
21
|
+
*/
|
|
22
|
+
const REQUEST_ALS_KEY = Symbol.for("ilha.requestAls");
|
|
23
|
+
/** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
|
|
24
|
+
* inside island renders and frames, not just `/__oxide/action`. */
|
|
25
|
+
const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
|
|
26
|
+
/** Run `fn` with `request` available to `useContext().request`. When oxidejs
|
|
27
|
+
* is loaded, its action scope is entered too, so `useRequest()` works in
|
|
28
|
+
* island renders and streamed frames. */
|
|
29
|
+
function runWithIslandRequest(request, fn) {
|
|
30
|
+
const g = globalThis;
|
|
31
|
+
const als = g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage();
|
|
32
|
+
const oxide = g[OXIDE_RUN_WITH_REQUEST];
|
|
33
|
+
return als.run(request, () => oxide ? oxide(request, fn) : fn());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/ssr.ts
|
|
38
|
+
const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
|
|
39
|
+
function registry() {
|
|
40
|
+
const g = globalThis;
|
|
41
|
+
let map = g[REGISTRY_KEY];
|
|
42
|
+
if (!map) {
|
|
43
|
+
map = /* @__PURE__ */ new Map();
|
|
44
|
+
g[REGISTRY_KEY] = map;
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
const GUARD_KEY = Symbol.for("ilha.frameGuard");
|
|
49
|
+
/**
|
|
50
|
+
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
51
|
+
* and the production `@ilha/router/ssr` handler share this slot — both read
|
|
52
|
+
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
53
|
+
* allow. Island state is world-readable through frames unless you gate them,
|
|
54
|
+
* so apps serving private data should install a session check here.
|
|
55
|
+
*/
|
|
56
|
+
function setFrameGuard(guard) {
|
|
57
|
+
const g = globalThis;
|
|
58
|
+
g[GUARD_KEY] = guard;
|
|
59
|
+
}
|
|
60
|
+
function getFrameGuard() {
|
|
61
|
+
return globalThis[GUARD_KEY];
|
|
62
|
+
}
|
|
63
|
+
const LOADER_GUARD_KEY = Symbol.for("ilha.loaderGuard");
|
|
64
|
+
/**
|
|
65
|
+
* Install a guard consulted only by `GET /__ilha/loader`. When absent, the
|
|
66
|
+
* loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
|
|
67
|
+
* Prefer a dedicated loader guard so gating the loader endpoint is independent
|
|
68
|
+
* of frame rendering.
|
|
69
|
+
*/
|
|
70
|
+
function setLoaderGuard(guard) {
|
|
71
|
+
const g = globalThis;
|
|
72
|
+
g[LOADER_GUARD_KEY] = guard;
|
|
73
|
+
}
|
|
74
|
+
function getLoaderGuard() {
|
|
75
|
+
return globalThis[LOADER_GUARD_KEY];
|
|
76
|
+
}
|
|
77
|
+
const AUTH_KEY = Symbol.for("ilha.frameAuth");
|
|
78
|
+
/**
|
|
79
|
+
* Install the frame-authorization policy consumed by the production
|
|
80
|
+
* `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
|
|
81
|
+
* the dev middleware (via `IlhaPagesOptions`).
|
|
82
|
+
*/
|
|
83
|
+
function setFrameAuth(policy) {
|
|
84
|
+
const g = globalThis;
|
|
85
|
+
g[AUTH_KEY] = policy;
|
|
86
|
+
}
|
|
87
|
+
function getFrameAuth() {
|
|
88
|
+
return globalThis[AUTH_KEY];
|
|
89
|
+
}
|
|
90
|
+
function normalizeOrigin(value) {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(value).origin;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Same-origin check for frame/loader requests. Browsers always send `Origin`
|
|
99
|
+
* on cross-origin and same-origin `POST`; its absence implies a non-browser
|
|
100
|
+
* caller (allowed — gate those via a guard or `csrf`). When `Origin` is
|
|
101
|
+
* present it must match the configured trusted origins, else the request's
|
|
102
|
+
* own `Host`.
|
|
103
|
+
*/
|
|
104
|
+
function isTrustedOrigin(request, policy) {
|
|
105
|
+
const originHeader = request.headers.get("origin");
|
|
106
|
+
if (originHeader === null) return true;
|
|
107
|
+
const origin = normalizeOrigin(originHeader);
|
|
108
|
+
if (origin === null) return false;
|
|
109
|
+
const trusted = policy?.trustedOrigins ?? [];
|
|
110
|
+
if (trusted.length > 0) return trusted.some((o) => normalizeOrigin(o) === origin);
|
|
111
|
+
const host = request.headers.get("host");
|
|
112
|
+
if (!host) return false;
|
|
113
|
+
return origin === `https://${host}` || origin === `http://${host}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Path-only route context for frame/loader scoped requests. Leading slash,
|
|
117
|
+
* no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
|
|
118
|
+
* `\evil.com` prefix would smuggle a foreign authority past a plain `//`
|
|
119
|
+
* check), bounded length. `false` for anything else.
|
|
120
|
+
*/
|
|
121
|
+
function isSafeFramePath(path) {
|
|
122
|
+
return path.startsWith("/") && !path.includes("//") && !path.includes("\\") && path.length <= 2048;
|
|
123
|
+
}
|
|
124
|
+
/** Identity headers forwarded onto scoped render/loader requests. */
|
|
125
|
+
const FORWARD_IDENTITY_HEADERS = [
|
|
126
|
+
"cookie",
|
|
127
|
+
"authorization",
|
|
128
|
+
"user-agent"
|
|
129
|
+
];
|
|
130
|
+
/**
|
|
131
|
+
* Copy identity headers (cookie, authorization, user-agent) onto a fresh
|
|
132
|
+
* `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
|
|
133
|
+
* object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
|
|
134
|
+
* it is spoofable and must not be trusted by loaders for IP checks.
|
|
135
|
+
*/
|
|
136
|
+
function forwardIdentityHeaders(source) {
|
|
137
|
+
const out = new Headers();
|
|
138
|
+
const read = (name) => {
|
|
139
|
+
const s = source;
|
|
140
|
+
if (typeof s.get === "function") return s.get(name);
|
|
141
|
+
const v = source[name];
|
|
142
|
+
return Array.isArray(v) ? v[0] : v;
|
|
143
|
+
};
|
|
144
|
+
for (const name of FORWARD_IDENTITY_HEADERS) {
|
|
145
|
+
const v = read(name);
|
|
146
|
+
if (v !== null && v !== void 0) out.set(name, v);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function frameEnvelope(status, body) {
|
|
151
|
+
return {
|
|
152
|
+
status,
|
|
153
|
+
headers: {
|
|
154
|
+
"cache-control": "no-store",
|
|
155
|
+
"content-type": "application/json;charset=utf-8"
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify(body)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const LOADER_RUNNER_KEY = Symbol.for("ilha.frameLoaderRunner");
|
|
161
|
+
/**
|
|
162
|
+
* Install the handler backing `GET /__ilha/loader` in production. The
|
|
163
|
+
* generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
|
|
164
|
+
* regular-page server loads get full route matching, layout chains, and
|
|
165
|
+
* redirect/error semantics. Dev and prod handlers share the slot.
|
|
166
|
+
*/
|
|
167
|
+
function setFrameLoaderRunner(runner) {
|
|
168
|
+
const g = globalThis;
|
|
169
|
+
g[LOADER_RUNNER_KEY] = runner;
|
|
170
|
+
}
|
|
171
|
+
function getFrameLoaderRunner() {
|
|
172
|
+
return globalThis[LOADER_RUNNER_KEY];
|
|
173
|
+
}
|
|
174
|
+
setServerManifestSerializer({ template(manifest) {
|
|
175
|
+
return `<template data-ilha-actions='${JSON.stringify(Object.fromEntries(manifest)).replace(/&/g, "&").replace(/'/g, "'").replace(/</g, "<")}'></template>`;
|
|
176
|
+
} });
|
|
177
|
+
/** @internal Brand an exported server action with its generated RPC transport key. */
|
|
178
|
+
function __ilhaServerAction(key, fn) {
|
|
179
|
+
return bindServerAction(fn, key);
|
|
180
|
+
}
|
|
181
|
+
function registerServerIsland(id, render, options) {
|
|
182
|
+
registry().set(id, {
|
|
183
|
+
render,
|
|
184
|
+
load: options?.load,
|
|
185
|
+
pattern: options?.pattern
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function getServerIslandEntry(id) {
|
|
189
|
+
return registry().get(id);
|
|
190
|
+
}
|
|
191
|
+
/** Client-facing frame failure. `redirect` carries a loader redirect target. */
|
|
192
|
+
var FrameError = class extends Error {
|
|
193
|
+
status;
|
|
194
|
+
redirect;
|
|
195
|
+
constructor(status, message, redirect) {
|
|
196
|
+
super(message);
|
|
197
|
+
this.status = status;
|
|
198
|
+
this.redirect = redirect;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
/** Match a route pattern (`/user/:id`, `/docs/**:slug`) against a pathname.
|
|
202
|
+
* Returns decoded params, or null when the path doesn't match. Shares the
|
|
203
|
+
* router's matcher semantics via `route-match.ts`. */
|
|
204
|
+
function matchPatternParams(pattern, pathname) {
|
|
205
|
+
const raw = matchSegments(parsePattern(pattern).segments, pathname);
|
|
206
|
+
if (!raw) return null;
|
|
207
|
+
const params = {};
|
|
208
|
+
for (const [k, v] of Object.entries(raw)) params[k] = safeDecode(v);
|
|
209
|
+
return params;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Shared tail of every frame request: run the page's `load` when registered
|
|
213
|
+
* (params matched from the frame path), then invoke the renderer inside the
|
|
214
|
+
* caller's scope. Throws `FrameError` with an HTTP status for client-facing
|
|
215
|
+
* failures; loader redirects surface via `FrameError.redirect`.
|
|
216
|
+
*/
|
|
217
|
+
async function renderServerIsland(id, request, runWithScope, onHead) {
|
|
218
|
+
const entry = registry().get(id);
|
|
219
|
+
if (!entry) throw new FrameError(400, "unknown island");
|
|
220
|
+
let props;
|
|
221
|
+
if (entry.load) {
|
|
222
|
+
let url;
|
|
223
|
+
try {
|
|
224
|
+
url = new URL(request.url);
|
|
225
|
+
} catch {
|
|
226
|
+
throw new FrameError(400, "frame failed");
|
|
227
|
+
}
|
|
228
|
+
const params = entry.pattern ? matchPatternParams(entry.pattern, url.pathname) : {};
|
|
229
|
+
if (!params) throw new FrameError(400, "frame failed");
|
|
230
|
+
try {
|
|
231
|
+
const headEntries = [];
|
|
232
|
+
const result = await entry.load({
|
|
233
|
+
params,
|
|
234
|
+
request,
|
|
235
|
+
url,
|
|
236
|
+
signal: request.signal,
|
|
237
|
+
head: (input) => headEntries.push(input)
|
|
238
|
+
});
|
|
239
|
+
if (headEntries.length > 0) onHead?.(headEntries);
|
|
240
|
+
props = { load: {
|
|
241
|
+
loading: false,
|
|
242
|
+
value: result ?? {},
|
|
243
|
+
error: void 0
|
|
244
|
+
} };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const marker = error;
|
|
247
|
+
if (marker.__ilhaRedirect === true) {
|
|
248
|
+
const r = error;
|
|
249
|
+
const safe = resolveRedirectTarget(r.to, url, false);
|
|
250
|
+
if (!safe.ok) throw new FrameError(500, "unsafe redirect target");
|
|
251
|
+
throw new FrameError(r.status || 302, "frame failed", safe.to);
|
|
252
|
+
}
|
|
253
|
+
if (marker.__ilhaLoaderError === true) throw new FrameError(error.status || 500, "frame failed");
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const render = entry.render();
|
|
258
|
+
if (typeof render !== "function") throw new FrameError(400, "unknown island");
|
|
259
|
+
const html = await runWithScope(request, () => render(props));
|
|
260
|
+
return String(html);
|
|
261
|
+
}
|
|
262
|
+
const FRAME_ENDPOINT = "/__ilha/frame";
|
|
263
|
+
/** Regular-page server loads: served through the loader-runner slot. */
|
|
264
|
+
const LOADER_ENDPOINT = "/__ilha/loader";
|
|
265
|
+
/** Max request body size — matches the dev middleware cap. */
|
|
266
|
+
const MAX_BODY = 16384;
|
|
267
|
+
function json(status, body) {
|
|
268
|
+
const env = frameEnvelope(status, body);
|
|
269
|
+
return new Response(env.body, {
|
|
270
|
+
status: env.status,
|
|
271
|
+
headers: env.headers
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Read a request body as UTF-8, streaming it with a hard byte cap. Returns
|
|
276
|
+
* `null` when the body exceeds `maxBytes` (the reader is cancelled before the
|
|
277
|
+
* cap is far exceeded) or when decoding fails.
|
|
278
|
+
*/
|
|
279
|
+
async function readBodyBounded(request, maxBytes) {
|
|
280
|
+
const contentLength = request.headers.get("content-length");
|
|
281
|
+
if (contentLength !== null && Number(contentLength) > maxBytes) return null;
|
|
282
|
+
const reader = request.body?.getReader();
|
|
283
|
+
if (!reader) return "";
|
|
284
|
+
const chunks = [];
|
|
285
|
+
let size = 0;
|
|
286
|
+
for (;;) {
|
|
287
|
+
const { done, value } = await reader.read();
|
|
288
|
+
if (done) break;
|
|
289
|
+
size += value?.byteLength ?? 0;
|
|
290
|
+
if (size > maxBytes) {
|
|
291
|
+
await reader.cancel().catch(() => {});
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
chunks.push(value);
|
|
295
|
+
}
|
|
296
|
+
const decoder = new TextDecoder();
|
|
297
|
+
return chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Shared frame-request authorization used by both the production handler
|
|
301
|
+
* below and the Vite/Rsbuild dev middleware: same-origin check against the
|
|
302
|
+
* frame-auth policy, the registered frame guard, and the optional CSRF
|
|
303
|
+
* verifier. `defaultAction` selects the deny-by-default production posture or
|
|
304
|
+
* the permissive development one.
|
|
305
|
+
*
|
|
306
|
+
* Returns the forwarded identity headers on success so callers render frames
|
|
307
|
+
* with cookie/auth/UA context, or the HTTP status to reject with.
|
|
308
|
+
*/
|
|
309
|
+
async function authorizeFrameRequest(request, options) {
|
|
310
|
+
const auth = getFrameAuth();
|
|
311
|
+
if (!isTrustedOrigin(request, auth)) return {
|
|
312
|
+
ok: false,
|
|
313
|
+
status: 403
|
|
314
|
+
};
|
|
315
|
+
const guard = getFrameGuard();
|
|
316
|
+
if (!guard && (auth?.defaultAction ?? "deny") === "deny") return {
|
|
317
|
+
ok: false,
|
|
318
|
+
status: 403
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
const denied = await guard?.(request);
|
|
322
|
+
if (denied) return {
|
|
323
|
+
ok: false,
|
|
324
|
+
status: denied.status
|
|
325
|
+
};
|
|
326
|
+
} catch (error) {
|
|
327
|
+
options.onGuardError?.(error);
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
status: 403
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (auth?.csrf) try {
|
|
334
|
+
if (!await auth.csrf(request)) return {
|
|
335
|
+
ok: false,
|
|
336
|
+
status: 403
|
|
337
|
+
};
|
|
338
|
+
} catch {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
status: 403
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
identityHeaders: forwardIdentityHeaders(request.headers)
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function ssr(request) {
|
|
350
|
+
let pathname;
|
|
351
|
+
try {
|
|
352
|
+
pathname = new URL(request.url).pathname;
|
|
353
|
+
} catch {
|
|
354
|
+
return json(400, { error: "frame failed" });
|
|
355
|
+
}
|
|
356
|
+
if (pathname !== "/__ilha/frame" && pathname !== "/__ilha/loader") return;
|
|
357
|
+
const auth = getFrameAuth();
|
|
358
|
+
if (!isTrustedOrigin(request, auth)) return json(403, { error: "frame failed" });
|
|
359
|
+
if (pathname === "/__ilha/loader") {
|
|
360
|
+
if (request.method !== "GET") return json(405, { error: "method not allowed" });
|
|
361
|
+
const guard = getLoaderGuard() ?? getFrameGuard();
|
|
362
|
+
if (!guard && (auth?.defaultAction ?? "deny") === "deny") return json(403, { error: "loader failed" });
|
|
363
|
+
try {
|
|
364
|
+
const denied = await guard?.(request);
|
|
365
|
+
if (denied) return denied;
|
|
366
|
+
} catch {
|
|
367
|
+
return json(403, { error: "loader failed" });
|
|
368
|
+
}
|
|
369
|
+
const runner = getFrameLoaderRunner();
|
|
370
|
+
if (!runner) return json(404, {
|
|
371
|
+
kind: "error",
|
|
372
|
+
status: 404,
|
|
373
|
+
message: "not found"
|
|
374
|
+
});
|
|
375
|
+
const cl = request.headers.get("content-length");
|
|
376
|
+
if (cl && Number(cl) > 16384) return json(413, { error: "frame failed" });
|
|
377
|
+
let target = "/";
|
|
378
|
+
try {
|
|
379
|
+
target = new URL(request.url).searchParams.get("path") ?? "/";
|
|
380
|
+
} catch {
|
|
381
|
+
return json(400, {
|
|
382
|
+
kind: "error",
|
|
383
|
+
status: 400,
|
|
384
|
+
message: "bad request"
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (!isSafeFramePath(target)) return json(400, {
|
|
388
|
+
kind: "error",
|
|
389
|
+
status: 400,
|
|
390
|
+
message: "bad request"
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
const result = await runWithIslandRequest(request, () => runner(target, request));
|
|
394
|
+
if (result.kind === "redirect") return json(result.status || 302, {
|
|
395
|
+
kind: "redirect",
|
|
396
|
+
to: result.to,
|
|
397
|
+
status: result.status
|
|
398
|
+
});
|
|
399
|
+
if (result.kind !== "data") {
|
|
400
|
+
const status = result.status || 500;
|
|
401
|
+
return json(status, {
|
|
402
|
+
kind: result.kind,
|
|
403
|
+
status,
|
|
404
|
+
message: result.message
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return json(200, result);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("[ilha-router] loader endpoint failed:", error);
|
|
410
|
+
return json(500, {
|
|
411
|
+
kind: "error",
|
|
412
|
+
status: 500,
|
|
413
|
+
message: "loader failed"
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (request.method !== "POST") return json(405, { error: "frame failed" });
|
|
418
|
+
if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return json(415, { error: "frame failed" });
|
|
419
|
+
const authorized = await authorizeFrameRequest(request, {
|
|
420
|
+
defaultAction: auth?.defaultAction ?? "deny",
|
|
421
|
+
onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
|
|
422
|
+
});
|
|
423
|
+
if (!authorized.ok) return json(authorized.status, { error: "frame failed" });
|
|
424
|
+
let id;
|
|
425
|
+
let path = "/";
|
|
426
|
+
try {
|
|
427
|
+
const text = await readBodyBounded(request, MAX_BODY);
|
|
428
|
+
if (text === null) return json(413, { error: "frame failed" });
|
|
429
|
+
const body = JSON.parse(text);
|
|
430
|
+
id = String(body.id ?? "");
|
|
431
|
+
if (typeof body.path === "string") {
|
|
432
|
+
if (!isSafeFramePath(body.path)) return json(400, { error: "frame failed" });
|
|
433
|
+
path = body.path;
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
return json(400, { error: "frame failed" });
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
let origin;
|
|
440
|
+
try {
|
|
441
|
+
origin = new URL(request.url).origin;
|
|
442
|
+
} catch {
|
|
443
|
+
return json(400, { error: "frame failed" });
|
|
444
|
+
}
|
|
445
|
+
const headers = forwardIdentityHeaders(request.headers);
|
|
446
|
+
const scoped = new Request(new URL(path, origin), {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers
|
|
449
|
+
});
|
|
450
|
+
for (const sym of Object.getOwnPropertySymbols(request)) {
|
|
451
|
+
if (Symbol.keyFor(sym) === void 0) continue;
|
|
452
|
+
try {
|
|
453
|
+
scoped[sym] = request[sym];
|
|
454
|
+
} catch {}
|
|
455
|
+
}
|
|
456
|
+
let head;
|
|
457
|
+
return json(200, {
|
|
458
|
+
html: await renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), (entries) => head = entries),
|
|
459
|
+
head
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (error instanceof FrameError) {
|
|
463
|
+
if (error.redirect) return json(error.status, { redirect: error.redirect });
|
|
464
|
+
if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
|
|
465
|
+
return json(error.status, { error: "frame failed" });
|
|
466
|
+
}
|
|
467
|
+
console.error("[ilha-router] frame render failed:", error);
|
|
468
|
+
return json(400, { error: "frame failed" });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** Side-effect imports required alongside this handler. */
|
|
472
|
+
ssr.imports = ["ilha:pages/server", "ilha:loaders"];
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
export { setLoaderGuard as C, setFrameLoaderRunner as S, runWithIslandRequest as T, readBodyBounded as _, __ilhaServerAction as a, setFrameAuth as b, frameEnvelope as c, getFrameLoaderRunner as d, getLoaderGuard as f, json as g, isTrustedOrigin as h, MAX_BODY as i, getFrameAuth as l, isSafeFramePath as m, FrameError as n, authorizeFrameRequest as o, getServerIslandEntry as p, LOADER_ENDPOINT as r, forwardIdentityHeaders as s, FRAME_ENDPOINT as t, getFrameGuard as u, registerServerIsland as v, ssr as w, setFrameGuard as x, renderServerIsland as y };
|
package/dist/ssr.d.ts
CHANGED
|
@@ -16,6 +16,155 @@
|
|
|
16
16
|
* - `GET /__ilha/loader?path=…` — regular-page server loads via the loader
|
|
17
17
|
* runner (`setFrameLoaderRunner`, wired by the generated server module).
|
|
18
18
|
*/
|
|
19
|
+
import "ilha";
|
|
20
|
+
import { type ServerAction } from "ilha/internal";
|
|
21
|
+
import type { HeadInput } from "./head";
|
|
22
|
+
/**
|
|
23
|
+
* Server-frame state shared by the dev middleware and the production
|
|
24
|
+
* `@ilha/router/ssr` handler: the renderers registry (keyed by the public
|
|
25
|
+
* island id, `sha256(file#name)`, see `serverIslandPublicId`), frame/loader
|
|
26
|
+
* guards and auth policy, and the loader runner. Lives on `globalThis` so
|
|
27
|
+
* every module copy (plugin bundle, SSR graph, frame entry) shares one
|
|
28
|
+
* instance — same pattern as `request-scope.ts`.
|
|
29
|
+
*
|
|
30
|
+
* `.server` modules self-register when the plugin appends registration code
|
|
31
|
+
* to their server-graph copy; the `/__ilha/frame` handler below consumes the
|
|
32
|
+
* registry to re-render an island from a client state snapshot. Server pages
|
|
33
|
+
* additionally register their `load` and route pattern so frame handlers can
|
|
34
|
+
* run the loader with matched params.
|
|
35
|
+
*/
|
|
36
|
+
/** Loader context for server-page `load` — mirrors the router's shape. */
|
|
37
|
+
export interface FrameLoaderContext {
|
|
38
|
+
params: Record<string, string>;
|
|
39
|
+
request: Request;
|
|
40
|
+
url: URL;
|
|
41
|
+
signal: AbortSignal;
|
|
42
|
+
/** Contribute `<head>` data for this route. Safe to call multiple times. */
|
|
43
|
+
head: (input: HeadInput) => void;
|
|
44
|
+
}
|
|
45
|
+
export type ServerPageLoader = (ctx: FrameLoaderContext) => unknown;
|
|
46
|
+
/** A frame render: optionally preceded by running the page's `load`. */
|
|
47
|
+
export interface ServerIslandEntry {
|
|
48
|
+
/** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
|
|
49
|
+
render: () => unknown;
|
|
50
|
+
/** The module's `load` export — runs at frame time; its return value
|
|
51
|
+
* becomes the island's render props. */
|
|
52
|
+
load?: ServerPageLoader;
|
|
53
|
+
/** Route pattern for the page (`/user/:id`) — matches params for `load`. */
|
|
54
|
+
pattern?: string;
|
|
55
|
+
}
|
|
56
|
+
export type FrameGuard = (request: Request) => Response | void | Promise<Response | void>;
|
|
57
|
+
/**
|
|
58
|
+
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
59
|
+
* and the production `@ilha/router/ssr` handler share this slot — both read
|
|
60
|
+
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
61
|
+
* allow. Island state is world-readable through frames unless you gate them,
|
|
62
|
+
* so apps serving private data should install a session check here.
|
|
63
|
+
*/
|
|
64
|
+
export declare function setFrameGuard(guard: FrameGuard): void;
|
|
65
|
+
export declare function getFrameGuard(): FrameGuard | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Install a guard consulted only by `GET /__ilha/loader`. When absent, the
|
|
68
|
+
* loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
|
|
69
|
+
* Prefer a dedicated loader guard so gating the loader endpoint is independent
|
|
70
|
+
* of frame rendering.
|
|
71
|
+
*/
|
|
72
|
+
export declare function setLoaderGuard(guard: FrameGuard): void;
|
|
73
|
+
export declare function getLoaderGuard(): FrameGuard | undefined;
|
|
74
|
+
/** Frame-authorization policy, installed via {@link setFrameAuth}. */
|
|
75
|
+
export interface FrameAuthPolicy {
|
|
76
|
+
/**
|
|
77
|
+
* Action taken when no frame guard is registered. `"deny"` (default in the
|
|
78
|
+
* production handler) rejects every `/__ilha/frame` request with 403;
|
|
79
|
+
* `"open"` preserves the legacy unauthenticated behavior. The dev
|
|
80
|
+
* middleware stays permissive unless a guard is registered.
|
|
81
|
+
*/
|
|
82
|
+
defaultAction?: "open" | "deny";
|
|
83
|
+
/**
|
|
84
|
+
* Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
|
|
85
|
+
* origin checks accept only these; otherwise the check compares the `Origin`
|
|
86
|
+
* header against `https://{host}` / `http://{host}`.
|
|
87
|
+
*/
|
|
88
|
+
trustedOrigins?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* Optional CSRF verifier for the state-changing frame POST. Receives the
|
|
91
|
+
* original `Request`; returning falsy rejects the request. Use this for
|
|
92
|
+
* server-to-server frame callers that have no browser `Origin`.
|
|
93
|
+
*/
|
|
94
|
+
csrf?: (request: Request) => boolean | Promise<boolean>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Install the frame-authorization policy consumed by the production
|
|
98
|
+
* `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
|
|
99
|
+
* the dev middleware (via `IlhaPagesOptions`).
|
|
100
|
+
*/
|
|
101
|
+
export declare function setFrameAuth(policy: FrameAuthPolicy): void;
|
|
102
|
+
export declare function getFrameAuth(): FrameAuthPolicy | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Same-origin check for frame/loader requests. Browsers always send `Origin`
|
|
105
|
+
* on cross-origin and same-origin `POST`; its absence implies a non-browser
|
|
106
|
+
* caller (allowed — gate those via a guard or `csrf`). When `Origin` is
|
|
107
|
+
* present it must match the configured trusted origins, else the request's
|
|
108
|
+
* own `Host`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function isTrustedOrigin(request: Request, policy: FrameAuthPolicy | undefined): boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Path-only route context for frame/loader scoped requests. Leading slash,
|
|
113
|
+
* no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
|
|
114
|
+
* `\evil.com` prefix would smuggle a foreign authority past a plain `//`
|
|
115
|
+
* check), bounded length. `false` for anything else.
|
|
116
|
+
*/
|
|
117
|
+
export declare function isSafeFramePath(path: string): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Copy identity headers (cookie, authorization, user-agent) onto a fresh
|
|
120
|
+
* `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
|
|
121
|
+
* object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
|
|
122
|
+
* it is spoofable and must not be trusted by loaders for IP checks.
|
|
123
|
+
*/
|
|
124
|
+
export declare function forwardIdentityHeaders(source: Headers | Record<string, string | string[] | undefined>): Headers;
|
|
125
|
+
/** No-store JSON envelope shared by dev and production frame handlers. */
|
|
126
|
+
export interface FrameEnvelope {
|
|
127
|
+
status: number;
|
|
128
|
+
headers: Record<string, string>;
|
|
129
|
+
body: string;
|
|
130
|
+
}
|
|
131
|
+
export declare function frameEnvelope(status: number, body: Record<string, unknown>): FrameEnvelope;
|
|
132
|
+
export type FrameLoaderRunner = (path: string, request?: Request) => Promise<{
|
|
133
|
+
kind: string;
|
|
134
|
+
data?: unknown;
|
|
135
|
+
headEntries?: unknown;
|
|
136
|
+
status?: number;
|
|
137
|
+
to?: string;
|
|
138
|
+
message?: string;
|
|
139
|
+
}>;
|
|
140
|
+
/**
|
|
141
|
+
* Install the handler backing `GET /__ilha/loader` in production. The
|
|
142
|
+
* generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
|
|
143
|
+
* regular-page server loads get full route matching, layout chains, and
|
|
144
|
+
* redirect/error semantics. Dev and prod handlers share the slot.
|
|
145
|
+
*/
|
|
146
|
+
export declare function setFrameLoaderRunner(runner: FrameLoaderRunner): void;
|
|
147
|
+
export declare function getFrameLoaderRunner(): FrameLoaderRunner | undefined;
|
|
148
|
+
/** @internal Brand an exported server action with its generated RPC transport key. */
|
|
149
|
+
export declare function __ilhaServerAction<A extends unknown[], R>(key: string, fn: (...args: A) => R): ServerAction<A, R>;
|
|
150
|
+
export declare function registerServerIsland(id: string, render: () => unknown, options?: {
|
|
151
|
+
load?: ServerPageLoader;
|
|
152
|
+
pattern?: string;
|
|
153
|
+
}): void;
|
|
154
|
+
export declare function getServerIslandEntry(id: string): ServerIslandEntry | undefined;
|
|
155
|
+
/** Client-facing frame failure. `redirect` carries a loader redirect target. */
|
|
156
|
+
export declare class FrameError extends Error {
|
|
157
|
+
status: number;
|
|
158
|
+
redirect?: string;
|
|
159
|
+
constructor(status: number, message: string, redirect?: string);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Shared tail of every frame request: run the page's `load` when registered
|
|
163
|
+
* (params matched from the frame path), then invoke the renderer inside the
|
|
164
|
+
* caller's scope. Throws `FrameError` with an HTTP status for client-facing
|
|
165
|
+
* failures; loader redirects surface via `FrameError.redirect`.
|
|
166
|
+
*/
|
|
167
|
+
export declare function renderServerIsland(id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, onHead?: (entries: HeadInput[]) => void): Promise<string>;
|
|
19
168
|
export declare const FRAME_ENDPOINT = "/__ilha/frame";
|
|
20
169
|
/** Regular-page server loads: served through the loader-runner slot. */
|
|
21
170
|
export declare const LOADER_ENDPOINT = "/__ilha/loader";
|
|
@@ -28,5 +177,25 @@ export declare function json(status: number, body: Record<string, unknown>): Res
|
|
|
28
177
|
* cap is far exceeded) or when decoding fails.
|
|
29
178
|
*/
|
|
30
179
|
export declare function readBodyBounded(request: Request, maxBytes: number): Promise<string | null>;
|
|
180
|
+
/**
|
|
181
|
+
* Shared frame-request authorization used by both the production handler
|
|
182
|
+
* below and the Vite/Rsbuild dev middleware: same-origin check against the
|
|
183
|
+
* frame-auth policy, the registered frame guard, and the optional CSRF
|
|
184
|
+
* verifier. `defaultAction` selects the deny-by-default production posture or
|
|
185
|
+
* the permissive development one.
|
|
186
|
+
*
|
|
187
|
+
* Returns the forwarded identity headers on success so callers render frames
|
|
188
|
+
* with cookie/auth/UA context, or the HTTP status to reject with.
|
|
189
|
+
*/
|
|
190
|
+
export declare function authorizeFrameRequest(request: Request, options: {
|
|
191
|
+
defaultAction: "open" | "deny";
|
|
192
|
+
onGuardError?: (error: unknown) => void;
|
|
193
|
+
}): Promise<{
|
|
194
|
+
ok: true;
|
|
195
|
+
identityHeaders: Headers;
|
|
196
|
+
} | {
|
|
197
|
+
ok: false;
|
|
198
|
+
status: number;
|
|
199
|
+
}>;
|
|
31
200
|
declare function ssr(request: Request): Promise<Response | undefined>;
|
|
32
201
|
export default ssr;
|