@ilha/router 0.1.1 → 0.2.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/index.js CHANGED
@@ -2,6 +2,54 @@ import ilha, { context, html, mount } from "ilha";
2
2
  import { addRoute, createRouter, findRoute } from "rou3";
3
3
  //#region src/index.ts
4
4
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
5
+ /**
6
+ * Identity function for declaring a loader. Exists purely as a type anchor and
7
+ * a marker for the Vite plugin to detect by export name.
8
+ */
9
+ function loader(fn) {
10
+ return fn;
11
+ }
12
+ var Redirect = class {
13
+ __ilhaRedirect = true;
14
+ to;
15
+ status;
16
+ constructor(to, status = 302) {
17
+ this.to = to;
18
+ this.status = status;
19
+ }
20
+ };
21
+ var LoaderError = class {
22
+ __ilhaLoaderError = true;
23
+ status;
24
+ message;
25
+ constructor(status, message) {
26
+ this.status = status;
27
+ this.message = message;
28
+ }
29
+ };
30
+ function redirect(to, status = 302) {
31
+ throw new Redirect(to, status);
32
+ }
33
+ function error(status, message) {
34
+ throw new LoaderError(status, message);
35
+ }
36
+ /**
37
+ * Compose a list of loaders into a single loader. Later loaders win on key
38
+ * collision (page loader overrides layout loader for the same key). All loaders
39
+ * run concurrently within a chain since they share the same abort signal and
40
+ * request — re-fetching is cheap with a request-scoped cache (future work).
41
+ *
42
+ * For v1 we run them in parallel via `Promise.all`. If a loader throws a
43
+ * `Redirect` or `LoaderError`, the composed loader re-throws it unchanged.
44
+ */
45
+ function composeLoaders(loaders) {
46
+ if (loaders.length === 0) return async () => ({});
47
+ if (loaders.length === 1) return loaders[0];
48
+ return async (ctx) => {
49
+ const results = await Promise.all(loaders.map((l) => l(ctx)));
50
+ return Object.assign({}, ...results);
51
+ };
52
+ }
5
53
  function wrapLayout(layout, page) {
6
54
  return layout(page);
7
55
  }
@@ -44,33 +92,114 @@ function wrapError(handler, page) {
44
92
  };
45
93
  return wrapper;
46
94
  }
95
+ function defineLayout(layout) {
96
+ return layout;
97
+ }
47
98
  function buildReverseRegistry(registry) {
48
99
  const map = /* @__PURE__ */ new Map();
49
100
  for (const [name, island] of Object.entries(registry)) if (!map.has(island)) map.set(island, name);
50
101
  return map;
51
102
  }
103
+ /** Path of the loader endpoint served by the Vite plugin / production adapter. */
104
+ const LOADER_ENDPOINT = "/__ilha/loader";
105
+ /** In-memory cache for prefetched loader data, keyed by path+search. */
106
+ const prefetchCache = /* @__PURE__ */ new Map();
107
+ async function fetchLoaderData(pathWithSearch, signal) {
108
+ const cached = prefetchCache.get(pathWithSearch);
109
+ if (cached) {
110
+ prefetchCache.delete(pathWithSearch);
111
+ try {
112
+ return await cached;
113
+ } catch {}
114
+ }
115
+ const url = `${LOADER_ENDPOINT}?path=${encodeURIComponent(pathWithSearch)}`;
116
+ try {
117
+ const res = await fetch(url, {
118
+ signal,
119
+ headers: { accept: "application/json" }
120
+ });
121
+ if (!res.ok) {
122
+ try {
123
+ const body = await res.json();
124
+ if (body && typeof body === "object" && "kind" in body) return body;
125
+ } catch {}
126
+ return {
127
+ kind: "error",
128
+ status: res.status,
129
+ message: res.statusText
130
+ };
131
+ }
132
+ return await res.json();
133
+ } catch (e) {
134
+ if (e?.name === "AbortError") throw e;
135
+ return {
136
+ kind: "error",
137
+ status: 0,
138
+ message: e?.message ?? "network error"
139
+ };
140
+ }
141
+ }
142
+ /**
143
+ * Prefetch loader data for a given path. Safe to call repeatedly — a single
144
+ * inflight request is reused until it either resolves (and is consumed by
145
+ * navigation) or is superseded by another prefetch.
146
+ */
147
+ function prefetch(pathWithSearch) {
148
+ if (!isBrowser) return;
149
+ if (prefetchCache.has(pathWithSearch)) return;
150
+ const pathOnly = pathWithSearch.split("?")[0] ?? "";
151
+ if (!findRoute(_rou3, "GET", pathOnly)?.data?.loader) return;
152
+ const promise = fetchLoaderData(pathWithSearch).catch((e) => {
153
+ return {
154
+ kind: "error",
155
+ status: 0,
156
+ message: e?.message ?? "prefetch failed"
157
+ };
158
+ });
159
+ prefetchCache.set(pathWithSearch, promise);
160
+ }
52
161
  /**
53
162
  * Mounts a route island with proper hydration for client-side navigation.
54
- * Looks up the island in the reverse registry, renders it with hydration
55
- * markers, and mounts it for interactivity.
163
+ * Looks up the island in the reverse registry, runs the loader (via fetch),
164
+ * renders it with hydration markers, and mounts it for interactivity.
56
165
  */
