@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/README.md +1 -1
- package/dist/codegen.d.ts +3 -0
- package/dist/index.d.ts +28 -3
- package/dist/index.js +2231 -2
- package/dist/{plugin-DdquB3Nf.js → plugin-DPGzrIVj.js} +510 -15
- package/dist/plugin.d.ts +9 -1
- package/dist/request-scope-D6_4rqMb.js +27 -0
- package/dist/request-scope.d.ts +20 -0
- package/dist/rolldown.d.ts +1 -0
- 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 +130 -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 +132 -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,27 @@
|
|
|
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) without depending on Oxide's action-only `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
|
+
/** Run `fn` with `request` available to `useContext().request`. */
|
|
21
|
+
function runWithIslandRequest(request, fn) {
|
|
22
|
+
const g = globalThis;
|
|
23
|
+
return (g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage()).run(request, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { runWithIslandRequest as t };
|
|
@@ -0,0 +1,20 @@
|
|
|
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) without depending on Oxide's action-only `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`. */
|
|
19
|
+
export declare function runWithIslandRequest<T>(request: Request, fn: () => T): T;
|
|
20
|
+
export type { IslandContext };
|
package/dist/rolldown.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { ilhaPages, type IlhaPagesOptions } from "./plugin";
|
|
|
3
3
|
import { type IlhaPagesOptions } from "./plugin";
|
|
4
4
|
/** Rolldown plugin — use via `@ilha/router/rolldown`. */
|
|
5
5
|
export declare function pages(options?: IlhaPagesOptions): import("unplugin").RolldownPlugin<any> | import("unplugin").RolldownPlugin<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-DPGzrIVj.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-DPGzrIVj.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,130 @@
|
|
|
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 = await entry.load({
|
|
108
|
+
params,
|
|
109
|
+
request,
|
|
110
|
+
url,
|
|
111
|
+
signal: request.signal
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const marker = error;
|
|
115
|
+
if (marker.__ilhaRedirect === true) {
|
|
116
|
+
const r = error;
|
|
117
|
+
throw new FrameError(r.status || 302, "frame failed", r.to);
|
|
118
|
+
}
|
|
119
|
+
if (marker.__ilhaLoaderError === true) throw new FrameError(error.status || 500, "frame failed");
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const render = entry.render();
|
|
124
|
+
if (typeof render !== "function") throw new FrameError(400, "unknown island");
|
|
125
|
+
const html = await runWithScope(request, () => render(props));
|
|
126
|
+
return String(html);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
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 };
|
|
@@ -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;
|