@c9up/aurora 0.1.12 → 0.1.14

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/src/command.ts CHANGED
@@ -91,14 +91,32 @@ class CommandRunner<TArgs extends unknown[], TData>
91
91
  this.#loading(true);
92
92
  this.#error(null);
93
93
  try {
94
- const result = await this.#task(...args);
94
+ let result: TData;
95
+ try {
96
+ result = await this.#task(...args);
97
+ } catch (error) {
98
+ if (id !== this.#runId) return; // superseded — drop
99
+ this.#error(error);
100
+ for (const handler of this.#onFail) handler(error);
101
+ return;
102
+ }
95
103
  if (id !== this.#runId) return; // superseded by a newer run — drop
96
104
  this.#data(result);
97
- for (const handler of this.#onSuccess) handler(result);
98
- } catch (error) {
99
- if (id !== this.#runId) return; // supersededdrop
100
- this.#error(error);
101
- for (const handler of this.#onFail) handler(error);
105
+ // onSuccess runs OUTSIDE the task's failure boundary: success is
106
+ // decided by the task, never by the callback. A throw here (e.g. a
107
+ // render error after the data lands) is a handler bug surfacing it
108
+ // as a task failure would route to onFail, which on a guarded page
109
+ // masquerades as a logout. Report it, but never reclassify it.
110
+ try {
111
+ for (const handler of this.#onSuccess) handler(result);
112
+ } catch (handlerError) {
113
+ if (typeof console !== "undefined") {
114
+ console.error(
115
+ "[aurora] a command onSuccess handler threw — not treated as a task failure:",
116
+ handlerError,
117
+ );
118
+ }
119
+ }
102
120
  } finally {
103
121
  if (id === this.#runId) {
104
122
  this.#loading(false);
package/src/hydrate.ts CHANGED
@@ -320,15 +320,50 @@ function hydrateTemplateResult(
320
320
  * Text-slot paths point to a comment marker that doesn't exist in
321
321
  * hydration markup — we tolerate the miss and return null.
322
322
  */
323
+ /**
324
+ * Collapse each top-level `<!--$-->…<!--/$-->` range in `nodes` to a SINGLE
325
+ * entry (its start marker), dropping the in-range content + end marker from the
326
+ * count. SSR expands a structured slot (reactive OR a direct nested template)
327
+ * to a node RANGE, but the parsed client template counts every slot as exactly
328
+ * ONE comment node — so without this collapse the extra range nodes shift the
329
+ * childNode index of every FOLLOWING sibling slot (dead bindings / "slot path
330
+ * not found"). Nested ranges (depth > 0) are skipped wholesale: they belong to
331
+ * the outer slot's content and are hydrated when we recurse into it.
332
+ */
333
+ function collapseMarkerRanges(nodes: ChildNode[]): ChildNode[] {
334
+ const out: ChildNode[] = [];
335
+ let depth = 0;
336
+ for (const n of nodes) {
337
+ if (n.nodeType === 8 /* Comment */) {
338
+ const data = (n as Comment).data;
339
+ if (data === SLOT_START) {
340
+ if (depth === 0) out.push(n); // the whole range counts as one node
341
+ depth += 1;
342
+ continue;
343
+ }
344
+ if (data === SLOT_END) {
345
+ if (depth > 0) depth -= 1;
346
+ continue;
347
+ }
348
+ }
349
+ if (depth === 0) out.push(n);
350
+ }
351
+ return out;
352
+ }
353
+
323
354
  function resolvePathLive(
324
355
  _root: ParentNode,
325
356
  path: NodePath,
326
357
  rootNodes: ChildNode[],
327
358
  ): Node | null {
328
359
  if (path.length === 0) return null;
329
- let node: Node | null = rootNodes[path[0]] ?? null;
360
+ // Collapse marker ranges at EVERY level so the live child list matches the
361
+ // parsed template's one-node-per-slot shape (see collapseMarkerRanges).
362
+ let children = collapseMarkerRanges(rootNodes);
363
+ let node: Node | null = children[path[0]] ?? null;
330
364
  for (let i = 1; node && i < path.length; i++) {
331
- node = node.childNodes[path[i]] ?? null;
365
+ children = collapseMarkerRanges(Array.from(node.childNodes));
366
+ node = children[path[i]] ?? null;
332
367
  }
333
368
  return node;
334
369
  }
@@ -341,6 +376,19 @@ function hydrateSlot(
341
376
  mountHooks: Array<EffectCallback>,
342
377
  markerCursor: MarkerCursor,
343
378
  ): void {
379
+ // Type guard: an attr/bool/prop/event slot needs an Element. On a SSR↔client
380
+ // structural desync the path can resolve to an EXISTING node of the wrong
381
+ // type (Text/Comment); casting it to Element and calling setAttribute would
382
+ // throw "setAttribute is not a function". Skip the binding (fail-soft) rather
383
+ // than crash hydration. Text slots accept text/comment/element, so they pass.
384
+ if (slot.kind !== "text" && node.nodeType !== 1) {
385
+ if (typeof console !== "undefined") {
386
+ console.warn(
387
+ `[aurora] hydrate: slot (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
388
+ );
389
+ }
390
+ return;
391
+ }
344
392
  switch (slot.kind) {
345
393
  case "text":
346
394
  hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
@@ -370,6 +418,24 @@ function hydrateSlot(
370
418
  * existing text node in place. For nested TemplateResults, we
371
419
  * recursively hydrate against the captured sibling range.
372
420
  */
421
+ /**
422
+ * First text node inside a marker pair's range, or a fresh empty one inserted
423
+ * before the end marker (when the SSR value was empty → no text node yet).
424
+ */
425
+ function reactiveTextNode(pair: MarkerPair): Text {
426
+ for (
427
+ let n = pair.start.nextSibling;
428
+ n !== null && n !== pair.end;
429
+ n = n.nextSibling
430
+ ) {
431
+ if (n.nodeType === 3 /* TEXT */) return n as Text;
432
+ }
433
+ const doc = pair.end.ownerDocument ?? document;
434
+ const fresh = doc.createTextNode("");
435
+ pair.end.parentNode?.insertBefore(fresh, pair.end);
436
+ return fresh;
437
+ }
438
+
373
439
  function hydrateTextSlot(
374
440
  commentMarker: Node,
375
441
  value: unknown,
@@ -377,14 +443,82 @@ function hydrateTextSlot(
377
443
  mountHooks: Array<EffectCallback>,
378
444
  markerCursor: MarkerCursor,
379
445
  ): void {
380
- // The path lands on the comment marker that EXISTS in the parsed
381
- // template but not in SSR output. Hydration walks the live siblings
382
- // to find the text node that holds the SSR value.
383
- // Strategy: the comment was located at child index N inside its
384
- // parent; SSR wrote the value as the immediately-preceding text
385
- // node (or nothing for null/false). Live node here is whatever the
386
- // path resolution returned — often a text node, sometimes an
387
- // element (for nested templates). We rebind in-place.
446
+ // Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
447
+ // resolves (via collapseMarkerRanges) to the start marker. Consume the
448
+ // matching pair in document order and bind within its range.
449
+ const pair = markerCursor.pairs[markerCursor.i];
450
+ if (pair === undefined) {
451
+ // Legacy markup without per-slot markers (mismatched older SSR build).
452
+ legacyHydrateTextSlot(
453
+ commentMarker,
454
+ value,
455
+ cleanups,
456
+ mountHooks,
457
+ markerCursor,
458
+ );
459
+ return;
460
+ }
461
+ markerCursor.i += 1;
462
+
463
+ const reactiveFn =
464
+ isSignal(value) || typeof value === "function"
465
+ ? (value as () => unknown)
466
+ : null;
467
+ const current = reactiveFn ? reactiveFn() : value;
468
+
469
+ // Structured value (nested template / array): reactive → swap on change;
470
+ // direct → adopt the SSR range once (inner bindings wired against it).
471
+ if (isTemplateResult(current) || Array.isArray(current)) {
472
+ if (reactiveFn) {
473
+ hydrateReactiveStructured(
474
+ reactiveFn,
475
+ pair,
476
+ cleanups,
477
+ mountHooks,
478
+ markerCursor,
479
+ );
480
+ return;
481
+ }
482
+ const range: ChildNode[] = [];
483
+ for (
484
+ let n = pair.start.nextSibling;
485
+ n !== null && n !== pair.end;
486
+ n = n.nextSibling
487
+ ) {
488
+ range.push(n as ChildNode);
489
+ }
490
+ if (isTemplateResult(current)) {
491
+ hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
492
+ }
493
+ // A direct (non-reactive) array is static SSR markup; per-item reactive
494
+ // bindings aren't individually re-hydrated (use `${() => arr.map(…)}`).
495
+ return;
496
+ }
497
+
498
+ // Scalar: a reactive scalar updates the range's text node on change; a static
499
+ // scalar is already rendered between the markers (nothing to wire).
500
+ if (reactiveFn) {
501
+ const textNode = reactiveTextNode(pair);
502
+ const dispose = effect(() => {
503
+ const v = reactiveFn();
504
+ textNode.data = v == null || v === false ? "" : String(v);
505
+ });
506
+ cleanups.push(dispose);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Pre-marker fallback — best-effort hydration when the SSR markup carries no
512
+ * per-slot boundary markers (a mismatched older SSR build). Current builds wrap
513
+ * every text slot, so this path is dead for matched server/client versions.
514
+ */
515
+ function legacyHydrateTextSlot(
516
+ commentMarker: Node,
517
+ value: unknown,
518
+ cleanups: Disposer[],
519
+ mountHooks: Array<EffectCallback>,
520
+ markerCursor: MarkerCursor,
521
+ ): void {
388
522
  if (isSignal(value) || typeof value === "function") {
389
523
  const fn = value as () => unknown;
390
524
  // First, evaluate eagerly to detect a structured value (nested
@@ -451,6 +585,27 @@ function hydrateTextSlot(
451
585
  return;
452
586
  }
453
587
  if (isTemplateResult(value)) {
588
+ // DIRECT nested template (component composition, `${Layout({…})}`). SSR
589
+ // wrapped it in a boundary-marker pair (same scheme as a reactive
590
+ // structured slot). Consume the pair in document order and hydrate the
591
+ // nested template against its captured range — wiring inner bindings to
592
+ // the SSR nodes and keeping the marker cursor aligned.
593
+ const pair = markerCursor.pairs[markerCursor.i];
594
+ if (pair !== undefined) {
595
+ markerCursor.i += 1;
596
+ const range: ChildNode[] = [];
597
+ for (
598
+ let n = pair.start.nextSibling;
599
+ n !== null && n !== pair.end;
600
+ n = n.nextSibling
601
+ ) {
602
+ range.push(n as ChildNode);
603
+ }
604
+ hydrateTemplateResult(value, range, cleanups, mountHooks, markerCursor);
605
+ return;
606
+ }
607
+ // Legacy markup without markers (older SSR build): best-effort against
608
+ // the single resolved node.
454
609
  hydrateTemplateResult(
455
610
  value,
456
611
  [commentMarker as ChildNode],
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  WebStorage,
35
35
  windowSize,
36
36
  } from "./browser.js";
37
+ export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
37
38
  export type { Command } from "./command.js";
38
39
  export { command } from "./command.js";
39
40
  export { component, onMount, onUnmount } from "./component.js";
@@ -126,3 +127,4 @@ export {
126
127
  } from "./rpc.js";
127
128
  export { renderToString } from "./ssr.js";
128
129
  export type { TemplateResult } from "./types.js";
130
+ export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
package/src/render.ts CHANGED
@@ -107,6 +107,30 @@ export function mount(
107
107
  for (let i = 0; i < tpl.slots.length; i++) {
108
108
  const slot = tpl.slots[i];
109
109
  const node = resolvePath(fragment, slot.path);
110
+ if (node === null) {
111
+ // Path didn't resolve — skip this binding rather than crash (see
112
+ // resolvePath). Degrades to a dead binding; the surrounding render
113
+ // (and any command driving it) survives.
114
+ if (typeof console !== "undefined") {
115
+ console.warn(
116
+ `[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} did not resolve — skipping binding`,
117
+ );
118
+ }
119
+ continue;
120
+ }
121
+ // Type guard: an attr/bool/prop/event slot needs an Element. On a
122
+ // structural desync the path can land on an EXISTING node of the wrong
123
+ // type (Text/Comment) — casting it to Element and calling setAttribute
124
+ // would throw "setAttribute is not a function". Degrade like the null
125
+ // case (skip the binding) instead of crashing the whole render.
126
+ if (slot.kind !== "text" && node.nodeType !== 1) {
127
+ if (typeof console !== "undefined") {
128
+ console.warn(
129
+ `[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
130
+ );
131
+ }
132
+ continue;
133
+ }
110
134
  if (slot.kind === "attr" && slot.staticParts !== undefined) {
111
135
  collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
112
136
  } else {
@@ -124,9 +148,20 @@ export function mount(
124
148
  return fragment;
125
149
  }
126
150
 
127
- function resolvePath(root: ParentNode, path: NodePath): Node {
151
+ function resolvePath(root: ParentNode, path: NodePath): Node | null {
128
152
  let node: Node = root;
129
- for (const i of path) node = node.childNodes[i];
153
+ for (const i of path) {
154
+ const next = node.childNodes[i];
155
+ // Fail-soft: a path step that runs off the live child list means the
156
+ // tree diverged from the parsed template (a hydration desync). Return
157
+ // null so the caller skips the binding instead of dereferencing
158
+ // `undefined.childNodes` and crashing the whole render — which, when the
159
+ // render runs inside a command's onSuccess, used to masquerade as a
160
+ // task failure (and on a guarded page, a logout). Mirrors
161
+ // `resolvePathLive` in hydrate.ts.
162
+ if (next === undefined) return null;
163
+ node = next;
164
+ }
130
165
  return node;
131
166
  }
132
167
 
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type { Pages } from "../Pages.js";
27
27
  import { renderToString } from "../ssr.js";
28
+ import { setRouteManifest } from "../url.js";
28
29
 
29
30
  /**
30
31
  * Structural slice of the host framework's response. Same shape
@@ -62,6 +63,14 @@ export interface RenderPageOptions {
62
63
  * targets.
63
64
  */
64
65
  rootId?: string;
66
+ /**
67
+ * Named-route manifest (`name → path-pattern`) for the isomorphic
68
+ * `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
69
+ * installed server-side before the page renders AND serialized into the page
70
+ * so the hydrate bootstrap re-installs it, making `urlFor` work identically
71
+ * in SSR and the browser. Omit if the app doesn't use `urlFor`.
72
+ */
73
+ routes?: Record<string, string>;
65
74
  }
66
75
 
67
76
  export async function renderPage<P>(
@@ -71,6 +80,10 @@ export async function renderPage<P>(
71
80
  props: P,
72
81
  options: RenderPageOptions = {},
73
82
  ): Promise<void> {
83
+ // Install the route manifest BEFORE rendering so a page calling `urlFor`
84
+ // during SSR resolves against the same map the client will get.
85
+ if (options.routes) setRouteManifest(options.routes);
86
+
74
87
  const factory = await pages.resolve(name);
75
88
  // The factory must be invoked the SAME way client-side for hydrate
76
89
  // to find matching slots — `Page(props)` is the contract.
@@ -100,11 +113,13 @@ ${options.headExtra ?? ""}
100
113
  props,
101
114
  url: pageUrl,
102
115
  rootId,
116
+ routes: options.routes ?? {},
103
117
  })}</script>
104
118
  <script type="module">
105
- import { hydrate } from '@c9up/aurora'
119
+ import { hydrate, setRouteManifest } from '@c9up/aurora'
106
120
  import Page from ${JSON.stringify(pageUrl)}
107
121
  const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
122
+ setRouteManifest(data.routes ?? {})
108
123
  hydrate(document.getElementById(data.rootId), () => Page(data.props))
109
124
  </script>
110
125
  </body>
package/src/ssr.ts CHANGED
@@ -66,29 +66,23 @@ function stringifyTemplateResult(result: TemplateResult): string {
66
66
  if (i < values.length && !skipValue) {
67
67
  const value = values[i];
68
68
  const inAttr = isInsideAttribute(out);
69
- if (!inAttr && isReactiveStructuredSlot(value)) {
70
- // Reactive text slot whose value is a nested template /
71
- // array — wrap the rendered content in boundary markers so
72
- // hydration can locate the exact node range and SWAP it when
73
- // the signal changes client-side. Without these markers a
74
- // nested-template slot hydrates once and then goes stale
75
- // (no way to find where the subtree starts/ends). Scalar
76
- // reactive slots (`${signal}` → text) are NOT wrapped: their
77
- // hydration updates the text node in place, no range needed.
69
+ if (inAttr) {
70
+ out += stringifyValue(value, true);
71
+ } else {
72
+ // Text-region slot ALWAYS wrap in boundary markers so the SSR
73
+ // node structure matches the client template, which keeps exactly
74
+ // ONE comment node per slot. An inlined value otherwise MERGES
75
+ // with adjacent static text or sibling values when the browser
76
+ // parses the SSR HTML (`<p>Hello ${x}!</p>`ONE text node, not
77
+ // three), dropping the node count and desyncing the slot AND every
78
+ // following sibling binding (text, attr, event). Hydration
79
+ // collapses each `<!--$-->…<!--/$-->` range back to one node
80
+ // (collapseMarkerRanges) so paths align exactly; the range also
81
+ // anchors scalar text updates and nested-template swaps. Same
82
+ // part-marker approach as lit-html / Solid.
78
83
  out += `<!--${SLOT_START}-->`;
79
84
  out += stringifyValue(value, false);
80
85
  out += `<!--${SLOT_END}-->`;
81
- } else if (inAttr) {
82
- out += stringifyValue(value, true);
83
- } else {
84
- // Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
85
- // would emit NO node and desync the path-based hydration of the
86
- // following sibling slots (their @input/@submit bindings break).
87
- // Emit an empty-comment placeholder so the position is preserved
88
- // — lit-html / Solid do the same; hydration materializes the text
89
- // node there.
90
- const text = stringifyValue(value, false);
91
- out += text === "" ? "<!---->" : text;
92
86
  }
93
87
  }
94
88
  }
@@ -99,27 +93,6 @@ function stringifyTemplateResult(result: TemplateResult): string {
99
93
  const SLOT_START = "$";
100
94
  const SLOT_END = "/$";
101
95
 
102
- /**
103
- * True when `value` is a reactive expression (signal / function) whose
104
- * current evaluation is a structured node payload (a nested
105
- * TemplateResult, or an array). These are the slots that can SWAP their
106
- * subtree on a client-side change and therefore need boundary markers
107
- * for hydration to find the range. A reactive slot resolving to a
108
- * scalar (string / number) is updated in place and needs no markers.
109
- */
110
- function isReactiveStructuredSlot(value: unknown): boolean {
111
- if (!isSignal(value) && typeof value !== "function") return false;
112
- let evaluated: unknown;
113
- try {
114
- evaluated = isSignal(value)
115
- ? (value as () => unknown)()
116
- : (value as () => unknown)();
117
- } catch {
118
- return false;
119
- }
120
- return isTemplateResult(evaluated) || Array.isArray(evaluated);
121
- }
122
-
123
96
  /**
124
97
  * Returns true if the position at the end of `htmlSoFar` lives inside
125
98
  * the value region of an HTML tag (between `<` and `>`). The check
package/src/url.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `urlFor` — isomorphic named-route URL builder (AdonisJS v7 parity), the client
3
+ * half of Ream's `router.urlFor()`.
4
+ *
5
+ * Routes live server-side in Ream's router; this helper builds URLs from a
6
+ * serialized `name → path-pattern` manifest so the SAME `urlFor(name, params)`
7
+ * works in a page during SSR and after hydration in the browser — no more
8
+ * hard-coded paths like `redirect('/login')` / `href: '/team'`.
9
+ *
10
+ * urlFor('users.show', { id: 42 }) // → '/users/42'
11
+ * urlFor('auth.login') // → '/login'
12
+ * urlFor('search', {}, { q: 'ream', p: 2 })// → '/search?q=ream&p=2'
13
+ *
14
+ * The manifest is populated by {@link setRouteManifest}: `renderPage` calls it
15
+ * server-side from `options.routes` (build it with `router.namedManifest()`), and
16
+ * injects the same map into the page so the hydrate bootstrap re-sets it client
17
+ * side. Node-free — part of aurora's client runtime.
18
+ */
19
+
20
+ let manifest: Record<string, string> = {};
21
+
22
+ /**
23
+ * Install the `name → path-pattern` map `urlFor` resolves against (e.g.
24
+ * `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
25
+ * Replaces any previous manifest. Routes are static per app, so this is set once
26
+ * per environment (server boot / page render, and the hydrate bootstrap).
27
+ */
28
+ export function setRouteManifest(routes: Record<string, string>): void {
29
+ manifest = { ...routes };
30
+ }
31
+
32
+ /** The currently-installed route manifest (mainly for tests/introspection). */
33
+ export function getRouteManifest(): Record<string, string> {
34
+ return { ...manifest };
35
+ }
36
+
37
+ /**
38
+ * Build a URL for a named route — fills `:param` placeholders, drops unprovided
39
+ * optional (`:name?`) segments, appends `query` as a query string, and throws on
40
+ * an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
41
+ */
42
+ export function urlFor(
43
+ name: string,
44
+ params?: Record<string, string | number>,
45
+ query?: Record<string, string | number>,
46
+ ): string {
47
+ const pattern = manifest[name];
48
+ if (pattern === undefined) {
49
+ const known = Object.keys(manifest);
50
+ throw new Error(
51
+ `[aurora] urlFor: unknown route '${name}'. ${
52
+ known.length > 0
53
+ ? `Known: ${known.join(", ")}`
54
+ : "No routes registered — was the manifest passed to render() / setRouteManifest() called?"
55
+ }`,
56
+ );
57
+ }
58
+
59
+ let url = pattern;
60
+ if (params) {
61
+ for (const [key, value] of Object.entries(params)) {
62
+ // Word-boundary substitution so `:id` doesn't corrupt `:idx`.
63
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ url = url.replace(
65
+ new RegExp(`:${escaped}\\??(?![\\w])`, "g"),
66
+ encodeURIComponent(String(value)),
67
+ );
68
+ }
69
+ }
70
+
71
+ // Strip remaining optional placeholders (`:name?` not provided).
72
+ url = url.replace(/\/:[A-Za-z_][\w]*\?/g, "");
73
+
74
+ const missing = url.match(/:[A-Za-z_][\w]*/g);
75
+ if (missing && missing.length > 0) {
76
+ throw new Error(
77
+ `[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`,
78
+ );
79
+ }
80
+
81
+ if (query) {
82
+ const qs = Object.entries(query)
83
+ .map(
84
+ ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`,
85
+ )
86
+ .join("&");
87
+ if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
88
+ }
89
+
90
+ return url;
91
+ }