@c9up/aurora 0.1.3

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/AuroraManager.d.ts +44 -0
  4. package/dist/AuroraManager.js +47 -0
  5. package/dist/AuroraProvider.d.ts +52 -0
  6. package/dist/AuroraProvider.js +145 -0
  7. package/dist/Pages.d.ts +78 -0
  8. package/dist/Pages.js +116 -0
  9. package/dist/component.d.ts +55 -0
  10. package/dist/component.js +97 -0
  11. package/dist/html.d.ts +30 -0
  12. package/dist/html.js +246 -0
  13. package/dist/hydrate.d.ts +29 -0
  14. package/dist/hydrate.js +379 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +12 -0
  17. package/dist/reactive.d.ts +83 -0
  18. package/dist/reactive.js +217 -0
  19. package/dist/relay.d.ts +43 -0
  20. package/dist/relay.js +144 -0
  21. package/dist/render.d.ts +25 -0
  22. package/dist/render.js +283 -0
  23. package/dist/route.d.ts +64 -0
  24. package/dist/route.js +49 -0
  25. package/dist/server/renderPage.d.ts +62 -0
  26. package/dist/server/renderPage.js +83 -0
  27. package/dist/server/serveAssets.d.ts +43 -0
  28. package/dist/server/serveAssets.js +89 -0
  29. package/dist/services/main.d.ts +18 -0
  30. package/dist/services/main.js +31 -0
  31. package/dist/ssr.d.ts +22 -0
  32. package/dist/ssr.js +179 -0
  33. package/dist/types.d.ts +78 -0
  34. package/dist/types.js +15 -0
  35. package/package.json +69 -0
  36. package/src/AuroraManager.ts +76 -0
  37. package/src/AuroraProvider.ts +187 -0
  38. package/src/Pages.ts +164 -0
  39. package/src/component.ts +138 -0
  40. package/src/html.ts +296 -0
  41. package/src/hydrate.ts +518 -0
  42. package/src/index.ts +43 -0
  43. package/src/reactive.ts +265 -0
  44. package/src/relay.ts +171 -0
  45. package/src/render.ts +378 -0
  46. package/src/route.ts +96 -0
  47. package/src/server/renderPage.ts +135 -0
  48. package/src/server/serveAssets.ts +135 -0
  49. package/src/services/main.ts +40 -0
  50. package/src/ssr.ts +179 -0
  51. package/src/types.ts +97 -0
