@c9up/aurora 0.1.11 → 0.1.13

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/command.js CHANGED
@@ -61,19 +61,35 @@ class CommandRunner {
61
61
  this.#loading(true);
62
62
  this.#error(null);
63
63
  try {
64
- const result = await this.#task(...args);
64
+ let result;
65
+ try {
66
+ result = await this.#task(...args);
67
+ }
68
+ catch (error) {
69
+ if (id !== this.#runId)
70
+ return; // superseded — drop
71
+ this.#error(error);
72
+ for (const handler of this.#onFail)
73
+ handler(error);
74
+ return;
75
+ }
65
76
  if (id !== this.#runId)
66
77
  return; // superseded by a newer run — drop
67
78
  this.#data(result);
68
- for (const handler of this.#onSuccess)
69
- handler(result);
70
- }
71
- catch (error) {
72
- if (id !== this.#runId)
73
- return; // superseded — drop
74
- this.#error(error);
75
- for (const handler of this.#onFail)
76
- handler(error);
79
+ // onSuccess runs OUTSIDE the task's failure boundary: success is
80
+ // decided by the task, never by the callback. A throw here (e.g. a
81
+ // render error after the data lands) is a handler bug — surfacing it
82
+ // as a task failure would route to onFail, which on a guarded page
83
+ // masquerades as a logout. Report it, but never reclassify it.
84
+ try {
85
+ for (const handler of this.#onSuccess)
86
+ handler(result);
87
+ }
88
+ catch (handlerError) {
89
+ if (typeof console !== "undefined") {
90
+ console.error("[aurora] a command onSuccess handler threw — not treated as a task failure:", handlerError);
91
+ }
92
+ }
77
93
  }
78
94
  finally {
79
95
  if (id === this.#runId) {
package/dist/hydrate.js CHANGED
@@ -231,12 +231,49 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
231
231
  * Text-slot paths point to a comment marker that doesn't exist in
232
232
  * hydration markup — we tolerate the miss and return null.
233
233
  */
234
+ /**
235
+ * Collapse each top-level `<!--$-->…<!--/$-->` range in `nodes` to a SINGLE
236
+ * entry (its start marker), dropping the in-range content + end marker from the
237
+ * count. SSR expands a structured slot (reactive OR a direct nested template)
238
+ * to a node RANGE, but the parsed client template counts every slot as exactly
239
+ * ONE comment node — so without this collapse the extra range nodes shift the
240
+ * childNode index of every FOLLOWING sibling slot (dead bindings / "slot path
241
+ * not found"). Nested ranges (depth > 0) are skipped wholesale: they belong to
242
+ * the outer slot's content and are hydrated when we recurse into it.
243
+ */
244
+ function collapseMarkerRanges(nodes) {
245
+ const out = [];
246
+ let depth = 0;
247
+ for (const n of nodes) {
248
+ if (n.nodeType === 8 /* Comment */) {
249
+ const data = n.data;
250
+ if (data === SLOT_START) {
251
+ if (depth === 0)
252
+ out.push(n); // the whole range counts as one node
253
+ depth += 1;
254
+ continue;
255
+ }
256
+ if (data === SLOT_END) {
257
+ if (depth > 0)
258
+ depth -= 1;
259
+ continue;
260
+ }
261
+ }
262
+ if (depth === 0)
263
+ out.push(n);
264
+ }
265
+ return out;
266
+ }
234
267
  function resolvePathLive(_root, path, rootNodes) {
235
268
  if (path.length === 0)
236
269
  return null;
237
- let node = rootNodes[path[0]] ?? null;
270
+ // Collapse marker ranges at EVERY level so the live child list matches the
271
+ // parsed template's one-node-per-slot shape (see collapseMarkerRanges).
272
+ let children = collapseMarkerRanges(rootNodes);
273
+ let node = children[path[0]] ?? null;
238
274
  for (let i = 1; node && i < path.length; i++) {
239
- node = node.childNodes[path[i]] ?? null;
275
+ children = collapseMarkerRanges(Array.from(node.childNodes));
276
+ node = children[path[i]] ?? null;
240
277
  }
241
278
  return node;
242
279
  }
@@ -332,6 +369,23 @@ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCurso
332
369
  return;
333
370
  }
334
371
  if (isTemplateResult(value)) {
372
+ // DIRECT nested template (component composition, `${Layout({…})}`). SSR
373
+ // wrapped it in a boundary-marker pair (same scheme as a reactive
374
+ // structured slot). Consume the pair in document order and hydrate the
375
+ // nested template against its captured range — wiring inner bindings to
376
+ // the SSR nodes and keeping the marker cursor aligned.
377
+ const pair = markerCursor.pairs[markerCursor.i];
378
+ if (pair !== undefined) {
379
+ markerCursor.i += 1;
380
+ const range = [];
381
+ for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
382
+ range.push(n);
383
+ }
384
+ hydrateTemplateResult(value, range, cleanups, mountHooks, markerCursor);
385
+ return;
386
+ }
387
+ // Legacy markup without markers (older SSR build): best-effort against
388
+ // the single resolved node.
335
389
  hydrateTemplateResult(value, [commentMarker], cleanups, mountHooks, markerCursor);
336
390
  return;
337
391
  }
package/dist/index.d.ts CHANGED
@@ -9,14 +9,15 @@ export { html, isTemplateResult } from "./html.js";
9
9
  export type { HttpClientOptions, HttpRequestOptions, HttpResult, } from "./http.js";
10
10
  export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
11
11
  export { hydrate } from "./hydrate.js";
12
+ export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./live.js";
13
+ export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } from "./liveBroadcast.js";
14
+ export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
15
+ export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
16
+ export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
17
+ export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
12
18
  export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
13
19
  export { type Disposer, render } from "./render.js";
14
20
  export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
21
+ export { createRpcClient, isRpcError, type RpcCall, type RpcClient, type RpcClientOptions, RpcError, type RpcResult, } from "./rpc.js";
15
22
  export { renderToString } from "./ssr.js";
16
- export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./live.js";
17
- export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
18
- export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } from "./liveBroadcast.js";
19
- export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
20
- export { buildLiveTransport, liveClient, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, type RelaySubscribeClient, } from "./liveClient.js";
21
- export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, wireLiveEvents, type WireLiveEventsOptions, } from "./liveServer.js";
22
23
  export type { TemplateResult } from "./types.js";
package/dist/index.js CHANGED
@@ -5,13 +5,14 @@ export { form } from "./form.js";
5
5
  export { html, isTemplateResult } from "./html.js";
6
6
  export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
7
7
  export { hydrate } from "./hydrate.js";
8
- export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
9
- export { render } from "./render.js";
10
- export { auroraRoute, } from "./route.js";
11
- export { renderToString } from "./ssr.js";
12
8
  export { mountLiveSession, } from "./live.js";
13
- export { createLiveRegistry, } from "./liveRegistry.js";
14
9
  export { connectPatches, liveStore, } from "./liveBroadcast.js";
15
- export { createLiveRouter, } from "./liveRouter.js";
16
10
  export { buildLiveTransport, liveClient, } from "./liveClient.js";
11
+ export { createLiveRegistry, } from "./liveRegistry.js";
12
+ export { createLiveRouter, } from "./liveRouter.js";
17
13
  export { DEFAULT_LIVE_EVENT_PATH, wireLiveEvents, } from "./liveServer.js";
14
+ export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
15
+ export { render } from "./render.js";
16
+ export { auroraRoute, } from "./route.js";
17
+ export { createRpcClient, isRpcError, RpcError, } from "./rpc.js";
18
+ export { renderToString } from "./ssr.js";
package/dist/render.js CHANGED
@@ -71,6 +71,15 @@ export function mount(result, cleanups, mounted, mountHooks) {
71
71
  for (let i = 0; i < tpl.slots.length; i++) {
72
72
  const slot = tpl.slots[i];
73
73
  const node = resolvePath(fragment, slot.path);
74
+ if (node === null) {
75
+ // Path didn't resolve — skip this binding rather than crash (see
76
+ // resolvePath). Degrades to a dead binding; the surrounding render
77
+ // (and any command driving it) survives.
78
+ if (typeof console !== "undefined") {
79
+ console.warn(`[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} did not resolve — skipping binding`);
80
+ }
81
+ continue;
82
+ }
74
83
  if (slot.kind === "attr" && slot.staticParts !== undefined) {
75
84
  collectMultiAttr(slot, node, result.values[i], multiGroups);
76
85
  }
@@ -88,8 +97,19 @@ export function mount(result, cleanups, mounted, mountHooks) {
88
97
  }
89
98
  function resolvePath(root, path) {
90
99
  let node = root;
91
- for (const i of path)
92
- node = node.childNodes[i];
100
+ for (const i of path) {
101
+ const next = node.childNodes[i];
102
+ // Fail-soft: a path step that runs off the live child list means the
103
+ // tree diverged from the parsed template (a hydration desync). Return
104
+ // null so the caller skips the binding instead of dereferencing
105
+ // `undefined.childNodes` and crashing the whole render — which, when the
106
+ // render runs inside a command's onSuccess, used to masquerade as a
107
+ // task failure (and on a guarded page, a logout). Mirrors
108
+ // `resolvePathLive` in hydrate.ts.
109
+ if (next === undefined)
110
+ return null;
111
+ node = next;
112
+ }
93
113
  return node;
94
114
  }
95
115
  function applySlot(slot, node, value, cleanups, mounted, mountHooks) {
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
+ * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
+ * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
+ * headers, and timeouts.
6
+ *
7
+ * const rpc = createRpcClient() // POST /rpc, same-origin
8
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
+ * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
+ *
11
+ * Pairs with aurora's `command()` for reactive calls:
12
+ * const validate = command((p) => rpc.call('task.validate', p))
13
+ */
14
+ import { HttpClient } from "./http.js";
15
+ export interface RpcClientOptions {
16
+ /** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
17
+ url?: string;
18
+ /** Reuse an existing HttpClient — its baseURL / headers / auth carry over. */
19
+ http?: HttpClient;
20
+ /** Default headers — only used when no `http` client is supplied. */
21
+ headers?: Record<string, string>;
22
+ }
23
+ /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
24
+ export declare class RpcError extends Error {
25
+ readonly code: number;
26
+ readonly data?: unknown;
27
+ constructor(code: number, message: string, data?: unknown);
28
+ }
29
+ /** Type guard for {@link RpcError}. */
30
+ export declare function isRpcError(value: unknown): value is RpcError;
31
+ /** One call in a batch. `parse` optionally validates that call's result (cast-free). */
32
+ export interface RpcCall<T = unknown> {
33
+ method: string;
34
+ params?: unknown;
35
+ parse?: (data: unknown) => T;
36
+ }
37
+ /** A settled batch entry — the result, or the JSON-RPC error for that call. */
38
+ export type RpcResult<T = unknown> = {
39
+ ok: true;
40
+ value: T;
41
+ } | {
42
+ ok: false;
43
+ error: RpcError;
44
+ };
45
+ export interface RpcClient {
46
+ /**
47
+ * Call one method. Returns the result, or throws {@link RpcError} on a
48
+ * JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
49
+ * the unchecked `T` assertion).
50
+ */
51
+ call<T = unknown>(method: string, params?: unknown, parse?: (data: unknown) => T): Promise<T>;
52
+ /** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
53
+ batch(calls: RpcCall[]): Promise<RpcResult[]>;
54
+ }
55
+ export declare function createRpcClient(options?: RpcClientOptions): RpcClient;
package/dist/rpc.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
+ * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
+ * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
+ * headers, and timeouts.
6
+ *
7
+ * const rpc = createRpcClient() // POST /rpc, same-origin
8
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
+ * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
+ *
11
+ * Pairs with aurora's `command()` for reactive calls:
12
+ * const validate = command((p) => rpc.call('task.validate', p))
13
+ */
14
+ import { HttpClient } from "./http.js";
15
+ /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
16
+ export class RpcError extends Error {
17
+ code;
18
+ data;
19
+ constructor(code, message, data) {
20
+ super(message);
21
+ this.name = "RpcError";
22
+ this.code = code;
23
+ this.data = data;
24
+ }
25
+ }
26
+ /** Type guard for {@link RpcError}. */
27
+ export function isRpcError(value) {
28
+ return value instanceof RpcError;
29
+ }
30
+ function isObject(value) {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+ /** Turn a JSON-RPC `error` member into an {@link RpcError}. */
34
+ function toRpcError(error) {
35
+ if (isObject(error) &&
36
+ typeof error.code === "number" &&
37
+ typeof error.message === "string") {
38
+ return new RpcError(error.code, error.message, error.data);
39
+ }
40
+ return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
41
+ }
42
+ export function createRpcClient(options = {}) {
43
+ const http = options.http ?? new HttpClient({ headers: options.headers });
44
+ const url = options.url ?? "/rpc";
45
+ let nextId = 0;
46
+ return {
47
+ async call(method, params, parse) {
48
+ const id = ++nextId;
49
+ const res = await http.post(url, {
50
+ jsonrpc: "2.0",
51
+ method,
52
+ params,
53
+ id,
54
+ });
55
+ if (!isObject(res)) {
56
+ throw new RpcError(-32603, `Malformed JSON-RPC response for "${method}"`);
57
+ }
58
+ if (res.error !== undefined)
59
+ throw toRpcError(res.error);
60
+ // Result boundary — the same unchecked `T` assertion HttpClient uses,
61
+ // with `parse` as the cast-free, runtime-validated escape hatch.
62
+ return parse ? parse(res.result) : res.result;
63
+ },
64
+ async batch(calls) {
65
+ if (calls.length === 0)
66
+ return [];
67
+ const requests = calls.map((c, index) => ({
68
+ jsonrpc: "2.0",
69
+ method: c.method,
70
+ params: c.params,
71
+ id: index, // index = request position; responses are matched back by id
72
+ }));
73
+ const res = await http.post(url, requests);
74
+ if (!Array.isArray(res)) {
75
+ throw new RpcError(-32603, "Malformed JSON-RPC batch response");
76
+ }
77
+ const byId = new Map();
78
+ for (const item of res)
79
+ if (isObject(item))
80
+ byId.set(item.id, item);
81
+ return calls.map((c, index) => {
82
+ const envelope = byId.get(index);
83
+ if (!envelope) {
84
+ return {
85
+ ok: false,
86
+ error: new RpcError(-32603, `No response for "${c.method}"`),
87
+ };
88
+ }
89
+ if (envelope.error !== undefined) {
90
+ return { ok: false, error: toRpcError(envelope.error) };
91
+ }
92
+ const value = c.parse ? c.parse(envelope.result) : envelope.result;
93
+ return { ok: true, value };
94
+ });
95
+ },
96
+ };
97
+ }
package/dist/ssr.js CHANGED
@@ -75,6 +75,19 @@ function stringifyTemplateResult(result) {
75
75
  out += stringifyValue(value, false);
76
76
  out += `<!--${SLOT_END}-->`;
77
77
  }
78
+ else if (!inAttr && isTemplateResult(value)) {
79
+ // DIRECT (non-reactive) nested template — component composition,
80
+ // e.g. `${Layout({ children })}` or `${table}`. It renders to a
81
+ // node RANGE just like a reactive structured slot, so it needs the
82
+ // SAME boundary markers: the client template counts every slot as
83
+ // ONE comment node, so without a markable range a multi-node child
84
+ // shifts the childNode indices of every FOLLOWING sibling slot →
85
+ // dead bindings / "slot path not found". Hydration collapses the
86
+ // marked range back to one node so sibling paths stay aligned.
87
+ out += `<!--${SLOT_START}-->`;
88
+ out += stringifyValue(value, false);
89
+ out += `<!--${SLOT_END}-->`;
90
+ }
78
91
  else if (inAttr) {
79
92
  out += stringifyValue(value, true);
80
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
  }
@@ -451,6 +486,27 @@ function hydrateTextSlot(
451
486
  return;
452
487
  }
453
488
  if (isTemplateResult(value)) {
489
+ // DIRECT nested template (component composition, `${Layout({…})}`). SSR
490
+ // wrapped it in a boundary-marker pair (same scheme as a reactive
491
+ // structured slot). Consume the pair in document order and hydrate the
492
+ // nested template against its captured range — wiring inner bindings to
493
+ // the SSR nodes and keeping the marker cursor aligned.
494
+ const pair = markerCursor.pairs[markerCursor.i];
495
+ if (pair !== undefined) {
496
+ markerCursor.i += 1;
497
+ const range: ChildNode[] = [];
498
+ for (
499
+ let n = pair.start.nextSibling;
500
+ n !== null && n !== pair.end;
501
+ n = n.nextSibling
502
+ ) {
503
+ range.push(n as ChildNode);
504
+ }
505
+ hydrateTemplateResult(value, range, cleanups, mountHooks, markerCursor);
506
+ return;
507
+ }
508
+ // Legacy markup without markers (older SSR build): best-effort against
509
+ // the single resolved node.
454
510
  hydrateTemplateResult(
455
511
  value,
456
512
  [commentMarker as ChildNode],
package/src/index.ts CHANGED
@@ -60,60 +60,69 @@ export {
60
60
  isHttpError,
61
61
  } from "./http.js";
62
62
  export { hydrate } from "./hydrate.js";
63
- export {
64
- batch,
65
- effect,
66
- isSignal,
67
- memo,
68
- onCleanup,
69
- type ReadSignal,
70
- type Signal,
71
- signal,
72
- untrack,
73
- } from "./reactive.js";
74
- export { type Disposer, render } from "./render.js";
75
- export {
76
- type AuroraHttpContext,
77
- type AuroraResponse,
78
- type AuroraRouteConfig,
79
- auroraRoute,
80
- } from "./route.js";
81
- export { renderToString } from "./ssr.js";
82
63
  export {
83
64
  type LiveComponentDefinition,
84
65
  type LiveSession,
85
66
  mountLiveSession,
86
67
  type SlotPatch,
87
68
  } from "./live.js";
88
- export {
89
- createLiveRegistry,
90
- type LiveRegistry,
91
- type LiveSessionHandle,
92
- } from "./liveRegistry.js";
93
69
  export {
94
70
  connectPatches,
95
71
  type LiveStore,
96
72
  liveStore,
97
73
  type RelayBroadcaster,
98
74
  } from "./liveBroadcast.js";
99
- export {
100
- createLiveRouter,
101
- type LiveMount,
102
- type LiveRouter,
103
- } from "./liveRouter.js";
104
75
  export {
105
76
  buildLiveTransport,
106
- liveClient,
107
77
  type LiveClientOptions,
108
78
  type LiveClientTransport,
109
79
  type LiveHttpPoster,
80
+ liveClient,
110
81
  type RelaySubscribeClient,
111
82
  } from "./liveClient.js";
83
+ export {
84
+ createLiveRegistry,
85
+ type LiveRegistry,
86
+ type LiveSessionHandle,
87
+ } from "./liveRegistry.js";
88
+ export {
89
+ createLiveRouter,
90
+ type LiveMount,
91
+ type LiveRouter,
92
+ } from "./liveRouter.js";
112
93
  export {
113
94
  DEFAULT_LIVE_EVENT_PATH,
114
95
  type LiveHttpContext,
115
96
  type LiveHttpRouter,
116
- wireLiveEvents,
117
97
  type WireLiveEventsOptions,
98
+ wireLiveEvents,
118
99
  } from "./liveServer.js";
100
+ export {
101
+ batch,
102
+ effect,
103
+ isSignal,
104
+ memo,
105
+ onCleanup,
106
+ type ReadSignal,
107
+ type Signal,
108
+ signal,
109
+ untrack,
110
+ } from "./reactive.js";
111
+ export { type Disposer, render } from "./render.js";
112
+ export {
113
+ type AuroraHttpContext,
114
+ type AuroraResponse,
115
+ type AuroraRouteConfig,
116
+ auroraRoute,
117
+ } from "./route.js";
118
+ export {
119
+ createRpcClient,
120
+ isRpcError,
121
+ type RpcCall,
122
+ type RpcClient,
123
+ type RpcClientOptions,
124
+ RpcError,
125
+ type RpcResult,
126
+ } from "./rpc.js";
127
+ export { renderToString } from "./ssr.js";
119
128
  export type { TemplateResult } from "./types.js";
package/src/render.ts CHANGED
@@ -107,6 +107,17 @@ 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
+ }
110
121
  if (slot.kind === "attr" && slot.staticParts !== undefined) {
111
122
  collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
112
123
  } else {
@@ -124,9 +135,20 @@ export function mount(
124
135
  return fragment;
125
136
  }
126
137
 
127
- function resolvePath(root: ParentNode, path: NodePath): Node {
138
+ function resolvePath(root: ParentNode, path: NodePath): Node | null {
128
139
  let node: Node = root;
129
- for (const i of path) node = node.childNodes[i];
140
+ for (const i of path) {
141
+ const next = node.childNodes[i];
142
+ // Fail-soft: a path step that runs off the live child list means the
143
+ // tree diverged from the parsed template (a hydration desync). Return
144
+ // null so the caller skips the binding instead of dereferencing
145
+ // `undefined.childNodes` and crashing the whole render — which, when the
146
+ // render runs inside a command's onSuccess, used to masquerade as a
147
+ // task failure (and on a guarded page, a logout). Mirrors
148
+ // `resolvePathLive` in hydrate.ts.
149
+ if (next === undefined) return null;
150
+ node = next;
151
+ }
130
152
  return node;
131
153
  }
132
154
 
package/src/rpc.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
+ * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
+ * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
+ * headers, and timeouts.
6
+ *
7
+ * const rpc = createRpcClient() // POST /rpc, same-origin
8
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
+ * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
+ *
11
+ * Pairs with aurora's `command()` for reactive calls:
12
+ * const validate = command((p) => rpc.call('task.validate', p))
13
+ */
14
+ import { HttpClient } from "./http.js";
15
+
16
+ export interface RpcClientOptions {
17
+ /** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
18
+ url?: string;
19
+ /** Reuse an existing HttpClient — its baseURL / headers / auth carry over. */
20
+ http?: HttpClient;
21
+ /** Default headers — only used when no `http` client is supplied. */
22
+ headers?: Record<string, string>;
23
+ }
24
+
25
+ /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
26
+ export class RpcError extends Error {
27
+ readonly code: number;
28
+ readonly data?: unknown;
29
+ constructor(code: number, message: string, data?: unknown) {
30
+ super(message);
31
+ this.name = "RpcError";
32
+ this.code = code;
33
+ this.data = data;
34
+ }
35
+ }
36
+
37
+ /** Type guard for {@link RpcError}. */
38
+ export function isRpcError(value: unknown): value is RpcError {
39
+ return value instanceof RpcError;
40
+ }
41
+
42
+ /** One call in a batch. `parse` optionally validates that call's result (cast-free). */
43
+ export interface RpcCall<T = unknown> {
44
+ method: string;
45
+ params?: unknown;
46
+ parse?: (data: unknown) => T;
47
+ }
48
+
49
+ /** A settled batch entry — the result, or the JSON-RPC error for that call. */
50
+ export type RpcResult<T = unknown> =
51
+ | { ok: true; value: T }
52
+ | { ok: false; error: RpcError };
53
+
54
+ export interface RpcClient {
55
+ /**
56
+ * Call one method. Returns the result, or throws {@link RpcError} on a
57
+ * JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
58
+ * the unchecked `T` assertion).
59
+ */
60
+ call<T = unknown>(
61
+ method: string,
62
+ params?: unknown,
63
+ parse?: (data: unknown) => T,
64
+ ): Promise<T>;
65
+ /** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
66
+ batch(calls: RpcCall[]): Promise<RpcResult[]>;
67
+ }
68
+
69
+ function isObject(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null;
71
+ }
72
+
73
+ /** Turn a JSON-RPC `error` member into an {@link RpcError}. */
74
+ function toRpcError(error: unknown): RpcError {
75
+ if (
76
+ isObject(error) &&
77
+ typeof error.code === "number" &&
78
+ typeof error.message === "string"
79
+ ) {
80
+ return new RpcError(error.code, error.message, error.data);
81
+ }
82
+ return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
83
+ }
84
+
85
+ export function createRpcClient(options: RpcClientOptions = {}): RpcClient {
86
+ const http = options.http ?? new HttpClient({ headers: options.headers });
87
+ const url = options.url ?? "/rpc";
88
+ let nextId = 0;
89
+
90
+ return {
91
+ async call<T>(
92
+ method: string,
93
+ params?: unknown,
94
+ parse?: (data: unknown) => T,
95
+ ): Promise<T> {
96
+ const id = ++nextId;
97
+ const res = await http.post<unknown>(url, {
98
+ jsonrpc: "2.0",
99
+ method,
100
+ params,
101
+ id,
102
+ });
103
+ if (!isObject(res)) {
104
+ throw new RpcError(
105
+ -32603,
106
+ `Malformed JSON-RPC response for "${method}"`,
107
+ );
108
+ }
109
+ if (res.error !== undefined) throw toRpcError(res.error);
110
+ // Result boundary — the same unchecked `T` assertion HttpClient uses,
111
+ // with `parse` as the cast-free, runtime-validated escape hatch.
112
+ return parse ? parse(res.result) : (res.result as T);
113
+ },
114
+
115
+ async batch(calls: RpcCall[]): Promise<RpcResult[]> {
116
+ if (calls.length === 0) return [];
117
+ const requests = calls.map((c, index) => ({
118
+ jsonrpc: "2.0",
119
+ method: c.method,
120
+ params: c.params,
121
+ id: index, // index = request position; responses are matched back by id
122
+ }));
123
+ const res = await http.post<unknown>(url, requests);
124
+ if (!Array.isArray(res)) {
125
+ throw new RpcError(-32603, "Malformed JSON-RPC batch response");
126
+ }
127
+ const byId = new Map<unknown, Record<string, unknown>>();
128
+ for (const item of res) if (isObject(item)) byId.set(item.id, item);
129
+ return calls.map((c, index) => {
130
+ const envelope = byId.get(index);
131
+ if (!envelope) {
132
+ return {
133
+ ok: false,
134
+ error: new RpcError(-32603, `No response for "${c.method}"`),
135
+ };
136
+ }
137
+ if (envelope.error !== undefined) {
138
+ return { ok: false, error: toRpcError(envelope.error) };
139
+ }
140
+ const value = c.parse ? c.parse(envelope.result) : envelope.result;
141
+ return { ok: true, value };
142
+ });
143
+ },
144
+ };
145
+ }
package/src/ssr.ts CHANGED
@@ -78,6 +78,18 @@ function stringifyTemplateResult(result: TemplateResult): string {
78
78
  out += `<!--${SLOT_START}-->`;
79
79
  out += stringifyValue(value, false);
80
80
  out += `<!--${SLOT_END}-->`;
81
+ } else if (!inAttr && isTemplateResult(value)) {
82
+ // DIRECT (non-reactive) nested template — component composition,
83
+ // e.g. `${Layout({ children })}` or `${table}`. It renders to a
84
+ // node RANGE just like a reactive structured slot, so it needs the
85
+ // SAME boundary markers: the client template counts every slot as
86
+ // ONE comment node, so without a markable range a multi-node child
87
+ // shifts the childNode indices of every FOLLOWING sibling slot →
88
+ // dead bindings / "slot path not found". Hydration collapses the
89
+ // marked range back to one node so sibling paths stay aligned.
90
+ out += `<!--${SLOT_START}-->`;
91
+ out += stringifyValue(value, false);
92
+ out += `<!--${SLOT_END}-->`;
81
93
  } else if (inAttr) {
82
94
  out += stringifyValue(value, true);
83
95
  } else {