@ilha/router 0.11.2 → 0.11.4
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 +75 -66
- package/dist/codegen.d.ts +3 -3
- package/dist/hash.d.ts +13 -9
- package/dist/head-BLtm0xtK.js +342 -0
- package/dist/head.d.ts +42 -28
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +52 -55
- package/dist/index.js +339 -305
- package/dist/plugin-DCdQ9yO8.js +1140 -0
- package/dist/plugin.d.ts +15 -15
- package/dist/request-scope.d.ts +3 -5
- package/dist/route-error.d.ts +6 -0
- package/dist/route-match.d.ts +3 -3
- package/dist/rsbuild.d.ts +3 -3
- package/dist/rsbuild.js +2 -4
- package/dist/server-island.d.ts +26 -15
- package/dist/server-island.js +166 -136
- package/dist/server-islands.d.ts +15 -14
- package/dist/snapshot-XpAkSV8s.js +57 -0
- package/dist/snapshot.d.ts +15 -2
- package/dist/{ssr-CV_vNpS3.js → ssr-Dvq_qXs4.js} +170 -142
- package/dist/ssr.d.ts +38 -39
- package/dist/ssr.js +2 -2
- package/dist/vite.d.ts +3 -3
- package/dist/vite.js +2 -4
- package/oxlint.cjs +201 -122
- package/package.json +3 -3
- package/dist/head-DpYV3bHH.js +0 -285
- package/dist/plugin-Bfnn44nu.js +0 -1034
- package/dist/snapshot-C0E2OGwL.js +0 -55
package/dist/server-islands.d.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* through oxidejs's tacho stub replacement.
|
|
8
8
|
*/
|
|
9
9
|
/** Oxide RPC client key — basename without `.server.*` (oxide indexes by module name). */
|
|
10
|
-
export declare
|
|
10
|
+
export declare const serverModuleRpcKey: (spec: string) => string;
|
|
11
11
|
/** Client repaint group key — full normalized path so same-basename files stay distinct. */
|
|
12
|
-
export declare
|
|
12
|
+
export declare const serverModuleRepaintKey: (spec: string) => string;
|
|
13
13
|
export interface ScannedServerIsland {
|
|
14
14
|
/** Export binding name, or `"default"` for `export default ilha…`. */
|
|
15
15
|
name: string;
|
|
@@ -31,7 +31,7 @@ export interface ClientIslandRef {
|
|
|
31
31
|
* capture-aware shim on ALREADY-COMPILED module code. Must run inside the
|
|
32
32
|
* SSR transform so upstream JSX/TS output is preserved.
|
|
33
33
|
*/
|
|
34
|
-
export declare
|
|
34
|
+
export declare const rewriteServerActions: (code: string, rpcActions: Record<string, string>) => string;
|
|
35
35
|
export interface ServerModuleScan {
|
|
36
36
|
islands: ScannedServerIsland[];
|
|
37
37
|
/** All value-export names of the module (transport candidates). */
|
|
@@ -47,36 +47,37 @@ export interface ServerModuleScan {
|
|
|
47
47
|
/** Imported JSX components that must hydrate inside the server island. */
|
|
48
48
|
clientRefs: ClientIslandRef[];
|
|
49
49
|
}
|
|
50
|
-
export declare
|
|
50
|
+
export declare const clientRefPublicId: (spec: string, imported: string) => string;
|
|
51
51
|
/** Scan a `*.server.ts(x)` module source for island exports and their
|
|
52
|
-
* declarative wiring.
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
export declare
|
|
52
|
+
* declarative wiring. Islands are `export const Name = [async] function[*]`
|
|
53
|
+
* (and the `export default` form); stream/action transports are discovered
|
|
54
|
+
* from calls inside each island body. */
|
|
55
|
+
export declare const scanServerIslands: (source: string) => ServerModuleScan;
|
|
56
|
+
export declare const loadServerModuleScan: (filePath: string) => ServerModuleScan;
|
|
56
57
|
/** Virtual-module id prefix for generated client proxies of server islands.
|
|
57
58
|
* The file path rides base64url-encoded: a raw suffix like
|
|
58
59
|
* `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
|
|
59
60
|
* loader would claim the virtual module before us. */
|
|
60
61
|
export declare const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
|
|
61
62
|
/** Virtual-module specifier serving the client proxy for one server island file. */
|
|
62
|
-
export declare
|
|
63
|
+
export declare const serverIslandVirtualSpec: (file: string) => string;
|
|
63
64
|
/** Emit the client virtual module for one scanned server file. Plain JS —
|
|
64
65
|
* `\0` virtual modules bypass Vite's built-in TS transform, so type-only
|
|
65
66
|
* constructs here would reach the browser unparsed. Editor types are
|
|
66
67
|
* unaffected: TS resolves the ORIGINAL specifier (the real server module);
|
|
67
68
|
* this module exists only inside the client bundle. Frames are fetched from
|
|
68
69
|
* the plugin's `/__ilha/frame` dev middleware. */
|
|
69
|
-
export declare
|
|
70
|
-
export declare
|
|
70
|
+
export declare const serverIslandPublicId: (spec: string, name: string) => string;
|
|
71
|
+
export declare const generateServerIslandModule: (spec: string, scan: ServerModuleScan) => string;
|
|
71
72
|
export interface SplitContext {
|
|
72
73
|
/** Resolved absolute path of the imported specifier, when it's a scanned
|
|
73
74
|
* server module carrying islands; null otherwise. */
|
|
74
|
-
islandNamesFor(spec: string)
|
|
75
|
+
islandNamesFor: (spec: string) => {
|
|
75
76
|
islands: Set<string>;
|
|
76
77
|
hasDefault: boolean;
|
|
77
78
|
} | null;
|
|
78
79
|
/** Virtual module specifier that provides the island bindings. */
|
|
79
|
-
virtualSpecFor(spec: string)
|
|
80
|
+
virtualSpecFor: (spec: string) => string;
|
|
80
81
|
}
|
|
81
82
|
/**
|
|
82
83
|
* Rewrite import sites whose specifier targets a server module containing
|
|
@@ -84,4 +85,4 @@ export interface SplitContext {
|
|
|
84
85
|
* bindings stay on the original specifier (oxidejs replaces them with tacho
|
|
85
86
|
* stubs). Returns null when no statement needed rewriting.
|
|
86
87
|
*/
|
|
87
|
-
export declare
|
|
88
|
+
export declare const splitServerImports: (code: string, ctx: SplitContext) => string | null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/snapshot.ts
|
|
2
|
+
/**
|
|
3
|
+
* Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
|
|
4
|
+
* attributes on the router side. Mirrors the core ilha guards: size cap,
|
|
5
|
+
* plain-object check, depth cap, and prototype-key stripping. Returns
|
|
6
|
+
* undefined (degrade gracefully) on any failure.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_SNAPSHOT_CHARS = 262144;
|
|
9
|
+
const MAX_SNAPSHOT_DEPTH = 32;
|
|
10
|
+
const objectTag = (value) => Object.prototype.toString.call(value);
|
|
11
|
+
const isObject = (value) => value !== null && objectTag(value) === "[object Object]";
|
|
12
|
+
const exceedsMaxDepth = (value, depth) => {
|
|
13
|
+
if (depth > MAX_SNAPSHOT_DEPTH) return true;
|
|
14
|
+
if (value === null || !isObject(value)) {
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
for (const item of value) if (exceedsMaxDepth(item, depth + 1)) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const obj = value;
|
|
21
|
+
for (const key of Object.keys(obj)) if (exceedsMaxDepth(obj[key], depth + 1)) return true;
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
const deleteUnsafeKey = (obj, key) => {
|
|
25
|
+
if (Object.hasOwn(obj, key)) Reflect.deleteProperty(obj, key);
|
|
26
|
+
};
|
|
27
|
+
const stripUnsafeKeys = (value) => {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
for (const item of value) stripUnsafeKeys(item);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (value === null || !isObject(value)) return;
|
|
33
|
+
const obj = value;
|
|
34
|
+
deleteUnsafeKey(obj, "__proto__");
|
|
35
|
+
deleteUnsafeKey(obj, "constructor");
|
|
36
|
+
deleteUnsafeKey(obj, "prototype");
|
|
37
|
+
for (const key of Object.getOwnPropertyNames(obj)) stripUnsafeKeys(obj[key]);
|
|
38
|
+
};
|
|
39
|
+
const sanitizeSnapshotObject = (value) => {
|
|
40
|
+
if (!isObject(value) || Array.isArray(value)) return;
|
|
41
|
+
if (exceedsMaxDepth(value, 1)) return;
|
|
42
|
+
stripUnsafeKeys(value);
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
const parseSnapshotAttr = (raw) => {
|
|
46
|
+
if (raw.length > MAX_SNAPSHOT_CHARS) return;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
return sanitizeSnapshotObject(parsed);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { sanitizeSnapshotObject as n, parseSnapshotAttr as t };
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
|
|
3
|
+
* attributes on the router side. Mirrors the core ilha guards: size cap,
|
|
4
|
+
* plain-object check, depth cap, and prototype-key stripping. Returns
|
|
5
|
+
* undefined (degrade gracefully) on any failure.
|
|
6
|
+
*/
|
|
7
|
+
/** JSON leaf values accepted in SSR hydrate snapshots. */
|
|
8
|
+
export type SnapshotLeaf = string | number | boolean | null;
|
|
9
|
+
/** JSON values accepted in SSR hydrate snapshots. */
|
|
10
|
+
export type SnapshotValue = SnapshotLeaf | SnapshotObject | readonly SnapshotValue[];
|
|
11
|
+
export interface SnapshotObject {
|
|
12
|
+
readonly [key: string]: SnapshotValue | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare const sanitizeSnapshotObject: <T>(value: T) => SnapshotObject | undefined;
|
|
15
|
+
export declare const parseSnapshotAttr: (raw: string) => SnapshotObject | undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
|
|
2
|
-
import {
|
|
2
|
+
import { a as withHeadStore } from "./head-BLtm0xtK.js";
|
|
3
|
+
import { n as sanitizeSnapshotObject } from "./snapshot-XpAkSV8s.js";
|
|
3
4
|
import { renderToString } from "ilha";
|
|
4
5
|
import * as Result from "effect/Result";
|
|
5
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -29,12 +30,19 @@ const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
|
|
|
29
30
|
/** Run `fn` with `request` available to `useContext().request`. When oxidejs
|
|
30
31
|
* is loaded, its action scope is entered too, so `useRequest()` works in
|
|
31
32
|
* island renders and streamed frames. */
|
|
32
|
-
|
|
33
|
+
const runWithIslandRequest = (request, fn) => {
|
|
33
34
|
const g = globalThis;
|
|
34
|
-
const
|
|
35
|
-
|
|
35
|
+
const existing = g[REQUEST_ALS_KEY];
|
|
36
|
+
let als;
|
|
37
|
+
if (existing instanceof AsyncLocalStorage) als = existing;
|
|
38
|
+
else {
|
|
39
|
+
als = new AsyncLocalStorage();
|
|
40
|
+
g[REQUEST_ALS_KEY] = als;
|
|
41
|
+
}
|
|
42
|
+
const oxideSlot = g[OXIDE_RUN_WITH_REQUEST];
|
|
43
|
+
const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
|
|
36
44
|
return als.run(request, () => oxide ? oxide(request, fn) : fn());
|
|
37
|
-
}
|
|
45
|
+
};
|
|
38
46
|
|
|
39
47
|
//#endregion
|
|
40
48
|
//#region src/ssr.ts
|
|
@@ -53,16 +61,36 @@ function runWithIslandRequest(request, fn) {
|
|
|
53
61
|
* in, `{ html }` out). Renderers come from the process-global registry
|
|
54
62
|
* populated by self-registration code appended to `.server` modules.
|
|
55
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Server-frame state shared by the dev middleware and the production
|
|
66
|
+
* `@ilha/router/ssr` handler: the renderers registry (keyed by the public
|
|
67
|
+
* island id, `sha256(file#name)`, see `serverIslandPublicId`), frame/loader
|
|
68
|
+
* guards and auth policy, and the loader runner. Lives on `globalThis` so
|
|
69
|
+
* every module copy (plugin bundle, SSR graph, frame entry) shares one
|
|
70
|
+
* instance — same pattern as `request-scope.ts`.
|
|
71
|
+
*
|
|
72
|
+
* `.server` modules self-register when the plugin appends registration code
|
|
73
|
+
* to their server-graph copy; the `/__ilha/frame` handler below consumes the
|
|
74
|
+
* registry to re-render an island from a client state snapshot. Server pages
|
|
75
|
+
* additionally register their `load` and route pattern so frame handlers can
|
|
76
|
+
* run the loader with matched params.
|
|
77
|
+
*/
|
|
78
|
+
const objectTag = (value) => Object.prototype.toString.call(value);
|
|
79
|
+
const isString = (value) => objectTag(value) === "[object String]";
|
|
80
|
+
const isFunction = (value) => {
|
|
81
|
+
const tag = objectTag(value);
|
|
82
|
+
return tag === "[object Function]" || tag === "[object AsyncFunction]" || tag === "[object GeneratorFunction]";
|
|
83
|
+
};
|
|
84
|
+
const isObject = (value) => value !== null && objectTag(value) === "[object Object]";
|
|
56
85
|
const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
|
|
57
|
-
|
|
86
|
+
const registry = () => {
|
|
58
87
|
const g = globalThis;
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
88
|
+
const existing = g[REGISTRY_KEY];
|
|
89
|
+
if (existing instanceof Map) return existing;
|
|
90
|
+
const map = /* @__PURE__ */ new Map();
|
|
91
|
+
g[REGISTRY_KEY] = map;
|
|
64
92
|
return map;
|
|
65
|
-
}
|
|
93
|
+
};
|
|
66
94
|
const GUARD_KEY = Symbol.for("ilha.frameGuard");
|
|
67
95
|
/**
|
|
68
96
|
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
@@ -71,33 +99,36 @@ const GUARD_KEY = Symbol.for("ilha.frameGuard");
|
|
|
71
99
|
* allow. Island state is world-readable through frames unless you gate them,
|
|
72
100
|
* so apps serving private data should install a session check here.
|
|
73
101
|
*/
|
|
74
|
-
|
|
102
|
+
const setFrameGuard = (guard) => {
|
|
75
103
|
const g = globalThis;
|
|
76
104
|
g[GUARD_KEY] = guard;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
};
|
|
106
|
+
const getFrameGuard = () => {
|
|
107
|
+
const slot = globalThis[GUARD_KEY];
|
|
108
|
+
if (!isFunction(slot)) return;
|
|
109
|
+
return slot;
|
|
110
|
+
};
|
|
81
111
|
const AUTH_KEY = Symbol.for("ilha.frameAuth");
|
|
82
112
|
/**
|
|
83
113
|
* Install the frame-authorization policy consumed by the production
|
|
84
114
|
* `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
|
|
85
115
|
* the dev middleware (via `IlhaPagesOptions`).
|
|
86
116
|
*/
|
|
87
|
-
|
|
117
|
+
const setFrameAuth = (policy) => {
|
|
88
118
|
const g = globalThis;
|
|
89
119
|
g[AUTH_KEY] = policy;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
};
|
|
121
|
+
const getFrameAuth = () => {
|
|
122
|
+
const slot = globalThis[AUTH_KEY];
|
|
123
|
+
return isObject(slot) ? slot : void 0;
|
|
124
|
+
};
|
|
125
|
+
const normalizeOrigin = (value) => {
|
|
95
126
|
try {
|
|
96
127
|
return new URL(value).origin;
|
|
97
128
|
} catch {
|
|
98
129
|
return null;
|
|
99
130
|
}
|
|
100
|
-
}
|
|
131
|
+
};
|
|
101
132
|
/**
|
|
102
133
|
* Same-origin check for frame/loader requests. Browsers always send `Origin`
|
|
103
134
|
* on cross-origin and same-origin `POST`; its absence implies a non-browser
|
|
@@ -105,7 +136,7 @@ function normalizeOrigin(value) {
|
|
|
105
136
|
* present it must match the configured trusted origins, else the request's
|
|
106
137
|
* own `Host`.
|
|
107
138
|
*/
|
|
108
|
-
|
|
139
|
+
const isTrustedOrigin = (request, policy) => {
|
|
109
140
|
const originHeader = request.headers.get("origin");
|
|
110
141
|
if (originHeader === null) return true;
|
|
111
142
|
const origin = normalizeOrigin(originHeader);
|
|
@@ -115,40 +146,38 @@ function isTrustedOrigin(request, policy) {
|
|
|
115
146
|
const host = request.headers.get("host");
|
|
116
147
|
if (!host) return false;
|
|
117
148
|
return origin === `https://${host}` || origin === `http://${host}`;
|
|
118
|
-
}
|
|
149
|
+
};
|
|
119
150
|
/**
|
|
120
151
|
* Path-only route context for frame/loader scoped requests. Leading slash,
|
|
121
152
|
* no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
|
|
122
153
|
* `\evil.com` prefix would smuggle a foreign authority past a plain `//`
|
|
123
154
|
* check), bounded length. `false` for anything else.
|
|
124
155
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
const isSafeFramePath = (framePath) => framePath.startsWith("/") && !framePath.includes("//") && !framePath.includes("\\") && framePath.length <= 2048;
|
|
157
|
+
const parseServerOrigin = (value) => {
|
|
158
|
+
if (!value.includes("://")) throw new Error("frameScopedUrl: serverOrigin must be an absolute URL");
|
|
159
|
+
const parsed = new URL(value);
|
|
160
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("frameScopedUrl: serverOrigin must be http(s)");
|
|
161
|
+
return parsed.origin;
|
|
162
|
+
};
|
|
128
163
|
/**
|
|
129
164
|
* Build the scoped frame render URL from the incoming request URL and frame
|
|
130
165
|
* path. Absolute `incomingUrl` values supply the origin. Relative values (for
|
|
131
166
|
* example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
|
|
132
167
|
* a client `Host` header.
|
|
133
168
|
*/
|
|
134
|
-
|
|
169
|
+
const frameScopedUrl = (incomingUrl, framePath, serverOrigin) => {
|
|
135
170
|
let origin;
|
|
136
171
|
try {
|
|
137
172
|
const incoming = new URL(incomingUrl);
|
|
138
173
|
if (incoming.protocol !== "http:" && incoming.protocol !== "https:") throw new TypeError("unsupported protocol");
|
|
139
|
-
origin = incoming
|
|
174
|
+
({origin} = incoming);
|
|
140
175
|
} catch {
|
|
141
|
-
if (serverOrigin
|
|
176
|
+
if (serverOrigin === null || serverOrigin === void 0) throw new Error("frameScopedUrl: relative incomingUrl requires serverOrigin");
|
|
142
177
|
origin = parseServerOrigin(serverOrigin);
|
|
143
178
|
}
|
|
144
179
|
return new URL(framePath, origin).href;
|
|
145
|
-
}
|
|
146
|
-
function parseServerOrigin(value) {
|
|
147
|
-
if (!value.includes("://")) throw new Error("frameScopedUrl: serverOrigin must be an absolute URL");
|
|
148
|
-
const parsed = new URL(value);
|
|
149
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("frameScopedUrl: serverOrigin must be http(s)");
|
|
150
|
-
return parsed.origin;
|
|
151
|
-
}
|
|
180
|
+
};
|
|
152
181
|
/** Identity headers forwarded onto scoped render/loader requests. */
|
|
153
182
|
const FORWARD_IDENTITY_HEADERS = [
|
|
154
183
|
"cookie",
|
|
@@ -161,11 +190,10 @@ const FORWARD_IDENTITY_HEADERS = [
|
|
|
161
190
|
* object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
|
|
162
191
|
* it is spoofable and must not be trusted by loaders for IP checks.
|
|
163
192
|
*/
|
|
164
|
-
|
|
193
|
+
const forwardIdentityHeaders = (source) => {
|
|
165
194
|
const out = new Headers();
|
|
166
195
|
const read = (name) => {
|
|
167
|
-
|
|
168
|
-
if (typeof s.get === "function") return s.get(name);
|
|
196
|
+
if (source instanceof Headers) return source.get(name);
|
|
169
197
|
const v = source[name];
|
|
170
198
|
return Array.isArray(v) ? v[0] : v;
|
|
171
199
|
};
|
|
@@ -174,28 +202,24 @@ function forwardIdentityHeaders(source) {
|
|
|
174
202
|
if (v !== null && v !== void 0) out.set(name, v);
|
|
175
203
|
}
|
|
176
204
|
return out;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
function __ilhaServerAction(key, fn) {
|
|
190
|
-
const handle = typeof fn === "function" && fn.$$atom === 1 ? fn : action(fn);
|
|
205
|
+
};
|
|
206
|
+
const frameEnvelope = (status, body) => ({
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
headers: {
|
|
209
|
+
"cache-control": "no-store",
|
|
210
|
+
"content-type": "application/json;charset=utf-8"
|
|
211
|
+
},
|
|
212
|
+
status
|
|
213
|
+
});
|
|
214
|
+
/** Brand an exported server action with its generated RPC transport key. */
|
|
215
|
+
const __ilhaServerAction = (key, fn) => {
|
|
216
|
+
const handle = fn.$$atom === 1 ? fn : action(fn);
|
|
191
217
|
return brandServerAction(key, handle);
|
|
192
|
-
}
|
|
193
|
-
|
|
218
|
+
};
|
|
219
|
+
const registerServerIsland = (id, render) => {
|
|
194
220
|
registry().set(id, { render });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return registry().get(id);
|
|
198
|
-
}
|
|
221
|
+
};
|
|
222
|
+
const getServerIslandEntry = (id) => registry().get(id);
|
|
199
223
|
/** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
|
|
200
224
|
var FrameError = class extends Data.TaggedError("FrameError") {};
|
|
201
225
|
/**
|
|
@@ -205,87 +229,86 @@ var FrameError = class extends Data.TaggedError("FrameError") {};
|
|
|
205
229
|
* failures; loader redirects surface via `FrameError.redirect`.
|
|
206
230
|
*/
|
|
207
231
|
const frameFail = (status, message) => new FrameError({
|
|
208
|
-
|
|
209
|
-
|
|
232
|
+
message: message ?? "frame failed",
|
|
233
|
+
status
|
|
210
234
|
});
|
|
235
|
+
const renderToStringOf = async (render, props) => {
|
|
236
|
+
const out = render(props);
|
|
237
|
+
const view = await Promise.resolve(out);
|
|
238
|
+
if (isString(view)) return view;
|
|
239
|
+
return renderToString(() => view, {
|
|
240
|
+
captureActions: true,
|
|
241
|
+
markers: false
|
|
242
|
+
});
|
|
243
|
+
};
|
|
211
244
|
/**
|
|
212
245
|
* Render a registered server island. Typed error channel: the only failure is
|
|
213
246
|
* `FrameError`; everything else is a defect surfaced as a 400 to the client.
|
|
214
247
|
*/
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return await runWithScope(request, () => renderToStringOf(render, incomingProps));
|
|
224
|
-
},
|
|
225
|
-
catch: (error) => error instanceof FrameError ? error : frameFail(400, error instanceof Error ? error.message : void 0)
|
|
226
|
-
});
|
|
248
|
+
const renderServerIsland = (id, request, runWithScope, incomingProps) => Effect.gen(function* renderIsland() {
|
|
249
|
+
const entry = registry().get(id);
|
|
250
|
+
if (!entry) return yield* Effect.fail(frameFail(400, "unknown island"));
|
|
251
|
+
const render = entry.render();
|
|
252
|
+
if (!isFunction(render)) return yield* Effect.fail(frameFail(400, "unknown island"));
|
|
253
|
+
return yield* Effect.tryPromise({
|
|
254
|
+
catch: (error) => error instanceof FrameError ? error : frameFail(400, error instanceof Error ? error.message : void 0),
|
|
255
|
+
try: async () => await runWithScope(request, () => withHeadStore({ entries: [] }, () => renderToStringOf(render, incomingProps)))
|
|
227
256
|
});
|
|
228
|
-
}
|
|
257
|
+
});
|
|
229
258
|
/** Convenience for non-Effect callers: run the render and resolve to a Result. */
|
|
230
|
-
|
|
231
|
-
return Effect.runPromise(Effect.result(renderServerIsland(id, request, runWithScope, incomingProps)));
|
|
232
|
-
}
|
|
233
|
-
function renderToStringOf(render, props) {
|
|
234
|
-
const out = render(props);
|
|
235
|
-
return Promise.resolve(out).then((view) => typeof view === "string" ? view : renderToString(() => view, {
|
|
236
|
-
captureActions: true,
|
|
237
|
-
markers: false
|
|
238
|
-
}));
|
|
239
|
-
}
|
|
259
|
+
const renderServerIslandResult = (id, request, runWithScope, incomingProps) => Effect.runPromise(Effect.result(renderServerIsland(id, request, runWithScope, incomingProps)));
|
|
240
260
|
const FRAME_ENDPOINT = "/__ilha/frame";
|
|
241
261
|
/** Max request body size — matches the dev middleware cap. */
|
|
242
262
|
const MAX_BODY = 16384;
|
|
243
263
|
/** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
|
|
244
|
-
|
|
245
|
-
if (value
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
264
|
+
const parseFrameProps = (value) => {
|
|
265
|
+
if (value === null || value === void 0) return;
|
|
266
|
+
if (!isObject(value) || Array.isArray(value)) throw new FrameError({
|
|
267
|
+
message: "frame failed",
|
|
268
|
+
status: 400
|
|
249
269
|
});
|
|
250
270
|
const sanitized = sanitizeSnapshotObject(value);
|
|
251
271
|
if (!sanitized) throw new FrameError({
|
|
252
|
-
|
|
253
|
-
|
|
272
|
+
message: "frame failed",
|
|
273
|
+
status: 400
|
|
254
274
|
});
|
|
255
275
|
return sanitized;
|
|
256
|
-
}
|
|
257
|
-
|
|
276
|
+
};
|
|
277
|
+
const json = (status, body) => {
|
|
258
278
|
const env = frameEnvelope(status, body);
|
|
259
279
|
return new Response(env.body, {
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
headers: env.headers,
|
|
281
|
+
status: env.status
|
|
262
282
|
});
|
|
263
|
-
}
|
|
283
|
+
};
|
|
284
|
+
const readChunksBounded = async (reader, maxBytes, chunks, size) => {
|
|
285
|
+
const { done, value } = await reader.read();
|
|
286
|
+
if (done) return chunks;
|
|
287
|
+
const nextSize = size + (value?.byteLength ?? 0);
|
|
288
|
+
if (nextSize > maxBytes) {
|
|
289
|
+
try {
|
|
290
|
+
await reader.cancel();
|
|
291
|
+
} catch {}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (value) chunks.push(value);
|
|
295
|
+
return readChunksBounded(reader, maxBytes, chunks, nextSize);
|
|
296
|
+
};
|
|
264
297
|
/**
|
|
265
298
|
* Read a request body as UTF-8, streaming it with a hard byte cap. Returns
|
|
266
299
|
* `null` when the body exceeds `maxBytes` (the reader is cancelled before the
|
|
267
300
|
* cap is far exceeded) or when decoding fails.
|
|
268
301
|
*/
|
|
269
|
-
async
|
|
302
|
+
const readBodyBounded = async (request, maxBytes) => {
|
|
270
303
|
const contentLength = request.headers.get("content-length");
|
|
271
304
|
if (contentLength !== null && Number(contentLength) > maxBytes) return null;
|
|
272
305
|
const reader = request.body?.getReader();
|
|
273
306
|
if (!reader) return "";
|
|
274
|
-
const chunks = [];
|
|
275
|
-
|
|
276
|
-
for (;;) {
|
|
277
|
-
const { done, value } = await reader.read();
|
|
278
|
-
if (done) break;
|
|
279
|
-
size += value?.byteLength ?? 0;
|
|
280
|
-
if (size > maxBytes) {
|
|
281
|
-
await reader.cancel().catch(() => {});
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
chunks.push(value);
|
|
285
|
-
}
|
|
307
|
+
const chunks = await readChunksBounded(reader, maxBytes, [], 0);
|
|
308
|
+
if (chunks === null) return null;
|
|
286
309
|
const decoder = new TextDecoder();
|
|
287
310
|
return chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
|
|
288
|
-
}
|
|
311
|
+
};
|
|
289
312
|
/**
|
|
290
313
|
* Shared frame-request authorization used by both the production handler
|
|
291
314
|
* below and the Vite/Rsbuild dev middleware: same-origin check against the
|
|
@@ -296,7 +319,7 @@ async function readBodyBounded(request, maxBytes) {
|
|
|
296
319
|
* Returns the forwarded identity headers on success so callers render frames
|
|
297
320
|
* with cookie/auth/UA context, or the HTTP status to reject with.
|
|
298
321
|
*/
|
|
299
|
-
async
|
|
322
|
+
const authorizeFrameRequest = async (request, options) => {
|
|
300
323
|
const auth = getFrameAuth();
|
|
301
324
|
if (!isTrustedOrigin(request, auth)) return {
|
|
302
325
|
ok: false,
|
|
@@ -332,65 +355,71 @@ async function authorizeFrameRequest(request, options) {
|
|
|
332
355
|
};
|
|
333
356
|
}
|
|
334
357
|
return {
|
|
335
|
-
|
|
336
|
-
|
|
358
|
+
identityHeaders: forwardIdentityHeaders(request.headers),
|
|
359
|
+
ok: true
|
|
337
360
|
};
|
|
338
|
-
}
|
|
339
|
-
|
|
361
|
+
};
|
|
362
|
+
const copyFrameworkSymbols = (from, to) => {
|
|
363
|
+
for (const sym of Object.getOwnPropertySymbols(from)) {
|
|
364
|
+
if (Symbol.keyFor(sym) === void 0) continue;
|
|
365
|
+
const desc = Object.getOwnPropertyDescriptor(from, sym);
|
|
366
|
+
if (!desc) continue;
|
|
367
|
+
try {
|
|
368
|
+
Object.defineProperty(to, sym, desc);
|
|
369
|
+
} catch {}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const ssr = async (request) => {
|
|
340
373
|
let url;
|
|
341
374
|
try {
|
|
342
375
|
url = new URL(request.url);
|
|
343
376
|
} catch {
|
|
344
377
|
return json(400, { error: "frame failed" });
|
|
345
378
|
}
|
|
346
|
-
if (url.pathname !== "/__ilha/frame") return
|
|
347
|
-
const program = Effect.gen(function* () {
|
|
379
|
+
if (url.pathname !== "/__ilha/frame") return;
|
|
380
|
+
const program = Effect.gen(function* handleFrame() {
|
|
348
381
|
const auth = getFrameAuth();
|
|
349
382
|
if (!isTrustedOrigin(request, auth)) return yield* Effect.fail(frameFail(403));
|
|
350
383
|
if (request.method !== "POST") return yield* Effect.fail(frameFail(405));
|
|
351
384
|
if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return yield* Effect.fail(frameFail(415));
|
|
352
385
|
const authorized = yield* Effect.tryPromise({
|
|
386
|
+
catch: () => frameFail(403),
|
|
353
387
|
try: () => authorizeFrameRequest(request, {
|
|
354
388
|
defaultAction: auth?.defaultAction ?? "deny",
|
|
355
389
|
onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
|
|
356
|
-
})
|
|
357
|
-
catch: () => frameFail(403)
|
|
390
|
+
})
|
|
358
391
|
});
|
|
359
392
|
if (!authorized.ok) return yield* Effect.fail(frameFail(authorized.status));
|
|
360
393
|
let id;
|
|
361
|
-
let
|
|
394
|
+
let framePath = "/";
|
|
362
395
|
let incomingProps;
|
|
363
396
|
{
|
|
364
397
|
const text = yield* Effect.tryPromise({
|
|
365
|
-
|
|
366
|
-
|
|
398
|
+
catch: () => frameFail(400),
|
|
399
|
+
try: () => readBodyBounded(request, MAX_BODY)
|
|
367
400
|
});
|
|
368
401
|
if (text === null) return yield* Effect.fail(frameFail(413));
|
|
369
402
|
try {
|
|
370
403
|
const body = JSON.parse(text);
|
|
371
404
|
id = String(body.id ?? "");
|
|
372
405
|
incomingProps = parseFrameProps(body.props);
|
|
373
|
-
if (
|
|
406
|
+
if (isString(body.path)) {
|
|
374
407
|
if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
|
|
375
|
-
|
|
408
|
+
framePath = body.path;
|
|
376
409
|
}
|
|
377
410
|
} catch {
|
|
378
411
|
return yield* Effect.fail(frameFail(400));
|
|
379
412
|
}
|
|
380
413
|
}
|
|
381
|
-
const scoped = new Request(frameScopedUrl(url.href,
|
|
382
|
-
|
|
383
|
-
|
|
414
|
+
const scoped = new Request(frameScopedUrl(url.href, framePath), {
|
|
415
|
+
headers: forwardIdentityHeaders(request.headers),
|
|
416
|
+
method: "POST"
|
|
384
417
|
});
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
scoped[sym] = request[sym];
|
|
389
|
-
} catch {}
|
|
390
|
-
}
|
|
391
|
-
return json(200, { html: yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps) });
|
|
418
|
+
copyFrameworkSymbols(request, scoped);
|
|
419
|
+
const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
|
|
420
|
+
return json(200, { html });
|
|
392
421
|
});
|
|
393
|
-
return Effect.runPromise(Effect.map(Effect.result(program), Result.match({
|
|
422
|
+
return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
|
|
394
423
|
onFailure: (error) => {
|
|
395
424
|
if (error.redirect) return json(error.status, { redirect: error.redirect });
|
|
396
425
|
if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
|
|
@@ -398,9 +427,8 @@ async function ssr(request) {
|
|
|
398
427
|
},
|
|
399
428
|
onSuccess: (response) => response
|
|
400
429
|
})));
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
ssr.imports = ["ilha:pages/server"];
|
|
430
|
+
};
|
|
431
|
+
const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
|
|
404
432
|
|
|
405
433
|
//#endregion
|
|
406
|
-
export { runWithIslandRequest as C,
|
|
434
|
+
export { runWithIslandRequest as C, ssrWithImports as S, registerServerIsland as _, authorizeFrameRequest as a, setFrameAuth as b, frameScopedUrl as c, getServerIslandEntry as d, isSafeFramePath as f, readBodyBounded as g, parseFrameProps as h, __ilhaServerAction as i, getFrameAuth as l, json as m, FrameError as n, forwardIdentityHeaders as o, isTrustedOrigin as p, MAX_BODY as r, frameEnvelope as s, FRAME_ENDPOINT as t, getFrameGuard as u, renderServerIsland as v, setFrameGuard as x, renderServerIslandResult as y };
|