@ilha/router 0.8.13 → 0.9.0

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/ssr.d.ts CHANGED
@@ -1,94 +1,23 @@
1
- import "ilha:loaders";
2
- import type { HeadInput, HydratableRenderOptions, SerializedHead } from "./index";
3
- export interface AssetAttrs {
4
- href: string;
5
- "data-vite-dev-id"?: string;
6
- }
7
- /** Manifest slice without `merge` — matches Nitro/Vite `?assets` raw shape. */
8
- export interface AssetsRaw {
9
- entry?: string;
10
- js: AssetAttrs[];
11
- css: AssetAttrs[];
12
- }
13
1
  /**
14
- * The asset descriptor produced by the `?assets=client` / `?assets=ssr`
15
- * imports. `merge` combines several descriptors, de-duping by href.
2
+ * Production SSR endpoints for server-owned islands and regular-page loads.
16
3
  *
17
- * Structurally compatible with Nitro's `ImportAssetsResult`.
18
- */
19
- export type Assets = AssetsRaw & {
20
- merge(...others: AssetsRaw[]): Assets;
21
- };
22
- /** Client + SSR manifests from `?assets=client` / `?assets=ssr` imports. */
23
- export interface IlhaAssetsPair {
24
- client: Assets;
25
- server: Assets;
26
- }
27
- /**
28
- * Merge client and SSR asset manifests (same as `client.merge(server)` from Nitro).
29
- */
30
- export declare function mergeAssets({ client, server }: IlhaAssetsPair): Assets;
31
- /**
32
- * App-wide `<head>` defaults for `IlhaHandler`. Same shape as route `head()`;
33
- * use so entry files read clearly: `head: appHead({ title, script: [...] })`.
34
- */
35
- export declare function appHead(head: HeadInput): HeadInput;
36
- export interface IlhaHandlerOptions {
37
- /**
38
- * Merged asset manifest — use `mergeAssets({ client, server })` with imports
39
- * from `?assets=client` and `?assets=ssr`.
40
- */
41
- assets: Assets;
42
- /** `<html lang>` value. Default: `"en"`. */
43
- lang?: string;
44
- /** Id of the hydration mount container. Default: `"app"`. */
45
- appId?: string;
46
- /** Fallback client entry used when the merged assets have no `entry`. */
47
- clientEntry?: string;
48
- /**
49
- * App-wide `<head>` defaults — same shape as the `head()` API. Route-level
50
- * head (from loaders/pages) is merged on top, so anything here acts as a
51
- * base. Use it for the default `title`, app meta, and inline scripts.
52
- *
53
- * @example
54
- * head: {
55
- * title: "Ilha + Oxide",
56
- * script: [{ children: themeScript }],
57
- * }
58
- */
59
- head?: HeadInput;
60
- /** Options forwarded to `pageRouter.renderHydratable` / `renderResponse`. */
61
- renderOptions?: HydratableRenderOptions;
62
- }
63
- /**
64
- * SSR host helper for Ilha apps. Wires the generated `pageRouter` /
65
- * `registry` and the asset manifest into a single `fetch`-style handler so a
66
- * host entry collapses to:
4
+ * Default export is an oxidejs-style fetch middleware:
5
+ * `(request) => Response | undefined`. Returns `undefined` for any request it
6
+ * does not own, so hosts can chain it ahead of their own handler:
67
7
  *
68
8
  * ```ts
69
- * import { IlhaHandler } from "@ilha/router/ssr";
70
- * import client from "./entry-client.ts?assets=client";
71
- * import server from "./entry-server.ts?assets=ssr";
72
- *
73
- * const handler = new IlhaHandler({
74
- * assets: mergeAssets({ client, server }),
75
- * head: appHead({ title: "My app", script: [{ children: "..." }] }),
76
- * });
77
- *
78
- * export default { fetch: (request: Request) => handler.handle(request) };
9
+ * oxide({ middleware: ["@ilha/router/ssr"] });
79
10
  * ```
11
+ *
12
+ * Serves:
13
+ * - `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }` in,
14
+ * `{ html }` out). Renderers come from the process-global registry
15
+ * populated by self-registration code appended to `.server` modules.
16
+ * - `GET /__ilha/loader?path=…` — regular-page server loads via the loader
17
+ * runner (`setFrameLoaderRunner`, wired by the generated server module).
80
18
  */
