@ilha/router 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { t as runWithIslandRequest } from "./request-scope-D6_4rqMb.js";
2
- import { FrameError, getFrameGuard, renderServerIsland, setFrameGuard } from "./server-island-registry.js";
1
+ import { t as runWithIslandRequest } from "./request-scope-C4reU4v0.js";
2
+ import { FrameError, getFrameAuth, getFrameGuard, isTrustedOrigin, renderServerIsland, setFrameAuth, setFrameGuard, setLoaderGuard } from "./server-island-registry.js";
3
3
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
4
4
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
5
  import { createUnplugin } from "unplugin";
@@ -472,7 +472,7 @@ function buildServerFile(entries, serverFile) {
472
472
  `// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
473
473
  ``,
474
474
  ...imports,
475
- ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/server-island-registry";`, `setFrameLoaderRunner((path) => pageRouter.runLoader(path));`] : [],
475
+ ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/server-island-registry";`, `setFrameLoaderRunner((path, request) => pageRouter.runLoader(path, request));`] : [],
476
476
  ``,
477
477
  ...wrappedIslandLines,
478
478
  ``,
@@ -989,6 +989,12 @@ const pagesFactory = (options = {}) => {
989
989
  configureServer(server) {
990
990
  server.watcher.add(state.pagesDir);
991
991
  if (options.frameGuard) setFrameGuard(options.frameGuard);
992
+ if (options.loaderGuard) setLoaderGuard(options.loaderGuard);
993
+ if (options.trustedOrigins || options.csrf) setFrameAuth({
994
+ trustedOrigins: options.trustedOrigins,
995
+ csrf: options.csrf,
996
+ defaultAction: "open"
997
+ });
992
998
  server.middlewares.use(async (req, res, next) => {
993
999
  if ((req.url ?? "").split("?")[0] !== "/__ilha/frame") return next();
994
1000
  if (req.method !== "POST") {
@@ -1001,19 +1007,24 @@ const pagesFactory = (options = {}) => {
1001
1007
  res.end();
1002
1008
  return;
1003
1009
  }
1010
+ const guardHeaders = new Headers();
1011
+ if (req.headers.origin) guardHeaders.set("origin", String(req.headers.origin));
1012
+ if (req.headers.host) guardHeaders.set("host", String(req.headers.host));
1013
+ if (!isTrustedOrigin(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, { headers: guardHeaders }), getFrameAuth())) {
1014
+ console.warn(`[ilha-router] dev frame request rejected: Origin ${String(req.headers.origin)} is not trusted (host: ${String(req.headers.host ?? "localhost")}). Configure trustedOrigins via IlhaPagesOptions if this origin is expected.`);
1015
+ res.statusCode = 403;
1016
+ res.end();
1017
+ return;
1018
+ }
1019
+ const identityHeaders = new Headers();
1020
+ for (const name of ["cookie", "authorization"]) {
1021
+ const v = req.headers[name];
1022
+ if (typeof v === "string") identityHeaders.set(name, v);
1023
+ }
1004
1024
  try {
1005
- const guardHeaders = new Headers();
1006
- for (const name of [
1007
- "cookie",
1008
- "authorization",
1009
- "x-forwarded-for"
1010
- ]) {
1011
- const v = req.headers[name];
1012
- if (typeof v === "string") guardHeaders.set(name, v);
1013
- }
1014
1025
  const denied = await getFrameGuard()?.(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
1015
1026
  method: req.method,
1016
- headers: guardHeaders
1027
+ headers: identityHeaders
1017
1028
  }));
1018
1029
  if (denied) {
1019
1030
  res.statusCode = denied.status;
@@ -1026,9 +1037,17 @@ const pagesFactory = (options = {}) => {
1026
1037
  res.end();
1027
1038
  return;
1028
1039
  }
1029
- const origin = req.headers.origin;
1030
- const host = req.headers.host;
1031
- if (origin && origin !== `http://${host}` && origin !== `https://${host}`) {
1040
+ const csrf = getFrameAuth()?.csrf;
1041
+ if (csrf) try {
1042
+ if (!await csrf(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
1043
+ method: req.method,
1044
+ headers: identityHeaders
1045
+ }))) {
1046
+ res.statusCode = 403;
1047
+ res.end();
1048
+ return;
1049
+ }
1050
+ } catch {
1032
1051
  res.statusCode = 403;
1033
1052
  res.end();
1034
1053
  return;
@@ -1049,15 +1068,14 @@ const pagesFactory = (options = {}) => {
1049
1068
  const target = serverIslands.get(body.id ?? "");
1050
1069
  if (!target) throw new Error("unknown island");
1051
1070
  let framePath = "/__ilha/frame";
1052
- if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && body.path.length <= 2048) framePath = body.path;
1071
+ if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && !body.path.includes("\\") && body.path.length <= 2048) framePath = body.path;
1053
1072
  await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
1054
1073
  if (typeof (await server.ssrLoadModule(target.file))[target.name]?.[Symbol.for("ilha.renderState")] !== "function") throw new Error("unknown island");
1055
1074
  const headers = new Headers();
1056
1075
  for (const name of [
1057
1076
  "cookie",
1058
1077
  "authorization",
1059
- "user-agent",
1060
- "x-forwarded-for"
1078
+ "user-agent"
1061
1079
  ]) {
1062
1080
  const value = req.headers[name];
1063
1081
  if (typeof value === "string") headers.set(name, value);
package/dist/plugin.d.ts CHANGED
@@ -36,6 +36,23 @@ export interface IlhaPagesOptions {
36
36
  * `setFrameGuard()` from `@ilha/router/server-island-registry`.
37
37
  */
38
38
  frameGuard?: (request: Request) => Response | void | Promise<Response | void>;
39
+ /**
40
+ * Guard consulted only by `GET /__ilha/loader` in production. When absent,
41
+ * the loader endpoint falls back to the frame guard for backwards
42
+ * compatibility. Mirrors `setLoaderGuard()`.
43
+ */
44
+ loaderGuard?: (request: Request) => Response | void | Promise<Response | void>;
45
+ /**
46
+ * Explicit trusted origins for frame/loader requests (e.g. a `.vercel.app`
47
+ * or custom domain). When unset, origin checks compare the `Origin` header
48
+ * against the request's own `Host`. Mirrors `setFrameAuth({ trustedOrigins })`.
49
+ */
50
+ trustedOrigins?: string[];
51
+ /**
52
+ * Optional CSRF verifier for the state-changing `/__ilha/frame` POST.
53
+ * Mirrors `setFrameAuth({ csrf })`.
54
+ */
55
+ csrf?: (request: Request) => boolean | Promise<boolean>;
39
56
  /**
40
57
  * Fail codegen on duplicate route patterns / registry name collisions
41
58
  * instead of warning. Recommended for CI/production builds. Default: `false`.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Compile-time type anchors for the router API. Every imperative call lives
3
+ * inside this never-invoked function so module scope has NO side effects:
4
+ * `router()` resets the module-global route registry, which must not happen
5
+ * at import time (tsconfig.build emits declarations for these anchors only).
6
+ */
7
+ export declare function typecheckRouterApi(): void;
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
8
8
  * page SSR through the router, or streamed frames through the plugin's
9
9
  * `/__ilha/frame` endpoint. Both seed this scope with the originating
10
10
  * `Request`, so render functions can read request data (URL, headers,
11
- * cookies) without depending on Oxide's action-only `useRequest()`.
11
+ * cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
12
12
  *
13
13
  * The storage lives on `globalThis` under `ilha.requestAls` so every module
14
14
  * copy (plugin bundle, SSR graph) shares one instance. The public accessor
@@ -17,10 +17,17 @@ import { AsyncLocalStorage } from "node:async_hooks";
17
17
  * sole place that constructs it.
18
18
  */
19
19
  const REQUEST_ALS_KEY = Symbol.for("ilha.requestAls");
20
- /** Run `fn` with `request` available to `useContext().request`. */
20
+ /** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
21
+ * inside island renders and frames, not just `/__oxide/action`. */
22
+ const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
23
+ /** Run `fn` with `request` available to `useContext().request`. When oxidejs
24
+ * is loaded, its action scope is entered too, so `useRequest()` works in
25
+ * island renders and streamed frames. */
21
26
  function runWithIslandRequest(request, fn) {
22
27
  const g = globalThis;
23
- return (g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage()).run(request, fn);
28
+ const als = g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage();
29
+ const oxide = g[OXIDE_RUN_WITH_REQUEST];
30
+ return als.run(request, () => oxide ? oxide(request, fn) : fn());
24
31
  }
25
32
 
26
33
  //#endregion
@@ -5,7 +5,7 @@
5
5
  * page SSR through the router, or streamed frames through the plugin's
6
6
  * `/__ilha/frame` endpoint. Both seed this scope with the originating
7
7
  * `Request`, so render functions can read request data (URL, headers,
8
- * cookies) without depending on Oxide's action-only `useRequest()`.
8
+ * cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
9
9
  *
10
10
  * The storage lives on `globalThis` under `ilha.requestAls` so every module
11
11
  * copy (plugin bundle, SSR graph) shares one instance. The public accessor
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import type { IslandContext } from "./index";
17
17
  export declare const REQUEST_ALS_KEY: unique symbol;
18
- /** Run `fn` with `request` available to `useContext().request`. */
18
+ /** Run `fn` with `request` available to `useContext().request`. When oxidejs
19
+ * is loaded, its action scope is entered too, so `useRequest()` works in
20
+ * island renders and streamed frames. */
19
21
  export declare function runWithIslandRequest<T>(request: Request, fn: () => T): T;
20
22
  export type { IslandContext };
@@ -2,5 +2,5 @@ export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError } from "./ind
2
2
  export { ilhaPages, type IlhaPagesOptions } from "./plugin";
3
3
  import { type IlhaPagesOptions } from "./plugin";
4
4
  /** Rolldown plugin — use via `@ilha/router/rolldown`. */
5
- export declare function pages(options?: IlhaPagesOptions): import("unplugin").RolldownPlugin<any> | import("unplugin").RolldownPlugin<any>[];
5
+ export declare function pages(options?: IlhaPagesOptions): import("rolldown").Plugin<any> | import("rolldown").Plugin<any>[];
6
6
  export default pages;
package/dist/rolldown.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-DPGzrIVj.js";
1
+ import { t as ilhaPages } from "./plugin-BHuojFhQ.js";
2
2
 
3
3
  //#region src/rolldown.ts
4
4
  /** Rolldown plugin — use via `@ilha/router/rolldown`. */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared, decoded-aware route pattern matching used by both the router
3
+ * (`index.ts`) and the server-frame path (`server-island-registry.ts`) so the
4
+ * two never drift on segment semantics or parameter decoding.
5
+ *
6
+ * Patterns support `:name` segments, a bare mid-pattern `*` (one segment), and
7
+ * a trailing `/**:name` catch-all. Static segments take priority over params,
8
+ * which take priority over catch-alls — callers sort by that before matching.
9
+ */
10
+ export interface ParsedPattern {
11
+ segments: string[];
12
+ kinds: number[];
13
+ }
14
+ export declare function parsePattern(pattern: string): ParsedPattern;
15
+ /**
16
+ * Match `segments` against a pathname. Returns raw (still-encoded) captured
17
+ * params, or `null` when the path doesn't match — the caller decides whether to
18
+ * decode via {@link safeDecode}.
19
+ */
20
+ export declare function matchSegments(segments: string[], pathname: string): Record<string, string> | null;
21
+ /** Decode a route-param value, tolerating malformed percent-encoding. */
22
+ export declare function safeDecode(value: string): string;
package/dist/rspack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-DPGzrIVj.js";
1
+ import { t as ilhaPages } from "./plugin-BHuojFhQ.js";
2
2
 
3
3
  //#region src/rspack.ts
4
4
  /** Rspack plugin — use via `@ilha/router/rspack`. */
@@ -38,7 +38,52 @@ export type FrameGuard = (request: Request) => Response | void | Promise<Respons
38
38
  */
39
39
  export declare function setFrameGuard(guard: FrameGuard): void;
40
40
  export declare function getFrameGuard(): FrameGuard | undefined;
41
- export type FrameLoaderRunner = (path: string) => Promise<{
41
+ /**
42
+ * Install a guard consulted only by `GET /__ilha/loader`. When absent, the
43
+ * loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
44
+ * Prefer a dedicated loader guard so gating the loader endpoint is independent
45
+ * of frame rendering.
46
+ */
47
+ export declare function setLoaderGuard(guard: FrameGuard): void;
48
+ export declare function getLoaderGuard(): FrameGuard | undefined;
49
+ /** Frame-authorization policy, installed via {@link setFrameAuth}. */
50
+ export interface FrameAuthPolicy {
51
+ /**
52
+ * Action taken when no frame guard is registered. `"deny"` (default in the
53
+ * production handler) rejects every `/__ilha/frame` request with 403;
54
+ * `"open"` preserves the legacy unauthenticated behavior. The dev
55
+ * middleware stays permissive unless a guard is registered.
56
+ */
57
+ defaultAction?: "open" | "deny";
58
+ /**
59
+ * Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
60
+ * origin checks accept only these; otherwise the check compares the `Origin`
61
+ * header against `https://{host}` / `http://{host}`.
62
+ */
63
+ trustedOrigins?: string[];
64
+ /**
65
+ * Optional CSRF verifier for the state-changing frame POST. Receives the
66
+ * original `Request`; returning falsy rejects the request. Use this for
67
+ * server-to-server frame callers that have no browser `Origin`.
68
+ */
69
+ csrf?: (request: Request) => boolean | Promise<boolean>;
70
+ }
71
+ /**
72
+ * Install the frame-authorization policy consumed by the production
73
+ * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
74
+ * the dev middleware (via `IlhaPagesOptions`).
75
+ */
76
+ export declare function setFrameAuth(policy: FrameAuthPolicy): void;
77
+ export declare function getFrameAuth(): FrameAuthPolicy | undefined;
78
+ /**
79
+ * Same-origin check for frame/loader requests. Browsers always send `Origin`
80
+ * on cross-origin and same-origin `POST`; its absence implies a non-browser
81
+ * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
82
+ * present it must match the configured trusted origins, else the request's
83
+ * own `Host`.
84
+ */
85
+ export declare function isTrustedOrigin(request: Request, policy: FrameAuthPolicy | undefined): boolean;
86
+ export type FrameLoaderRunner = (path: string, request?: Request) => Promise<{
42
87
  kind: string;
43
88
  data?: unknown;
44
89
  headEntries?: unknown;
@@ -1,4 +1,18 @@
1
+ import { F as parsePattern, I as safeDecode, P as matchSegments, S as resolveRedirectTarget } from "./src-BBsbD5vU.js";
2
+
1
3
  //#region src/server-island-registry.ts
4
+ /**
5
+ * Process-global registry of server-island renderers, keyed by the public
6
+ * island id (`sha256(file#name)`, see `serverIslandPublicId`). Lives on
7
+ * `globalThis` so every module copy (plugin bundle, SSR graph, frame entry)
8
+ * shares one instance — same pattern as `request-scope.ts`.
9
+ *
10
+ * `.server` modules self-register when the plugin appends registration code
11
+ * to their server-graph copy; the production `/__ilha/frame` handler (see
12
+ * `@ilha/router/frame`) consumes the registry to re-render an island from a
13
+ * client state snapshot. Server pages additionally register their `load` and
14
+ * route pattern so frame handlers can run the loader with matched params.
15
+ */
2
16
  const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
3
17
  function registry() {
4
18
  const g = globalThis;
@@ -24,6 +38,58 @@ function setFrameGuard(guard) {
24
38
  function getFrameGuard() {
25
39
  return globalThis[GUARD_KEY];
26
40
  }
41
+ const LOADER_GUARD_KEY = Symbol.for("ilha.loaderGuard");
42
+ /**
43
+ * Install a guard consulted only by `GET /__ilha/loader`. When absent, the
44
+ * loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
45
+ * Prefer a dedicated loader guard so gating the loader endpoint is independent
46
+ * of frame rendering.
47
+ */
48
+ function setLoaderGuard(guard) {
49
+ const g = globalThis;
50
+ g[LOADER_GUARD_KEY] = guard;
51
+ }
52
+ function getLoaderGuard() {
53
+ return globalThis[LOADER_GUARD_KEY];
54
+ }
55
+ const AUTH_KEY = Symbol.for("ilha.frameAuth");
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
+ function setFrameAuth(policy) {
62
+ const g = globalThis;
63
+ g[AUTH_KEY] = policy;
64
+ }
65
+ function getFrameAuth() {
66
+ return globalThis[AUTH_KEY];
67
+ }
68
+ function normalizeOrigin(value) {
69
+ try {
70
+ return new URL(value).origin;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Same-origin check for frame/loader requests. Browsers always send `Origin`
77
+ * on cross-origin and same-origin `POST`; its absence implies a non-browser
78
+ * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
79
+ * present it must match the configured trusted origins, else the request's
80
+ * own `Host`.
81
+ */
82
+ function isTrustedOrigin(request, policy) {
83
+ const originHeader = request.headers.get("origin");
84
+ if (originHeader === null) return true;
85
+ const origin = normalizeOrigin(originHeader);
86
+ if (origin === null) return false;
87
+ const trusted = policy?.trustedOrigins ?? [];
88
+ if (trusted.length > 0) return trusted.some((o) => normalizeOrigin(o) === origin);
89
+ const host = request.headers.get("host");
90
+ if (!host) return false;
91
+ return origin === `https://${host}` || origin === `http://${host}`;
92
+ }
27
93
  const LOADER_RUNNER_KEY = Symbol.for("ilha.frameLoaderRunner");
28
94
  /**
29
95
  * Install the handler backing `GET /__ilha/loader` in production. The
@@ -62,27 +128,14 @@ var FrameError = class extends Error {
62
128
  }
63
129
  };
64
130
  /** Match a route pattern (`/user/:id`, `/docs/**:slug`) against a pathname.
65
- * Returns raw (still-encoded) params, or null when the path doesn't match.
66
- * Mirrors the router's matcher semantics in miniature. */
131
+ * Returns decoded params, or null when the path doesn't match. Shares the
132
+ * router's matcher semantics via `route-match.ts`. */
67
133
  function matchPatternParams(pattern, pathname) {
68
- const patternSegments = pattern.split("/").filter(Boolean);
69
- const pathSegments = pathname.split("/").filter(Boolean);
134
+ const raw = matchSegments(parsePattern(pattern).segments, pathname);
135
+ if (!raw) return null;
70
136
  const params = {};
71
- let cursor = 0;
72
- for (const segment of patternSegments) {
73
- if (segment.startsWith("*")) {
74
- const name = segment.slice(2).replace(/^:/, "");
75
- if (name) params[name] = pathSegments.slice(cursor).join("/");
76
- cursor = pathSegments.length;
77
- break;
78
- }
79
- const value = pathSegments[cursor];
80
- if (value === void 0) return null;
81
- if (segment.startsWith(":")) params[segment.slice(1)] = value;
82
- else if (value !== segment) return null;
83
- cursor++;
84
- }
85
- return cursor === pathSegments.length ? params : null;
137
+ for (const [k, v] of Object.entries(raw)) params[k] = safeDecode(v);
138
+ return params;
86
139
  }
87
140
  /**
88
141
  * Shared tail of every frame request: run the page's `load` when registered
@@ -104,17 +157,23 @@ async function renderServerIsland(id, request, runWithScope) {
104
157
  const params = entry.pattern ? matchPatternParams(entry.pattern, url.pathname) : {};
105
158
  if (!params) throw new FrameError(400, "frame failed");
106
159
  try {
107
- props = await entry.load({
108
- params,
109
- request,
110
- url,
111
- signal: request.signal
112
- });
160
+ props = { load: {
161
+ loading: false,
162
+ value: await entry.load({
163
+ params,
164
+ request,
165
+ url,
166
+ signal: request.signal
167
+ }) ?? {},
168
+ error: void 0
169
+ } };
113
170
  } catch (error) {
114
171
  const marker = error;
115
172
  if (marker.__ilhaRedirect === true) {
116
173
  const r = error;
117
- throw new FrameError(r.status || 302, "frame failed", r.to);
174
+ const safe = resolveRedirectTarget(r.to, url, false);
175
+ if (!safe.ok) throw new FrameError(500, "unsafe redirect target");
176
+ throw new FrameError(r.status || 302, "frame failed", safe.to);
118
177
  }
119
178
  if (marker.__ilhaLoaderError === true) throw new FrameError(error.status || 500, "frame failed");
120
179
  throw error;
@@ -127,4 +186,4 @@ async function renderServerIsland(id, request, runWithScope) {
127
186
  }
128
187
 
129
188
  //#endregion
130
- export { FrameError, getFrameGuard, getFrameLoaderRunner, getServerIslandEntry, getServerIslandRenderer, registerServerIsland, renderServerIsland, setFrameGuard, setFrameLoaderRunner };
189
+ export { FrameError, getFrameAuth, getFrameGuard, getFrameLoaderRunner, getLoaderGuard, getServerIslandEntry, getServerIslandRenderer, isTrustedOrigin, registerServerIsland, renderServerIsland, setFrameAuth, setFrameGuard, setFrameLoaderRunner, setLoaderGuard };