@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.
- package/README.md +1 -1
- package/dist/codegen.d.ts +3 -0
- package/dist/index.d.ts +28 -3
- package/dist/index.js +2247 -2
- package/dist/{plugin-DdquB3Nf.js → plugin-DogkskcY.js} +510 -15
- package/dist/plugin.d.ts +9 -1
- package/dist/request-scope-C4reU4v0.js +34 -0
- package/dist/request-scope.d.ts +22 -0
- package/dist/rolldown.d.ts +2 -1
- package/dist/rolldown.js +2 -2
- package/dist/rspack.d.ts +1 -0
- package/dist/rspack.js +2 -2
- package/dist/server-island-registry.d.ts +77 -0
- package/dist/server-island-registry.js +134 -0
- package/dist/server-island.d.ts +44 -0
- package/dist/server-island.js +229 -0
- package/dist/server-islands.d.ts +72 -0
- package/dist/ssr.d.ts +17 -88
- package/dist/ssr.js +138 -130
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +2 -2
- package/package.json +12 -5
- package/dist/src-BKWkRtMx.js +0 -2119
package/dist/plugin.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare const RESOLVED_LOADERS = "\0ilha:loaders";
|
|
|
8
8
|
export declare const RESOLVED_VIRTUAL_IDS: readonly ["\0ilha:pages/server", "\0ilha:pages/client", "\0ilha:loaders"];
|
|
9
9
|
/** Query suffix used on page/layout imports in the client file. */
|
|
10
10
|
export declare const CLIENT_QUERY = "?client";
|
|
11
|
-
/** Query suffix that re-exports a page/layout's `
|
|
11
|
+
/** Query suffix that re-exports a page/layout's `load` (loader.client) for the browser bundle. */
|
|
12
12
|
export declare const CLIENT_LOADER_QUERY = "?client-loader";
|
|
13
13
|
export interface IlhaPagesOptions {
|
|
14
14
|
/** Directory containing page files. Default: `src/pages` */
|
|
@@ -28,6 +28,14 @@ export interface IlhaPagesOptions {
|
|
|
28
28
|
* Default: `true`.
|
|
29
29
|
*/
|
|
30
30
|
interceptLinks?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Guard consulted on every `/__ilha/frame` request before a render runs.
|
|
33
|
+
* Return a `Response` to reject; return nothing to allow. Island state is
|
|
34
|
+
* world-readable through frames unless gated — install a session check here
|
|
35
|
+
* when islands serve private data. Production equivalents register via
|
|
36
|
+
* `setFrameGuard()` from `@ilha/router/server-island-registry`.
|
|
37
|
+
*/
|
|
38
|
+
frameGuard?: (request: Request) => Response | void | Promise<Response | void>;
|
|
31
39
|
/**
|
|
32
40
|
* Fail codegen on duplicate route patterns / registry name collisions
|
|
33
41
|
* instead of warning. Recommended for CI/production builds. Default: `false`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
//#region src/request-scope.ts
|
|
4
|
+
/**
|
|
5
|
+
* Request scope for server-owned island rendering.
|
|
6
|
+
*
|
|
7
|
+
* A `.server.tsx` island's render function always executes on the server —
|
|
8
|
+
* page SSR through the router, or streamed frames through the plugin's
|
|
9
|
+
* `/__ilha/frame` endpoint. Both seed this scope with the originating
|
|
10
|
+
* `Request`, so render functions can read request data (URL, headers,
|
|
11
|
+
* cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
|
|
12
|
+
*
|
|
13
|
+
* The storage lives on `globalThis` under `ilha.requestAls` so every module
|
|
14
|
+
* copy (plugin bundle, SSR graph) shares one instance. The public accessor
|
|
15
|
+
* is `useContext()` from the main `@ilha/router` entry, which reads the
|
|
16
|
+
* storage without importing `node:async_hooks`; this node-only module is the
|
|
17
|
+
* sole place that constructs it.
|
|
18
|
+
*/
|
|
19
|
+
const REQUEST_ALS_KEY = Symbol.for("ilha.requestAls");
|
|
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. */
|
|
26
|
+
function runWithIslandRequest(request, fn) {
|
|
27
|
+
const g = globalThis;
|
|
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());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { runWithIslandRequest as t };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request scope for server-owned island rendering.
|
|
3
|
+
*
|
|
4
|
+
* A `.server.tsx` island's render function always executes on the server —
|
|
5
|
+
* page SSR through the router, or streamed frames through the plugin's
|
|
6
|
+
* `/__ilha/frame` endpoint. Both seed this scope with the originating
|
|
7
|
+
* `Request`, so render functions can read request data (URL, headers,
|
|
8
|
+
* cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
|
|
9
|
+
*
|
|
10
|
+
* The storage lives on `globalThis` under `ilha.requestAls` so every module
|
|
11
|
+
* copy (plugin bundle, SSR graph) shares one instance. The public accessor
|
|
12
|
+
* is `useContext()` from the main `@ilha/router` entry, which reads the
|
|
13
|
+
* storage without importing `node:async_hooks`; this node-only module is the
|
|
14
|
+
* sole place that constructs it.
|
|
15
|
+
*/
|
|
16
|
+
import type { IslandContext } from "./index";
|
|
17
|
+
export declare const REQUEST_ALS_KEY: unique symbol;
|
|
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. */
|
|
21
|
+
export declare function runWithIslandRequest<T>(request: Request, fn: () => T): T;
|
|
22
|
+
export type { IslandContext };
|
package/dist/rolldown.d.ts
CHANGED
|
@@ -2,4 +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("
|
|
5
|
+
export declare function pages(options?: IlhaPagesOptions): import("rolldown").Plugin<any> | import("rolldown").Plugin<any>[];
|
|
6
|
+
export default pages;
|
package/dist/rolldown.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as ilhaPages } from "./plugin-
|
|
1
|
+
import { t as ilhaPages } from "./plugin-DogkskcY.js";
|
|
2
2
|
|
|
3
3
|
//#region src/rolldown.ts
|
|
4
4
|
/** Rolldown plugin — use via `@ilha/router/rolldown`. */
|
|
@@ -7,4 +7,4 @@ function pages(options = {}) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
|
-
export {
|
|
10
|
+
export { pages as default, pages, ilhaPages };
|
package/dist/rspack.d.ts
CHANGED
package/dist/rspack.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as ilhaPages } from "./plugin-
|
|
1
|
+
import { t as ilhaPages } from "./plugin-DogkskcY.js";
|
|
2
2
|
|
|
3
3
|
//#region src/rspack.ts
|
|
4
4
|
/** Rspack plugin — use via `@ilha/router/rspack`. */
|
|
@@ -7,4 +7,4 @@ function pages(options = {}) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
|
-
export {
|
|
10
|
+
export { pages as default, pages, ilhaPages };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-global registry of server-island renderers, keyed by the public
|
|
3
|
+
* island id (`sha256(file#name)`, see `serverIslandPublicId`). Lives on
|
|
4
|
+
* `globalThis` so every module copy (plugin bundle, SSR graph, frame entry)
|
|
5
|
+
* shares one instance — same pattern as `request-scope.ts`.
|
|
6
|
+
*
|
|
7
|
+
* `.server` modules self-register when the plugin appends registration code
|
|
8
|
+
* to their server-graph copy; the production `/__ilha/frame` handler (see
|
|
9
|
+
* `@ilha/router/frame`) consumes the registry to re-render an island from a
|
|
10
|
+
* client state snapshot. Server pages additionally register their `load` and
|
|
11
|
+
* route pattern so frame handlers can run the loader with matched params.
|
|
12
|
+
*/
|
|
13
|
+
/** Loader context for server-page `load` — mirrors the router's shape. */
|
|
14
|
+
export interface FrameLoaderContext {
|
|
15
|
+
params: Record<string, string>;
|
|
16
|
+
request: Request;
|
|
17
|
+
url: URL;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export type ServerPageLoader = (ctx: FrameLoaderContext) => unknown;
|
|
21
|
+
/** A frame render: optionally preceded by running the page's `load`. */
|
|
22
|
+
export interface ServerIslandEntry {
|
|
23
|
+
/** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
|
|
24
|
+
render: () => unknown;
|
|
25
|
+
/** The module's `load` export — runs at frame time; its return value
|
|
26
|
+
* becomes the island's render props. */
|
|
27
|
+
load?: ServerPageLoader;
|
|
28
|
+
/** Route pattern for the page (`/user/:id`) — matches params for `load`. */
|
|
29
|
+
pattern?: string;
|
|
30
|
+
}
|
|
31
|
+
export type FrameGuard = (request: Request) => Response | void | Promise<Response | void>;
|
|
32
|
+
/**
|
|
33
|
+
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
34
|
+
* and the production `@ilha/router/frame` handler share this slot — both read
|
|
35
|
+
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
36
|
+
* allow. Island state is world-readable through frames unless you gate them,
|
|
37
|
+
* so apps serving private data should install a session check here.
|
|
38
|
+
*/
|
|
39
|
+
export declare function setFrameGuard(guard: FrameGuard): void;
|
|
40
|
+
export declare function getFrameGuard(): FrameGuard | undefined;
|
|
41
|
+
export type FrameLoaderRunner = (path: string) => Promise<{
|
|
42
|
+
kind: string;
|
|
43
|
+
data?: unknown;
|
|
44
|
+
headEntries?: unknown;
|
|
45
|
+
status?: number;
|
|
46
|
+
to?: string;
|
|
47
|
+
message?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Install the handler backing `GET /__ilha/loader` in production. The
|
|
51
|
+
* generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
|
|
52
|
+
* regular-page server loads get full route matching, layout chains, and
|
|
53
|
+
* redirect/error semantics. Dev and prod handlers share the slot.
|
|
54
|
+
*/
|
|
55
|
+
export declare function setFrameLoaderRunner(runner: FrameLoaderRunner): void;
|
|
56
|
+
export declare function getFrameLoaderRunner(): FrameLoaderRunner | undefined;
|
|
57
|
+
/** Register `id` → entry. Later registrations win (id encodes file + name). */
|
|
58
|
+
export declare function registerServerIsland(id: string, render: () => unknown, options?: {
|
|
59
|
+
load?: ServerPageLoader;
|
|
60
|
+
pattern?: string;
|
|
61
|
+
}): void;
|
|
62
|
+
export declare function getServerIslandEntry(id: string): ServerIslandEntry | undefined;
|
|
63
|
+
/** Back-compat alias used by tests. */
|
|
64
|
+
export declare const getServerIslandRenderer: typeof getServerIslandEntry;
|
|
65
|
+
/** Client-facing frame failure. `redirect` carries a loader redirect target. */
|
|
66
|
+
export declare class FrameError extends Error {
|
|
67
|
+
status: number;
|
|
68
|
+
redirect?: string;
|
|
69
|
+
constructor(status: number, message: string, redirect?: string);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Shared tail of every frame request: run the page's `load` when registered
|
|
73
|
+
* (params matched from the frame path), then invoke the renderer inside the
|
|
74
|
+
* caller's scope. Throws `FrameError` with an HTTP status for client-facing
|
|
75
|
+
* failures; loader redirects surface via `FrameError.redirect`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function renderServerIsland(id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>): Promise<string>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
//#region src/server-island-registry.ts
|
|
2
|
+
const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
|
|
3
|
+
function registry() {
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
let map = g[REGISTRY_KEY];
|
|
6
|
+
if (!map) {
|
|
7
|
+
map = /* @__PURE__ */ new Map();
|
|
8
|
+
g[REGISTRY_KEY] = map;
|
|
9
|
+
}
|
|
10
|
+
return map;
|
|
11
|
+
}
|
|
12
|
+
const GUARD_KEY = Symbol.for("ilha.frameGuard");
|
|
13
|
+
/**
|
|
14
|
+
* Install a guard consulted by every `/__ilha/frame` request (dev middleware
|
|
15
|
+
* and the production `@ilha/router/frame` handler share this slot — both read
|
|
16
|
+
* it from `globalThis`). Return a `Response` to reject; return nothing to
|
|
17
|
+
* allow. Island state is world-readable through frames unless you gate them,
|
|
18
|
+
* so apps serving private data should install a session check here.
|
|
19
|
+
*/
|
|
20
|
+
function setFrameGuard(guard) {
|
|
21
|
+
const g = globalThis;
|
|
22
|
+
g[GUARD_KEY] = guard;
|
|
23
|
+
}
|
|
24
|
+
function getFrameGuard() {
|
|
25
|
+
return globalThis[GUARD_KEY];
|
|
26
|
+
}
|
|
27
|
+
const LOADER_RUNNER_KEY = Symbol.for("ilha.frameLoaderRunner");
|
|
28
|
+
/**
|
|
29
|
+
* Install the handler backing `GET /__ilha/loader` in production. The
|
|
30
|
+
* generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
|
|
31
|
+
* regular-page server loads get full route matching, layout chains, and
|
|
32
|
+
* redirect/error semantics. Dev and prod handlers share the slot.
|
|
33
|
+
*/
|
|
34
|
+
function setFrameLoaderRunner(runner) {
|
|
35
|
+
const g = globalThis;
|
|
36
|
+
g[LOADER_RUNNER_KEY] = runner;
|
|
37
|
+
}
|
|
38
|
+
function getFrameLoaderRunner() {
|
|
39
|
+
return globalThis[LOADER_RUNNER_KEY];
|
|
40
|
+
}
|
|
41
|
+
/** Register `id` → entry. Later registrations win (id encodes file + name). */
|
|
42
|
+
function registerServerIsland(id, render, options) {
|
|
43
|
+
registry().set(id, {
|
|
44
|
+
render,
|
|
45
|
+
load: options?.load,
|
|
46
|
+
pattern: options?.pattern
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function getServerIslandEntry(id) {
|
|
50
|
+
return registry().get(id);
|
|
51
|
+
}
|
|
52
|
+
/** Back-compat alias used by tests. */
|
|
53
|
+
const getServerIslandRenderer = getServerIslandEntry;
|
|
54
|
+
/** Client-facing frame failure. `redirect` carries a loader redirect target. */
|
|
55
|
+
var FrameError = class extends Error {
|
|
56
|
+
status;
|
|
57
|
+
redirect;
|
|
58
|
+
constructor(status, message, redirect) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.status = status;
|
|
61
|
+
this.redirect = redirect;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/** 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. */
|
|
67
|
+
function matchPatternParams(pattern, pathname) {
|
|
68
|
+
const patternSegments = pattern.split("/").filter(Boolean);
|
|
69
|
+
const pathSegments = pathname.split("/").filter(Boolean);
|
|
70
|
+
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;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Shared tail of every frame request: run the page's `load` when registered
|
|
89
|
+
* (params matched from the frame path), then invoke the renderer inside the
|
|
90
|
+
* caller's scope. Throws `FrameError` with an HTTP status for client-facing
|
|
91
|
+
* failures; loader redirects surface via `FrameError.redirect`.
|
|
92
|
+
*/
|
|
93
|
+
async function renderServerIsland(id, request, runWithScope) {
|
|
94
|
+
const entry = registry().get(id);
|
|
95
|
+
if (!entry) throw new FrameError(400, "unknown island");
|
|
96
|
+
let props;
|
|
97
|
+
if (entry.load) {
|
|
98
|
+
let url;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(request.url);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new FrameError(400, "frame failed");
|
|
103
|
+
}
|
|
104
|
+
const params = entry.pattern ? matchPatternParams(entry.pattern, url.pathname) : {};
|
|
105
|
+
if (!params) throw new FrameError(400, "frame failed");
|
|
106
|
+
try {
|
|
107
|
+
props = { load: {
|
|
108
|
+
loading: false,
|
|
109
|
+
value: await entry.load({
|
|
110
|
+
params,
|
|
111
|
+
request,
|
|
112
|
+
url,
|
|
113
|
+
signal: request.signal
|
|
114
|
+
}) ?? {},
|
|
115
|
+
error: void 0
|
|
116
|
+
} };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const marker = error;
|
|
119
|
+
if (marker.__ilhaRedirect === true) {
|
|
120
|
+
const r = error;
|
|
121
|
+
throw new FrameError(r.status || 302, "frame failed", r.to);
|
|
122
|
+
}
|
|
123
|
+
if (marker.__ilhaLoaderError === true) throw new FrameError(error.status || 500, "frame failed");
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const render = entry.render();
|
|
128
|
+
if (typeof render !== "function") throw new FrameError(400, "unknown island");
|
|
129
|
+
const html = await runWithScope(request, () => render(props));
|
|
130
|
+
return String(html);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
export { FrameError, getFrameGuard, getFrameLoaderRunner, getServerIslandEntry, getServerIslandRenderer, registerServerIsland, renderServerIsland, setFrameGuard, setFrameLoaderRunner };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side proxies for islands defined in server-only modules
|
|
3
|
+
* (`*.server.ts(x)`).
|
|
4
|
+
*
|
|
5
|
+
* The real island never ships to the browser — it closes over server code.
|
|
6
|
+
* The Vite plugin rewrites client-graph imports of island exports to this
|
|
7
|
+
* factory, wiring each stream/action key to the tacho stub of the exported
|
|
8
|
+
* function it calls. The proxy is a branded ilha island so composition
|
|
9
|
+
* (`<Tasks />` inside a parent render) works unchanged:
|
|
10
|
+
*
|
|
11
|
+
* - SSR (server graph): imports resolve to the REAL module — no proxies.
|
|
12
|
+
* - Hydration (client): `mount` seeds state from `data-ilha-state`, preserves
|
|
13
|
+
* the SSR DOM, resumes streams through the wired stubs, and reconnects
|
|
14
|
+
* `[data-ilha-on]` event sentinels to named actions using the
|
|
15
|
+
* `data-ilha-actions` manifest emitted by `hydratable()`.
|
|
16
|
+
*/
|
|
17
|
+
export type ServerStreamFn = (signal: AbortSignal) => AsyncGenerator<unknown> | Generator<unknown>;
|
|
18
|
+
export interface ServerIslandWiring {
|
|
19
|
+
/** Stream key → client transport. The plugin wires these to tacho stubs. */
|
|
20
|
+
streams?: Record<string, ServerStreamFn>;
|
|
21
|
+
/** Action key → client transport. Event payloads are not serializable;
|
|
22
|
+
* handlers receive `undefined` and should read island state instead. */
|
|
23
|
+
actions?: Record<string, (payload?: unknown) => unknown>;
|
|
24
|
+
/** Frame transport: re-renders the island from server-owned state. */
|
|
25
|
+
frame?: () => unknown;
|
|
26
|
+
/** RPC transport for the module's `loader.client` export — invoked once
|
|
27
|
+
* when the view hydrates. Side-effect loader on server pages. */
|
|
28
|
+
clientLoader?: () => unknown;
|
|
29
|
+
/** Client-capable islands nested in the server render, keyed by opaque ref. */
|
|
30
|
+
children?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export interface ServerIslandHandle {
|
|
33
|
+
unmount: () => void;
|
|
34
|
+
updateProps: (props?: Record<string, unknown>) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a client proxy island for a server-defined island. Called by
|
|
38
|
+
* generated virtual modules — not by application code.
|
|
39
|
+
*
|
|
40
|
+
* @param id - Stable identity (`<relative-path>#<export>`), for diagnostics.
|
|
41
|
+
* @param as - Slot tag declared by the server island's `.as()` (default div).
|
|
42
|
+
* @param wiring - Stream/action transports wired to tacho stubs by codegen.
|
|
43
|
+
*/
|
|
44
|
+
export declare function __ilhaServerIsland(id: string, as: string, wiring?: ServerIslandWiring): unknown;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { morph } from "ilha";
|
|
2
|
+
|
|
3
|
+
//#region src/server-island.ts
|
|
4
|
+
/**
|
|
5
|
+
* Client-side proxies for islands defined in server-only modules
|
|
6
|
+
* (`*.server.ts(x)`).
|
|
7
|
+
*
|
|
8
|
+
* The real island never ships to the browser — it closes over server code.
|
|
9
|
+
* The Vite plugin rewrites client-graph imports of island exports to this
|
|
10
|
+
* factory, wiring each stream/action key to the tacho stub of the exported
|
|
11
|
+
* function it calls. The proxy is a branded ilha island so composition
|
|
12
|
+
* (`<Tasks />` inside a parent render) works unchanged:
|
|
13
|
+
*
|
|
14
|
+
* - SSR (server graph): imports resolve to the REAL module — no proxies.
|
|
15
|
+
* - Hydration (client): `mount` seeds state from `data-ilha-state`, preserves
|
|
16
|
+
* the SSR DOM, resumes streams through the wired stubs, and reconnects
|
|
17
|
+
* `[data-ilha-on]` event sentinels to named actions using the
|
|
18
|
+
* `data-ilha-actions` manifest emitted by `hydratable()`.
|
|
19
|
+
*/
|
|
20
|
+
/** Symbol.for keeps brands stable across duplicate ilha copies in one realm. */
|
|
21
|
+
const ISLAND = Symbol.for("ilha.island");
|
|
22
|
+
const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
|
|
23
|
+
const ISLAND_MOUNT_INTERNAL$1 = Symbol.for("ilha.islandMountInternal");
|
|
24
|
+
const STATE_ATTR = "data-ilha-state";
|
|
25
|
+
const EVENT_SENTINEL_ATTR = "data-ilha-on";
|
|
26
|
+
const ACTIONS_ATTR = "data-ilha-actions";
|
|
27
|
+
const PROPS_ATTR = "data-ilha-props";
|
|
28
|
+
const CLIENT_REF_ATTR = "data-ilha-client-ref";
|
|
29
|
+
/** Defensive snapshot parse — mirrors core's guards in miniature. */
|
|
30
|
+
function parseSnapshot(raw) {
|
|
31
|
+
if (raw.length > 262144) return void 0;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
35
|
+
return parsed;
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function assertValidTag(tag) {
|
|
41
|
+
const trimmed = tag.trim();
|
|
42
|
+
if (/^[a-z][a-z0-9-]*$/i.test(trimmed)) return trimmed.toLowerCase();
|
|
43
|
+
return "div";
|
|
44
|
+
}
|
|
45
|
+
/** True when `candidate` is owned by `host`: walking up must not cross another
|
|
46
|
+
* island or slot boundary before reaching it. */
|
|
47
|
+
function belongsToHost(host, candidate) {
|
|
48
|
+
let el = candidate.parentElement;
|
|
49
|
+
while (el && el !== host) {
|
|
50
|
+
if (el.hasAttribute("data-ilha") || el.hasAttribute("data-ilha-slot")) return false;
|
|
51
|
+
el = el.parentElement;
|
|
52
|
+
}
|
|
53
|
+
return el === host;
|
|
54
|
+
}
|
|
55
|
+
function hydrateServerIsland(host, id, wiring) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const cleanups = [];
|
|
58
|
+
cleanups.push(() => controller.abort());
|
|
59
|
+
const state = {};
|
|
60
|
+
const rawState = host.getAttribute(STATE_ATTR);
|
|
61
|
+
if (rawState) {
|
|
62
|
+
const parsed = parseSnapshot(rawState);
|
|
63
|
+
if (parsed) {
|
|
64
|
+
for (const [key, value] of Object.entries(parsed)) if (!key.startsWith("_")) state[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const frame = wiring.frame;
|
|
68
|
+
let repaintChain = Promise.resolve();
|
|
69
|
+
const scheduleRepaint = () => {
|
|
70
|
+
if (!frame || controller.signal.aborted) return;
|
|
71
|
+
repaintChain = repaintChain.then(async () => {
|
|
72
|
+
if (controller.signal.aborted || !host.isConnected) return;
|
|
73
|
+
const html = await frame();
|
|
74
|
+
if (controller.signal.aborted || typeof html !== "string") return;
|
|
75
|
+
morph(host, html);
|
|
76
|
+
syncChildren();
|
|
77
|
+
wireEvents();
|
|
78
|
+
}).catch((err) => {
|
|
79
|
+
if (!controller.signal.aborted) console.error(`[ilha-router] frame render failed for "${id}":`, err);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const attached = [];
|
|
83
|
+
const readManifest = () => {
|
|
84
|
+
const raw = Array.from(host.children).find((c) => c.matches(`template[${ACTIONS_ATTR}]`))?.getAttribute(ACTIONS_ATTR) ?? host.getAttribute(ACTIONS_ATTR) ?? null;
|
|
85
|
+
return raw ? parseSnapshot(raw) : void 0;
|
|
86
|
+
};
|
|
87
|
+
const wireEvents = () => {
|
|
88
|
+
for (const { el, type, listener } of attached) el.removeEventListener(type, listener);
|
|
89
|
+
attached.length = 0;
|
|
90
|
+
const manifest = readManifest();
|
|
91
|
+
if (!manifest) return;
|
|
92
|
+
const sentinels = [host, ...Array.from(host.querySelectorAll(`[${EVENT_SENTINEL_ATTR}]`))];
|
|
93
|
+
for (const el of sentinels) {
|
|
94
|
+
if (!el.hasAttribute(EVENT_SENTINEL_ATTR)) continue;
|
|
95
|
+
if (el !== host && !belongsToHost(host, el)) continue;
|
|
96
|
+
const spec = el.getAttribute(EVENT_SENTINEL_ATTR) ?? "";
|
|
97
|
+
for (const part of spec.split(",")) {
|
|
98
|
+
const sep = part.lastIndexOf(":");
|
|
99
|
+
if (sep < 1) continue;
|
|
100
|
+
const type = part.slice(0, sep);
|
|
101
|
+
const entry = manifest[part];
|
|
102
|
+
let actionKey;
|
|
103
|
+
let callArgs = [];
|
|
104
|
+
if (typeof entry === "string") actionKey = entry;
|
|
105
|
+
else if (entry && typeof entry === "object") {
|
|
106
|
+
actionKey = String(entry.k);
|
|
107
|
+
if (Array.isArray(entry.a)) callArgs = entry.a;
|
|
108
|
+
}
|
|
109
|
+
const action = actionKey == null ? void 0 : wiring.actions?.[actionKey];
|
|
110
|
+
if (!action) continue;
|
|
111
|
+
const listener = () => {
|
|
112
|
+
Promise.resolve(action(...callArgs)).then(() => scheduleRepaint()).catch((err) => {
|
|
113
|
+
console.error(`[ilha-router] action "${String(actionKey)}" failed:`, err);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
el.addEventListener(type, listener);
|
|
117
|
+
attached.push({
|
|
118
|
+
el,
|
|
119
|
+
type,
|
|
120
|
+
listener
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
wireEvents();
|
|
126
|
+
const mountedChildren = /* @__PURE__ */ new Map();
|
|
127
|
+
const reviveChildProps = (props) => {
|
|
128
|
+
if (!props) return void 0;
|
|
129
|
+
for (const [key, value] of Object.entries(props)) {
|
|
130
|
+
if (!value || typeof value !== "object") continue;
|
|
131
|
+
const marker = value;
|
|
132
|
+
if (marker.__ilha !== "action" || typeof marker.k !== "string") continue;
|
|
133
|
+
const action = wiring.actions?.[marker.k];
|
|
134
|
+
if (!action) continue;
|
|
135
|
+
const args = Array.isArray(marker.a) ? marker.a : [];
|
|
136
|
+
props[key] = (..._runtimeArgs) => Promise.resolve(action(...args)).then((result) => {
|
|
137
|
+
scheduleRepaint();
|
|
138
|
+
return result;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return props;
|
|
142
|
+
};
|
|
143
|
+
const syncChildren = () => {
|
|
144
|
+
for (const [el, handle] of mountedChildren) {
|
|
145
|
+
if (el.isConnected && belongsToHost(host, el) && el.hasAttribute(CLIENT_REF_ATTR)) continue;
|
|
146
|
+
handle.unmount();
|
|
147
|
+
mountedChildren.delete(el);
|
|
148
|
+
}
|
|
149
|
+
for (const el of host.querySelectorAll(`[${CLIENT_REF_ATTR}]`)) {
|
|
150
|
+
if (!belongsToHost(host, el)) continue;
|
|
151
|
+
const props = reviveChildProps(parseSnapshot(el.getAttribute(PROPS_ATTR) ?? "") ?? void 0);
|
|
152
|
+
const mounted = mountedChildren.get(el);
|
|
153
|
+
if (mounted) {
|
|
154
|
+
mounted.updateProps(props);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const mount = (wiring.children?.[el.getAttribute(CLIENT_REF_ATTR) ?? ""])?.[ISLAND_MOUNT_INTERNAL$1];
|
|
158
|
+
if (mount) mountedChildren.set(el, mount(el, props));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
syncChildren();
|
|
162
|
+
for (const [key, fn] of Object.entries(wiring.streams ?? {})) (async () => {
|
|
163
|
+
try {
|
|
164
|
+
const gen = await fn(controller.signal);
|
|
165
|
+
try {
|
|
166
|
+
for (;;) {
|
|
167
|
+
const { done, value } = await gen.next();
|
|
168
|
+
if (controller.signal.aborted || done) break;
|
|
169
|
+
state[key] = value;
|
|
170
|
+
scheduleRepaint();
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (!controller.signal.aborted && err?.name !== "AbortError") console.error(`[ilha-router] stream "${key}" failed:`, err);
|
|
174
|
+
} finally {
|
|
175
|
+
Promise.resolve(gen.return?.(void 0)).catch(() => {});
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (!controller.signal.aborted && err?.name !== "AbortError") console.error(`[ilha-router] stream "${key}" failed:`, err);
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
if (frame && !host.hasAttribute(STATE_ATTR) && host.childNodes.length === 0) scheduleRepaint();
|
|
182
|
+
if (wiring.clientLoader) Promise.resolve(wiring.clientLoader()).catch((err) => {
|
|
183
|
+
if (!controller.signal.aborted) console.error(`[ilha-router] client loader failed for "${id}":`, err);
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
unmount: () => {
|
|
187
|
+
for (const { el, type, listener } of attached) el.removeEventListener(type, listener);
|
|
188
|
+
attached.length = 0;
|
|
189
|
+
for (const handle of mountedChildren.values()) handle.unmount();
|
|
190
|
+
mountedChildren.clear();
|
|
191
|
+
for (const cleanup of cleanups) cleanup();
|
|
192
|
+
cleanups.length = 0;
|
|
193
|
+
},
|
|
194
|
+
updateProps: () => {}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create a client proxy island for a server-defined island. Called by
|
|
199
|
+
* generated virtual modules — not by application code.
|
|
200
|
+
*
|
|
201
|
+
* @param id - Stable identity (`<relative-path>#<export>`), for diagnostics.
|
|
202
|
+
* @param as - Slot tag declared by the server island's `.as()` (default div).
|
|
203
|
+
* @param wiring - Stream/action transports wired to tacho stubs by codegen.
|
|
204
|
+
*/
|
|
205
|
+
function __ilhaServerIsland(id, as, wiring = {}) {
|
|
206
|
+
const slotTag = assertValidTag(as);
|
|
207
|
+
const island = ((props) => {
|
|
208
|
+
return "";
|
|
209
|
+
});
|
|
210
|
+
island[ISLAND] = true;
|
|
211
|
+
island[ISLAND_SLOT_TAG] = slotTag;
|
|
212
|
+
island.toString = () => "";
|
|
213
|
+
const ISLAND_CALL = Symbol.for("ilha.islandCall");
|
|
214
|
+
island.key = (slotKey) => {
|
|
215
|
+
if (typeof slotKey !== "string" || slotKey.trim().length === 0 || slotKey.includes(":")) throw new Error("server island key() requires a non-empty key without \":\".");
|
|
216
|
+
return (props) => ({
|
|
217
|
+
[ISLAND_CALL]: true,
|
|
218
|
+
island,
|
|
219
|
+
props,
|
|
220
|
+
key: slotKey
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
island[ISLAND_MOUNT_INTERNAL$1] = (host) => hydrateServerIsland(host, id, wiring);
|
|
224
|
+
island.mount = (host) => hydrateServerIsland(host, id, wiring).unmount;
|
|
225
|
+
return island;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
//#endregion
|
|
229
|
+
export { __ilhaServerIsland };
|