81
- export declare class IlhaHandler {
82
- private readonly assets;
83
- private readonly lang;
84
- private readonly appId;
85
- private readonly clientEntry;
86
- private readonly head;
87
- private readonly renderOptions;
88
- constructor(options: IlhaHandlerOptions);
89
- /** Render the document shell around an already-rendered island body. */
90
- document(body: string, head?: SerializedHead): string;
91
- /** Handle an incoming request and return a full HTML / redirect Response. */
92
- handle(request: Request): Promise<Response>;
93
- private handleInner;
94
- }
19
+ export declare const FRAME_ENDPOINT = "/__ilha/frame";
20
+ /** Regular-page server loads: served through the loader-runner slot. */
21
+ export declare const LOADER_ENDPOINT = "/__ilha/loader";
22
+ declare function ssr(request: Request): Promise<Response | undefined>;
23
+ export default ssr;
package/dist/ssr.js CHANGED
@@ -1,152 +1,154 @@
1
- import { t as LOADER_ENDPOINT } from "./src-BKWkRtMx.js";
2
- import { pageRouter, registry } from "ilha:pages/server";
3
- import "ilha:loaders";
1
+ import { t as runWithIslandRequest } from "./request-scope-D6_4rqMb.js";
2
+ import { FrameError, getFrameGuard, getFrameLoaderRunner, renderServerIsland } from "./server-island-registry.js";
4
3
 
5
4
  //#region src/ssr.ts
