@ilha/router 0.11.6 → 0.11.8
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/dist/frame.d.ts +151 -0
- package/dist/oxide-action.d.ts +9 -0
- package/dist/{plugin-Ci-W2rMt.js → plugin-Cn_H92L0.js} +1 -1
- package/dist/{ssr-D9q5C-OV.js → request-scope-CI9BJVS-.js} +118 -215
- package/dist/rsbuild.js +1 -1
- package/dist/ssr.d.ts +6 -142
- package/dist/ssr.js +116 -1
- package/dist/vite.js +1 -1
- package/package.json +3 -3
package/dist/frame.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared server-frame helpers used by the Vite/Rsbuild pages plugin and by
|
|
3
|
+
* `@ilha/router/ssr`. Kept free of the optional `oxidejs` peer so
|
|
4
|
+
* `@ilha/router/vite` can load without oxide installed.
|
|
5
|
+
*
|
|
6
|
+
* Server-frame state (renderers registry, frame/loader guards, auth policy)
|
|
7
|
+
* lives on `globalThis` so every module copy (plugin bundle, SSR graph, frame
|
|
8
|
+
* entry) shares one instance — same pattern as `request-scope.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import type * as Result from "effect/Result";
|
|
12
|
+
import type { SnapshotObject } from "./snapshot";
|
|
13
|
+
/** JSON payload for frame envelopes. */
|
|
14
|
+
export type FrameJsonValue = string | number | boolean | null | FrameJsonValue[] | FrameJsonObject;
|
|
15
|
+
export interface FrameJsonObject {
|
|
16
|
+
readonly [key: string]: FrameJsonValue | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** A frame render: optionally preceded by running the page's `load`. */
|
|
19
|
+
export interface ServerIslandEntry {
|
|
20
|
+
/** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
|
|
21
|
+
render: () => ServerIslandRenderFn;
|
|
22
|
+
}
|
|
23
|
+
export type ServerIslandRenderFn = (props?: SnapshotObject) => string | Promise<string> | object;
|
|
24
|
+
export type FrameGuard = (request: Request) => Response | undefined | Promise<Response | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
27
|
+
* and the production `@ilha/router/ssr` handler share this slot — both read
|
|
28
|
+
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
29
|
+
* allow. Island state is world-readable through frames unless you gate them,
|
|
30
|
+
* so apps serving private data should install a session check here.
|
|
31
|
+
*/
|
|
32
|
+
export declare const setFrameGuard: (guard: FrameGuard) => void;
|
|
33
|
+
export declare const getFrameGuard: () => FrameGuard | undefined;
|
|
34
|
+
/** Frame-authorization policy, installed via {@link setFrameAuth}. */
|
|
35
|
+
export interface FrameAuthPolicy {
|
|
36
|
+
/**
|
|
37
|
+
* Action taken when no frame guard is registered. `"deny"` (default in the
|
|
38
|
+
* production handler) rejects every `/__ilha/frame` request with 403;
|
|
39
|
+
* `"open"` preserves the legacy unauthenticated behavior. The dev
|
|
40
|
+
* middleware stays permissive unless a guard is registered.
|
|
41
|
+
*/
|
|
42
|
+
defaultAction?: "open" | "deny";
|
|
43
|
+
/**
|
|
44
|
+
* Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
|
|
45
|
+
* origin checks accept only these; otherwise the check compares the `Origin`
|
|
46
|
+
* header against `https://{host}` / `http://{host}`.
|
|
47
|
+
*/
|
|
48
|
+
trustedOrigins?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Optional CSRF verifier for the state-changing frame POST. Receives the
|
|
51
|
+
* original `Request`; returning falsy rejects the request. Use this for
|
|
52
|
+
* server-to-server frame callers that have no browser `Origin`.
|
|
53
|
+
*/
|
|
54
|
+
csrf?: (request: Request) => boolean | Promise<boolean>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Install the frame-authorization policy consumed by the production
|
|
58
|
+
* `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
|
|
59
|
+
* the dev middleware (via `IlhaPagesOptions`).
|
|
60
|
+
*/
|
|
61
|
+
export declare const setFrameAuth: (policy: FrameAuthPolicy) => void;
|
|
62
|
+
export declare const getFrameAuth: () => FrameAuthPolicy | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Same-origin check for frame/loader requests. Browsers always send `Origin`
|
|
65
|
+
* on cross-origin and same-origin `POST`; its absence implies a non-browser
|
|
66
|
+
* caller (allowed — gate those via a guard or `csrf`). When `Origin` is
|
|
67
|
+
* present it must match the configured trusted origins, else the request's
|
|
68
|
+
* own `Host`.
|
|
69
|
+
*/
|
|
70
|
+
export declare const isTrustedOrigin: (request: Request, policy: FrameAuthPolicy | undefined) => boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Path-only route context for frame/loader scoped requests. Leading slash,
|
|
73
|
+
* no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
|
|
74
|
+
* `\evil.com` prefix would smuggle a foreign authority past a plain `//`
|
|
75
|
+
* check), bounded length. `false` for anything else.
|
|
76
|
+
*/
|
|
77
|
+
export declare const isSafeFramePath: (framePath: string) => boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Build the scoped frame render URL from the incoming request URL and frame
|
|
80
|
+
* path. Absolute `incomingUrl` values supply the origin. Relative values (for
|
|
81
|
+
* example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
|
|
82
|
+
* a client `Host` header.
|
|
83
|
+
*/
|
|
84
|
+
export declare const frameScopedUrl: (incomingUrl: string, framePath: string, serverOrigin?: string) => string;
|
|
85
|
+
type HeaderSource = Headers | Readonly<Record<string, string | string[] | undefined>>;
|
|
86
|
+
/**
|
|
87
|
+
* Copy identity headers (cookie, authorization, user-agent) onto a fresh
|
|
88
|
+
* `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
|
|
89
|
+
* object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
|
|
90
|
+
* it is spoofable and must not be trusted by loaders for IP checks.
|
|
91
|
+
*/
|
|
92
|
+
export declare const forwardIdentityHeaders: (source: HeaderSource) => Headers;
|
|
93
|
+
/** No-store JSON envelope shared by dev and production frame handlers. */
|
|
94
|
+
export interface FrameEnvelope {
|
|
95
|
+
status: number;
|
|
96
|
+
headers: Record<string, string>;
|
|
97
|
+
body: string;
|
|
98
|
+
}
|
|
99
|
+
export declare const frameEnvelope: (status: number, body: FrameJsonObject) => FrameEnvelope;
|
|
100
|
+
export declare const registerServerIsland: (id: string, render: () => ServerIslandRenderFn) => void;
|
|
101
|
+
export declare const getServerIslandEntry: (id: string) => ServerIslandEntry | undefined;
|
|
102
|
+
declare const FrameError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
103
|
+
readonly _tag: "FrameError";
|
|
104
|
+
} & Readonly<A>;
|
|
105
|
+
/** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
|
|
106
|
+
export declare class FrameError extends FrameError_base<{
|
|
107
|
+
status: number;
|
|
108
|
+
message?: string;
|
|
109
|
+
redirect?: string;
|
|
110
|
+
}> {
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Render a registered server island. Typed error channel: the only failure is
|
|
114
|
+
* `FrameError`; everything else is a defect surfaced as a 400 to the client.
|
|
115
|
+
*/
|
|
116
|
+
export declare const renderServerIsland: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Effect.Effect<string, FrameError>;
|
|
117
|
+
/** Convenience for non-Effect callers: run the render and resolve to a Result. */
|
|
118
|
+
export declare const renderServerIslandResult: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Promise<Result.Result<string, FrameError>>;
|
|
119
|
+
export declare const FRAME_ENDPOINT = "/__ilha/frame";
|
|
120
|
+
/** Max request body size — matches the dev middleware cap. */
|
|
121
|
+
export declare const MAX_BODY: number;
|
|
122
|
+
/** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
|
|
123
|
+
export declare const parseFrameProps: <T>(value?: T) => SnapshotObject | undefined;
|
|
124
|
+
export declare const json: (status: number, body: FrameJsonObject) => Response;
|
|
125
|
+
/**
|
|
126
|
+
* Read a request body as UTF-8, streaming it with a hard byte cap. Returns
|
|
127
|
+
* `null` when the body exceeds `maxBytes` (the reader is cancelled before the
|
|
128
|
+
* cap is far exceeded) or when decoding fails.
|
|
129
|
+
*/
|
|
130
|
+
export declare const readBodyBounded: (request: Request, maxBytes: number) => Promise<string | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Shared frame-request authorization used by both the production handler
|
|
133
|
+
* below and the Vite/Rsbuild dev middleware: same-origin check against the
|
|
134
|
+
* frame-auth policy, the registered frame guard, and the optional CSRF
|
|
135
|
+
* verifier. `defaultAction` selects the deny-by-default production posture or
|
|
136
|
+
* the permissive development one.
|
|
137
|
+
*
|
|
138
|
+
* Returns the forwarded identity headers on success so callers render frames
|
|
139
|
+
* with cookie/auth/UA context, or the HTTP status to reject with.
|
|
140
|
+
*/
|
|
141
|
+
export declare const authorizeFrameRequest: (request: Request, options: {
|
|
142
|
+
defaultAction: "open" | "deny";
|
|
143
|
+
onGuardError?: <E>(error: E) => void;
|
|
144
|
+
}) => Promise<{
|
|
145
|
+
ok: true;
|
|
146
|
+
identityHeaders: Headers;
|
|
147
|
+
} | {
|
|
148
|
+
ok: false;
|
|
149
|
+
status: number;
|
|
150
|
+
}>;
|
|
151
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oxide-branded server-action wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so `@ilha/router/vite` / the pages plugin can load
|
|
5
|
+
* frame helpers without resolving the optional `oxidejs` peer.
|
|
6
|
+
*/
|
|
7
|
+
import type { SnapshotValue } from "./snapshot";
|
|
8
|
+
/** Brand an exported server action with its generated RPC transport key. */
|
|
9
|
+
export declare const __ilhaServerAction: <A extends SnapshotValue[], R extends SnapshotValue>(key: string, fn: (...args: A) => R | Promise<R>) => import("oxidejs").ServerActionHandle<A, Awaited<Awaited<R>>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, r as FrameError, s as frameEnvelope, t as runWithIslandRequest, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./request-scope-CI9BJVS-.js";
|
|
2
2
|
import { existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import * as Result from "effect/Result";
|
|
@@ -2,158 +2,19 @@ import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
|
|
|
2
2
|
import { a as withHeadStore } from "./head-WG_AtbdX.js";
|
|
3
3
|
import { n as sanitizeSnapshotObject } from "./snapshot-XpAkSV8s.js";
|
|
4
4
|
import { renderToString } from "ilha";
|
|
5
|
-
import * as Result from "effect/Result";
|
|
6
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
5
|
import * as Data from "effect/Data";
|
|
8
6
|
import * as Effect from "effect/Effect";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
//#region src/webcontainer.ts
|
|
12
|
-
/**
|
|
13
|
-
* StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
|
|
14
|
-
* Safe to import from browser-shared modules: on the client this always
|
|
15
|
-
* returns false (no `process.versions.webcontainer`).
|
|
16
|
-
*/
|
|
17
|
-
let webcontainerOverride = null;
|
|
18
|
-
/** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
|
|
19
|
-
const inWebcontainer = () => {
|
|
20
|
-
if (webcontainerOverride !== null) return webcontainerOverride;
|
|
21
|
-
try {
|
|
22
|
-
const versions = globalThis.process?.versions;
|
|
23
|
-
return Boolean(versions?.webcontainer);
|
|
24
|
-
} catch {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
//#endregion
|
|
30
|
-
//#region src/request-scope.ts
|
|
31
|
-
/**
|
|
32
|
-
* Request scope for server-owned island rendering.
|
|
33
|
-
*
|
|
34
|
-
* A `.server.tsx` island's render function always executes on the server —
|
|
35
|
-
* page SSR through the router, or streamed frames through the plugin's
|
|
36
|
-
* `/__ilha/frame` endpoint. Both seed this scope with the originating
|
|
37
|
-
* `Request`, so render functions can read request data (URL, headers,
|
|
38
|
-
* cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
|
|
39
|
-
*
|
|
40
|
-
* The storage lives on `globalThis` under `ilha.requestAls` so every module
|
|
41
|
-
* copy (plugin bundle, SSR graph) shares one instance. The public accessor
|
|
42
|
-
* is `useContext()` from the main `@ilha/router` entry, which reads the
|
|
43
|
-
* storage without importing `node:async_hooks`; this node-only module is the
|
|
44
|
-
* sole place that constructs it.
|
|
45
|
-
*
|
|
46
|
-
* StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
|
|
47
|
-
* module sync fallback keeps `useContext().request` readable, and unrelated
|
|
48
|
-
* fallback entries are serialized so concurrent renders cannot stomp each
|
|
49
|
-
* other. Nested calls reenter only while owned by the active entry (sync nest
|
|
50
|
-
* or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
|
|
51
|
-
*/
|
|
52
|
-
/** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
|
|
53
|
-
* inside island renders and frames, not just `/__oxide/action`. */
|
|
54
|
-
const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
|
|
55
|
-
const innerAls = new AsyncLocalStorage();
|
|
56
|
-
/** Tracks the active fallback lock owner across awaits where ALS survives. */
|
|
57
|
-
const entryOwnerAls = new AsyncLocalStorage();
|
|
58
|
-
let syncStore = null;
|
|
59
|
-
/** When true, getStore ignores ALS so tests exercise the sync fallback path. */
|
|
60
|
-
let alsBypassForTests = false;
|
|
61
|
-
/** Serialize unrelated WebContainer fallback entries (set syncStore → run → clear). */
|
|
62
|
-
let fallbackTail = Promise.resolve(null);
|
|
63
|
-
/** Lock holder identity — nested reentry requires matching ALS owner (or sync nest). */
|
|
64
|
-
let activeOwner = null;
|
|
65
|
-
/**
|
|
66
|
-
* Sync-only nest depth while `runScoped` is invoking `fn`. Lets nested
|
|
67
|
-
* `runWithIslandRequest` reenter before `fn` awaits; cleared before awaiting
|
|
68
|
-
* so a suspended entry does not admit unrelated concurrent requests.
|
|
69
|
-
*/
|
|
70
|
-
let syncNestDepth = 0;
|
|
71
|
-
const withFallbackEntry = async (fn) => {
|
|
72
|
-
const owner = alsBypassForTests ? void 0 : entryOwnerAls.getStore();
|
|
73
|
-
if (syncNestDepth > 0 || owner !== void 0 && owner === activeOwner) return await fn();
|
|
74
|
-
const previous = fallbackTail;
|
|
75
|
-
const next = Promise.withResolvers();
|
|
76
|
-
fallbackTail = next.promise;
|
|
77
|
-
await previous;
|
|
78
|
-
const myOwner = {};
|
|
79
|
-
activeOwner = myOwner;
|
|
80
|
-
try {
|
|
81
|
-
return await entryOwnerAls.run(myOwner, fn);
|
|
82
|
-
} finally {
|
|
83
|
-
activeOwner = null;
|
|
84
|
-
next.resolve(null);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
const ensureScope = () => {
|
|
88
|
-
const g = globalThis;
|
|
89
|
-
const existing = g[REQUEST_ALS_KEY];
|
|
90
|
-
if (existing && "getStore" in existing) return existing;
|
|
91
|
-
const scope = { getStore: () => (alsBypassForTests ? void 0 : innerAls.getStore()) ?? syncStore ?? void 0 };
|
|
92
|
-
g[REQUEST_ALS_KEY] = scope;
|
|
93
|
-
return scope;
|
|
94
|
-
};
|
|
95
|
-
const runScoped = (request, oxide, fn) => innerAls.run(request, () => oxide ? oxide(request, fn) : fn());
|
|
96
|
-
/**
|
|
97
|
-
* Run `fn` with `request` available to `useContext().request`. When oxidejs
|
|
98
|
-
* is loaded, its action scope is entered too, so `useRequest()` works in
|
|
99
|
-
* island renders and streamed frames.
|
|
100
|
-
*
|
|
101
|
-
* On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
|
|
102
|
-
* the return matches `fn` (sync or async).
|
|
103
|
-
*/
|
|
104
|
-
const runWithIslandRequest = (request, fn) => {
|
|
105
|
-
ensureScope();
|
|
106
|
-
const oxideSlot = globalThis[OXIDE_RUN_WITH_REQUEST];
|
|
107
|
-
const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
|
|
108
|
-
if (!inWebcontainer()) return runScoped(request, oxide, fn);
|
|
109
|
-
return withFallbackEntry(async () => {
|
|
110
|
-
const previous = syncStore;
|
|
111
|
-
syncStore = request;
|
|
112
|
-
try {
|
|
113
|
-
syncNestDepth += 1;
|
|
114
|
-
let result;
|
|
115
|
-
try {
|
|
116
|
-
result = runScoped(request, oxide, fn);
|
|
117
|
-
} finally {
|
|
118
|
-
syncNestDepth -= 1;
|
|
119
|
-
}
|
|
120
|
-
return await Promise.resolve(result);
|
|
121
|
-
} finally {
|
|
122
|
-
syncStore = previous;
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
};
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
126
8
|
|
|
127
|
-
//#
|
|
128
|
-
//#region src/ssr.ts
|
|
129
|
-
/**
|
|
130
|
-
* Production SSR endpoint for server-owned islands.
|
|
131
|
-
*
|
|
132
|
-
* Default export is an oxidejs-style fetch middleware:
|
|
133
|
-
* `(request) => Response | undefined`. Returns `undefined` for any request it
|
|
134
|
-
* does not own, so hosts can chain it ahead of their own handler:
|
|
135
|
-
*
|
|
136
|
-
* ```ts
|
|
137
|
-
* oxide({ middleware: ["@ilha/router/ssr"] });
|
|
138
|
-
* ```
|
|
139
|
-
*
|
|
140
|
-
* Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
|
|
141
|
-
* in, `{ html }` out). Renderers come from the process-global registry
|
|
142
|
-
* populated by self-registration code appended to `.server` modules.
|
|
143
|
-
*/
|
|
9
|
+
//#region src/frame.ts
|
|
144
10
|
/**
|
|
145
|
-
*
|
|
146
|
-
* `@ilha/router/ssr
|
|
147
|
-
*
|
|
148
|
-
* guards and auth policy, and the loader runner. Lives on `globalThis` so
|
|
149
|
-
* every module copy (plugin bundle, SSR graph, frame entry) shares one
|
|
150
|
-
* instance — same pattern as `request-scope.ts`.
|
|
11
|
+
* Shared server-frame helpers used by the Vite/Rsbuild pages plugin and by
|
|
12
|
+
* `@ilha/router/ssr`. Kept free of the optional `oxidejs` peer so
|
|
13
|
+
* `@ilha/router/vite` can load without oxide installed.
|
|
151
14
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* additionally register their `load` and route pattern so frame handlers can
|
|
156
|
-
* run the loader with matched params.
|
|
15
|
+
* Server-frame state (renderers registry, frame/loader guards, auth policy)
|
|
16
|
+
* lives on `globalThis` so every module copy (plugin bundle, SSR graph, frame
|
|
17
|
+
* entry) shares one instance — same pattern as `request-scope.ts`.
|
|
157
18
|
*/
|
|
158
19
|
const objectTag = (value) => Object.prototype.toString.call(value);
|
|
159
20
|
const isString = (value) => objectTag(value) === "[object String]";
|
|
@@ -291,11 +152,6 @@ const frameEnvelope = (status, body) => ({
|
|
|
291
152
|
},
|
|
292
153
|
status
|
|
293
154
|
});
|
|
294
|
-
/** Brand an exported server action with its generated RPC transport key. */
|
|
295
|
-
const __ilhaServerAction = (key, fn) => {
|
|
296
|
-
const handle = fn.$$atom === 1 ? fn : action(fn);
|
|
297
|
-
return brandServerAction(key, handle);
|
|
298
|
-
};
|
|
299
155
|
const registerServerIsland = (id, render) => {
|
|
300
156
|
registry().set(id, { render });
|
|
301
157
|
};
|
|
@@ -406,7 +262,7 @@ const authorizeFrameRequest = async (request, options) => {
|
|
|
406
262
|
status: 403
|
|
407
263
|
};
|
|
408
264
|
const guard = getFrameGuard();
|
|
409
|
-
if (!guard && (auth?.defaultAction ?? "deny") === "deny") return {
|
|
265
|
+
if (!guard && (auth?.defaultAction ?? options.defaultAction ?? "deny") === "deny") return {
|
|
410
266
|
ok: false,
|
|
411
267
|
status: 403
|
|
412
268
|
};
|
|
@@ -439,76 +295,123 @@ const authorizeFrameRequest = async (request, options) => {
|
|
|
439
295
|
ok: true
|
|
440
296
|
};
|
|
441
297
|
};
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/webcontainer.ts
|
|
301
|
+
/**
|
|
302
|
+
* StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
|
|
303
|
+
* Safe to import from browser-shared modules: on the client this always
|
|
304
|
+
* returns false (no `process.versions.webcontainer`).
|
|
305
|
+
*/
|
|
306
|
+
let webcontainerOverride = null;
|
|
307
|
+
/** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
|
|
308
|
+
const inWebcontainer = () => {
|
|
309
|
+
if (webcontainerOverride !== null) return webcontainerOverride;
|
|
310
|
+
try {
|
|
311
|
+
const versions = globalThis.process?.versions;
|
|
312
|
+
return Boolean(versions?.webcontainer);
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
450
315
|
}
|
|
451
316
|
};
|
|
452
|
-
|
|
453
|
-
|
|
317
|
+
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/request-scope.ts
|
|
320
|
+
/**
|
|
321
|
+
* Request scope for server-owned island rendering.
|
|
322
|
+
*
|
|
323
|
+
* A `.server.tsx` island's render function always executes on the server —
|
|
324
|
+
* page SSR through the router, or streamed frames through the plugin's
|
|
325
|
+
* `/__ilha/frame` endpoint. Both seed this scope with the originating
|
|
326
|
+
* `Request`, so render functions can read request data (URL, headers,
|
|
327
|
+
* cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
|
|
328
|
+
*
|
|
329
|
+
* The storage lives on `globalThis` under `ilha.requestAls` so every module
|
|
330
|
+
* copy (plugin bundle, SSR graph) shares one instance. The public accessor
|
|
331
|
+
* is `useContext()` from the main `@ilha/router` entry, which reads the
|
|
332
|
+
* storage without importing `node:async_hooks`; this node-only module is the
|
|
333
|
+
* sole place that constructs it.
|
|
334
|
+
*
|
|
335
|
+
* StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
|
|
336
|
+
* module sync fallback keeps `useContext().request` readable, and unrelated
|
|
337
|
+
* fallback entries are serialized so concurrent renders cannot stomp each
|
|
338
|
+
* other. Nested calls reenter only while owned by the active entry (sync nest
|
|
339
|
+
* or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
|
|
340
|
+
*/
|
|
341
|
+
/** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
|
|
342
|
+
* inside island renders and frames, not just `/__oxide/action`. */
|
|
343
|
+
const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
|
|
344
|
+
const innerAls = new AsyncLocalStorage();
|
|
345
|
+
/** Tracks the active fallback lock owner across awaits where ALS survives. */
|
|
346
|
+
const entryOwnerAls = new AsyncLocalStorage();
|
|
347
|
+
let syncStore = null;
|
|
348
|
+
/** When true, getStore ignores ALS so tests exercise the sync fallback path. */
|
|
349
|
+
let alsBypassForTests = false;
|
|
350
|
+
/** Serialize unrelated WebContainer fallback entries (set syncStore → run → clear). */
|
|
351
|
+
let fallbackTail = Promise.resolve(null);
|
|
352
|
+
/** Lock holder identity — nested reentry requires matching ALS owner (or sync nest). */
|
|
353
|
+
let activeOwner = null;
|
|
354
|
+
/**
|
|
355
|
+
* Sync-only nest depth while `runScoped` is invoking `fn`. Lets nested
|
|
356
|
+
* `runWithIslandRequest` reenter before `fn` awaits; cleared before awaiting
|
|
357
|
+
* so a suspended entry does not admit unrelated concurrent requests.
|
|
358
|
+
*/
|
|
359
|
+
let syncNestDepth = 0;
|
|
360
|
+
const withFallbackEntry = async (fn) => {
|
|
361
|
+
const owner = alsBypassForTests ? void 0 : entryOwnerAls.getStore();
|
|
362
|
+
if (syncNestDepth > 0 || owner !== void 0 && owner === activeOwner) return await fn();
|
|
363
|
+
const previous = fallbackTail;
|
|
364
|
+
const next = Promise.withResolvers();
|
|
365
|
+
fallbackTail = next.promise;
|
|
366
|
+
await previous;
|
|
367
|
+
const myOwner = {};
|
|
368
|
+
activeOwner = myOwner;
|
|
454
369
|
try {
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
370
|
+
return await entryOwnerAls.run(myOwner, fn);
|
|
371
|
+
} finally {
|
|
372
|
+
activeOwner = null;
|
|
373
|
+
next.resolve(null);
|
|
458
374
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
375
|
+
};
|
|
376
|
+
const ensureScope = () => {
|
|
377
|
+
const g = globalThis;
|
|
378
|
+
const existing = g[REQUEST_ALS_KEY];
|
|
379
|
+
if (existing && "getStore" in existing) return existing;
|
|
380
|
+
const scope = { getStore: () => (alsBypassForTests ? void 0 : innerAls.getStore()) ?? syncStore ?? void 0 };
|
|
381
|
+
g[REQUEST_ALS_KEY] = scope;
|
|
382
|
+
return scope;
|
|
383
|
+
};
|
|
384
|
+
const runScoped = (request, oxide, fn) => innerAls.run(request, () => oxide ? oxide(request, fn) : fn());
|
|
385
|
+
/**
|
|
386
|
+
* Run `fn` with `request` available to `useContext().request`. When oxidejs
|
|
387
|
+
* is loaded, its action scope is entered too, so `useRequest()` works in
|
|
388
|
+
* island renders and streamed frames.
|
|
389
|
+
*
|
|
390
|
+
* On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
|
|
391
|
+
* the return matches `fn` (sync or async).
|
|
392
|
+
*/
|
|
393
|
+
const runWithIslandRequest = (request, fn) => {
|
|
394
|
+
ensureScope();
|
|
395
|
+
const oxideSlot = globalThis[OXIDE_RUN_WITH_REQUEST];
|
|
396
|
+
const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
|
|
397
|
+
if (!inWebcontainer()) return runScoped(request, oxide, fn);
|
|
398
|
+
return withFallbackEntry(async () => {
|
|
399
|
+
const previous = syncStore;
|
|
400
|
+
syncStore = request;
|
|
401
|
+
try {
|
|
402
|
+
syncNestDepth += 1;
|
|
403
|
+
let result;
|
|
482
404
|
try {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (isString(body.path)) {
|
|
487
|
-
if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
|
|
488
|
-
framePath = body.path;
|
|
489
|
-
}
|
|
490
|
-
} catch {
|
|
491
|
-
return yield* Effect.fail(frameFail(400));
|
|
405
|
+
result = runScoped(request, oxide, fn);
|
|
406
|
+
} finally {
|
|
407
|
+
syncNestDepth -= 1;
|
|
492
408
|
}
|
|
409
|
+
return await Promise.resolve(result);
|
|
410
|
+
} finally {
|
|
411
|
+
syncStore = previous;
|
|
493
412
|
}
|
|
494
|
-
const scoped = new Request(frameScopedUrl(url.href, framePath), {
|
|
495
|
-
headers: forwardIdentityHeaders(request.headers),
|
|
496
|
-
method: "POST"
|
|
497
|
-
});
|
|
498
|
-
copyFrameworkSymbols(request, scoped);
|
|
499
|
-
const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
|
|
500
|
-
return json(200, { html });
|
|
501
413
|
});
|
|
502
|
-
return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
|
|
503
|
-
onFailure: (error) => {
|
|
504
|
-
if (error.redirect) return json(error.status, { redirect: error.redirect });
|
|
505
|
-
if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
|
|
506
|
-
return json(error.status, { error: "frame failed" });
|
|
507
|
-
},
|
|
508
|
-
onSuccess: (response) => response
|
|
509
|
-
})));
|
|
510
414
|
};
|
|
511
|
-
const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
|
|
512
415
|
|
|
513
416
|
//#endregion
|
|
514
|
-
export {
|
|
417
|
+
export { registerServerIsland as _, authorizeFrameRequest as a, setFrameAuth as b, frameScopedUrl as c, getServerIslandEntry as d, isSafeFramePath as f, readBodyBounded as g, parseFrameProps as h, MAX_BODY as i, getFrameAuth as l, json as m, FRAME_ENDPOINT as n, forwardIdentityHeaders as o, isTrustedOrigin as p, FrameError as r, frameEnvelope as s, runWithIslandRequest as t, getFrameGuard as u, renderServerIsland as v, setFrameGuard as x, renderServerIslandResult as y };
|
package/dist/rsbuild.js
CHANGED
package/dist/ssr.d.ts
CHANGED
|
@@ -12,150 +12,14 @@
|
|
|
12
12
|
* Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
|
|
13
13
|
* in, `{ html }` out). Renderers come from the process-global registry
|
|
14
14
|
* populated by self-registration code appended to `.server` modules.
|
|
15
|
-
*/
|
|
16
|
-
import * as Effect from "effect/Effect";
|
|
17
|
-
import * as Result from "effect/Result";
|
|
18
|
-
import type { SnapshotObject, SnapshotValue } from "./snapshot";
|
|
19
|
-
/** JSON payload for frame envelopes. */
|
|
20
|
-
export type FrameJsonValue = string | number | boolean | null | FrameJsonValue[] | FrameJsonObject;
|
|
21
|
-
export interface FrameJsonObject {
|
|
22
|
-
readonly [key: string]: FrameJsonValue | undefined;
|
|
23
|
-
}
|
|
24
|
-
/** A frame render: optionally preceded by running the page's `load`. */
|
|
25
|
-
export interface ServerIslandEntry {
|
|
26
|
-
/** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
|
|
27
|
-
render: () => ServerIslandRenderFn;
|
|
28
|
-
}
|
|
29
|
-
export type ServerIslandRenderFn = (props?: SnapshotObject) => string | Promise<string> | object;
|
|
30
|
-
export type FrameGuard = (request: Request) => Response | undefined | Promise<Response | undefined>;
|
|
31
|
-
/**
|
|
32
|
-
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
33
|
-
* and the production `@ilha/router/ssr` handler share this slot — both read
|
|
34
|
-
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
35
|
-
* allow. Island state is world-readable through frames unless you gate them,
|
|
36
|
-
* so apps serving private data should install a session check here.
|
|
37
|
-
*/
|
|
38
|
-
export declare const setFrameGuard: (guard: FrameGuard) => void;
|
|
39
|
-
export declare const getFrameGuard: () => FrameGuard | undefined;
|
|
40
|
-
/** Frame-authorization policy, installed via {@link setFrameAuth}. */
|
|
41
|
-
export interface FrameAuthPolicy {
|
|
42
|
-
/**
|
|
43
|
-
* Action taken when no frame guard is registered. `"deny"` (default in the
|
|
44
|
-
* production handler) rejects every `/__ilha/frame` request with 403;
|
|
45
|
-
* `"open"` preserves the legacy unauthenticated behavior. The dev
|
|
46
|
-
* middleware stays permissive unless a guard is registered.
|
|
47
|
-
*/
|
|
48
|
-
defaultAction?: "open" | "deny";
|
|
49
|
-
/**
|
|
50
|
-
* Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
|
|
51
|
-
* origin checks accept only these; otherwise the check compares the `Origin`
|
|
52
|
-
* header against `https://{host}` / `http://{host}`.
|
|
53
|
-
*/
|
|
54
|
-
trustedOrigins?: string[];
|
|
55
|
-
/**
|
|
56
|
-
* Optional CSRF verifier for the state-changing frame POST. Receives the
|
|
57
|
-
* original `Request`; returning falsy rejects the request. Use this for
|
|
58
|
-
* server-to-server frame callers that have no browser `Origin`.
|
|
59
|
-
*/
|
|
60
|
-
csrf?: (request: Request) => boolean | Promise<boolean>;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Install the frame-authorization policy consumed by the production
|
|
64
|
-
* `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
|
|
65
|
-
* the dev middleware (via `IlhaPagesOptions`).
|
|
66
|
-
*/
|
|
67
|
-
export declare const setFrameAuth: (policy: FrameAuthPolicy) => void;
|
|
68
|
-
export declare const getFrameAuth: () => FrameAuthPolicy | undefined;
|
|
69
|
-
/**
|
|
70
|
-
* Same-origin check for frame/loader requests. Browsers always send `Origin`
|
|
71
|
-
* on cross-origin and same-origin `POST`; its absence implies a non-browser
|
|
72
|
-
* caller (allowed — gate those via a guard or `csrf`). When `Origin` is
|
|
73
|
-
* present it must match the configured trusted origins, else the request's
|
|
74
|
-
* own `Host`.
|
|
75
|
-
*/
|
|
76
|
-
export declare const isTrustedOrigin: (request: Request, policy: FrameAuthPolicy | undefined) => boolean;
|
|
77
|
-
/**
|
|
78
|
-
* Path-only route context for frame/loader scoped requests. Leading slash,
|
|
79
|
-
* no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
|
|
80
|
-
* `\evil.com` prefix would smuggle a foreign authority past a plain `//`
|
|
81
|
-
* check), bounded length. `false` for anything else.
|
|
82
|
-
*/
|
|
83
|
-
export declare const isSafeFramePath: (framePath: string) => boolean;
|
|
84
|
-
/**
|
|
85
|
-
* Build the scoped frame render URL from the incoming request URL and frame
|
|
86
|
-
* path. Absolute `incomingUrl` values supply the origin. Relative values (for
|
|
87
|
-
* example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
|
|
88
|
-
* a client `Host` header.
|
|
89
|
-
*/
|
|
90
|
-
export declare const frameScopedUrl: (incomingUrl: string, framePath: string, serverOrigin?: string) => string;
|
|
91
|
-
type HeaderSource = Headers | Readonly<Record<string, string | string[] | undefined>>;
|
|
92
|
-
/**
|
|
93
|
-
* Copy identity headers (cookie, authorization, user-agent) onto a fresh
|
|
94
|
-
* `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
|
|
95
|
-
* object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
|
|
96
|
-
* it is spoofable and must not be trusted by loaders for IP checks.
|
|
97
|
-
*/
|
|
98
|
-
export declare const forwardIdentityHeaders: (source: HeaderSource) => Headers;
|
|
99
|
-
/** No-store JSON envelope shared by dev and production frame handlers. */
|
|
100
|
-
export interface FrameEnvelope {
|
|
101
|
-
status: number;
|
|
102
|
-
headers: Record<string, string>;
|
|
103
|
-
body: string;
|
|
104
|
-
}
|
|
105
|
-
export declare const frameEnvelope: (status: number, body: FrameJsonObject) => FrameEnvelope;
|
|
106
|
-
/** Brand an exported server action with its generated RPC transport key. */
|
|
107
|
-
export declare const __ilhaServerAction: <A extends SnapshotValue[], R>(key: string, fn: (...args: A) => R | Promise<R>) => import("oxidejs").ServerActionHandle<A, Awaited<Awaited<R>>>;
|
|
108
|
-
export declare const registerServerIsland: (id: string, render: () => ServerIslandRenderFn) => void;
|
|
109
|
-
export declare const getServerIslandEntry: (id: string) => ServerIslandEntry | undefined;
|
|
110
|
-
declare const FrameError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
111
|
-
readonly _tag: "FrameError";
|
|
112
|
-
} & Readonly<A>;
|
|
113
|
-
/** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
|
|
114
|
-
export declare class FrameError extends FrameError_base<{
|
|
115
|
-
status: number;
|
|
116
|
-
message?: string;
|
|
117
|
-
redirect?: string;
|
|
118
|
-
}> {
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Render a registered server island. Typed error channel: the only failure is
|
|
122
|
-
* `FrameError`; everything else is a defect surfaced as a 400 to the client.
|
|
123
|
-
*/
|
|
124
|
-
export declare const renderServerIsland: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Effect.Effect<string, FrameError>;
|
|
125
|
-
/** Convenience for non-Effect callers: run the render and resolve to a Result. */
|
|
126
|
-
export declare const renderServerIslandResult: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Promise<Result.Result<string, FrameError>>;
|
|
127
|
-
export declare const FRAME_ENDPOINT = "/__ilha/frame";
|
|
128
|
-
/** Max request body size — matches the dev middleware cap. */
|
|
129
|
-
export declare const MAX_BODY: number;
|
|
130
|
-
/** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
|
|
131
|
-
export declare const parseFrameProps: <T>(value?: T) => SnapshotObject | undefined;
|
|
132
|
-
export declare const json: (status: number, body: FrameJsonObject) => Response;
|
|
133
|
-
/**
|
|
134
|
-
* Read a request body as UTF-8, streaming it with a hard byte cap. Returns
|
|
135
|
-
* `null` when the body exceeds `maxBytes` (the reader is cancelled before the
|
|
136
|
-
* cap is far exceeded) or when decoding fails.
|
|
137
|
-
*/
|
|
138
|
-
export declare const readBodyBounded: (request: Request, maxBytes: number) => Promise<string | null>;
|
|
139
|
-
/**
|
|
140
|
-
* Shared frame-request authorization used by both the production handler
|
|
141
|
-
* below and the Vite/Rsbuild dev middleware: same-origin check against the
|
|
142
|
-
* frame-auth policy, the registered frame guard, and the optional CSRF
|
|
143
|
-
* verifier. `defaultAction` selects the deny-by-default production posture or
|
|
144
|
-
* the permissive development one.
|
|
145
15
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
16
|
+
* Frame helpers live in `./frame` (no oxidejs). `__ilhaServerAction` lives in
|
|
17
|
+
* `./oxide-action` so `@ilha/router/vite` can load the pages plugin without
|
|
18
|
+
* the optional oxide peer.
|
|
148
19
|
*/
|
|
149
|
-
export
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}) => Promise<{
|
|
153
|
-
ok: true;
|
|
154
|
-
identityHeaders: Headers;
|
|
155
|
-
} | {
|
|
156
|
-
ok: false;
|
|
157
|
-
status: number;
|
|
158
|
-
}>;
|
|
20
|
+
export { FRAME_ENDPOINT, FrameError, MAX_BODY, authorizeFrameRequest, forwardIdentityHeaders, frameEnvelope, frameScopedUrl, getFrameAuth, getFrameGuard, getServerIslandEntry, isSafeFramePath, isTrustedOrigin, json, parseFrameProps, readBodyBounded, registerServerIsland, renderServerIsland, renderServerIslandResult, setFrameAuth, setFrameGuard, } from "./frame";
|
|
21
|
+
export type { FrameAuthPolicy, FrameEnvelope, FrameGuard, FrameJsonObject, FrameJsonValue, ServerIslandEntry, ServerIslandRenderFn, } from "./frame";
|
|
22
|
+
export { __ilhaServerAction } from "./oxide-action";
|
|
159
23
|
/** Side-effect imports required alongside this handler. */
|
|
160
24
|
interface SsrMiddleware {
|
|
161
25
|
(request: Request): Promise<Response | undefined>;
|
package/dist/ssr.js
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as registerServerIsland, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, d as getServerIslandEntry, f as isSafeFramePath, g as readBodyBounded, h as parseFrameProps, i as MAX_BODY, l as getFrameAuth, m as json, n as FRAME_ENDPOINT, o as forwardIdentityHeaders, p as isTrustedOrigin, r as FrameError, s as frameEnvelope, t as runWithIslandRequest, u as getFrameGuard, v as renderServerIsland, x as setFrameGuard, y as renderServerIslandResult } from "./request-scope-CI9BJVS-.js";
|
|
2
|
+
import * as Result from "effect/Result";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { action, brandServerAction } from "oxidejs";
|
|
2
5
|
|
|
6
|
+
//#region src/oxide-action.ts
|
|
7
|
+
/**
|
|
8
|
+
* Oxide-branded server-action wrapper.
|
|
9
|
+
*
|
|
10
|
+
* Lives in its own module so `@ilha/router/vite` / the pages plugin can load
|
|
11
|
+
* frame helpers without resolving the optional `oxidejs` peer.
|
|
12
|
+
*/
|
|
13
|
+
/** Brand an exported server action with its generated RPC transport key. */
|
|
14
|
+
const __ilhaServerAction = (key, fn) => {
|
|
15
|
+
const handle = fn.$$atom === 1 ? fn : action(fn);
|
|
16
|
+
return brandServerAction(key, handle);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/ssr.ts
|
|
21
|
+
/**
|
|
22
|
+
* Production SSR endpoint for server-owned islands.
|
|
23
|
+
*
|
|
24
|
+
* Default export is an oxidejs-style fetch middleware:
|
|
25
|
+
* `(request) => Response | undefined`. Returns `undefined` for any request it
|
|
26
|
+
* does not own, so hosts can chain it ahead of their own handler:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* oxide({ middleware: ["@ilha/router/ssr"] });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
|
|
33
|
+
* in, `{ html }` out). Renderers come from the process-global registry
|
|
34
|
+
* populated by self-registration code appended to `.server` modules.
|
|
35
|
+
*
|
|
36
|
+
* Frame helpers live in `./frame` (no oxidejs). `__ilhaServerAction` lives in
|
|
37
|
+
* `./oxide-action` so `@ilha/router/vite` can load the pages plugin without
|
|
38
|
+
* the optional oxide peer.
|
|
39
|
+
*/
|
|
40
|
+
const objectTag = (value) => Object.prototype.toString.call(value);
|
|
41
|
+
const isString = (value) => objectTag(value) === "[object String]";
|
|
42
|
+
const copyFrameworkSymbols = (from, to) => {
|
|
43
|
+
for (const sym of Object.getOwnPropertySymbols(from)) {
|
|
44
|
+
if (Symbol.keyFor(sym) === void 0) continue;
|
|
45
|
+
const desc = Object.getOwnPropertyDescriptor(from, sym);
|
|
46
|
+
if (!desc) continue;
|
|
47
|
+
try {
|
|
48
|
+
Object.defineProperty(to, sym, desc);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const frameFail = (status, message) => new FrameError({
|
|
53
|
+
message: message ?? "frame failed",
|
|
54
|
+
status
|
|
55
|
+
});
|
|
56
|
+
const ssr = async (request) => {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(request.url);
|
|
60
|
+
} catch {
|
|
61
|
+
return json(400, { error: "frame failed" });
|
|
62
|
+
}
|
|
63
|
+
if (url.pathname !== "/__ilha/frame") return;
|
|
64
|
+
const program = Effect.gen(function* handleFrame() {
|
|
65
|
+
const auth = getFrameAuth();
|
|
66
|
+
if (!isTrustedOrigin(request, auth)) return yield* Effect.fail(frameFail(403));
|
|
67
|
+
if (request.method !== "POST") return yield* Effect.fail(frameFail(405));
|
|
68
|
+
if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return yield* Effect.fail(frameFail(415));
|
|
69
|
+
const authorized = yield* Effect.tryPromise({
|
|
70
|
+
catch: () => frameFail(403),
|
|
71
|
+
try: () => authorizeFrameRequest(request, {
|
|
72
|
+
defaultAction: auth?.defaultAction ?? "deny",
|
|
73
|
+
onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
|
|
74
|
+
})
|
|
75
|
+
});
|
|
76
|
+
if (!authorized.ok) return yield* Effect.fail(frameFail(authorized.status));
|
|
77
|
+
let id;
|
|
78
|
+
let framePath = "/";
|
|
79
|
+
let incomingProps;
|
|
80
|
+
{
|
|
81
|
+
const text = yield* Effect.tryPromise({
|
|
82
|
+
catch: () => frameFail(400),
|
|
83
|
+
try: () => readBodyBounded(request, MAX_BODY)
|
|
84
|
+
});
|
|
85
|
+
if (text === null) return yield* Effect.fail(frameFail(413));
|
|
86
|
+
try {
|
|
87
|
+
const body = JSON.parse(text);
|
|
88
|
+
id = String(body.id ?? "");
|
|
89
|
+
incomingProps = parseFrameProps(body.props);
|
|
90
|
+
if (isString(body.path)) {
|
|
91
|
+
if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
|
|
92
|
+
framePath = body.path;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return yield* Effect.fail(frameFail(400));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const scoped = new Request(frameScopedUrl(url.href, framePath), {
|
|
99
|
+
headers: forwardIdentityHeaders(request.headers),
|
|
100
|
+
method: "POST"
|
|
101
|
+
});
|
|
102
|
+
copyFrameworkSymbols(request, scoped);
|
|
103
|
+
const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
|
|
104
|
+
return json(200, { html });
|
|
105
|
+
});
|
|
106
|
+
return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
|
|
107
|
+
onFailure: (error) => {
|
|
108
|
+
if (error.redirect) return json(error.status, { redirect: error.redirect });
|
|
109
|
+
if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
|
|
110
|
+
return json(error.status, { error: "frame failed" });
|
|
111
|
+
},
|
|
112
|
+
onSuccess: (response) => response
|
|
113
|
+
})));
|
|
114
|
+
};
|
|
115
|
+
const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
3
118
|
export { FRAME_ENDPOINT, FrameError, MAX_BODY, __ilhaServerAction, authorizeFrameRequest, ssrWithImports as default, forwardIdentityHeaders, frameEnvelope, frameScopedUrl, getFrameAuth, getFrameGuard, getServerIslandEntry, isSafeFramePath, isTrustedOrigin, json, parseFrameProps, readBodyBounded, registerServerIsland, renderServerIsland, renderServerIslandResult, setFrameAuth, setFrameGuard };
|
package/dist/vite.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ilha/router",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.8",
|
|
4
4
|
"description": "A tiny SPA router for Ilha",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontend",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
"unplugin": "3.3.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"ilha": "^0.14.
|
|
76
|
+
"ilha": "^0.14.5",
|
|
77
77
|
"oxidejs": "^0.3.2",
|
|
78
78
|
"vite": "^8.2.2"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"ilha": ">=0.14.
|
|
81
|
+
"ilha": ">=0.14.5",
|
|
82
82
|
"oxidejs": ">=0.3.2"
|
|
83
83
|
},
|
|
84
84
|
"peerDependenciesMeta": {
|