@ilha/router 0.8.13 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Build-time support for server-defined islands. The plugin scans
3
+ * `*.server.ts(x)` modules for island exports (`export const X = ilha…`),
4
+ * generates a client virtual module per file that re-creates those exports as
5
+ * proxies wired to tacho stubs, and rewrites client-graph import sites so
6
+ * island bindings resolve to the proxy while everything else keeps flowing
7
+ * through oxidejs's tacho stub replacement.
8
+ */
9
+ export interface ScannedServerIsland {
10
+ /** Export binding name, or `"default"` for `export default ilha…`. */
11
+ name: string;
12
+ /** Slot tag from `.as()` — must match what SSR emits. */
13
+ as: string;
14
+ /** Stream key → referenced module export used as its transport. */
15
+ streams: Record<string, string>;
16
+ /** Action key → referenced module export used as its transport. */
17
+ actions: Record<string, string>;
18
+ }
19
+ export interface ClientIslandRef {
20
+ id: string;
21
+ local: string;
22
+ imported: string;
23
+ spec: string;
24
+ }
25
+ export interface ServerModuleScan {
26
+ islands: ScannedServerIsland[];
27
+ /** All value-export names of the module (transport candidates). */
28
+ exports: string[];
29
+ /** Imported JSX components that must hydrate inside the server island. */
30
+ clientRefs: ClientIslandRef[];
31
+ /** True when the module declares `export const load = loader.client(…)` —
32
+ * the proxy wires it as an RPC call invoked when the view hydrates. */
33
+ clientLoader?: boolean;
34
+ }
35
+ export declare function clientRefPublicId(spec: string, imported: string): string;
36
+ /** Scan a `*.server.ts(x)` module source for island exports and their
37
+ * declarative wiring. Convention: islands start with `ilha` — both builder
38
+ * chains (`ilha.state()…render()`) and direct factories (`ilha(() => …)`). */
39
+ export declare function scanServerIslands(source: string): ServerModuleScan;
40
+ export declare function loadServerModuleScan(path: string): ServerModuleScan;
41
+ /** Virtual-module id prefix for generated client proxies of server islands.
42
+ * The file path rides base64url-encoded: a raw suffix like
43
+ * `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
44
+ * loader would claim the virtual module before us. */
45
+ export declare const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
46
+ /** Virtual-module specifier serving the client proxy for one server island file. */
47
+ export declare function serverIslandVirtualSpec(file: string): string;
48
+ /** Emit the client virtual module for one scanned server file. Plain JS —
49
+ * `\0` virtual modules bypass Vite's built-in TS transform, so type-only
50
+ * constructs here would reach the browser unparsed. Editor types are
51
+ * unaffected: TS resolves the ORIGINAL specifier (the real server module);
52
+ * this module exists only inside the client bundle. Frames are fetched from
53
+ * the plugin's `/__ilha/frame` dev middleware. */
54
+ export declare function serverIslandPublicId(spec: string, name: string): string;
55
+ export declare function generateServerIslandModule(spec: string, scan: ServerModuleScan): string;
56
+ export interface SplitContext {
57
+ /** Resolved absolute path of the imported specifier, when it's a scanned
58
+ * server module carrying islands; null otherwise. */
59
+ islandNamesFor(spec: string): {
60
+ islands: Set<string>;
61
+ hasDefault: boolean;
62
+ } | null;
63
+ /** Virtual module specifier that provides the island bindings. */
64
+ virtualSpecFor(spec: string): string;
65
+ }
66
+ /**
67
+ * Rewrite import sites whose specifier targets a server module containing
68
+ * island exports. Island bindings move to the virtual proxy module; all other
69
+ * bindings stay on the original specifier (oxidejs replaces them with tacho
70
+ * stubs). Returns null when no statement needed rewriting.
71
+ */
72
+ export declare function splitServerImports(code: string, ctx: SplitContext): string | null;
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,160 @@
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-C4reU4v0.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
+ for (const sym of Object.getOwnPropertySymbols(request)) {
140
+ if (Symbol.keyFor(sym) === void 0) continue;
141
+ try {
142
+ scoped[sym] = request[sym];
143
+ } catch {}
144
+ }
145
+ return json(200, { html: await renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn))) });
146
+ } catch (error) {
147
+ if (error instanceof FrameError) {
148
+ if (error.redirect) return json(error.status, { redirect: error.redirect });
149
+ if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
150
+ return json(error.status, { error: "frame failed" });
151
+ }
152
+ console.error("[ilha-router] frame render failed:", error);
153
+ return json(400, { error: "frame failed" });
148
154
  }
149
- };
155
+ }
156
+ /** Side-effect imports required alongside this handler. */
157
+ ssr.imports = ["ilha:pages/server", "ilha:loaders"];
150
158
 
151
159
  //#endregion
152
- export { IlhaHandler, appHead, mergeAssets };
160
+ 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-DogkskcY.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.1",
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
  }