6
5
  /**
7
- * Merge client and SSR asset manifests (same as `client.merge(server)` from Nitro).
8
- */
9
- function mergeAssets({ client, server }) {
10
- return client.merge(server);
11
- }
12
- /**
13
- * App-wide `<head>` defaults for `IlhaHandler`. Same shape as route `head()`;
14
- * use so entry files read clearly: `head: appHead({ title, script: [...] })`.
15
- */
16
- function appHead(head) {
17
- return head;
18
- }
19
- const ATTR_ESC = {
20
- "&": "&amp;",
21
- "<": "&lt;",
22
- ">": "&gt;",
23
- "\"": "&quot;",
24
- "'": "&#39;"
25
- };
26
- function escapeAttr(value) {
27
- return value.replace(/[&<>"']/g, (c) => ATTR_ESC[c]);
28
- }
29
- function stylesheetTag(attrs) {
30
- const devId = attrs["data-vite-dev-id"] ? ` data-vite-dev-id="${escapeAttr(attrs["data-vite-dev-id"])}"` : "";
31
- return `<link rel="stylesheet" href="${escapeAttr(attrs.href)}"${devId} />`;
32
- }
33
- /**
34
- * SSR host helper for Ilha apps. Wires the generated `pageRouter` /
35
- * `registry` and the asset manifest into a single `fetch`-style handler so a
36
- * host entry collapses to:
6
+ * Production SSR endpoints for server-owned islands and regular-page loads.
37
7
  *
38
- * ```ts
39
- * import { IlhaHandler } from "@ilha/router/ssr";
40
- * import client from "./entry-client.ts?assets=client";
41
- * import server from "./entry-server.ts?assets=ssr";
8
+ * Default export is an oxidejs-style fetch middleware:
9
+ * `(request) => Response | undefined`. Returns `undefined` for any request it
10
+ * does not own, so hosts can chain it ahead of their own handler:
42
11
  *
43
- * const handler = new IlhaHandler({
44
- * assets: mergeAssets({ client, server }),
45
- * head: appHead({ title: "My app", script: [{ children: "..." }] }),
46
- * });
47
- *
48
- * export default { fetch: (request: Request) => handler.handle(request) };
12
+ * ```ts
13
+ * oxide({ middleware: ["@ilha/router/ssr"] });
49
14
  * ```
15
+ *
16
+ * Serves:
17
+ * - `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }` in,
18
+ * `{ html }` out). Renderers come from the process-global registry
19
+ * populated by self-registration code appended to `.server` modules.
20
+ * - `GET /__ilha/loader?path=…` — regular-page server loads via the loader
21
+ * runner (`setFrameLoaderRunner`, wired by the generated server module).
50
22
  */
51
- var IlhaHandler = class {
52
- assets;
53
- lang;
54
- appId;
55
- clientEntry;
56
- head;
57
- renderOptions;
58
- constructor(options) {
59
- this.assets = options.assets;
60
- this.lang = options.lang ?? "en";
61
- this.appId = options.appId ?? "app";
62
- this.clientEntry = options.clientEntry ?? "/entry-client.js";
63
- this.head = options.head ?? {};
64
- this.renderOptions = options.renderOptions ?? {};
65
- }
66
- /** Render the document shell around an already-rendered island body. */
67
- document(body, head) {
68
- const clientEntry = this.assets.entry ?? this.clientEntry;
69
- const styles = this.assets.css.map(stylesheetTag).join("\n ");
70
- const titleTag = head?.headTags.includes("<title") ? "" : `<title>Ilha</title>\n `;
71
- const routeHead = head?.headTags ? `\n ${head.headTags}` : "";
72
- const htmlAttrsStr = head?.htmlAttrs ?? "";
73
- const langFromHead = htmlAttrsStr.match(/\blang="([^"]*)"/)?.[1];
74
- return `<!doctype html>
75
- <html lang="${langFromHead == null ? this.lang : langFromHead}"${htmlAttrsStr.replace(/\s*lang="[^"]*"/, "")}>
76
- <head>
77
- <meta charset="UTF-8" />
78
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
79
- ${titleTag}<link rel="icon" href="/favicon.svg" />
80
- ${styles}${routeHead}
81
- </head>
82
- <body${head?.bodyAttrs ?? ""}>
83
- <div id="${escapeAttr(this.appId)}">${body}</div>
84
- <script type="module" src="${escapeAttr(clientEntry)}"><\/script>
85
- </body>
86
- </html>`;
23
+ const FRAME_ENDPOINT = "/__ilha/frame";
24
+ /** Regular-page server loads: served through the loader-runner slot. */
25
+ const LOADER_ENDPOINT = "/__ilha/loader";
26
+ /** Max request body size — matches the dev middleware cap. */
27
+ const MAX_BODY = 16384;
28
+ function json(status, body) {
29
+ return new Response(JSON.stringify(body), {
30
+ status,
31
+ headers: {
32
+ "cache-control": "no-store",
33
+ "content-type": "application/json;charset=utf-8"
34
+ }
35
+ });
36
+ }
37
+ async function ssr(request) {
38
+ let pathname;
39
+ try {
40
+ pathname = new URL(request.url).pathname;
41
+ } catch {
42
+ return json(400, { error: "frame failed" });
87
43
  }
88
- /** Handle an incoming request and return a full HTML / redirect Response. */
89
- async handle(request) {
44
+ if (pathname !== "/__ilha/frame" && pathname !== "/__ilha/loader") return;
45
+ const origin = request.headers.get("origin");
46
+ const host = request.headers.get("host");
47
+ if (origin && host && origin !== `http://${host}` && origin !== `https://${host}`) return json(403, { error: "frame failed" });
48
+ if (pathname === "/__ilha/loader") {
49
+ if (request.method !== "GET") return json(405, { error: "method not allowed" });
90
50
  try {
91
- return await this.handleInner(request);
92
- } catch (e) {
93
- console.error("[ilha-router] request handling failed:", e);
94
- return new Response("Internal Server Error", {
95
- status: 500,
96
- headers: { "content-type": "text/plain;charset=utf-8" }
97
- });
51
+ const denied = await getFrameGuard()?.(request);
52
+ if (denied) return denied;
53
+ } catch {
54
+ return json(403, { error: "loader failed" });
98
55
  }
99
- }
100
- async handleInner(request) {
101
- let url;
56
+ const runner = getFrameLoaderRunner();
57
+ if (!runner) return json(404, {
58
+ kind: "error",
59
+ status: 404,
60
+ message: "not found"
61
+ });
62
+ const cl = request.headers.get("content-length");
63
+ if (cl && Number(cl) > MAX_BODY) return json(413, { error: "frame failed" });
64
+ let target = "/";
102
65
  try {
103
- url = new URL(request.url);
66
+ target = new URL(request.url).searchParams.get("path") ?? "/";
104
67
  } catch {
105
- return new Response("Bad Request", {
68
+ return json(400, {
69
+ kind: "error",
106
70
  status: 400,
107
- headers: { "content-type": "text/plain;charset=utf-8" }
71
+ message: "bad request"
108
72
  });
109
73
  }
110
- if (url.pathname === "/__ilha/loader") {
111
- if (request.method !== "GET" && request.method !== "HEAD") return new Response(JSON.stringify({
112
- kind: "error",
113
- status: 405,
114
- message: "Method Not Allowed"
115
- }), {
116
- status: 405,
117
- headers: {
118
- "content-type": "application/json;charset=utf-8",
119
- allow: "GET, HEAD"
120
- }
74
+ if (!target.startsWith("/") || target.includes("//") || target.length > 2048) return json(400, {
75
+ kind: "error",
76
+ status: 400,
77
+ message: "bad request"
78
+ });
79
+ try {
80
+ const result = await runner(target);
81
+ if (result.kind === "redirect") return json(result.status || 302, {
82
+ kind: "redirect",
83
+ to: result.to,
84
+ status: result.status
121
85
  });
122
- const path = url.searchParams.get("path") ?? "/";
123
- const result = await pageRouter.runLoader(path, request);
124
- const status = result.kind === "error" ? result.status : result.kind === "not-found" ? 404 : 200;
125
- return new Response(JSON.stringify(result), {
126
- status,
127
- headers: {
128
- "content-type": "application/json;charset=utf-8",
129
- "cache-control": "no-store"
130
- }
86
+ if (result.kind !== "data") {
87
+ const status = result.status || 500;
88
+ return json(status, {
89
+ kind: result.kind,
90
+ status,
91
+ message: result.message
92
+ });
93
+ }
94
+ return json(200, result);
95
+ } catch (error) {
96
+ console.error("[ilha-router] loader endpoint failed:", error);
97
+ return json(500, {
98
+ kind: "error",
99
+ status: 500,
100
+ message: "loader failed"
131
101
  });
132
102
  }
133
- const href = url.href.slice(url.origin.length);
134
- const renderOptions = {
135
- ...this.renderOptions,
136
- baseHead: this.head
137
- };
138
- const response = await pageRouter.renderResponse(href, registry, renderOptions, request);
139
- if (response.kind === "redirect") return new Response(null, {
140
- status: response.status,
141
- headers: { location: response.to }
142
- });
143
- const status = response.kind === "error" ? response.status : response.status ?? 200;
144
- return new Response(this.document(response.html, response.head), {
145
- status,
146
- headers: { "content-type": "text/html;charset=utf-8" }
103
+ }
104
+ if (request.method !== "POST") return json(405, { error: "frame failed" });
105
+ if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return json(415, { error: "frame failed" });
106
+ try {
107
+ const denied = await getFrameGuard()?.(request);
108
+ if (denied) return denied;
109
+ } catch (error) {
110
+ console.error("[ilha-router] frame guard failed:", error);
111
+ return json(403, { error: "frame failed" });
112
+ }
113
+ let id;
114
+ let path = "/";
115
+ try {
116
+ const text = await request.text();
117
+ if (text.length > MAX_BODY) return json(413, { error: "frame failed" });
118
+ const body = JSON.parse(text);
119
+ id = String(body.id ?? "");
120
+ if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && body.path.length <= 2048) path = body.path;
121
+ } catch {
122
+ return json(400, { error: "frame failed" });
123
+ }
124
+ try {
125
+ const headers = new Headers();
126
+ for (const name of [
127
+ "cookie",
128
+ "authorization",
129
+ "user-agent",
130
+ "x-forwarded-for"
131
+ ]) {
132
+ const value = request.headers.get(name);
133
+ if (value !== null) headers.set(name, value);
134
+ }
135
+ const scoped = new Request(new URL(path, `http://${host ?? "localhost"}`), {
136
+ method: "POST",
137
+ headers
147
138
  });
139
+ return json(200, { html: await renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn))) });
140
+ } catch (error) {
141
+ if (error instanceof FrameError) {
142
+ if (error.redirect) return json(error.status, { redirect: error.redirect });
143
+ if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
144
+ return json(error.status, { error: "frame failed" });
145
+ }
146
+ console.error("[ilha-router] frame render failed:", error);
147
+ return json(400, { error: "frame failed" });
148
148
  }
149
- };
149
+ }
150
+ /** Side-effect imports required alongside this handler. */
151
+ ssr.imports = ["ilha:pages/server", "ilha:loaders"];
150
152
 
151
153
  //#endregion
152
- export { IlhaHandler, appHead, mergeAssets };
154
+ export { FRAME_ENDPOINT, LOADER_ENDPOINT, ssr as default };
package/dist/vite.d.ts CHANGED
@@ -4,3 +4,4 @@ export { ilhaPages, type IlhaPagesOptions } from "./plugin";
4
4
  import { type IlhaPagesOptions } from "./plugin";
5
5
  /** Vite plugin — use via `@ilha/router/vite`. */
6
6
  export declare function pages(options?: IlhaPagesOptions): Plugin;
7
+ export default pages;
package/dist/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-DdquB3Nf.js";
1
+ import { t as ilhaPages } from "./plugin-DPGzrIVj.js";
2
2
 
3
3
  //#region src/vite.ts
4
4
  /** Vite plugin — use via `@ilha/router/vite`. */
@@ -7,4 +7,4 @@ function pages(options = {}) {
7
7
  }
8
8
 
9
9
  //#endregion
10
- export { ilhaPages, pages };
10
+ export { pages as default, pages, ilhaPages };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.8.13",
3
+ "version": "0.9.0",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -39,10 +39,18 @@
39
39
  "types": "./dist/index.d.ts",
40
40
  "import": "./dist/index.js"
41
41
  },
42
+ "./server-island": {
43
+ "types": "./dist/server-island.d.ts",
44
+ "import": "./dist/server-island.js"
45
+ },
42
46
  "./ssr": {
43
47
  "types": "./dist/ssr.d.ts",
44
48
  "import": "./dist/ssr.js"
45
49
  },
50
+ "./server-island-registry": {
51
+ "types": "./dist/server-island-registry.d.ts",
52
+ "import": "./dist/server-island-registry.js"
53
+ },
46
54
  "./vite": {
47
55
  "types": "./dist/vite.d.ts",
48
56
  "import": "./dist/vite.js"
@@ -65,14 +73,13 @@
65
73
  "test": "bun test"
66
74
  },
67
75
  "dependencies": {
68
- "rou3": "0.9.1",
69
76
  "unplugin": "3.3.0"
70
77
  },
71
78
  "devDependencies": {
72
- "ilha": "0.10.3",
73
- "vite": "^8.2.0"
79
+ "ilha": "0.11.0",
80
+ "vite": "^8.2.2"
74
81
  },
75
82
  "peerDependencies": {
76
- "ilha": ">=0.10.3"
83
+ "ilha": ">=0.11.0"
77
84
  }
78
85
  }