@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/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 `clientLoad` for the browser bundle. */
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 };
@@ -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-DdquB3Nf.js";
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 { ilhaPages, pages };
10
+ export { pages as default, pages, ilhaPages };
package/dist/rspack.d.ts CHANGED
@@ -3,3 +3,4 @@ export { ilhaPages, type IlhaPagesOptions } from "./plugin";
3
3
  import { type IlhaPagesOptions } from "./plugin";
4
4
  /** Rspack plugin — use via `@ilha/router/rspack`. */
5
5
  export declare function pages(options?: IlhaPagesOptions): RspackPluginInstance;
6
+ export default pages;
package/dist/rspack.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/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 { ilhaPages, pages };
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;