package/src/hydrate.ts ADDED
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Hydration — adopt SSR-rendered HTML in the browser without rebuilding
3
+ * the DOM.
4
+ *
5
+ * `hydrate(container, factory)` runs the same component factory used
6
+ * server-side, recomputes the slot bindings, and attaches them to the
7
+ * existing nodes. Where SSR emitted plain text for `${signal}`, hydrate
8
+ * locates the same text node (via path resolution against the cloned
9
+ * template) and starts an effect that updates it on signal change.
10
+ *
11
+ * Implementation note: we still run `getTemplate(strings)` to know
12
+ * where each slot lives, then walk the LIVE container tree using the
13
+ * same path. SSR output must match the shape of the parsed template
14
+ * for hydration to find the right node — same constraint as React's
15
+ * hydration mismatch warning.
16
+ */
17
+
18
+ import { readComponentLifecycle } from "./component.js";
19
+ import { getTemplate } from "./html.js";
20
+ import { effect, isSignal } from "./reactive.js";
21
+ import { type Disposer, mount } from "./render.js";
22
+ import {
23
+ type AttrSlot,
24
+ type BooleanAttrSlot,
25
+ type EffectCallback,
26
+ type EventSlot,
27
+ isTemplateResult,
28
+ type NodePath,
29
+ type PropSlot,
30
+ type Slot,
31
+ type TemplateResult,
32
+ } from "./types.js";
33
+
34
+ /**
35
+ * Process-scoped flag so the "reactive nested template not reactive
36
+ * after hydration" warning fires once, not on every matching slot.
37
+ * Only reached on LEGACY markup that predates SSR boundary markers
38
+ * (the markered path keeps the subtree reactive — no warning).
39
+ */
40
+ let nestedReactiveWarned = false;
41
+
42
+ /** @internal Reset the warn-once flag (tests). */
43
+ export function resetHydrateWarnings(): void {
44
+ nestedReactiveWarned = false;
45
+ }
46
+
47
+ // Boundary-marker comment payloads (kept in sync with ssr.ts).
48
+ const SLOT_START = "$";
49
+ const SLOT_END = "/$";
50
+
51
+ /**
52
+ * An SSR-emitted `<!--$-->…<!--/$-->` pair delimiting a reactive
53
+ * structured slot's rendered subtree.
54
+ */
55
+ interface MarkerPair {
56
+ start: Comment;
57
+ end: Comment;
58
+ }
59
+
60
+ /**
61
+ * Document-ordered list of marker pairs + a consume cursor. Reactive
62
+ * structured text slots consume pairs in hydration order, which matches
63
+ * the document order of their `<!--$-->` start markers (a parent slot's
64
+ * start precedes its children's, and hydration visits parents first).
65
+ */
66
+ interface MarkerCursor {
67
+ pairs: MarkerPair[];
68
+ i: number;
69
+ /**
70
+ * The Document this hydration root belongs to. Threaded through (not
71
+ * a module global) so concurrent `hydrate()` calls on different
72
+ * documents / iframes each create swapped-in text nodes in THEIR own
73
+ * document — a shared global would let a second root's document
74
+ * clobber the first's.
75
+ */
76
+ doc: Document;
77
+ }
78
+
79
+ /**
80
+ * Collect every `<!--$-->…<!--/$-->` pair under `container`, ordered by
81
+ * the start marker's document position. Nesting is resolved with a
82
+ * stack so an inner pair's start/end never cross an outer pair's.
83
+ */
84
+ function collectMarkerPairs(container: Node): MarkerPair[] {
85
+ // Depth-first, document-order walk. We DON'T use createTreeWalker:
86
+ // some DOM implementations (happy-dom under vitest) ignore the
87
+ // numeric `whatToShow` filter and yield nothing. A manual recursion
88
+ // over childNodes is portable and visits comments in document order,
89
+ // so the stack pairs each `<!--$-->` with its matching `<!--/$-->`
90
+ // and the result is already start-ordered (no sort needed).
91
+ const pairs: MarkerPair[] = [];
92
+ const stack: Comment[] = [];
93
+ const visit = (node: Node): void => {
94
+ if (node.nodeType === 8 /* Comment */) {
95
+ const c = node as Comment;
96
+ if (c.data === SLOT_START) {
97
+ stack.push(c);
98
+ } else if (c.data === SLOT_END) {
99
+ const start = stack.pop();
100
+ if (start !== undefined) pairs.push({ start, end: c });
101
+ }
102
+ return;
103
+ }
104
+ for (
105
+ let child = node.firstChild;
106
+ child !== null;
107
+ child = child.nextSibling
108
+ ) {
109
+ visit(child);
110
+ }
111
+ };
112
+ visit(container);
113
+ // `pairs` is in END order (innermost closes first). Sort by start's
114
+ // document position so consumption matches hydration's
115
+ // parents-before-children visit order.
116
+ pairs.sort((a, b) =>
117
+ a.start.compareDocumentPosition(b.start) &
118
+ 4 /* DOCUMENT_POSITION_FOLLOWING */
119
+ ? -1
120
+ : 1,
121
+ );
122
+ return pairs;
123
+ }
124
+
125
+ /** Render a value (template / array / scalar) to detached client nodes. */
126
+ function renderValueToNodes(
127
+ value: unknown,
128
+ cleanups: Disposer[],
129
+ mountHooks: Array<EffectCallback>,
130
+ doc: Document,
131
+ ): ChildNode[] {
132
+ if (value === null || value === undefined || value === false) return [];
133
+ if (Array.isArray(value)) {
134
+ const out: ChildNode[] = [];
135
+ for (const item of value) {
136
+ out.push(...renderValueToNodes(item, cleanups, mountHooks, doc));
137
+ }
138
+ return out;
139
+ }
140
+ if (isTemplateResult(value)) {
141
+ const frag = mount(value, cleanups, [], mountHooks);
142
+ return Array.from(frag.childNodes);
143
+ }
144
+ if (value instanceof Node) return [value as ChildNode];
145
+ return [doc.createTextNode(String(value))];
146
+ }
147
+
148
+ /**
149
+ * Wire a reactive structured slot (signal/function → nested template or
150
+ * array) using its SSR boundary-marker pair. The first run hydrates the
151
+ * initial value against the captured SSR nodes (reusing server markup,
152
+ * no flash); every subsequent signal change disposes the old subtree
153
+ * and client-renders the new value into the same `<!--$-->…<!--/$-->`
154
+ * range — so the DOM stays correct on branch changes instead of going
155
+ * stale.
156
+ */
157
+ function hydrateReactiveStructured(
158
+ fn: () => unknown,
159
+ pair: MarkerPair,
160
+ cleanups: Disposer[],
161
+ mountHooks: Array<EffectCallback>,
162
+ markerCursor: MarkerCursor,
163
+ ): void {
164
+ const { start, end } = pair;
165
+ let currentNodes: ChildNode[] = [];
166
+ for (let n = start.nextSibling; n !== null && n !== end; n = n.nextSibling) {
167
+ currentNodes.push(n as ChildNode);
168
+ }
169
+ let localCleanups: Disposer[] = [];
170
+ let firstRun = true;
171
+
172
+ const dispose = effect(() => {
173
+ const next = fn();
174
+ if (firstRun) {
175
+ firstRun = false;
176
+ // Reuse SSR markup: hydrate reactive bindings INSIDE the nested
177
+ // template against the captured nodes. Inner boundary markers
178
+ // are consumed from the same cursor (document order).
179
+ if (isTemplateResult(next)) {
180
+ hydrateTemplateResult(
181
+ next,
182
+ currentNodes,
183
+ localCleanups,
184
+ mountHooks,
185
+ markerCursor,
186
+ );
187
+ }
188
+ return;
189
+ }
190
+ // Signal changed post-hydration: tear down the old subtree's
191
+ // effects/listeners, drop its nodes, client-render the new value
192
+ // into the same marker range.
193
+ for (const d of localCleanups) d();
194
+ localCleanups = [];
195
+ for (const n of currentNodes) n.remove();
196
+ currentNodes = [];
197
+ const parent = end.parentNode;
198
+ if (parent === null) return;
199
+ const fresh = renderValueToNodes(
200
+ next,
201
+ localCleanups,
202
+ mountHooks,
203
+ markerCursor.doc,
204
+ );
205
+ for (const n of fresh) parent.insertBefore(n, end);
206
+ currentNodes = fresh;
207
+ });
208
+
209
+ cleanups.push(() => {
210
+ dispose();
211
+ for (const d of localCleanups) d();
212
+ localCleanups = [];
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Adopt SSR markup inside `container`. `factory` is the same function
218
+ * that was rendered server-side — its output (a TemplateResult tree)
219
+ * tells hydrate which slots to wire.
220
+ *
221
+ * Returns a `Disposer` that detaches every effect and event listener,
222
+ * leaving the DOM in place.
223
+ */
224
+ export function hydrate(
225
+ container: Element,
226
+ factory: () => TemplateResult,
227
+ ): Disposer {
228
+ const cleanups: Disposer[] = [];
229
+ const mountHooks: Array<EffectCallback> = [];
230
+ const markerCursor: MarkerCursor = {
231
+ pairs: collectMarkerPairs(container),
232
+ i: 0,
233
+ doc: container.ownerDocument ?? document,
234
+ };
235
+ const result = factory();
236
+ hydrateTemplateResult(
237
+ result,
238
+ Array.from(container.childNodes),
239
+ cleanups,
240
+ mountHooks,
241
+ markerCursor,
242
+ );
243
+ for (const hook of mountHooks) {
244
+ try {
245
+ const teardown = hook();
246
+ if (typeof teardown === "function") cleanups.push(teardown);
247
+ } catch {
248
+ /* swallow */
249
+ }
250
+ }
251
+ let disposed = false;
252
+ return () => {
253
+ if (disposed) return;
254
+ disposed = true;
255
+ for (const c of cleanups.splice(0)) c();
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Hydrate a TemplateResult against a list of live root nodes. The list
261
+ * is sliced as we consume children — text-slot anchors don't exist in
262
+ * the SSR output (we inlined the value), so we count text-slot
263
+ * boundaries by reading the static `strings` between values.
264
+ */
265
+ function hydrateTemplateResult(
266
+ result: TemplateResult,
267
+ liveNodes: ChildNode[],
268
+ cleanups: Disposer[],
269
+ mountHooks: Array<EffectCallback>,
270
+ markerCursor: MarkerCursor,
271
+ ): void {
272
+ const lifecycle = readComponentLifecycle(result);
273
+ if (lifecycle) {
274
+ for (const hook of lifecycle.mountHooks) mountHooks.push(hook);
275
+ for (const c of lifecycle.cleanups) cleanups.push(c);
276
+ }
277
+
278
+ // Hydration walks via the SAME path resolver as render, but against
279
+ // a synthetic root that mimics the parsed template's child list.
280
+ const tpl = getTemplate(result.strings);
281
+ // The live container's children should structurally match the
282
+ // template's content children. Wrap them in a transient DocumentFragment
283
+ // for path resolution — DocumentFragment.childNodes is the same view
284
+ // we walked during parse.
285
+ const syntheticRoot = {
286
+ childNodes: liveNodes,
287
+ } as unknown as ParentNode;
288
+
289
+ for (let i = 0; i < tpl.slots.length; i++) {
290
+ const slot = tpl.slots[i];
291
+ const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
292
+ if (!liveNode) {
293
+ // Path missed in the live DOM — SSR markup diverges from the
294
+ // parsed template's shape. Surfacing the mismatch beats silent
295
+ // dead bindings: a stale slot doesn't update, but the developer
296
+ // has no clue why until they hit print-line debugging.
297
+ if (typeof console !== "undefined") {
298
+ console.warn(
299
+ `[aurora] hydration mismatch: slot ${i} (${slot.kind}) path ${slot.path.join(".")} not found in live DOM — SSR markup may diverge from the client template (did you forget to rerender after a server change?)`,
300
+ );
301
+ }
302
+ continue;
303
+ }
304
+ hydrateSlot(
305
+ slot,
306
+ liveNode,
307
+ result.values[i],
308
+ cleanups,
309
+ mountHooks,
310
+ markerCursor,
311
+ );
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Resolve a slot's path against the LIVE DOM. The first index of the
317
+ * path indexes into `liveNodes` directly (since we packaged them in a
318
+ * synthetic root); subsequent indices walk the child node list normally.
319
+ *
320
+ * Text-slot paths point to a comment marker that doesn't exist in
321
+ * hydration markup — we tolerate the miss and return null.
322
+ */
323
+ function resolvePathLive(
324
+ _root: ParentNode,
325
+ path: NodePath,
326
+ rootNodes: ChildNode[],
327
+ ): Node | null {
328
+ if (path.length === 0) return null;
329
+ let node: Node | null = rootNodes[path[0]] ?? null;
330
+ for (let i = 1; node && i < path.length; i++) {
331
+ node = node.childNodes[path[i]] ?? null;
332
+ }
333
+ return node;
334
+ }
335
+
336
+ function hydrateSlot(
337
+ slot: Slot,
338
+ node: Node,
339
+ value: unknown,
340
+ cleanups: Disposer[],
341
+ mountHooks: Array<EffectCallback>,
342
+ markerCursor: MarkerCursor,
343
+ ): void {
344
+ switch (slot.kind) {
345
+ case "text":
346
+ hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
347
+ return;
348
+ case "attr":
349
+ hydrateAttrSlot(slot, node as Element, value, cleanups);
350
+ return;
351
+ case "boolean-attr":
352
+ hydrateBooleanAttrSlot(slot, node as Element, value, cleanups);
353
+ return;
354
+ case "prop":
355
+ hydratePropSlot(slot, node as Element, value, cleanups);
356
+ return;
357
+ case "event":
358
+ hydrateEventSlot(slot, node as Element, value, cleanups);
359
+ return;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Hydrate a text slot. SSR inlined the value as a text node (or skipped
365
+ * it for null/false/undefined). We locate the **first text node sibling
366
+ * preceding the path's terminal index** — that's where SSR wrote the
367
+ * value — and wire an effect that overwrites its `data` on changes.
368
+ *
369
+ * For reactive values (signals/functions), the effect updates the
370
+ * existing text node in place. For nested TemplateResults, we
371
+ * recursively hydrate against the captured sibling range.
372
+ */
373
+ function hydrateTextSlot(
374
+ commentMarker: Node,
375
+ value: unknown,
376
+ cleanups: Disposer[],
377
+ mountHooks: Array<EffectCallback>,
378
+ markerCursor: MarkerCursor,
379
+ ): 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.
388
+ if (isSignal(value) || typeof value === "function") {
389
+ const fn = value as () => unknown;
390
+ // First, evaluate eagerly to detect a structured value (nested
391
+ // TemplateResult / array) — those need a SWAP on change, which
392
+ // means a node range, which the SSR boundary markers give us.
393
+ const first = fn();
394
+ if (isTemplateResult(first) || Array.isArray(first)) {
395
+ const pair = markerCursor.pairs[markerCursor.i];
396
+ if (pair !== undefined) {
397
+ markerCursor.i += 1;
398
+ hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor);
399
+ return;
400
+ }
401
+ // LEGACY markup (no boundary markers — produced by an older
402
+ // SSR build): we can't locate the subtree's range, so we
403
+ // hydrate once and warn that the subtree won't stay reactive.
404
+ // Fresh SSR always emits markers, so this path is dead for
405
+ // matched server/client builds.
406
+ if (!nestedReactiveWarned && typeof console !== "undefined") {
407
+ nestedReactiveWarned = true;
408
+ console.warn(
409
+ "[aurora] a reactive expression hydrated to a nested template but " +
410
+ "the SSR markup has no boundary markers — the subtree will not update " +
411
+ "on signal changes. Re-render with a current @c9up/aurora SSR build.",
412
+ );
413
+ }
414
+ if (isTemplateResult(first)) {
415
+ hydrateTemplateResult(
416
+ first,
417
+ [commentMarker as ChildNode],
418
+ cleanups,
419
+ mountHooks,
420
+ markerCursor,
421
+ );
422
+ }
423
+ return;
424
+ }
425
+ const textNode =
426
+ commentMarker.nodeType === 3 /* TEXT */
427
+ ? (commentMarker as Text)
428
+ : commentMarker.previousSibling?.nodeType === 3
429
+ ? (commentMarker.previousSibling as Text)
430
+ : null;
431
+ if (!textNode) return;
432
+ const dispose = effect(() => {
433
+ const v = fn();
434
+ textNode.data = v == null || v === false ? "" : String(v);
435
+ });
436
+ cleanups.push(dispose);
437
+ return;
438
+ }
439
+ if (isTemplateResult(value)) {
440
+ hydrateTemplateResult(
441
+ value,
442
+ [commentMarker as ChildNode],
443
+ cleanups,
444
+ mountHooks,
445
+ markerCursor,
446
+ );
447
+ return;
448
+ }
449
+ // Static value — SSR rendered it once and we don't need to do
450
+ // anything. The text already lives in the DOM.
451
+ }
452
+
453
+ function hydrateAttrSlot(
454
+ slot: AttrSlot,
455
+ el: Element,
456
+ value: unknown,
457
+ cleanups: Disposer[],
458
+ ): void {
459
+ function apply(v: unknown): void {
460
+ if (v === null || v === undefined || v === false) {
461
+ el.removeAttribute(slot.name);
462
+ } else if (v === true) {
463
+ el.setAttribute(slot.name, "");
464
+ } else {
465
+ el.setAttribute(slot.name, String(v));
466
+ }
467
+ }
468
+ if (isSignal(value) || typeof value === "function") {
469
+ const dispose = effect(() => apply((value as () => unknown)()));
470
+ cleanups.push(dispose);
471
+ }
472
+ // Static attrs need no hydration — SSR already wrote them.
473
+ }
474
+
475
+ function hydrateBooleanAttrSlot(
476
+ slot: BooleanAttrSlot,
477
+ el: Element,
478
+ value: unknown,
479
+ cleanups: Disposer[],
480
+ ): void {
481
+ function apply(v: unknown): void {
482
+ if (v) el.setAttribute(slot.name, "");
483
+ else el.removeAttribute(slot.name);
484
+ }
485
+ if (isSignal(value) || typeof value === "function") {
486
+ const dispose = effect(() => apply((value as () => unknown)()));
487
+ cleanups.push(dispose);
488
+ }
489
+ }
490
+
491
+ function hydratePropSlot(
492
+ slot: PropSlot,
493
+ el: Element,
494
+ value: unknown,
495
+ cleanups: Disposer[],
496
+ ): void {
497
+ function apply(v: unknown): void {
498
+ (el as unknown as Record<string, unknown>)[slot.name] = v;
499
+ }
500
+ if (isSignal(value) || typeof value === "function") {
501
+ const dispose = effect(() => apply((value as () => unknown)()));
502
+ cleanups.push(dispose);
503
+ } else {
504
+ apply(value);
505
+ }
506
+ }
507
+
508
+ function hydrateEventSlot(
509
+ slot: EventSlot,
510
+ el: Element,
511
+ value: unknown,
512
+ cleanups: Disposer[],
513
+ ): void {
514
+ if (typeof value !== "function") return;
515
+ const handler = value as EventListener;
516
+ el.addEventListener(slot.event, handler);
517
+ cleanups.push(() => el.removeEventListener(slot.event, handler));
518
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // ─── Inertia-shape server surface ─────────────────────────────────
2
+ export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
3
+ export { component, onMount, onUnmount } from "./component.js";
4
+ export { html, isTemplateResult } from "./html.js";
5
+ export { hydrate } from "./hydrate.js";
6
+ export {
7
+ type PageFactory,
8
+ Pages,
9
+ type PagesConfig,
10
+ } from "./Pages.js";
11
+ export {
12
+ batch,
13
+ effect,
14
+ isSignal,
15
+ memo,
16
+ onCleanup,
17
+ type ReadSignal,
18
+ type Signal,
19
+ signal,
20
+ untrack,
21
+ } from "./reactive.js";
22
+ export { type Disposer, render } from "./render.js";
23
+ export {
24
+ type AuroraHttpContext,
25
+ type AuroraResponse,
26
+ type AuroraRouteConfig,
27
+ auroraRoute,
28
+ } from "./route.js";
29
+ export {
30
+ type RenderHttpContext,
31
+ type RenderPageOptions,
32
+ type RenderResponse,
33
+ renderPage,
34
+ } from "./server/renderPage.js";
35
+ export {
36
+ type AssetsHttpContext,
37
+ type AssetsRequest,
38
+ type AssetsResponse,
39
+ type ServeAssetsOptions,
40
+ serveAssets,
41
+ } from "./server/serveAssets.js";
42
+ export { renderToString } from "./ssr.js";
43
+ export type { TemplateResult } from "./types.js";