57
- async function mountRouteWithHydration(island, host, registry, reverseRegistry) {
166
+ async function mountRouteWithHydration(island, host, pathWithSearch, signal, registry, reverseRegistry) {
58
167
  if (!island) {
59
168
  host.innerHTML = `<div data-router-empty></div>`;
60
169
  return () => {};
61
170
  }
171
+ const hasLoader = !!findRoute(_rou3, "GET", pathWithSearch.split("?")[0] ?? "")?.data?.loader;
172
+ let props = {};
173
+ const loaderResult = hasLoader ? await fetchLoaderData(pathWithSearch, signal) : {
174
+ kind: "data",
175
+ data: {}
176
+ };
177
+ if (loaderResult.kind === "redirect") {
178
+ navigate(loaderResult.to, { replace: true });
179
+ return () => {};
180
+ }
181
+ if (loaderResult.kind === "error") {
182
+ const escaped = String(loaderResult.message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
183
+ host.innerHTML = `<div data-router-view data-router-error="${loaderResult.status}">${escaped}</div>`;
184
+ return () => {};
185
+ }
186
+ if (loaderResult.kind === "not-found") {
187
+ host.innerHTML = `<div data-router-empty></div>`;
188
+ return () => {};
189
+ }
190
+ props = loaderResult.data;
62
191
  if (!registry) {
63
192
  console.warn("[ilha-router] No registry provided for client-side navigation. Island will not be interactive.");
64
- host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
193
+ host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
65
194
  return () => {};
66
195
  }
67
196
  const name = reverseRegistry?.get(island) ?? Object.entries(registry).find(([, v]) => v === island)?.[0];
68
197
  if (!name) {
69
198
  console.warn("[ilha-router] Island not found in registry for client-side navigation.");
70
- host.innerHTML = `<div data-router-view>${island.toString()}</div>`;
199
+ host.innerHTML = `<div data-router-view>${island.toString(props)}</div>`;
71
200
  return () => {};
72
201
  }
73
- host.innerHTML = `<div data-router-view>${await island.hydratable({}, {
202
+ host.innerHTML = `<div data-router-view>${await island.hydratable(props, {
74
203
  name,
75
204
  as: "div",
76
205
  snapshot: true
@@ -95,6 +224,7 @@ const activeIsland = context("router.active", null);
95
224
  let _records = [];
96
225
  let _rou3 = createRouter();
97
226
  let _islandToPattern = /* @__PURE__ */ new Map();
227
+ let _patternToData = /* @__PURE__ */ new Map();
98
228
  function extractParams(matchParams) {
99
229
  const params = {};
100
230
  if (matchParams) for (const [k, v] of Object.entries(matchParams)) params[k] = decodeURIComponent(v);
@@ -107,7 +237,7 @@ function syncRouteFromURL(url) {
107
237
  routeParams(extractParams(match?.params));
108
238
  routeSearch(parsed.search);
109
239
  routeHash(parsed.hash);
110
- activeIsland(match?.data ?? null);
240
+ activeIsland(match?.data?.island ?? null);
111
241
  }
112
242
  /** Client-only fast path — reads directly from `location` instead of parsing a URL. */
113
243
  function syncRouteFromLocation() {
@@ -116,25 +246,12 @@ function syncRouteFromLocation() {
116
246
  routeParams(extractParams(match?.params));
117
247
  routeSearch(location.search);
118
248
  routeHash(location.hash);
119
- activeIsland(match?.data ?? null);
249
+ activeIsland(match?.data?.island ?? null);
120
250
  }
121
251
  /**
122
252
  * Prime route context signals from the current `location` so that islands
123
253
  * hydrated by `ilha.mount()` see the correct route values on their first
124
254
  * render — preventing a mismatch morph that would destroy hydrated bindings.
125
- *
126
- * Call this **before** `ilha.mount()` and **after** all routes have been
127
- * registered (i.e. after the `router().route(…).route(…)` chain).
128
- *
129
- * ```ts
130
- * import { mount } from "ilha";
131
- * import { pageRouter } from "ilha:pages";
132
- * import { registry } from "ilha:registry";
133
- *
134
- * pageRouter.prime(); // ← sync signals first
135
- * mount(registry, { root: … }); // ← then hydrate islands
136
- * pageRouter.mount("#app", { hydrate: true });
137
- * ```
138
255
  */
139
256
  function prime() {
140
257
  if (isBrowser) syncRouteFromLocation();
@@ -146,25 +263,42 @@ function navigate(to, opts = {}) {
146
263
  else history.pushState(null, "", to);
147
264
  syncRouteFromLocation();
148
265
  }
149
- function enableLinkInterception(root = document) {
266
+ function enableLinkInterception(root = document, options = {}) {
150
267
  if (!isBrowser) return () => {};
151
- const handler = (e) => {
152
- if (e.defaultPrevented) return;
153
- const target = e.target.closest("a");
154
- if (!target) return;
268
+ const prefetchEnabled = options.prefetch !== false;
269
+ /** Determine whether this anchor is a same-origin in-app link we should handle. */
270
+ function eligibleLink(target, e) {
155
271
  const href = target.getAttribute("href");
156
- if (!href) return;
272
+ if (!href) return false;
157
273
  const isAnchorOnly = href.startsWith("#");
158
274
  const isBlank = target.getAttribute("target") === "_blank";
159
- const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey;
275
+ const hasModifier = !!e && (e.ctrlKey || e.metaKey || e.shiftKey);
160
276
  const isExternal = !!target.hostname && (target.hostname !== location.hostname || target.protocol !== location.protocol);
161
277
  const hasNoIntercept = target.hasAttribute("data-no-intercept");
162
- if (isExternal || isAnchorOnly || isBlank || hasModifier || hasNoIntercept) return;
278
+ return !(isExternal || isAnchorOnly || isBlank || hasModifier || hasNoIntercept);
279
+ }
280
+ const clickHandler = (e) => {
281
+ if (e.defaultPrevented) return;
282
+ const target = e.target.closest("a");
283
+ if (!target) return;
284
+ if (!eligibleLink(target, e)) return;
163
285
  e.preventDefault();
164
286
  navigate(target.pathname + target.search + target.hash);
165
287
  };
166
- root.addEventListener("click", handler);
167
- return () => root.removeEventListener("click", handler);
288
+ const hoverHandler = (e) => {
289
+ const target = e.target.closest("a");
290
+ if (!target) return;
291
+ const flag = target.getAttribute("data-prefetch");
292
+ if (flag === null || flag === "false") return;
293
+ if (!eligibleLink(target)) return;
294
+ prefetch(target.pathname + target.search);
295
+ };
296
+ root.addEventListener("click", clickHandler);
297
+ if (prefetchEnabled) root.addEventListener("mouseover", hoverHandler, { passive: true });
298
+ return () => {
299
+ root.removeEventListener("click", clickHandler);
300
+ if (prefetchEnabled) root.removeEventListener("mouseover", hoverHandler);
301
+ };
168
302
  }
169
303
  const RouterView = ilha.render(() => {
170
304
  const island = activeIsland();
@@ -174,28 +308,100 @@ const RouterView = ilha.render(() => {
174
308
  const RouterLink = ilha.state("href", "").state("label", "").on("[data-link]@click", ({ state, event }) => {
175
309
  event.preventDefault();
176
310
  navigate(state.href());
177
- }).render(({ state }) => html`<a data-link href="${state.href}">${state.label}</a>`);
311
+ }).on("[data-link]@mouseenter", ({ state }) => {
312
+ const href = state.href();
313
+ if (!href) return;
314
+ if (/^https?:\/\//i.test(href)) try {
315
+ const u = new URL(href);
316
+ if (u.origin !== location.origin) return;
317
+ prefetch(u.pathname + u.search);
318
+ return;
319
+ } catch {
320
+ return;
321
+ }
322
+ prefetch(href);
323
+ }).render(({ state }) => html`<a data-link data-prefetch href="${state.href}">${state.label}</a>`);
178
324
  function isActive(pattern) {
179
325
  const match = findRoute(_rou3, "GET", routePath());
180
326
  if (!match) return false;
181
- return _islandToPattern.get(match.data) === pattern;
327
+ return _islandToPattern.get(match.data.island) === pattern;
328
+ }
329
+ function parsedURL(url) {
330
+ return typeof url === "string" ? new URL(url, "http://localhost") : url;
331
+ }
332
+ function defaultRequest(url) {
333
+ try {
334
+ return new Request(url.toString());
335
+ } catch {
336
+ return {
337
+ url: url.toString(),
338
+ headers: new Headers()
339
+ };
340
+ }
341
+ }
342
+ async function executeLoader(loader, url, params, request, signal) {
343
+ try {
344
+ return {
345
+ kind: "data",
346
+ data: await loader({
347
+ params,
348
+ request,
349
+ url,
350
+ signal
351
+ }) ?? {}
352
+ };
353
+ } catch (e) {
354
+ if (e instanceof Redirect) return {
355
+ kind: "redirect",
356
+ to: e.to,
357
+ status: e.status
358
+ };
359
+ if (e instanceof LoaderError) return {
360
+ kind: "error",
361
+ status: e.status,
362
+ message: e.message
363
+ };
364
+ return {
365
+ kind: "error",
366
+ status: e?.status ?? 500,
367
+ message: e?.message ?? "Loader failed"
368
+ };
369
+ }
182
370
  }
183
371
  function router() {
184
372
  _records = [];
185
373
  _rou3 = createRouter();
186
374
  _islandToPattern = /* @__PURE__ */ new Map();
375
+ _patternToData = /* @__PURE__ */ new Map();
187
376
  let _popstateCleanup = null;
188
377
  let _linkCleanup = null;
189
378
  const builder = {
190
- route(pattern, island) {
379
+ route(pattern, island, loader) {
380
+ const data = {
381
+ island,
382
+ loader
383
+ };
191
384
  _records.push({
192
385
  pattern,
193
- island
386
+ island,
387
+ loader
194
388
  });
195
- addRoute(_rou3, "GET", pattern, island);
389
+ addRoute(_rou3, "GET", pattern, data);
390
+ _patternToData.set(pattern, data);
196
391
  if (!_islandToPattern.has(island)) _islandToPattern.set(island, pattern);
197
392
  return builder;
198
393
  },
394
+ attachLoader(pattern, loader) {
395
+ const data = _patternToData.get(pattern);
396
+ if (!data) {
397
+ console.warn(`[ilha-router] attachLoader("${pattern}", …): pattern was never registered via .route(). The loader will be ignored.`);
398
+ return builder;
399
+ }
400
+ data.loader = loader;
401
+ const rec = _records.find((r) => r.pattern === pattern);
402
+ if (rec) rec.loader = loader;
403
+ return builder;
404
+ },
199
405
  prime,
200
406
  mount(target, { hydrate = false, registry } = {}) {
201
407
  if (!isBrowser) {
@@ -213,6 +419,7 @@ function router() {
213
419
  _popstateCleanup = () => window.removeEventListener("popstate", popHandler);
214
420
  _linkCleanup = enableLinkInterception(document);
215
421
  let unmountView = null;
422
+ let navAbort = null;
216
423
  if (hydrate) {
217
424
  const viewHost = host.querySelector("[data-router-view]") ?? host;
218
425
  let currentMountedIsland = activeIsland();
@@ -222,10 +429,18 @@ function router() {
222
429
  const current = activeIsland();
223
430
  if (current !== currentMountedIsland) {
224
431
  const thisNav = ++navVersion;
432
+ navAbort?.abort();
433
+ navAbort = new AbortController();
434
+ const signal = navAbort.signal;
225
435
  queueMicrotask(async () => {
226
436
  if (thisNav !== navVersion) return;
227
437
  unmountView?.();
228
- unmountView = await mountRouteWithHydration(current, viewHost, registry, reverseRegistry);
438
+ try {
439
+ unmountView = await mountRouteWithHydration(current, viewHost, location.pathname + location.search, signal, registry, reverseRegistry);
440
+ } catch (e) {
441
+ if (e?.name === "AbortError") return;
442
+ throw e;
443
+ }
229
444
  currentMountedIsland = current;
230
445
  });
231
446
  }
@@ -237,6 +452,7 @@ function router() {
237
452
  const unmountNavHandler = navHandler.mount(navHost);
238
453
  return () => {
239
454
  ++navVersion;
455
+ navAbort?.abort();
240
456
  unmountNavHandler();
241
457
  navHost.remove();
242
458
  unmountView?.();
@@ -250,23 +466,43 @@ function router() {
250
466
  let currentMountedIsland = null;
251
467
  let navVersion = 0;
252
468
  unmountView = RouterView.mount(host);
253
- /** Mount the active island onto the [data-router-view] container for interactivity. */
254
- function mountActiveIsland(island) {
469
+ /**
470
+ * Fetch loader data and mount the active island. SPA mode also fetches
471
+ * from the loader endpoint — otherwise navigation after the initial SSR
472
+ * render would have no access to loader data.
473
+ */
474
+ async function mountActiveIsland(island, signal) {
255
475
  unmountIsland?.();
256
476
  unmountIsland = null;
257
477
  currentMountedIsland = island;
258
478
  if (!island) return;
259
479
  const viewHost = host?.querySelector("[data-router-view]");
260
- if (viewHost) unmountIsland = island.mount(viewHost);
480
+ if (!viewHost) return;
481
+ const result = !!findRoute(_rou3, "GET", location.pathname)?.data?.loader ? await fetchLoaderData(location.pathname + location.search, signal) : {
482
+ kind: "data",
483
+ data: {}
484
+ };
485
+ if (signal.aborted) return;
486
+ if (result.kind === "redirect") {
487
+ navigate(result.to, { replace: true });
488
+ return;
489
+ }
490
+ const props = result.kind === "data" ? result.data : {};
491
+ viewHost.innerHTML = island.toString(props);
492
+ unmountIsland = island.mount(viewHost, props);
261
493
  }
262
- mountActiveIsland(activeIsland());
494
+ navAbort = new AbortController();
495
+ mountActiveIsland(activeIsland(), navAbort.signal);
263
496
  const navHandler = ilha.render(() => {
264
497
  const current = activeIsland();
265
498
  if (current !== currentMountedIsland) {
266
499
  const thisNav = ++navVersion;
500
+ navAbort?.abort();
501
+ navAbort = new AbortController();
502
+ const signal = navAbort.signal;
267
503
  queueMicrotask(() => {
268
504
  if (thisNav !== navVersion) return;
269
- mountActiveIsland(current);
505
+ mountActiveIsland(current, signal);
270
506
  });
271
507
  }
272
508
  return "";
@@ -277,6 +513,7 @@ function router() {
277
513
  const unmountNavHandler = navHandler.mount(navHost);
278
514
  return () => {
279
515
  ++navVersion;
516
+ navAbort?.abort();
280
517
  unmountIsland?.();
281
518
  unmountNavHandler();
282
519
  navHost.remove();
@@ -291,21 +528,74 @@ function router() {
291
528
  syncRouteFromURL(url);
292
529
  return RouterView.toString();
293
530
  },
294
- async renderHydratable(url, registry, options = {}) {
295
- syncRouteFromURL(url);
296
- const island = activeIsland();
297
- if (!island) return `<div data-router-empty></div>`;
531
+ async renderHydratable(url, registry, options = {}, request) {
532
+ const response = await this.renderResponse(url, registry, options, request);
533
+ if (response.kind === "html") return response.html;
534
+ if (response.kind === "error") return response.html;
535
+ return `<meta http-equiv="refresh" content="0; url=${response.to}">`;
536
+ },
537
+ async renderResponse(url, registry, options = {}, request) {
538
+ const parsed = parsedURL(url);
539
+ syncRouteFromURL(parsed);
540
+ const match = findRoute(_rou3, "GET", parsed.pathname);
541
+ const island = match?.data?.island ?? null;
542
+ if (!island) return {
543
+ kind: "html",
544
+ html: `<div data-router-empty></div>`,
545
+ status: 404
546
+ };
547
+ let props = {};
548
+ if (match?.data?.loader) {
549
+ const req = request ?? defaultRequest(parsed);
550
+ const ctrl = new AbortController();
551
+ const result = await executeLoader(match.data.loader, parsed, routeParams(), req, ctrl.signal);
552
+ if (result.kind === "redirect") return {
553
+ kind: "redirect",
554
+ to: result.to,
555
+ status: result.status
556
+ };
557
+ if (result.kind === "error") {
558
+ const escapedMessage = String(result.message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
559
+ const html = `<div data-router-view data-router-error="${result.status}">${escapedMessage}</div>`;
560
+ return {
561
+ kind: "error",
562
+ status: result.status,
563
+ message: result.message,
564
+ html
565
+ };
566
+ }
567
+ props = result.data;
568
+ }
298
569
  const name = buildReverseRegistry(registry).get(island);
299
570
  if (!name) {
300
571
  console.warn(`[ilha-router] renderHydratable: active island for "${routePath()}" is not in the registry. Falling back to plain SSR — the island will not be interactive on the client.`);
301
- return `<div data-router-view>${island.toString()}</div>`;
572
+ return {
573
+ kind: "html",
574
+ html: `<div data-router-view>${island.toString(props)}</div>`
575
+ };
302
576
  }
303
- return `<div data-router-view>${await island.hydratable({}, {
304
- name,
305
- as: "div",
306
- snapshot: true,
307
- ...options
308
- })}</div>`;
577
+ return {
578
+ kind: "html",
579
+ html: `<div data-router-view>${await island.hydratable(props, {
580
+ name,
581
+ as: "div",
582
+ snapshot: true,
583
+ ...options
584
+ })}</div>`
585
+ };
586
+ },
587
+ async runLoader(url, request) {
588
+ const parsed = parsedURL(url);
589
+ const match = findRoute(_rou3, "GET", parsed.pathname);
590
+ if (!match?.data?.island) return { kind: "not-found" };
591
+ if (!match.data.loader) return {
592
+ kind: "data",
593
+ data: {}
594
+ };
595
+ const params = extractParams(match.params);
596
+ const req = request ?? defaultRequest(parsed);
597
+ const ctrl = new AbortController();
598
+ return executeLoader(match.data.loader, parsed, params, req, ctrl.signal);
309
599
  },
310
600
  hydrate(registry, options = {}) {
311
601
  if (!isBrowser) {
@@ -335,8 +625,13 @@ var src_default = {
335
625
  isActive,
336
626
  enableLinkInterception,
337
627
  prime,
628
+ prefetch,
338
629
  RouterView,
339
- RouterLink
630
+ RouterLink,
631
+ loader,
632
+ redirect,
633
+ error,
634
+ composeLoaders
340
635
  };
341
636
  //#endregion
342
- export { RouterLink, RouterView, src_default as default, enableLinkInterception, isActive, navigate, prime, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };
637
+ export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, isActive, loader, navigate, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, useRoute, wrapError, wrapLayout };