@ilha/router 0.10.3 → 0.11.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 CHANGED
@@ -1,1154 +1,377 @@
1
1
  # `@ilha/router`
2
2
 
3
- A lightweight, isomorphic router for [Ilha](https://github.com/ilhajs/ilha) islands. Runs in the browser with full reactivity and on the server as a synchronous HTML string renderer. Pairs natively with the file-system routing Vite plugin for zero-config page management.
3
+ A tiny, isomorphic SPA router for [Ilha](https://github.com/ilhajs/ilha) islands. You register routes (or scan a `src/pages/` directory), mount the router in the browser, and render the same routes to HTML on the server. Server islands re-render through a guarded frame endpoint.
4
4
 
5
5
  ---
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```bash
10
- npm install @ilha/router
11
- # or Bun
12
10
  bun add @ilha/router
13
11
  ```
14
12
 
15
- ---
16
-
17
- ## Quick Start
18
-
19
- ### Client-side
20
-
21
- ```ts
22
- import { router } from "@ilha/router";
23
- import { homePage, aboutPage, userPage, notFound } from "./pages";
24
-
25
- router()
26
- .route("/", homePage)
27
- .route("/about", aboutPage)
28
- .route("/user/:id", userPage)
29
- .route("/**", notFound)
30
- .mount("#app");
31
- ```
32
-
33
- ### Server-side (SSR)
34
-
35
- ```ts
36
- import { router } from "@ilha/router";
37
- import { homePage, aboutPage, userPage, notFound } from "./pages";
38
-
39
- export default {
40
- fetch(request: Request) {
41
- const html = router()
42
- .route("/", homePage)
43
- .route("/about", aboutPage)
44
- .route("/user/:id", userPage)
45
- .route("/**", notFound)
46
- .render(request.url);
47
- return new Response(`<!doctype html><html><body>${html}</body></html>`, {
48
- headers: { "content-type": "text/html" },
49
- });
50
- },
51
- };
52
- ```
53
-
54
- ### SSR + Client Hydration (recommended)
55
-
56
- ```ts
57
- // routes/[...].ts — Oxide handler (SSR/prerender)
58
- import { pageRouter, registry } from "ilha:pages/server";
59
- import "ilha:loaders"; // ← wire server-only loaders
60
-
61
- export default {
62
- async fetch(request: Request) {
63
- const html = await pageRouter.renderHydratable(request.url, registry);
64
- return new Response(`<!doctype html><html><body>${html}</body></html>`, {
65
- headers: { "content-type": "text/html" },
66
- });
67
- },
68
- };
69
- ```
70
-
71
- ```ts
72
- // src/client.ts — browser entry
73
- import { pageRouter, registry } from "ilha:pages/client";
74
-
75
- pageRouter.hydrate(registry);
76
- ```
13
+ `ilha` is a peer dependency. For server islands you also need [`oxidejs`](https://npmjs.com/package/oxidejs).
77
14
 
78
15
  ---
79
16
 
80
- ## Hash mode
81
-
82
- By default, the router uses the HTML5 History API and treats `location.pathname` as the route. This requires either a server that serves the SPA shell at every URL, or a static host with a SPA fallback. When neither is available — the document is loaded over `file://`, embedded in a desktop wrapper like Electron or Electrobun, opened directly from disk, or served from a host that can't be configured for SPA fallbacks — switch to **hash mode**, which stores the route in `location.hash`:
17
+ ## Import paths
83
18
 
84
- ```ts
85
- import { setHistoryMode, router } from "@ilha/router";
86
-
87
- setHistoryMode("hash"); // ← call once, before mounting
88
-
89
- router().route("/", homePage).route("/about", aboutPage).route("/user/:id", userPage).mount("#app");
90
- ```
19
+ | Import path | Use it for |
20
+ | ---------------------------- | ------------------------------------------------- |
21
+ | `@ilha/router` | Runtime router, navigation, `head`, route hooks |
22
+ | `@ilha/router/vite` | Vite file-system routing plugin (`pages()`) |
23
+ | `@ilha/router/rsbuild` | Rsbuild file-system routing plugin (`pages()`) |
24
+ | `@ilha/router/server-island` | Client proxies for `*.server` modules (generated) |
25
+ | `@ilha/router/ssr` | `POST /__ilha/frame` middleware and frame guards |
91
26
 
92
- `setHistoryMode("hash")` must be called **before** `.mount()`, `.hydrate()`, or `prime()`. Once set, every navigation API in this package — `navigate()`, `RouterLink`, `enableLinkInterception()`, popstate handling — operates against `location.hash` instead of `location.pathname`.
27
+ There is no loader API. Fetch data inside an async component, or stream from a server module.
93
28
 
94
- URLs in hash mode look like:
29
+ ---
95
30
 
96
- ```
97
- file:///path/to/index.html#/
98
- file:///path/to/index.html#/about
99
- file:///path/to/index.html#/user/42?tab=overview
100
- file:///path/to/index.html#/docs/intro#section
101
- ```
31
+ ## Quick start
102
32
 
103
- The portion after the `#` is parsed as if it were a real URL — the path comes first, followed by an optional query string and an optional in-page anchor. `routeHash()` returns the in-hash anchor (`#section`), so in-page anchor links keep working alongside hash routing.
33
+ ### Client SPA
104
34
 
105
- ### Links
35
+ ```tsx
36
+ import { router } from "@ilha/router";
106
37
 
107
- Both forms work — pick whichever is easier in your code:
38
+ const HomePage = () => <p>home</p>;
39
+ const AboutPage = () => <p>about</p>;
40
+ const NotFound = () => <p>not found</p>;
108
41
 
109
- ```html
110
- <a href="/about">About</a>
111
- <!-- plain path — preferred for shared code -->
112
- <a href="#/about">About</a>
113
- <!-- explicit hash form — also intercepted -->
42
+ router().route("/", HomePage).route("/about", AboutPage).route("/**", NotFound).mount("#app");
114
43
  ```
115
44
 
116
- `<RouterLink>` automatically renders the hash form (`<a href="#/about">`) in hash mode, so right-click → copy link gives a working URL.
117
-
118
- In-page anchor links (`<a href="#section">`) are not intercepted — they behave as normal browser anchors. Only links beginning with `#/` (a slash after the hash) are treated as in-app navigations.
45
+ A mounted SPA router intercepts same-origin `<a>` clicks. Use ordinary links for navigation; call `navigate()` after application logic.
119
46
 
120
- ### What's not supported in hash mode
121
-
122
- **SSR + hydration.** The hash is never sent to the server, so it cannot pre-render the active route. Calling `mount({ hydrate: true })` or `.hydrate(registry)` while in hash mode logs a warning. Use plain SPA mode for hash-mode apps:
47
+ ### Server HTML
123
48
 
124
49
  ```ts
125
- setHistoryMode("hash");
126
- pageRouter.mount("#app"); // ← no { hydrate: true }
127
- ```
128
-
129
- You can still register loaders, but they run on the client (via the loader endpoint or by calling `runLoader()` yourself) — there is no server-rendered initial state.
130
-
131
- **Per-router mode.** History mode is process-global, not per-builder. This is intentional: `navigate()`, `RouterLink`, and `prefetch()` are module-level and would otherwise need explicit instance threading. If your app needs both modes simultaneously, that's not a use case this router supports.
132
-
133
- ### Switching modes
134
-
135
- `setHistoryMode()` can be called more than once, but listeners registered before a switch keep using their original adapter until the router is unmounted and remounted. In practice, set the mode once at app entry and leave it alone.
136
-
137
- ---
138
-
139
- ## Core API
140
-
141
- ### `router(options?)`
142
-
143
- Creates a new router instance and **resets the route registry**. Always call `router()` fresh — never share instances across server requests.
144
-
145
- | Option | Type | Default | Description |
146
- | ------------------------ | ------------------- | ------- | ----------------------------------------------------------------------------------------- |
147
- | `mode` | `"spa" \| "static"` | `"spa"` | `"static"` disables client navigation — hydrate with `hydrateStatic()` |
148
- | `interceptLinks` | `boolean` | `true` | Intercept internal `<a>` clicks for SPA navigation |
149
- | `notFound` | `Island` | — | Custom 404 island (SSR status 404; mounted with a full lifecycle in the browser) |
150
- | `allowExternalRedirects` | `boolean` | `false` | Allow loader `redirect()` to cross-origin URLs; blocked targets become a 500 error |
151
- | `loaderTimeout` | `number` | — | Abort + fail a loader after this many ms (enforced even if the loader ignores its signal) |
152
- | `viewTransitions` | `boolean` | `false` | Wrap client view swaps in `document.startViewTransition()` when supported |
153
-
154
- Returns a `RouterBuilder`.
155
-
156
- ---
157
-
158
- #### `.route(pattern, island, loader?)`
50
+ import { router } from "@ilha/router";
51
+ import { httpResponse } from "@ilha/router";
159
52
 
160
- Registers a route. Patterns support `:param` segments and a trailing `/**:name` catch-all; static segments take priority over params, which take priority over the catch-all — regardless of registration order.
53
+ const app = router().route("/", HomePage).route("/**", NotFound);
161
54
 
162
- The optional `loader` is a data-fetching function that runs before the page renders. Its return value is passed as input props to the island. The loader runs **wherever the router runs**: during SSR it executes on the server; when the route was registered in the browser (a plain SPA, hash mode, `file://`), client navigations execute it locally — no server or `/__ilha/loader` endpoint needed. Routes marked via `.markLoader()` (the SSR-split pages build) still fetch from the endpoint.
55
+ const html = await app.render(new Request("https://app.test/"));
56
+ return httpResponse(html);
57
+ ```
163
58
 
164
- A locally-executed loader receives a synthetic `Request` (no cookies or server context) — rely on `url`, `params`, and `signal`. Loader `redirect()`s are checked against the same cross-origin policy as on the server (`allowExternalRedirects`).
59
+ ### SSR + hydration (recommended)
165
60
 
166
61
  ```ts
167
- import { loader } from "@ilha/router";
62
+ // server
63
+ const app = router().route("/", HomePage).route("/**", NotFound);
168
64
 
169
- const userLoader = loader(async ({ params }) => {
170
- return { user: await fetchUser(params.id) };
65
+ const res = await app.respond(new Request(request.url), {
66
+ shell: (head, html) =>
67
+ `<!doctype html><html${head.htmlAttrs}><head>${head.headTags}</head><body${head.bodyAttrs}>${html}</body></html>`,
171
68
  });
172
69
 
173
- router().route("/user/:id", userPage, userLoader).mount("#app");
70
+ // client
71
+ router().route("/", HomePage).route("/**", NotFound).mount("#app", { hydrate: true });
174
72
  ```
175
73
 
176
- | Pattern | Matches | `routeParams()` |
177
- | --------------- | ------------------- | --------------------------------- |
178
- | `/` | `/` | `{}` |
179
- | `/about` | `/about` | `{}` |
180
- | `/user/:id` | `/user/42` | `{ id: "42" }` |
181
- | `/:org/:repo` | `/ilha/router` | `{ org: "ilha", repo: "router" }` |
182
- | `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }` |
183
- | `/**` | anything | `{}` |
184
-
185
- > Static segments take priority over `:param` segments — `/user/me` will match before `/user/:id`.
186
-
187
- Returns the same `RouterBuilder` for chaining.
74
+ `respond()` renders the route, injects the serialized `<head>` into your shell, and emits security headers. On the client, `{ hydrate: true }` preserves the SSR DOM, seeds state from snapshots, and re-renders with hydration on later navigations.
188
75
 
189
76
  ---
190
77
 
191
- #### `.mount(target, options?)` — browser only
78
+ ## Hash mode
192
79
 
193
- Mounts the router into a DOM element or CSS selector. Sets up `popstate` listening and intercepts internal `<a>` clicks automatically.
80
+ The router uses the HTML5 History API by default. When you serve from `file://` (Electron, Electrobun, static disk) or a host without SPA fallbacks, switch to hash mode:
194
81
 
195
82
  ```ts
196
- const unmount = router().route("/", homePage).mount("#app");
83
+ import { setHistoryMode } from "@ilha/router";
197
84
 
198
- // later:
199
- unmount();
85
+ setHistoryMode("hash"); // call once, before .mount() or .hydrate()
200
86
  ```
201
87
 
202
- **Options:**
203
-
204
- | Option | Type | Default | Description |
205
- | ---------- | ------------------------ | ----------- | ---------------------------------------------------------- |
206
- | `hydrate` | `boolean` | `false` | Preserve SSR DOM on first mount (no destructive re-render) |
207
- | `registry` | `Record<string, Island>` | `undefined` | Island registry for interactive hydration on navigation |
208
-
209
- When `hydrate: true`, `.mount()` does **not** wipe existing SSR HTML. It instead mounts a hidden navigation handler that re-renders routes with hydration on subsequent navigations.
88
+ Routes live in `location.hash` (`/#/user/42`). `navigate()`, link interception, and `isActive()` all operate against the hash. Links render the hash form automatically, so right-click → copy link works.
210
89
 
211
- > Combining `hydrate: true` with hash mode logs a warning — hash routes are never visible to the server, so SSR can't pre-render them. Use plain SPA mode (no `hydrate`) for hash-mode apps.
212
-
213
- No-op with a console warning when called outside a browser environment.
90
+ SSR + hydration is not supported in hash mode — the server cannot see hash routes.
214
91
 
215
92
  ---
216
93
 
217
- #### `.render(url)` — server / SSR
218
-
219
- Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser.
220
-
221
- ```ts
222
- const html = router().route("/", HomePage).route("/**", notFound).render("/");
223
- // → '<div data-router-view><p>home</p></div>'
224
- ```
225
-
226
- Renders `<div data-router-empty></div>` when no route matches.
227
-
228
- ---
229
-
230
- #### `.renderHydratable(urlOrRequest, registry, options?, request?)` — server / SSR
94
+ ## Core API
231
95
 
232
- Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers so the client can rehydrate without a full re-render. If a loader is registered for the matched route, it runs first and its return value is serialized into `data-ilha-props`.
96
+ ### `router(options?)`
233
97
 
234
- ```ts
235
- const html = await router().route("/", HomePage).renderHydratable("/", registry);
236
- // → '<div data-router-view><div data-ilha="Home">…</div></div>'
98
+ | Option | Meaning |
99
+ | ------------------------ | --------------------------------------------------- |
100
+ | `mode` | `"spa"` (default) or `"static"` (registry only) |
101
+ | `notFound` | Component for unmatched paths |
102
+ | `interceptLinks` | Intercept same-origin `<a>` clicks (default `true`) |
103
+ | `viewTransitions` | Wrap navigations in the View Transition API |
104
+ | `allowExternalRedirects` | Allow cross-origin redirects (default `false`) |
105
+
106
+ ### Builder
107
+
108
+ | Method | Purpose |
109
+ | ---------------------------------------------- | --------------------------------------- |
110
+ | `route(pattern, page)` | Register a URL pattern |
111
+ | `errorBoundary(pattern, handler)` | Catch failures for a pattern |
112
+ | `routes()` | The route records |
113
+ | `prime()` | Prime route signals (browser) |
114
+ | `mount(target, { hydrate?, interceptLinks? })` | Activate in the browser |
115
+ | `render(url)` | HTML string (server) |
116
+ | `renderResponse(url)` | `RenderResponse` discriminated union |
117
+ | `respond(url, options?)` | `Response` with head + security headers |
118
+ | `hydrate({ root?, interceptLinks? })` | Hydrate SSR markup, then navigate |
119
+
120
+ `renderResponse()` resolves to `{ kind: "html", html, status?, head? }`, `{ kind: "redirect", to, status }`, or `{ kind: "error", status, message, html, head? }`.
121
+
122
+ `respond()` options: `status`, `headers`, `cspNonce`, `contentSecurityPolicy`, `timeout`, `snapshot`, `markers`, and `shell(head, html)` to inject the serialized head into your document shell.
123
+
124
+ ### Route patterns
125
+
126
+ | Pattern | Example URL | Params |
127
+ | --------------- | ------------------- | ------------------------- |
128
+ | `/` | `/` | `{}` |
129
+ | `/user/:id` | `/user/42` | `{ id: "42" }` |
130
+ | `/:org/:repo` | `/ilha/router` | `{ org, repo }` |
131
+ | `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }` |
132
+ | `/**` | any unmatched path | `{}` |
133
+
134
+ Static segments win over parameters, then catch-alls.
135
+
136
+ ### Route context
137
+
138
+ ```tsx
139
+ import { useRoute, navigate, isActive } from "@ilha/router";
140
+
141
+ const Breadcrumb = () => {
142
+ const { path, params, search } = useRoute();
143
+ if (!isActive("/user/*")) return <span>{path()}</span>;
144
+ return (
145
+ <span>
146
+ {path()} · {params().id}
147
+ </span>
148
+ );
149
+ };
237
150
  ```
238
151
 
239
- All server render APIs accept a `Request` as the first argument — route, origin, headers, and loader context derive from it, so server handlers can pass the real request directly.
240
-
241
- > **Redirects.** For callers using the string API, a loader redirect is encoded as a `<meta http-equiv="refresh">` tag. This is deprecated: it can't set a real HTTP status. Prefer `.renderResponse()` or `.respond()` to emit a proper 302.
242
-
243
- If the active island is not found in the registry, falls back to plain SSR and emits a `console.warn`.
244
-
245
- **Options** extend `HydratableOptions` from `ilha`:
246
-
247
- | Option | Type | Default | Description |
248
- | ---------- | --------- | ------- | ----------------------------------------------------- |
249
- | `snapshot` | `boolean` | `false` | Embed island state as `data-ilha-state` for hydration |
250
-
251
- ---
152
+ | Export | Meaning |
153
+ | ----------------------------------------------------------------- | ------------------------------------------------------ |
154
+ | `useRoute()` | `{ path, params, search, hash, navigating }` accessors |
155
+ | `routePath()` / `routeParams()` / `routeSearch()` / `routeHash()` | Standalone accessors |
156
+ | `navigate(to, { replace?, scroll? })` | Programmatic navigation |
157
+ | `navigating()` | True while a navigation is in flight |
158
+ | `isActive(pattern, { end? })` | True when the current path matches |
159
+ | `beforeNavigate(fn)` / `afterNavigate(fn)` | Navigation hooks (can cancel) |
160
+ | `useContext()` | `{ request }` during SSR |
161
+ | `enableLinkInterception(root?)` | Manual link interception |
252
162
 
253
- #### `.renderResponse(urlOrRequest, registry, options?, request?)` — server / SSR
163
+ ### Head
254
164
 
255
- Structured-envelope variant of `.renderHydratable()`. Returns a `RenderResponse` discriminated union instead of a raw HTML string, so the host server can emit proper HTTP status codes for redirects and loader errors. Accepts a `Request` as the first argument.
165
+ ```tsx
166
+ import { head } from "@ilha/router";
256
167
 
257
- ```ts
258
- const res = await router()
259
- .route("/protected", protectedPage, authLoader)
260
- .renderResponse("/protected", registry);
261
-
262
- if (res.kind === "redirect") {
263
- return Response.redirect(res.to, res.status);
168
+ export default function About() {
169
+ head({ title: "About" });
170
+ return <h1>About</h1>;
264
171
  }
265
- if (res.kind === "error") {
266
- return new Response(res.html, { status: res.status });
267
- }
268
- return new Response(res.html, { headers: { "content-type": "text/html" } });
269
172
  ```
270
173
 
271
- | `kind` | Fields | When |
272
- | ------------ | --------------------------------------------------- | ------------------------------------------ |
273
- | `"html"` | `html: string`, `status?: number` | Normal render; `status` is 404 if no match |
274
- | `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
275
- | `"error"` | `status: number`, `message: string`, `html: string` | Loader called `error()` or threw |
174
+ `HeadInput` fields: `title`, `titleTemplate`, `meta`, `link`, `script`, `htmlAttrs`, `bodyAttrs`. Call `head()` inside a page or layout; during SSR entries collect into the render window and `serializeHead()` turns them into shell fragments. On the client, entries apply to `document` on navigation.
276
175
 
277
- #### `.respond(urlOrRequest, registry, options?)` — server / SSR
176
+ ### Pages, layouts, and errors
278
177
 
279
- Renders a route to a ready-to-send HTTP `Response`, handling redirects, loader errors, and security headers (`Content-Type`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Cache-Control: no-store`, and an optional CSP nonce). Pass a `shell` to inject the serialized `<head>` into a document shell.
178
+ ```tsx
179
+ import { defineLayout, wrapError, error, redirect } from "@ilha/router";
280
180
 
281
- ```ts
282
- const response = await router()
283
- .route("/", HomePage)
284
- .respond(new Request(request.url), registry, {
285
- cspNonce,
286
- shell: (head, html) =>
287
- `<!doctype html><html lang="en"><head>${head.headTags}</head><body>${html}</body></html>`,
288
- });
289
- ```
290
-
291
- ---
292
-
293
- #### `.runLoader(urlOrRequest, request?)` — server / SSR
294
-
295
- Runs the loader chain for the matched route without rendering any HTML. Returns a discriminated union result. Used by the `/__ilha/loader` endpoint the Vite plugin exposes for client-side navigation — the originating `Request` (cookies, identity, abort signal) is forwarded to the loader through both the endpoint and this method.
296
-
297
- ```ts
298
- const result = await router().route("/user/:id", userPage, userLoader).runLoader("/user/42");
299
-
300
- if (result.kind === "data") {
301
- console.log(result.data); // → { user: { id: "42" } }
302
- }
181
+ export default defineLayout(({ children }) => <main>{children}</main>);
303
182
  ```
304
183
 
305
- | `kind` | Fields | When |
306
- | ------------- | ----------------------------------- | -------------------------------- |
307
- | `"data"` | `data: Record<string, unknown>` | Loader succeeded (or no loader) |
308
- | `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
309
- | `"error"` | `status: number`, `message: string` | Loader called `error()` or threw |
310
- | `"not-found"` | — | No route matched the URL |
184
+ | Export | Purpose |
185
+ | ------------------------------------------------------------------------------ | ----------------------------------------------- |
186
+ | `wrapLayout(layout, page)` | Wrap a page in a layout (`children` carries it) |
187
+ | `wrapError(handler, page)` | Catch page throws, render a fallback view |
188
+ | `defineLayout(layout)` | Type helper for layout components |
189
+ | `redirect(to, status?)` | Throw `Redirect` — the router navigates |
190
+ | `error(status, message)` | Throw `RouteError` — a boundary catches it |
191
+ | `httpResponse(html, { status?, headers?, cspNonce?, contentSecurityPolicy? })` | Headered `Response` |
311
192
 
312
- ---
313
-
314
- #### `.prime()` — browser only
315
-
316
- Primes route context signals from the current `window.location` **before** `ilha.mount()` runs. This prevents a signal mismatch that would destroy hydrated bindings.
317
-
318
- Call this after all routes are registered and before mounting islands for interactivity:
319
-
320
- ```ts
321
- import { mount } from "ilha";
322
- import { pageRouter } from "ilha:pages";
323
- import { registry } from "ilha:registry";
324
-
325
- pageRouter.prime(); // ← sync signals first
326
- mount(registry, { root: … }); // ← then hydrate islands
327
- pageRouter.mount("#app", { hydrate: true, registry });
328
- ```
193
+ An error handler receives `AppError` (`message`, `status?`) and a route snapshot, and returns a view or a component.
329
194
 
330
195
  ---
331
196
 
332
- #### `.hydrate(registry, options?)` — browser only
333
-
334
- Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point for SPA apps.**
197
+ ## File-system routing
335
198
 
336
199
  ```ts
337
- pageRouter.hydrate(registry);
200
+ // vite.config.ts
201
+ import pages from "@ilha/router/vite";
202
+ import { defineConfig } from "vite";
338
203
 
339
- // With options:
340
- pageRouter.hydrate(registry, {
341
- root: document.getElementById("root"), // defaults to document.body
342
- target: "#app", // defaults to root
204
+ export default defineConfig({
205
+ plugins: [pages()],
343
206
  });
344
207
  ```
345
208
 
346
- Returns an `unmount` function that tears down all listeners and hydrated islands.
209
+ | File | URL |
210
+ | --------------------- | -------------- |
211
+ | `pages/index.tsx` | `/` |
212
+ | `pages/about.tsx` | `/about` |
213
+ | `pages/user/[id].tsx` | `/user/:id` |
214
+ | `pages/[...slug].tsx` | `/**:slug` |
215
+ | `pages/+layout.tsx` | wraps children |
216
+ | `pages/+error.tsx` | error boundary |
347
217
 
348
- > `.hydrate()` is for SSR + history-mode apps. In hash mode, use plain `.mount("#app")` instead — the server has no visibility into hash routes, so there's nothing to hydrate against.
218
+ Page modules export a default component, and may call `head()`. Layouts receive `children`.
349
219
 
350
- ---
351
-
352
- #### `.hydrateStatic(registry, options?)` — browser only
353
-
354
- The lightest client entry point. Calls `prime()` then `ilha.mount()` — no route view is mounted, no navigation handler is installed, and no route graph is touched. Use this in `static` mode where each page is a self-contained pre-rendered HTML file.
220
+ ### Plugin options
355
221
 
356
222
  ```ts
357
- pageRouter.hydrateStatic(registry);
358
-
359
- // With options:
360
- pageRouter.hydrateStatic(registry, {
361
- root: document.getElementById("app"), // defaults to document.body
223
+ pages({
224
+ dir: "src/pages", // pages directory (default: "src/pages")
225
+ outDir: ".ilha", // generated modules (default: ".ilha")
226
+ mode: "spa", // "spa" | "static" (default: "spa")
227
+ interceptLinks: true,
228
+ frameGuard: (request) => {
229
+ /* dev frame guard */
230
+ },
231
+ trustedOrigins: ["https://app.example.com"],
232
+ csrf: (request) => true,
233
+ strict: false, // fail codegen on collisions instead of warning
362
234
  });
363
235
  ```
364
236
 
365
- Internal `<a href>` links navigate via normal browser page loads. Only interactive islands in the current page are activated.
366
-
367
- ---
368
-
369
- #### `.attachLoader(pattern, loader)` — runtime
370
-
371
- Attaches or replaces a loader on an already-registered route pattern. No-op if the pattern was never registered via `.route()`. Used by the `ilha:loaders` virtual module to wire server-only loaders onto the client-safe `pageRouter` at SSR time.
372
-
373
- ```ts
374
- router().route("/user/:id", userPage).attachLoader("/user/:id", serverLoader);
375
- ```
376
-
377
- ---
378
-
379
- #### `.clientLoader(pattern, loader)` — runtime
380
-
381
- Attaches a loader that runs **in the browser** on client navigations, instead of fetching from the `/__ilha/loader` endpoint. Used by the FS-routing codegen for `clientLoad` exports; also available for manual routers. When a route has both a server loader and a client loader, the client loader wins on client navigations and the server loader runs during SSR. No-op (with a warning) if the pattern was never registered via `.route()`.
382
-
383
- ```ts
384
- router()
385
- .route("/dashboard", dashboardPage)
386
- .clientLoader(
387
- "/dashboard",
388
- loader(async ({ signal }) => ({ stats: await fetchStats({ signal }) })),
389
- );
390
- ```
391
-
392
- ---
393
-
394
- #### `.errorBoundary(pattern, handler)` — runtime
395
-
396
- Attaches the route's `+error` boundary so **loader** errors render through it — on the server (`renderResponse` returns the boundary's HTML with the error status) and on client navigations. Render errors are already handled by `wrapError` inside the island; this closes the gap for errors thrown before rendering starts. The FS-routing codegen wires the nearest `+error.ts` automatically; manual routers can call it directly. A throwing boundary falls back to the minimal inline error.
397
-
398
- ```ts
399
- import { router } from "@ilha/router";
400
- import { ilha, html } from "ilha";
401
-
402
- router()
403
- .route("/user/:id", userPage, userLoader)
404
- .errorBoundary("/user/:id", (err, route) =>
405
- ilha(
406
- () =>
407
- html`<h1>${err.status ?? 500}</h1>
408
- <p>${err.message}</p>`,
409
- ),
410
- );
411
- ```
412
-
413
- ---
414
-
415
- ### `setHistoryMode(mode)` · `getHistoryMode()`
416
-
417
- Selects the history strategy used by the router. Defaults to `"history"` (HTML5 History API, reads/writes `location.pathname`). Set to `"hash"` to store the route in `location.hash` instead — see the [Hash mode](#hash-mode) section above for when to use it.
237
+ - `mode: "spa"` — full client route graph, SSR/hydration, client navigation.
238
+ - `mode: "spa", interceptLinks: false` — route graph and SSR, but links do full document navigations.
239
+ - `mode: "static"` — island registry only; no route graph in the client bundle.
418
240
 
419
- ```ts
420
- import { setHistoryMode, getHistoryMode } from "@ilha/router";
421
-
422
- setHistoryMode("hash");
423
- getHistoryMode(); // → "hash"
424
- ```
425
-
426
- The mode is process-global. Call `setHistoryMode()` once at app entry, before any `.mount()`, `.hydrate()`, or `prime()` call.
427
-
428
- ---
429
-
430
- ### `navigate(to, options?)`
431
-
432
- Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations (same URL) are no-ops.
433
-
434
- ```ts
435
- import { navigate } from "@ilha/router";
436
-
437
- navigate("/about");
438
- navigate("/about", { replace: true }); // replaces instead of pushing
439
- ```
440
-
441
- In hash mode, `navigate("/about")` writes `#/about` into `location.hash`. The argument is always the logical path — no need to prefix it with `#`.
442
-
443
- No-op on the server.
444
-
445
- ---
446
-
447
- ### `navigating()`
241
+ ### Virtual modules
448
242
 
449
- Reactive — `true` while a client navigation (loader fetch + view swap) is in flight. Read it inside any island render to drive a progress bar or spinner; it re-renders when the state flips. Also available as `useRoute().navigating`.
243
+ | Module | Exports | Use for |
244
+ | ------------------- | ------------------------ | ------------------------------- |
245
+ | `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, server handlers |
246
+ | `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
450
247
 
451
248
  ```ts
452
- import { navigating } from "@ilha/router";
453
- import { ilha, html } from "ilha";
249
+ // src/client.ts — browser entry
250
+ import { pageRouter } from "ilha:pages/client";
454
251
 
455
- const Spinner = ilha(() => (navigating() ? html`<div class="bar" />` : ""));
252
+ pageRouter.mount("#app");
456
253
  ```
457
254
 
458
- ---
459
-
460
- ### `invalidate()`
461
-
462
- Re-runs the current route's loader and re-renders the view with fresh data — call it after a mutation. Resolves when the view has updated. No-op on the server or when no router is mounted.
255
+ Hydrate when the host already has SSR markup:
463
256
 
464
257
  ```ts
465
- import { invalidate } from "@ilha/router";
466
-
467
- await api.deleteItem(id);
468
- await invalidate(); // current page refetches and re-renders
258
+ pageRouter.mount("#app", { hydrate: true });
469
259
  ```
470
260
 
471
- ---
472
-
473
- ### `prime()`
474
-
475
- Standalone export of the same signal-priming function available as `.prime()` on the builder. Useful when managing the priming step separately from the router instance.
261
+ Static mode hydrates the island registered for the page — no route graph in the bundle:
476
262
 
477
263
  ```ts
478
- import { prime } from "@ilha/router";
264
+ import { mount } from "ilha";
265
+ import { registry } from "ilha:pages/client";
479
266
 
480
- prime();
267
+ const host = document.querySelector<HTMLDivElement>("#app")!;
268
+ mount(host, registry["about"]);
481
269
  ```
482
270
 
483
271
  ---
484
272
 
485
- ### `loader(fn)`
273
+ ## Server islands
486
274
 
487
- Identity function for declaring a typed data loader. Exists as a type anchor and as a marker the Vite plugin uses to detect exported loaders automatically. The loader receives a `LoaderContext` and must return or resolve to a plain object (serializable to JSON for client-side fetches).
275
+ Put a component in a `*.server.ts(x)` module. It renders on the server only; the browser gets a generated proxy that re-renders it through `POST /__ilha/frame`.
488
276
 
489
- ```ts
490
- import { loader } from "@ilha/router";
277
+ ```tsx
278
+ // src/lib/tasks.server.tsx
279
+ import * as Stream from "effect/Stream";
280
+ import { action } from "oxidejs";
491
281
 
492
- export const load = loader(async ({ params, request, url, signal }) => {
493
- const user = await fetchUser(params.id, { signal });
494
- return { user };
282
+ export const getTasks = action(async function* () {
283
+ yield [{ id: "1", text: "One" }];
495
284
  });
496
- ```
497
-
498
- Inside a loader, call `redirect()` or `error()` to short-circuit rendering:
499
-
500
- ```ts
501
- import { loader, redirect, error } from "@ilha/router";
502
-
503
- export const load = loader(async ({ params }) => {
504
- const session = await getSession();
505
- if (!session) redirect("/login", 302);
506
- const post = await getPost(params.id);
507
- if (!post) error(404, "Post not found");
508
- return { post };
509
- });
510
- ```
511
-
512
- Returns `fn` unchanged.
513
-
514
- ---
515
-
516
- ### `redirect(to, status?)`
517
-
518
- Throws a `Redirect` sentinel that is caught by the loader execution pipeline. Always use inside a loader — do not catch it yourself.
519
-
520
- ```ts
521
- import { redirect } from "@ilha/router";
522
-
523
- redirect("/login"); // 302 by default
524
- redirect("/moved", 301); // permanent redirect
525
- ```
526
285
 
527
- ---
528
-
529
- ### `error(status, message)`
530
-
531
- Throws a `LoaderError` sentinel that is caught by the loader execution pipeline. The rendered output will be an inline error element; use `.renderResponse()` on the server to intercept loader errors before they reach the client.
532
-
533
- ```ts
534
- import { error } from "@ilha/router";
535
-
536
- error(404, "Not found");
537
- error(403, "Forbidden");
286
+ export const TaskList = async function TaskList() {
287
+ return Stream.map(
288
+ Stream.fromAsyncIterable(getTasks(), (error) =>
289
+ error instanceof Error ? error : new Error(String(error)),
290
+ ),
291
+ (list) => (
292
+ <ul>
293
+ {list.map((t) => (
294
+ <li key={t.id}>{t.text}</li>
295
+ ))}
296
+ </ul>
297
+ ),
298
+ );
299
+ };
538
300
  ```
539
301
 
540
- ---
541
-
542
- ### `composeLoaders(loaders)`
543
-
544
- Merges multiple loaders into a single loader. All loaders run **concurrently** via `Promise.all`. Later loaders win on key collision — the page loader overrides a layout loader for the same key.
302
+ Mark RPC functions with `action` from `oxidejs`. Event closures that call those actions serialize into the frame HTML — during hydration-manifest rendering the call is recorded, not executed. Each stream yield refetches the frame and morphs the HTML.
545
303
 
546
- Used internally by the Vite plugin to compose layout loaders with the page loader. Also available for manual composition.
304
+ ```tsx
305
+ // src/pages/index.tsx — plain page
306
+ import { TaskList } from "../lib/tasks.server";
547
307
 
548
- ```ts
549
- import { composeLoaders, loader } from "@ilha/router";
550
-
551
- const layoutLoader = loader(async () => ({ user: await getCurrentUser() }));
552
- const pageLoader = loader(async ({ params }) => ({ post: await getPost(params.id) }));
553
-
554
- const combined = composeLoaders([layoutLoader, pageLoader]);
555
- // → { user: …, post: … }
308
+ export default function Home() {
309
+ return <TaskList />;
310
+ }
556
311
  ```
557
312
 
558
- If any loader in the chain throws a `Redirect` or `LoaderError`, the composed loader re-throws it immediately.
313
+ The plugin rewrites the client-graph import of `TaskList` to a proxy (`@ilha/router/server-island`). You never import that module yourself.
559
314
 
560
315
  ---
561
316
 
562
- ### `prefetch(pathWithSearch)`
317
+ ## Frame security
563
318
 
564
- Prefetches the loader data for a given path by calling the `/__ilha/loader` endpoint in the background. The result is cached and consumed on the next navigation to that path, making the transition feel instant. Safe to call repeatedly — an in-flight request for the same path is reused until it resolves and is consumed, avoiding duplicate network requests.
319
+ The frame endpoint re-renders server islands from a client state snapshot. Island state is world-readable through frames unless you gate them.
565
320
 
566
- ```ts
567
- import { prefetch } from "@ilha/router";
568
-
569
- prefetch("/user/42");
570
- prefetch("/dashboard?tab=overview");
571
- ```
321
+ | Concern | How |
322
+ | ------------------ | ---------------------------------------------------------------------------------------- |
323
+ | Production posture | Deny-by-default: `/__ilha/frame` returns `403` until you install a guard |
324
+ | Dev posture | Permissive unless a `frameGuard` is registered (plugin option) |
325
+ | Origin checks | `Origin` compared against `setFrameAuth({ trustedOrigins })` or the request's own `Host` |
326
+ | CSRF | `setFrameAuth({ csrf })` verifier for the state-changing POST |
327
+ | Identity | Only `cookie`, `authorization`, `user-agent` are forwarded to the scoped render |
328
+ | Body cap | 16 KiB; oversized bodies return `413` |
572
329
 
573
- No-op on the server, for paths with no registered loader, or for unmatched paths.
330
+ ### `@ilha/router/ssr`
574
331
 
575
- `RouterLink` automatically calls `prefetch()` on `mouseenter` for links that carry the `data-prefetch` attribute (set by default). You can opt a specific link out with `data-prefetch="false"`.
576
-
577
- ---
578
-
579
- ### `useRoute()`
580
-
581
- Returns reactive signal accessors for the current route state. Safe to call inside any island render function on both client and server.
332
+ | Export | Purpose |
333
+ | ---------------------------------------------------------- | ------------------------------------------------ |
334
+ | `ssr` (default) | The production frame handler |
335
+ | `setFrameAuth({ defaultAction?, trustedOrigins?, csrf? })` | Install the frame-auth policy |
336
+ | `setFrameGuard(guard)` | Per-request allow/deny |
337
+ | `renderServerIsland(id, request, runWithScope, props?)` | Render one island — `Effect<string, FrameError>` |
338
+ | `renderServerIslandResult(...)` | Promise/`Result` variant for non-Effect callers |
582
339
 
583
340
  ```ts
584
- import { useRoute } from "@ilha/router";
585
- import { ilha, html } from "ilha";
341
+ import { setFrameAuth } from "@ilha/router/ssr";
586
342
 
587
- const MyPage = ilha(() => {
588
- const { path, params, search, hash } = useRoute();
589
- return html`<p>user id: ${params().id}</p>`;
343
+ setFrameAuth({
344
+ defaultAction: "open", // public demo; deny is the production default
590
345
  });
591
346
  ```
592
347
 
593
- ---
594
-
595
- ### `routePath` · `routeParams` · `routeSearch` · `routeHash`
596
-
597
- The underlying context signals — use these outside of islands when you need direct signal access.
598
-
599
- ```ts
600
- import { routePath, routeParams, routeSearch, routeHash } from "@ilha/router";
601
-
602
- routePath(); // → "/user/42"
603
- routeParams(); // → { id: "42" }
604
- routeSearch(); // → "?tab=docs"
605
- routeHash(); // → "#section"
606
- ```
607
-
608
- ---
609
-
610
- ### `isActive(pattern)`
611
-
612
- Returns `true` if the current path matches the given registered pattern. Uses O(1) reverse island lookup internally.
613
-
614
- ```ts
615
- import { isActive } from "@ilha/router";
616
-
617
- isActive("/about"); // → true / false
618
- isActive("/user/:id"); // → true when on any /user/* path
619
- ```
620
-
621
- ---
622
-
623
- ### `enableLinkInterception(root?, options?)`
624
-
625
- Attaches a delegated click listener to `root` (defaults to `document`) that intercepts `<a>` clicks and routes them client-side. Called automatically by `.mount()`.
626
-
627
- Skips links that are external, `target="_blank"`, anchor-only (`#hash`), modified (`Ctrl`/`Meta`/`Shift`), or marked with `data-no-intercept`. Also skips events already handled (`e.defaultPrevented`).
628
-
629
- Returns a cleanup function.
630
-
631
- ```ts
632
- const stop = enableLinkInterception(myContainer, { prefetch: true });
633
- stop(); // remove listener
634
- ```
635
-
636
- **Options:**
637
-
638
- | Option | Type | Default | Description |
639
- | ---------- | --------- | ------- | ------------------------------------- |
640
- | `prefetch` | `boolean` | `true` | Enable prefetch on `mouseenter` hover |
641
-
642
- No-op on the server.
643
-
644
- ---
645
-
646
- ### `RouterView`
647
-
648
- The outlet island rendered by `.mount()` and `.render()`. Wraps the active island in `<div data-router-view>`, or renders `<div data-router-empty></div>` when no route matches.
649
-
650
- ```ts
651
- import { RouterView } from "@ilha/router";
652
-
653
- RouterView.toString(); // SSR
654
- RouterView.mount(el); // client
655
- ```
656
-
657
- ---
658
-
659
- ### `RouterLink`
660
-
661
- A declarative link island that calls `navigate()` on click. Automatically prefetches loader data for the target path on `mouseenter` (opt out per-link with `data-prefetch="false"`).
662
-
663
348
  ```ts
664
- import { RouterLink } from "@ilha/router";
665
-
666
- RouterLink.toString({ href: "/about", label: "About" });
667
- // → '<a data-link data-prefetch href="/about">About</a>'
668
- ```
669
-
670
- ---
671
-
672
- ### `wrapLayout(layout, page)`
673
-
674
- Wraps a page island with a layout handler. Used internally by the Vite plugin codegen — also available for manual composition.
675
-
676
- On client hydration, `wrapLayout` mounts the full layout island (layout child slots `p:*` and the keyed page slot `k:page`) from existing SSR DOM — it does not re-render layout markup from serialized props. Interactive components in `+layout.tsx` (state, event handlers, nested islands) hydrate the same way as the page.
677
-
678
- **Nested islands under layouts are fully supported.** JSX children and callback props (`setPage`, etc.) are passed through the live slot map on every parent render — not recovered from `data-ilha-props` JSON. Compound children (e.g. Areia `<Pagination>` with `Pagination.Info` / `Controls`) mount into the slot host as real DOM; you do not need `.Static` or to lift controls into the layout.
679
-
680
- With **nested** layouts (codegen: `wrapLayout(outer, wrapLayout(inner, page))`), `renderHydratable()` composes layout markup by awaiting the **leaf** page’s `hydratable()` envelope and injecting that HTML into each layout’s `k:page` slot (outer slot receives the inner layout tree; the innermost slot receives the page). Page state snapshots always come from the leaf page island.
681
-
682
- ```ts
683
- import { wrapLayout } from "@ilha/router";
684
-
685
- const wrapped = wrapLayout(myLayout, myPage);
686
- ```
687
-
688
- ---
689
-
690
- ### `defineLayout(fn)`
349
+ import { setFrameGuard } from "@ilha/router/ssr";
691
350
 
692
- A typed helper that returns the layout function as-is. Use it instead of the `satisfies LayoutHandler` cast for a cleaner, import-light syntax.
693
-
694
- ```ts
695
- // src/pages/+layout.ts
696
- import { defineLayout } from "@ilha/router";
697
- import { ilha, html } from "ilha";
698
-
699
- export default defineLayout((children) =>
700
- ilha(
701
- () => html`
702
- <nav>
703
- <a href="/">Home</a>
704
- <a href="/about">About</a>
705
- </nav>
706
- <main>${children}</main>
707
- `,
708
- ),
351
+ setFrameGuard((request) =>
352
+ isSignedIn(request) ? undefined : new Response("Unauthorized", { status: 401 }),
709
353
  );
710
354
  ```
711
355
 
712
- Equivalent to annotating with `satisfies LayoutHandler` but requires no explicit type import.
356
+ Frame render failures surface as `"frame failed"` — error details are never leaked to clients in production.
713
357
 
714
358
  ---
715
359
 
716
- ### `wrapError(handler, page)`
717
-
718
- Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`) or on the client during `.mount()`, the `handler` receives the error and current route snapshot and returns a fallback island. The nearest (innermost) `wrapError` boundary catches first. If the inner handler re-throws, the next outer boundary takes over.
719
-
720
- ```ts
721
- import { wrapError } from "@ilha/router";
722
-
723
- const safe = wrapError(myErrorHandler, myPage);
724
- ```
725
-
726
- > **Note:** Error boundaries wrap the _page island's render_, not the loader. Loader errors (thrown via `error()`) route through the nearest `+error.ts` / `.errorBoundary()` boundary — on the server `renderResponse` returns the boundary's HTML with the error status, and on client navigations the boundary renders in place. `wrapError` covers render/mount throws inside the island only.
727
-
728
- ---
360
+ ## Deployment
729
361
 
730
- ## TypeScript Types
731
-
732
- ```ts
733
- interface RouteSnapshot {
734
- path: string;
735
- params: Record<string, string>;
736
- search: string;
737
- hash: string;
738
- }
739
-
740
- interface AppError {
741
- message: string;
742
- status?: number;
743
- stack?: string;
744
- }
745
-
746
- interface LoaderContext {
747
- params: Record<string, string>;
748
- request: Request;
749
- url: URL;
750
- signal: AbortSignal;
751
- }
752
-
753
- type Loader<T> = (ctx: LoaderContext) => Promise<T> | T;
754
-
755
- // Infer the complete page props produced by a loader
756
- type InferLoader<L> = L extends Loader<infer T>
757
- ? {
758
- load: {
759
- loading: boolean;
760
- value: Awaited<T>;
761
- error: Error | undefined;
762
- };
763
- }
764
- : never;
765
-
766
- // Merge multiple loader return types — later loaders win on key collision
767
- type MergeLoaders<Ls extends readonly Loader<any>[]> = /* … */;
768
-
769
- type LayoutHandler = (children: Island) => Island;
770
- type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island;
771
-
772
- type RenderResponse =
773
- | { kind: "html"; html: string; status?: number }
774
- | { kind: "redirect"; to: string; status: number }
775
- | { kind: "error"; status: number; message: string; html: string };
776
-
777
- interface NavigateOptions {
778
- replace?: boolean;
779
- }
780
-
781
- interface MountOptions {
782
- hydrate?: boolean;
783
- registry?: Record<string, Island>;
784
- interceptLinks?: boolean; // default: true
785
- }
786
-
787
- interface HydrateOptions {
788
- root?: Element;
789
- target?: string | Element;
790
- interceptLinks?: boolean; // default: true
791
- }
792
-
793
- type HistoryMode = "history" | "hash";
794
- type RouterMode = "spa" | "static";
795
-
796
- interface RouterOptions {
797
- mode?: RouterMode; // "spa" | "static", default: "spa"
798
- interceptLinks?: boolean; // default: true — only meaningful in spa mode
799
- }
800
-
801
- // Helper — returns fn as-is with LayoutHandler type enforced
802
- function defineLayout(fn: LayoutHandler): LayoutHandler;
803
-
804
- // Identity — type anchor and Vite plugin marker
805
- function loader<T>(fn: Loader<T>): Loader<T>;
806
-
807
- // Throws a Redirect sentinel — use inside loaders only
808
- function redirect(to: string, status?: number): never;
809
-
810
- // Throws a LoaderError sentinel — use inside loaders only
811
- function error(status: number, message: string): never;
812
-
813
- // Merges loaders — later loaders win on key collision
814
- function composeLoaders<Ls extends readonly Loader<any>[]>(loaders: Ls): Loader<MergeLoaders<Ls>>;
815
-
816
- // Selects the history strategy. Default: "history". Call before .mount() / .hydrate().
817
- function setHistoryMode(mode: HistoryMode): void;
818
- function getHistoryMode(): HistoryMode;
819
- ```
820
-
821
- ---
822
-
823
- ## File-system Routing
824
-
825
- `@ilha/router` includes a Vite plugin that scans `src/pages/`, resolves layout and error boundary chains, and generates a ready-to-use router — no manual route registration needed.
826
-
827
- ### Setup
362
+ With [oxidejs](https://npmjs.com/package/oxidejs), the SSR middleware serves the frame endpoint in production:
828
363
 
829
364
  ```ts
830
365
  // vite.config.ts
831
- import { pages } from "@ilha/router/vite";
366
+ import pages from "@ilha/router/vite";
367
+ import oxide from "oxidejs/vite";
832
368
 
833
369
  export default defineConfig({
834
- plugins: [pages()],
835
- });
836
- ```
837
-
838
- Add `.ilha/` (or your custom `generated` path) to `.gitignore`.
839
-
840
- ### Directory structure
841
-
842
- ```
843
- src/pages/
844
- +layout.ts ← root layout (wraps all pages)
845
- +error.ts ← root error boundary
846
- index.ts → /
847
- about.ts → /about
848
- (auth)/ ← route group — transparent to the URL
849
- +layout.ts ← layout scoped to (auth) pages only
850
- sign-in.ts → /sign-in
851
- sign-up.ts → /sign-up
852
- (marketing)/ ← another route group
853
- index.ts → /
854
- user/
855
- +layout.ts ← nested layout (wraps user/* only)
856
- +error.ts ← nested error boundary
857
- [id].ts → /user/:id
858
- [id]/
859
- settings.ts → /user/:id/settings
860
- [...slug].ts → /**:slug
861
- ```
862
-
863
- ### Filename → pattern mapping
864
-
865
- | File | Pattern |
866
- | ------------------------- | --------------- |
867
- | `index.ts` | `/` |
868
- | `about.ts` | `/about` |
869
- | `[id].ts` | `/:id` |
870
- | `user/[id].ts` | `/user/:id` |
871
- | `[org]/[repo].ts` | `/:org/:repo` |
872
- | `[...slug].ts` | `/**:slug` |
873
- | `(auth)/sign-in.ts` | `/sign-in` |
874
- | `(auth)/[token].ts` | `/:token` |
875
- | `(shop)/products/[id].ts` | `/products/:id` |
876
-
877
- `.test.ts`, `.spec.ts`, and `.d.ts` files are automatically excluded.
878
-
879
- ### Route groups
880
-
881
- Folders wrapped in parentheses — `(name)` — are **route groups**. They organise files without contributing a segment to the URL. The group name is completely invisible to the router.
882
-
883
- ```
884
- src/pages/
885
- (auth)/
886
- sign-in.ts → /sign-in ✓ (not /auth/sign-in)
887
- sign-up.ts → /sign-up ✓
888
- (marketing)/
889
- index.ts → / ✓
890
- pricing.ts → /pricing ✓
891
- ```
892
-
893
- Route groups are useful for:
894
-
895
- - **Shared layouts without a shared URL prefix** — place a `+layout.ts` inside `(auth)/` and it wraps only those pages, with no `/auth` prefix in the URL.
896
- - **Organising large page trees** — split pages into logical sections (`(admin)`, `(public)`, `(shop)`) while keeping flat URLs.
897
- - **Co-locating related pages** — keep sign-in, sign-up, and password reset together in `(auth)/` for clarity.
898
-
899
- > Groups can be nested: `(a)/(b)/page.ts` → `/page`. Both group folders are transparent.
900
-
901
- > If two files in different groups resolve to the **same pattern** (e.g. `(auth)/sign-in.ts` and `sign-in.ts` both produce `/sign-in`), the plugin warns about a duplicate pattern and the first match wins deterministically.
902
-
903
- ### Route sorting
904
-
905
- Routes are sorted automatically by specificity — no need to order files manually:
906
-
907
- 1. **Static** paths (`/about`) — highest priority
908
- 2. **Parameterised** paths (`/user/:id`)
909
- 3. **Wildcard** paths (`/**:slug`) — lowest priority
910
-
911
- Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism. Route group pages sort alongside regular pages by their resolved pattern — the group folder is transparent.
912
-
913
- ### Layouts
914
-
915
- A `+layout.ts` wraps every page in its directory and all subdirectories. Layouts compose **inside-out** — the nearest layout is innermost, the root layout is outermost.
916
-
917
- ```ts
918
- // src/pages/+layout.ts
919
- import { defineLayout } from "@ilha/router";
920
- import { ilha, html } from "ilha";
921
-
922
- export default defineLayout((children) =>
923
- ilha(
924
- () => html`
925
- <nav>
926
- <a href="/">Home</a>
927
- <a href="/about">About</a>
928
- </nav>
929
- <main>${children}</main>
930
- `,
931
- ),
932
- );
933
- ```
934
-
935
- Alternatively, using the explicit type annotation:
936
-
937
- ```ts
938
- // src/pages/+layout.ts — using satisfies (equivalent)
939
- import type { LayoutHandler } from "@ilha/router/vite";
940
- import { ilha, html } from "ilha";
941
-
942
- export default ((children) =>
943
- ilha(
944
- () => html`
945
- <nav>
946
- <a href="/">Home</a>
947
- <a href="/about">About</a>
948
- </nav>
949
- <main>${children}</main>
950
- `,
951
- )) satisfies LayoutHandler;
952
- ```
953
-
954
- A `+layout.ts` inside a route group folder works exactly like a regular nested layout — it wraps only the pages inside that group, without affecting pages elsewhere.
955
-
956
- ```
957
- src/pages/
958
- +layout.ts ← wraps ALL pages (including those in groups)
959
- (auth)/
960
- +layout.ts ← wraps (auth) pages only: /sign-in, /sign-up
961
- sign-in.ts
962
- sign-up.ts
963
- about.ts ← wrapped by root layout only
964
- ```
965
-
966
- ### Page loaders
967
-
968
- A page file can export a `load` function declared with the `loader()` helper. The Vite plugin automatically detects the named `load` export, composes it with any layout loaders in the chain (outermost first, then page), and wires them into the router via `.attachLoader()` at SSR time.
969
-
970
- ```ts
971
- // src/pages/user/[id].ts
972
- import { loader } from "@ilha/router";
973
- import { ilha, html } from "ilha";
974
-
975
- export const load = loader(async ({ params }) => {
976
- const user = await fetchUser(params.id);
977
- return { user };
370
+ plugins: [oxide({ middleware: ["@ilha/router/ssr"] }), pages()],
978
371
  });
979
-
980
- export default ilha<{ user: User }>(({ user }) => html`<h1>${user.name}</h1>`);
981
372
  ```
982
373
 
983
- The `load` export must be declared with the `loader()` helper so the Vite plugin can identify it via export name.
984
-
985
- ### Layout loaders
986
-
987
- A `+layout.ts` can also export a loader. Layout loaders run concurrently with the page loader. The page loader wins on key collision.
988
-
989
- ```ts
990
- // src/pages/+layout.ts
991
- import { defineLayout, loader } from "@ilha/router";
992
-
993
- export const load = loader(async () => {
994
- return { currentUser: await getCurrentUser() };
995
- });
996
-
997
- export default defineLayout((children) => /* … */);
998
- ```
999
-
1000
- Layout loaders are composed automatically — you do not need to call `composeLoaders()` manually.
1001
-
1002
- ### Client loaders (`clientLoad`)
1003
-
1004
- A page or layout can export a `clientLoad` function that runs **in the browser** on client navigations, instead of fetching from the loader endpoint. Use it for data that is fetchable from the client anyway (public APIs, the app's own REST endpoints) — it saves a server round-trip per navigation, and it works on static hosts with no loader endpoint at all.
1005
-
1006
- ```ts
1007
- // src/pages/dashboard.ts
1008
- import { loader } from "@ilha/router";
1009
- import { ilha } from "ilha";
1010
-
1011
- export const clientLoad = loader(async ({ signal }) => {
1012
- const stats = await fetch("/api/stats", { signal }).then((r) => r.json());
1013
- return { stats };
1014
- });
1015
-
1016
- export default ilha<{ stats: Stats }>(({ stats }) => html`<pre>${JSON.stringify(stats)}</pre>`);
1017
- ```
1018
-
1019
- Rules and caveats:
1020
-
1021
- - `clientLoad` is bundled into the client — never put secrets, database clients, or server-only imports in it. Keep those in `load`, which stays server-only.
1022
- - A page can export **both**: `load` runs during SSR (first paint), `clientLoad` runs on client navigations instead of the endpoint fetch. Make them return the same shape.
1023
- - Layout `clientLoad`s compose with the page's, layouts first — the page wins on key collision, mirroring server loaders.
1024
- - With SSR + hydration, a `clientLoad`-only page is server-rendered **without** its data; the router runs `clientLoad` on the client right after hydration and re-renders the route with the loaded props.
1025
- - `clientLoad` receives a synthetic `Request` — rely on `url`, `params`, and `signal`, not cookies or headers.
1026
-
1027
- ### Error boundaries
1028
-
1029
- A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over.
1030
-
1031
- ```ts
1032
- // src/pages/+error.ts
1033
- import type { ErrorHandler } from "@ilha/router/vite";
1034
- import { ilha, html } from "ilha";
1035
-
1036
- export default ((error, route) =>
1037
- ilha(
1038
- () => html`
1039
- <div class="error">
1040
- <h1>${error.status ?? 500}</h1>
1041
- <p>${error.message}</p>
1042
- <p>Path: ${route.path}</p>
1043
- </div>
1044
- `,
1045
- )) satisfies ErrorHandler;
1046
- ```
1047
-
1048
- ### Virtual modules
1049
-
1050
- The plugin exposes separate server and client virtual modules. **Always use the explicit `/server` or `/client` path** — they resolve to different generated files with different import strategies.
1051
-
1052
- | Module | Exports | Use for |
1053
- | ------------------- | ------------------------ | -------------------------------------- |
1054
- | `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, server handlers |
1055
- | `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
1056
- | `ilha:loaders` | — | Server-only side-effect: wires loaders |
1057
-
1058
- The server module imports page/layout/error modules **without** `?client` — raw imports so SSR sees full JSX including compound component render parts. The client module imports with `?client` which strips server-only `load` exports from the browser bundle.
1059
-
1060
- ```ts
1061
- // routes/[...].ts — SSR/prerender
1062
- import { pageRouter, registry } from "ilha:pages/server";
1063
- import "ilha:loaders"; // ← wire server loaders
1064
-
1065
- export default defineEventHandler(async (event) => {
1066
- const html = await pageRouter.renderHydratable(event.node.req.url ?? "/", registry);
1067
- return new Response(`<!doctype html><html><body>${html}</body></html>`, {
1068
- headers: { "content-type": "text/html" },
1069
- });
1070
- });
1071
- ```
1072
-
1073
- ```ts
1074
- // src/client.ts — browser entry
1075
- import { pageRouter, registry } from "ilha:pages/client";
1076
-
1077
- pageRouter.hydrate(registry);
1078
- ```
1079
-
1080
- For `static` MPA mode:
1081
-
1082
- ```ts
1083
- // src/entry-client.ts — static/SSG browser entry
1084
- import { pageRouter, registry } from "ilha:pages/client";
1085
-
1086
- pageRouter.hydrateStatic(registry);
1087
- ```
1088
-
1089
- ### Plugin options
1090
-
1091
- ```ts
1092
- pages({
1093
- dir: "src/pages", // pages directory (default: "src/pages")
1094
- outDir: ".ilha", // output directory for generated files (default: ".ilha")
1095
- mode: "spa", // "spa" | "static" (default: "spa")
1096
- interceptLinks: true, // only meaningful in spa mode (default: true)
1097
- });
1098
- ```
1099
-
1100
- - **`mode: "spa"`** — full client route graph, SSR/hydration, and client-side navigation.
1101
- - **`mode: "spa", interceptLinks: false`** — full route graph and SSR/hydration, but internal links perform full document navigations.
1102
- - **`mode: "static"`** — island registry only; no route graph bundled. Each pre-rendered page hydrates its own islands via `pageRouter.hydrateStatic(registry)`.
1103
-
1104
- The plugin regenerates the routes file only when content actually changes — avoiding unnecessary HMR invalidations. Structural changes (file add/remove, `+layout.ts`/`+error.ts` edits, or changes to loader exports) trigger full HMR reloads.
1105
-
1106
- ---
1107
-
1108
- ## SSR + Hydration
1109
-
1110
- The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client:
1111
-
1112
- ```ts
1113
- // server: resolves URL → hydratable HTML string
1114
- await pageRouter.renderHydratable("/user/42", registry);
1115
- routeParams(); // → { id: "42" }
1116
-
1117
- // client: hydrates SSR DOM, sets up navigation
1118
- pageRouter.hydrate(registry);
1119
- navigate("/user/99");
1120
- routeParams(); // → { id: "99" }
1121
- ```
1122
-
1123
- ### Full SSR → hydration flow
1124
-
1125
- ```
1126
- server client
1127
- ────────────────────────────── ───────────────────────────────────
1128
- renderHydratable(url, registry) pageRouter.prime() ← sync signals first
1129
- → data-ilha="…" markers mount(registry, { root }) ← hydrate islands
1130
- → data-ilha-state snapshot pageRouter.mount(target, ← setup navigation
1131
- { hydrate: true, registry })
1132
- ```
1133
-
1134
- Or use the one-liner: `pageRouter.hydrate(registry)`.
1135
-
1136
- ### Loader data flow
1137
-
1138
- On the **server**, loaders run inside `.renderHydratable()` / `.renderResponse()`. Their return value is serialized into `data-ilha-props` on the island element so the client can rehydrate without re-fetching.
1139
-
1140
- On the **client**, navigations resolve loader data before mounting the next island. Routes with a loader registered in the browser — a manual `.route(path, island, loader)` or an FS-routing `clientLoad` export — run that loader locally, with no network round-trip. Routes with only a server loader (`markLoader()` / a `load` export) fetch from the `/__ilha/loader` endpoint, served automatically by the Vite plugin (dev) and the server adapter (production). The originating `Request` (cookies, identity, abort signal) is forwarded to the loader and the island-request scope, so `ctx.request` and `useContext().request` behave in client navigations exactly as they do during SSR.
1141
-
1142
- Like `/__ilha/frame`, the loader endpoint is **denied by default** in production when no guard is registered — gate it with `setLoaderGuard()` (or the shared frame guard / `defaultAction: "open"` policy) or client navigations to server-loader routes return 403.
1143
-
1144
- ```
1145
- server client (navigation)
1146
- ──────────────────────────── ─────────────────────────────────────────
1147
- renderHydratable local loader (clientLoad / .route loader)?
1148
- → executeLoader(…) yes → runLocalLoader(…) in-browser
1149
- → island.hydratable(props) no → fetchLoaderData(…) GET /__ilha/loader?path=/user/42
1150
- → data-ilha-props="{…}" → mountRouteWithHydration(island, host, …)
1151
- ```
374
+ Without oxidejs, host the router in your own fetch handler and use `render()`, `renderResponse()`, or `respond()`. In static (`mode: "static"`) builds, prerender each route at build time and hydrate the island from the client `registry`.
1152
375
 
1153
376
  ---
1154
377