@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
@@ -0,0 +1,379 @@
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
+ import { readComponentLifecycle } from "./component.js";
18
+ import { getTemplate } from "./html.js";
19
+ import { effect, isSignal } from "./reactive.js";
20
+ import { mount } from "./render.js";
21
+ import { isTemplateResult, } from "./types.js";
22
+ /**
23
+ * Process-scoped flag so the "reactive nested template not reactive
24
+ * after hydration" warning fires once, not on every matching slot.
25
+ * Only reached on LEGACY markup that predates SSR boundary markers
26
+ * (the markered path keeps the subtree reactive — no warning).
27
+ */
28
+ let nestedReactiveWarned = false;
29
+ /** @internal Reset the warn-once flag (tests). */
30
+ export function resetHydrateWarnings() {
31
+ nestedReactiveWarned = false;
32
+ }
33
+ // Boundary-marker comment payloads (kept in sync with ssr.ts).
34
+ const SLOT_START = "$";
35
+ const SLOT_END = "/$";
36
+ /**
37
+ * Collect every `<!--$-->…<!--/$-->` pair under `container`, ordered by
38
+ * the start marker's document position. Nesting is resolved with a
39
+ * stack so an inner pair's start/end never cross an outer pair's.
40
+ */
41
+ function collectMarkerPairs(container) {
42
+ // Depth-first, document-order walk. We DON'T use createTreeWalker:
43
+ // some DOM implementations (happy-dom under vitest) ignore the
44
+ // numeric `whatToShow` filter and yield nothing. A manual recursion
45
+ // over childNodes is portable and visits comments in document order,
46
+ // so the stack pairs each `<!--$-->` with its matching `<!--/$-->`
47
+ // and the result is already start-ordered (no sort needed).
48
+ const pairs = [];
49
+ const stack = [];
50
+ const visit = (node) => {
51
+ if (node.nodeType === 8 /* Comment */) {
52
+ const c = node;
53
+ if (c.data === SLOT_START) {
54
+ stack.push(c);
55
+ }
56
+ else if (c.data === SLOT_END) {
57
+ const start = stack.pop();
58
+ if (start !== undefined)
59
+ pairs.push({ start, end: c });
60
+ }
61
+ return;
62
+ }
63
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) {
64
+ visit(child);
65
+ }
66
+ };
67
+ visit(container);
68
+ // `pairs` is in END order (innermost closes first). Sort by start's
69
+ // document position so consumption matches hydration's
70
+ // parents-before-children visit order.
71
+ pairs.sort((a, b) => a.start.compareDocumentPosition(b.start) &
72
+ 4 /* DOCUMENT_POSITION_FOLLOWING */
73
+ ? -1
74
+ : 1);
75
+ return pairs;
76
+ }
77
+ /** Render a value (template / array / scalar) to detached client nodes. */
78
+ function renderValueToNodes(value, cleanups, mountHooks, doc) {
79
+ if (value === null || value === undefined || value === false)
80
+ return [];
81
+ if (Array.isArray(value)) {
82
+ const out = [];
83
+ for (const item of value) {
84
+ out.push(...renderValueToNodes(item, cleanups, mountHooks, doc));
85
+ }
86
+ return out;
87
+ }
88
+ if (isTemplateResult(value)) {
89
+ const frag = mount(value, cleanups, [], mountHooks);
90
+ return Array.from(frag.childNodes);
91
+ }
92
+ if (value instanceof Node)
93
+ return [value];
94
+ return [doc.createTextNode(String(value))];
95
+ }
96
+ /**
97
+ * Wire a reactive structured slot (signal/function → nested template or
98
+ * array) using its SSR boundary-marker pair. The first run hydrates the
99
+ * initial value against the captured SSR nodes (reusing server markup,
100
+ * no flash); every subsequent signal change disposes the old subtree
101
+ * and client-renders the new value into the same `<!--$-->…<!--/$-->`
102
+ * range — so the DOM stays correct on branch changes instead of going
103
+ * stale.
104
+ */
105
+ function hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor) {
106
+ const { start, end } = pair;
107
+ let currentNodes = [];
108
+ for (let n = start.nextSibling; n !== null && n !== end; n = n.nextSibling) {
109
+ currentNodes.push(n);
110
+ }
111
+ let localCleanups = [];
112
+ let firstRun = true;
113
+ const dispose = effect(() => {
114
+ const next = fn();
115
+ if (firstRun) {
116
+ firstRun = false;
117
+ // Reuse SSR markup: hydrate reactive bindings INSIDE the nested
118
+ // template against the captured nodes. Inner boundary markers
119
+ // are consumed from the same cursor (document order).
120
+ if (isTemplateResult(next)) {
121
+ hydrateTemplateResult(next, currentNodes, localCleanups, mountHooks, markerCursor);
122
+ }
123
+ return;
124
+ }
125
+ // Signal changed post-hydration: tear down the old subtree's
126
+ // effects/listeners, drop its nodes, client-render the new value
127
+ // into the same marker range.
128
+ for (const d of localCleanups)
129
+ d();
130
+ localCleanups = [];
131
+ for (const n of currentNodes)
132
+ n.remove();
133
+ currentNodes = [];
134
+ const parent = end.parentNode;
135
+ if (parent === null)
136
+ return;
137
+ const fresh = renderValueToNodes(next, localCleanups, mountHooks, markerCursor.doc);
138
+ for (const n of fresh)
139
+ parent.insertBefore(n, end);
140
+ currentNodes = fresh;
141
+ });
142
+ cleanups.push(() => {
143
+ dispose();
144
+ for (const d of localCleanups)
145
+ d();
146
+ localCleanups = [];
147
+ });
148
+ }
149
+ /**
150
+ * Adopt SSR markup inside `container`. `factory` is the same function
151
+ * that was rendered server-side — its output (a TemplateResult tree)
152
+ * tells hydrate which slots to wire.
153
+ *
154
+ * Returns a `Disposer` that detaches every effect and event listener,
155
+ * leaving the DOM in place.
156
+ */
157
+ export function hydrate(container, factory) {
158
+ const cleanups = [];
159
+ const mountHooks = [];
160
+ const markerCursor = {
161
+ pairs: collectMarkerPairs(container),
162
+ i: 0,
163
+ doc: container.ownerDocument ?? document,
164
+ };
165
+ const result = factory();
166
+ hydrateTemplateResult(result, Array.from(container.childNodes), cleanups, mountHooks, markerCursor);
167
+ for (const hook of mountHooks) {
168
+ try {
169
+ const teardown = hook();
170
+ if (typeof teardown === "function")
171
+ cleanups.push(teardown);
172
+ }
173
+ catch {
174
+ /* swallow */
175
+ }
176
+ }
177
+ let disposed = false;
178
+ return () => {
179
+ if (disposed)
180
+ return;
181
+ disposed = true;
182
+ for (const c of cleanups.splice(0))
183
+ c();
184
+ };
185
+ }
186
+ /**
187
+ * Hydrate a TemplateResult against a list of live root nodes. The list
188
+ * is sliced as we consume children — text-slot anchors don't exist in
189
+ * the SSR output (we inlined the value), so we count text-slot
190
+ * boundaries by reading the static `strings` between values.
191
+ */
192
+ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCursor) {
193
+ const lifecycle = readComponentLifecycle(result);
194
+ if (lifecycle) {
195
+ for (const hook of lifecycle.mountHooks)
196
+ mountHooks.push(hook);
197
+ for (const c of lifecycle.cleanups)
198
+ cleanups.push(c);
199
+ }
200
+ // Hydration walks via the SAME path resolver as render, but against
201
+ // a synthetic root that mimics the parsed template's child list.
202
+ const tpl = getTemplate(result.strings);
203
+ // The live container's children should structurally match the
204
+ // template's content children. Wrap them in a transient DocumentFragment
205
+ // for path resolution — DocumentFragment.childNodes is the same view
206
+ // we walked during parse.
207
+ const syntheticRoot = {
208
+ childNodes: liveNodes,
209
+ };
210
+ for (let i = 0; i < tpl.slots.length; i++) {
211
+ const slot = tpl.slots[i];
212
+ const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
213
+ if (!liveNode) {
214
+ // Path missed in the live DOM — SSR markup diverges from the
215
+ // parsed template's shape. Surfacing the mismatch beats silent
216
+ // dead bindings: a stale slot doesn't update, but the developer
217
+ // has no clue why until they hit print-line debugging.
218
+ if (typeof console !== "undefined") {
219
+ console.warn(`[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?)`);
220
+ }
221
+ continue;
222
+ }
223
+ hydrateSlot(slot, liveNode, result.values[i], cleanups, mountHooks, markerCursor);
224
+ }
225
+ }
226
+ /**
227
+ * Resolve a slot's path against the LIVE DOM. The first index of the
228
+ * path indexes into `liveNodes` directly (since we packaged them in a
229
+ * synthetic root); subsequent indices walk the child node list normally.
230
+ *
231
+ * Text-slot paths point to a comment marker that doesn't exist in
232
+ * hydration markup — we tolerate the miss and return null.
233
+ */
234
+ function resolvePathLive(_root, path, rootNodes) {
235
+ if (path.length === 0)
236
+ return null;
237
+ let node = rootNodes[path[0]] ?? null;
238
+ for (let i = 1; node && i < path.length; i++) {
239
+ node = node.childNodes[path[i]] ?? null;
240
+ }
241
+ return node;
242
+ }
243
+ function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
244
+ switch (slot.kind) {
245
+ case "text":
246
+ hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
247
+ return;
248
+ case "attr":
249
+ hydrateAttrSlot(slot, node, value, cleanups);
250
+ return;
251
+ case "boolean-attr":
252
+ hydrateBooleanAttrSlot(slot, node, value, cleanups);
253
+ return;
254
+ case "prop":
255
+ hydratePropSlot(slot, node, value, cleanups);
256
+ return;
257
+ case "event":
258
+ hydrateEventSlot(slot, node, value, cleanups);
259
+ return;
260
+ }
261
+ }
262
+ /**
263
+ * Hydrate a text slot. SSR inlined the value as a text node (or skipped
264
+ * it for null/false/undefined). We locate the **first text node sibling
265
+ * preceding the path's terminal index** — that's where SSR wrote the
266
+ * value — and wire an effect that overwrites its `data` on changes.
267
+ *
268
+ * For reactive values (signals/functions), the effect updates the
269
+ * existing text node in place. For nested TemplateResults, we
270
+ * recursively hydrate against the captured sibling range.
271
+ */
272
+ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
273
+ // The path lands on the comment marker that EXISTS in the parsed
274
+ // template but not in SSR output. Hydration walks the live siblings
275
+ // to find the text node that holds the SSR value.
276
+ // Strategy: the comment was located at child index N inside its
277
+ // parent; SSR wrote the value as the immediately-preceding text
278
+ // node (or nothing for null/false). Live node here is whatever the
279
+ // path resolution returned — often a text node, sometimes an
280
+ // element (for nested templates). We rebind in-place.
281
+ if (isSignal(value) || typeof value === "function") {
282
+ const fn = value;
283
+ // First, evaluate eagerly to detect a structured value (nested
284
+ // TemplateResult / array) — those need a SWAP on change, which
285
+ // means a node range, which the SSR boundary markers give us.
286
+ const first = fn();
287
+ if (isTemplateResult(first) || Array.isArray(first)) {
288
+ const pair = markerCursor.pairs[markerCursor.i];
289
+ if (pair !== undefined) {
290
+ markerCursor.i += 1;
291
+ hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor);
292
+ return;
293
+ }
294
+ // LEGACY markup (no boundary markers — produced by an older
295
+ // SSR build): we can't locate the subtree's range, so we
296
+ // hydrate once and warn that the subtree won't stay reactive.
297
+ // Fresh SSR always emits markers, so this path is dead for
298
+ // matched server/client builds.
299
+ if (!nestedReactiveWarned && typeof console !== "undefined") {
300
+ nestedReactiveWarned = true;
301
+ console.warn("[aurora] a reactive expression hydrated to a nested template but " +
302
+ "the SSR markup has no boundary markers — the subtree will not update " +
303
+ "on signal changes. Re-render with a current @c9up/aurora SSR build.");
304
+ }
305
+ if (isTemplateResult(first)) {
306
+ hydrateTemplateResult(first, [commentMarker], cleanups, mountHooks, markerCursor);
307
+ }
308
+ return;
309
+ }
310
+ const textNode = commentMarker.nodeType === 3 /* TEXT */
311
+ ? commentMarker
312
+ : commentMarker.previousSibling?.nodeType === 3
313
+ ? commentMarker.previousSibling
314
+ : null;
315
+ if (!textNode)
316
+ return;
317
+ const dispose = effect(() => {
318
+ const v = fn();
319
+ textNode.data = v == null || v === false ? "" : String(v);
320
+ });
321
+ cleanups.push(dispose);
322
+ return;
323
+ }
324
+ if (isTemplateResult(value)) {
325
+ hydrateTemplateResult(value, [commentMarker], cleanups, mountHooks, markerCursor);
326
+ return;
327
+ }
328
+ // Static value — SSR rendered it once and we don't need to do
329
+ // anything. The text already lives in the DOM.
330
+ }
331
+ function hydrateAttrSlot(slot, el, value, cleanups) {
332
+ function apply(v) {
333
+ if (v === null || v === undefined || v === false) {
334
+ el.removeAttribute(slot.name);
335
+ }
336
+ else if (v === true) {
337
+ el.setAttribute(slot.name, "");
338
+ }
339
+ else {
340
+ el.setAttribute(slot.name, String(v));
341
+ }
342
+ }
343
+ if (isSignal(value) || typeof value === "function") {
344
+ const dispose = effect(() => apply(value()));
345
+ cleanups.push(dispose);
346
+ }
347
+ // Static attrs need no hydration — SSR already wrote them.
348
+ }
349
+ function hydrateBooleanAttrSlot(slot, el, value, cleanups) {
350
+ function apply(v) {
351
+ if (v)
352
+ el.setAttribute(slot.name, "");
353
+ else
354
+ el.removeAttribute(slot.name);
355
+ }
356
+ if (isSignal(value) || typeof value === "function") {
357
+ const dispose = effect(() => apply(value()));
358
+ cleanups.push(dispose);
359
+ }
360
+ }
361
+ function hydratePropSlot(slot, el, value, cleanups) {
362
+ function apply(v) {
363
+ el[slot.name] = v;
364
+ }
365
+ if (isSignal(value) || typeof value === "function") {
366
+ const dispose = effect(() => apply(value()));
367
+ cleanups.push(dispose);
368
+ }
369
+ else {
370
+ apply(value);
371
+ }
372
+ }
373
+ function hydrateEventSlot(slot, el, value, cleanups) {
374
+ if (typeof value !== "function")
375
+ return;
376
+ const handler = value;
377
+ el.addEventListener(slot.event, handler);
378
+ cleanups.push(() => el.removeEventListener(slot.event, handler));
379
+ }
@@ -0,0 +1,12 @@
1
+ export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
2
+ export { component, onMount, onUnmount } from "./component.js";
3
+ export { html, isTemplateResult } from "./html.js";
4
+ export { hydrate } from "./hydrate.js";
5
+ export { type PageFactory, Pages, type PagesConfig, } from "./Pages.js";
6
+ export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
7
+ export { type Disposer, render } from "./render.js";
8
+ export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
9
+ export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
10
+ export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
11
+ export { renderToString } from "./ssr.js";
12
+ export type { TemplateResult } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // ─── Inertia-shape server surface ─────────────────────────────────
2
+ export { AuroraManager } 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 { Pages, } from "./Pages.js";
7
+ export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
8
+ export { render } from "./render.js";
9
+ export { auroraRoute, } from "./route.js";
10
+ export { renderPage, } from "./server/renderPage.js";
11
+ export { serveAssets, } from "./server/serveAssets.js";
12
+ export { renderToString } from "./ssr.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Reactive core — signals + effects with auto-tracking.
3
+ *
4
+ * No proxies, no VDOM. A `signal<T>()` is a single read/write function that
5
+ * registers itself in the currently-running observer's dependency set when
6
+ * read, and notifies every dependent observer when written.
7
+ *
8
+ * Effects run their callback once eagerly, capture the signals they read,
9
+ * and re-run whenever any of those signals fires. Effects can return a
10
+ * cleanup function that runs before the next re-execution and at disposal.
11
+ */
12
+ import type { EffectCallback } from "./types.js";
13
+ /** Reader-only view of a signal. Returned by `memo()`. */
14
+ export interface ReadSignal<T> {
15
+ (): T;
16
+ readonly [SIGNAL_BRAND]: true;
17
+ }
18
+ /** Read-write signal — call with no args to read, with one arg to write. */
19
+ export interface Signal<T> {
20
+ (): T;
21
+ (next: T): void;
22
+ (updater: (prev: T) => T): void;
23
+ readonly [SIGNAL_BRAND]: true;
24
+ }
25
+ /**
26
+ * Brand symbol so consumers can distinguish a signal from a plain function
27
+ * without instanceof. Exposed only via the `isSignal` guard — never call
28
+ * sites need to import this directly.
29
+ */
30
+ export declare const SIGNAL_BRAND: unique symbol;
31
+ /**
32
+ * Create a writable signal seeded with `initial`. Reads register the
33
+ * current observer; writes notify every observer that previously read.
34
+ *
35
+ * Optional `{ equals }` swaps the default `Object.is` check — return
36
+ * `true` to skip notifying observers (the new value is "the same").
37
+ */
38
+ export declare function signal<T>(initial: T, options?: {
39
+ equals?: (a: T, b: T) => boolean;
40
+ }): Signal<T>;
41
+ /**
42
+ * @internal Observer-count test seam for the untrack-leak invariant: a
43
+ * read inside `untrack()` must NOT add an entry to a signal's observer
44
+ * Set. Returns -1 for a value that isn't a tracked signal.
45
+ */
46
+ export declare function observerCount(sig: object): number;
47
+ /** Runtime guard — distinguishes a signal accessor from any other callable. */
48
+ export declare function isSignal<T = unknown>(value: unknown): value is Signal<T>;
49
+ /**
50
+ * Run `fn` immediately and every time a signal it reads changes. Returns a
51
+ * dispose function — call it to stop the effect and run any pending
52
+ * cleanup.
53
+ *
54
+ * Inside `fn`, return another function to register cleanup that runs
55
+ * before the next execution AND at disposal. Multiple cleanups can also
56
+ * be registered via `onCleanup()`.
57
+ */
58
+ export declare function effect(fn: EffectCallback): () => void;
59
+ /**
60
+ * Register a cleanup callback against the currently-running effect.
61
+ * No-op when called outside an effect — same contract as Solid's
62
+ * `onCleanup`, more permissive than React's hook-only access.
63
+ */
64
+ export declare function onCleanup(fn: () => void): void;
65
+ /**
66
+ * Defer notifications until `fn` returns. Multiple writes to the same
67
+ * signal coalesce into a single observer re-run, and writes across
68
+ * signals re-run each affected observer at most once.
69
+ */
70
+ export declare function batch<T>(fn: () => T): T;
71
+ /**
72
+ * Read signals inside `fn` without registering them as dependencies of
73
+ * the current observer. Useful when an effect needs the current value of
74
+ * a signal but should not re-run when it changes.
75
+ */
76
+ export declare function untrack<T>(fn: () => T): T;
77
+ /**
78
+ * Derived read-only signal — `fn` re-runs when any signal it reads
79
+ * changes, and the latest return value is cached + handed out via the
80
+ * returned accessor. Cleanups inside `fn` (via `onCleanup`) run on every
81
+ * recomputation.
82
+ */
83
+ export declare function memo<T>(fn: () => T): ReadSignal<T>;