@c9up/aurora 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -24,7 +24,7 @@ providers: [
24
24
 
25
25
  ## Entry points
26
26
 
27
- - `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `command()` (async action + reactive loading/data/error), and `form()` (reactive form controller; optional rune validation + rosetta i18n)
27
+ - `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `createRpcClient()` (JSON-RPC 2.0), `command()` (async action + reactive loading/data/error), `form()` (reactive form controller; optional rune validation + rosetta i18n), `urlFor()` (isomorphic named-route URLs, paired with Ream's `router.namedManifest()`), and `cn()` (zero-dependency Tailwind v4 class merge — `clsx` + `tailwind-merge` reimplemented)
28
28
  - `@c9up/aurora/provider` — Ream IoC provider
29
29
  - `@c9up/aurora/services/main` — container service accessor
30
30
  - `@c9up/aurora/relay` — realtime adapter
@@ -22,13 +22,31 @@ export interface AuroraManagerConfig {
22
22
  * Override only if you want to serve a custom build.
23
23
  */
24
24
  auroraDistRoot?: string;
25
+ /**
26
+ * URL prefix the asset routes mount under. The aurora runtime is served
27
+ * from `<assetsPrefix>/aurora/*` and the app's pages from
28
+ * `<assetsPrefix>/pages/*`, and the SSR importmap + page URLs derive from
29
+ * it. Default `/_assets` (the leading underscore namespaces framework
30
+ * assets away from app routes, Next.js `/_next` style). Set e.g. `/assets`
31
+ * for an underscore-free scheme. An explicit `pages.urlPrefix` still wins.
32
+ */
33
+ assetsPrefix?: string;
25
34
  }
26
35
  export declare class AuroraManager {
27
36
  readonly pages: Pages;
28
37
  readonly auroraDistRoot: string;
38
+ /** Resolved asset prefix (default `/_assets`). */
39
+ readonly assetsPrefix: string;
40
+ /** Mount path for the aurora runtime — `<assetsPrefix>/aurora`. */
41
+ readonly auroraAssetPath: string;
42
+ /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
43
+ readonly pageAssetPath: string;
29
44
  constructor(config: AuroraManagerConfig);
30
45
  /**
31
- * SSR + hydrate + ship the document.
46
+ * SSR + hydrate + ship the document. The importmap default points
47
+ * `@c9up/aurora` at this manager's `assetsPrefix`; a caller's
48
+ * `options.importmap` still overrides (e.g. to remap to an app-curated
49
+ * browser entry).
32
50
  */
33
51
  render(ctx: RenderHttpContext, name: string, props: unknown, options?: RenderPageOptions): Promise<void>;
34
52
  /**
@@ -17,18 +17,46 @@ import { Pages } from "./Pages.js";
17
17
  import { renderPage, } from "./server/renderPage.js";
18
18
  import { serveAssets } from "./server/serveAssets.js";
19
19
  const DEFAULT_AURORA_DIST = resolvePath(dirname(fileURLToPath(import.meta.url)), "../dist");
20
+ /** Normalize an asset prefix: ensure a leading slash, drop trailing slashes. */
21
+ function normalizePrefix(prefix) {
22
+ const withLead = prefix.startsWith("/") ? prefix : `/${prefix}`;
23
+ return withLead.replace(/\/+$/, "") || "/";
24
+ }
20
25
  export class AuroraManager {
21
26
  pages;
22
27
  auroraDistRoot;
28
+ /** Resolved asset prefix (default `/_assets`). */
29
+ assetsPrefix;
30
+ /** Mount path for the aurora runtime — `<assetsPrefix>/aurora`. */
31
+ auroraAssetPath;
32
+ /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
33
+ pageAssetPath;
23
34
  constructor(config) {
24
- this.pages = new Pages(config.pages);
35
+ this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/_assets");
36
+ this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
37
+ this.pageAssetPath = `${this.assetsPrefix}/pages`;
38
+ // Pages serve their compiled JS from the same prefix unless the app
39
+ // pins an explicit urlPrefix.
40
+ this.pages = new Pages({
41
+ ...config.pages,
42
+ urlPrefix: config.pages.urlPrefix ?? this.pageAssetPath,
43
+ });
25
44
  this.auroraDistRoot = config.auroraDistRoot ?? DEFAULT_AURORA_DIST;
26
45
  }
27
46
  /**
28
- * SSR + hydrate + ship the document.
47
+ * SSR + hydrate + ship the document. The importmap default points
48
+ * `@c9up/aurora` at this manager's `assetsPrefix`; a caller's
49
+ * `options.importmap` still overrides (e.g. to remap to an app-curated
50
+ * browser entry).
29
51
  */
30
52
  render(ctx, name, props, options) {
31
- return renderPage(ctx, this.pages, name, props, options);
53
+ return renderPage(ctx, this.pages, name, props, {
54
+ ...options,
55
+ importmap: {
56
+ "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
57
+ ...options?.importmap,
58
+ },
59
+ });
32
60
  }
33
61
  /**
34
62
  * Handler for aurora's pre-built ESM runtime. Mount on
@@ -63,8 +63,10 @@ export default class AuroraProvider {
63
63
  return;
64
64
  const router = this.app.container.resolve("router");
65
65
  const manager = this.app.container.resolve(AuroraManager);
66
- router.get("/_assets/aurora/*", adaptHandler(manager.auroraAssetsHandler()));
67
- router.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
66
+ // Mount paths derive from the configured `assetsPrefix` (default
67
+ // `/_assets`) — set `config.aurora.assetsPrefix` to change the scheme.
68
+ router.get(`${manager.auroraAssetPath}/*`, adaptHandler(manager.auroraAssetsHandler()));
69
+ router.get(`${manager.pageAssetPath}/*`, adaptHandler(manager.pageAssetsHandler()));
68
70
  }
69
71
  async ready() { }
70
72
  async shutdown() { }
package/dist/hydrate.js CHANGED
@@ -115,11 +115,17 @@ function hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor)
115
115
  if (firstRun) {
116
116
  firstRun = false;
117
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).
118
+ // value against the captured nodes. Inner boundary markers are
119
+ // consumed from the same cursor (document order) — for an ARRAY this
120
+ // MUST recurse into every item, else the items' marker pairs go
121
+ // unconsumed and the cursor desyncs, wiring slots AFTER the list to
122
+ // the wrong range (SSR list "present but not painted").
120
123
  if (isTemplateResult(next)) {
121
124
  hydrateTemplateResult(next, currentNodes, localCleanups, mountHooks, markerCursor);
122
125
  }
126
+ else if (Array.isArray(next)) {
127
+ hydrateArrayItems(next, currentNodes, localCleanups, mountHooks, markerCursor);
128
+ }
123
129
  return;
124
130
  }
125
131
  // Signal changed post-hydration: tear down the old subtree's
@@ -146,6 +152,50 @@ function hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor)
146
152
  localCleanups = [];
147
153
  });
148
154
  }
155
+ /**
156
+ * Top-level live (SSR) node count a value contributes inside an array slot: a
157
+ * TemplateResult contributes its template's root-node count, a nested array the
158
+ * sum of its items, a non-empty scalar one text node, null/undefined/false none.
159
+ * Used to slice the array's range per item during hydration.
160
+ */
161
+ function liveNodeCount(value) {
162
+ if (value === null || value === undefined || value === false)
163
+ return 0;
164
+ if (isTemplateResult(value)) {
165
+ return getTemplate(value.strings).element.content.childNodes.length;
166
+ }
167
+ if (Array.isArray(value)) {
168
+ let n = 0;
169
+ for (const v of value)
170
+ n += liveNodeCount(v);
171
+ return n;
172
+ }
173
+ return 1; // scalar → one inlined text node
174
+ }
175
+ /**
176
+ * Hydrate the items of a reactive array against the SSR nodes inside its marker
177
+ * range. Each item is hydrated against its own slice of the (marker-collapsed)
178
+ * range, IN ORDER, so every item's inner marker pairs are consumed in document
179
+ * order and the global cursor stays aligned for slots AFTER the list. Item
180
+ * templates need a stable top-level node count (the common
181
+ * `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
182
+ * whitespace); bare adjacent scalar items can merge in the browser, so use
183
+ * template items for hydrated lists.
184
+ */
185
+ function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor) {
186
+ const nodes = collapseMarkerRanges(rangeNodes);
187
+ let offset = 0;
188
+ for (const item of items) {
189
+ const count = liveNodeCount(item);
190
+ if (isTemplateResult(item)) {
191
+ hydrateTemplateResult(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
192
+ }
193
+ else if (Array.isArray(item)) {
194
+ hydrateArrayItems(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
195
+ }
196
+ offset += count;
197
+ }
198
+ }
149
199
  /**
150
200
  * Adopt SSR markup inside `container`. `factory` is the same function
151
201
  * that was rendered server-side — its output (a TemplateResult tree)
@@ -360,8 +410,12 @@ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCurso
360
410
  if (isTemplateResult(current)) {
361
411
  hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
362
412
  }
363
- // A direct (non-reactive) array is static SSR markup; per-item reactive
364
- // bindings aren't individually re-hydrated (use `${() => arr.map(…)}`).
413
+ else if (Array.isArray(current)) {
414
+ // Direct (non-reactive) array hydrate each item so its inner marker
415
+ // pairs are consumed and the cursor stays aligned (same as a reactive
416
+ // array's first run).
417
+ hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
418
+ }
365
419
  return;
366
420
  }
367
421
  // Scalar: a reactive scalar updates the range's text node on change; a static
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",
@@ -46,7 +46,10 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.19.15",
49
+ "@vitest/browser": "4.1.6",
50
+ "@vitest/browser-playwright": "^4.1.9",
49
51
  "happy-dom": "^15.11.7",
52
+ "playwright": "^1.61.1",
50
53
  "typescript": "^6.0.2",
51
54
  "vitest": "^4.1.2"
52
55
  },
@@ -68,6 +71,7 @@
68
71
  "typecheck": "tsc --noEmit",
69
72
  "test": "vitest run",
70
73
  "lint": "biome check src/",
71
- "test:coverage": "vitest run --coverage"
74
+ "test:coverage": "vitest run --coverage",
75
+ "test:browser": "vitest run -c vitest.browser.config.ts"
72
76
  }
73
77
  }
@@ -30,6 +30,15 @@ export interface AuroraManagerConfig {
30
30
  * Override only if you want to serve a custom build.
31
31
  */
32
32
  auroraDistRoot?: string;
33
+ /**
34
+ * URL prefix the asset routes mount under. The aurora runtime is served
35
+ * from `<assetsPrefix>/aurora/*` and the app's pages from
36
+ * `<assetsPrefix>/pages/*`, and the SSR importmap + page URLs derive from
37
+ * it. Default `/_assets` (the leading underscore namespaces framework
38
+ * assets away from app routes, Next.js `/_next` style). Set e.g. `/assets`
39
+ * for an underscore-free scheme. An explicit `pages.urlPrefix` still wins.
40
+ */
41
+ assetsPrefix?: string;
33
42
  }
34
43
 
35
44
  const DEFAULT_AURORA_DIST = resolvePath(
@@ -37,17 +46,40 @@ const DEFAULT_AURORA_DIST = resolvePath(
37
46
  "../dist",
38
47
  );
39
48
 
49
+ /** Normalize an asset prefix: ensure a leading slash, drop trailing slashes. */
50
+ function normalizePrefix(prefix: string): string {
51
+ const withLead = prefix.startsWith("/") ? prefix : `/${prefix}`;
52
+ return withLead.replace(/\/+$/, "") || "/";
53
+ }
54
+
40
55
  export class AuroraManager {
41
56
  readonly pages: Pages;
42
57
  readonly auroraDistRoot: string;
58
+ /** Resolved asset prefix (default `/_assets`). */
59
+ readonly assetsPrefix: string;
60
+ /** Mount path for the aurora runtime — `<assetsPrefix>/aurora`. */
61
+ readonly auroraAssetPath: string;
62
+ /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
63
+ readonly pageAssetPath: string;
43
64
 
44
65
  constructor(config: AuroraManagerConfig) {
45
- this.pages = new Pages(config.pages);
66
+ this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/_assets");
67
+ this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
68
+ this.pageAssetPath = `${this.assetsPrefix}/pages`;
69
+ // Pages serve their compiled JS from the same prefix unless the app
70
+ // pins an explicit urlPrefix.
71
+ this.pages = new Pages({
72
+ ...config.pages,
73
+ urlPrefix: config.pages.urlPrefix ?? this.pageAssetPath,
74
+ });
46
75
  this.auroraDistRoot = config.auroraDistRoot ?? DEFAULT_AURORA_DIST;
47
76
  }
48
77
 
49
78
  /**
50
- * SSR + hydrate + ship the document.
79
+ * SSR + hydrate + ship the document. The importmap default points
80
+ * `@c9up/aurora` at this manager's `assetsPrefix`; a caller's
81
+ * `options.importmap` still overrides (e.g. to remap to an app-curated
82
+ * browser entry).
51
83
  */
52
84
  render(
53
85
  ctx: RenderHttpContext,
@@ -55,7 +87,13 @@ export class AuroraManager {
55
87
  props: unknown,
56
88
  options?: RenderPageOptions,
57
89
  ): Promise<void> {
58
- return renderPage(ctx, this.pages, name, props, options);
90
+ return renderPage(ctx, this.pages, name, props, {
91
+ ...options,
92
+ importmap: {
93
+ "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
94
+ ...options?.importmap,
95
+ },
96
+ });
59
97
  }
60
98
 
61
99
  /**
@@ -91,8 +91,16 @@ export default class AuroraProvider {
91
91
  if (!this.app.container.has("router")) return;
92
92
  const router = this.app.container.resolve<ReamRouter>("router");
93
93
  const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
94
- router.get("/_assets/aurora/*", adaptHandler(manager.auroraAssetsHandler()));
95
- router.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
94
+ // Mount paths derive from the configured `assetsPrefix` (default
95
+ // `/_assets`) — set `config.aurora.assetsPrefix` to change the scheme.
96
+ router.get(
97
+ `${manager.auroraAssetPath}/*`,
98
+ adaptHandler(manager.auroraAssetsHandler()),
99
+ );
100
+ router.get(
101
+ `${manager.pageAssetPath}/*`,
102
+ adaptHandler(manager.pageAssetsHandler()),
103
+ );
96
104
  }
97
105
 
98
106
  async ready(): Promise<void> {}
package/src/hydrate.ts CHANGED
@@ -174,8 +174,11 @@ function hydrateReactiveStructured(
174
174
  if (firstRun) {
175
175
  firstRun = false;
176
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).
177
+ // value against the captured nodes. Inner boundary markers are
178
+ // consumed from the same cursor (document order) — for an ARRAY this
179
+ // MUST recurse into every item, else the items' marker pairs go
180
+ // unconsumed and the cursor desyncs, wiring slots AFTER the list to
181
+ // the wrong range (SSR list "present but not painted").
179
182
  if (isTemplateResult(next)) {
180
183
  hydrateTemplateResult(
181
184
  next,
@@ -184,6 +187,14 @@ function hydrateReactiveStructured(
184
187
  mountHooks,
185
188
  markerCursor,
186
189
  );
190
+ } else if (Array.isArray(next)) {
191
+ hydrateArrayItems(
192
+ next,
193
+ currentNodes,
194
+ localCleanups,
195
+ mountHooks,
196
+ markerCursor,
197
+ );
187
198
  }
188
199
  return;
189
200
  }
@@ -213,6 +224,67 @@ function hydrateReactiveStructured(
213
224
  });
214
225
  }
215
226
 
227
+ /**
228
+ * Top-level live (SSR) node count a value contributes inside an array slot: a
229
+ * TemplateResult contributes its template's root-node count, a nested array the
230
+ * sum of its items, a non-empty scalar one text node, null/undefined/false none.
231
+ * Used to slice the array's range per item during hydration.
232
+ */
233
+ function liveNodeCount(value: unknown): number {
234
+ if (value === null || value === undefined || value === false) return 0;
235
+ if (isTemplateResult(value)) {
236
+ return getTemplate(value.strings).element.content.childNodes.length;
237
+ }
238
+ if (Array.isArray(value)) {
239
+ let n = 0;
240
+ for (const v of value) n += liveNodeCount(v);
241
+ return n;
242
+ }
243
+ return 1; // scalar → one inlined text node
244
+ }
245
+
246
+ /**
247
+ * Hydrate the items of a reactive array against the SSR nodes inside its marker
248
+ * range. Each item is hydrated against its own slice of the (marker-collapsed)
249
+ * range, IN ORDER, so every item's inner marker pairs are consumed in document
250
+ * order and the global cursor stays aligned for slots AFTER the list. Item
251
+ * templates need a stable top-level node count (the common
252
+ * `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
253
+ * whitespace); bare adjacent scalar items can merge in the browser, so use
254
+ * template items for hydrated lists.
255
+ */
256
+ function hydrateArrayItems(
257
+ items: unknown[],
258
+ rangeNodes: ChildNode[],
259
+ cleanups: Disposer[],
260
+ mountHooks: Array<EffectCallback>,
261
+ markerCursor: MarkerCursor,
262
+ ): void {
263
+ const nodes = collapseMarkerRanges(rangeNodes);
264
+ let offset = 0;
265
+ for (const item of items) {
266
+ const count = liveNodeCount(item);
267
+ if (isTemplateResult(item)) {
268
+ hydrateTemplateResult(
269
+ item,
270
+ nodes.slice(offset, offset + count),
271
+ cleanups,
272
+ mountHooks,
273
+ markerCursor,
274
+ );
275
+ } else if (Array.isArray(item)) {
276
+ hydrateArrayItems(
277
+ item,
278
+ nodes.slice(offset, offset + count),
279
+ cleanups,
280
+ mountHooks,
281
+ markerCursor,
282
+ );
283
+ }
284
+ offset += count;
285
+ }
286
+ }
287
+
216
288
  /**
217
289
  * Adopt SSR markup inside `container`. `factory` is the same function
218
290
  * that was rendered server-side — its output (a TemplateResult tree)
@@ -489,9 +561,12 @@ function hydrateTextSlot(
489
561
  }
490
562
  if (isTemplateResult(current)) {
491
563
  hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
564
+ } else if (Array.isArray(current)) {
565
+ // Direct (non-reactive) array — hydrate each item so its inner marker
566
+ // pairs are consumed and the cursor stays aligned (same as a reactive
567
+ // array's first run).
568
+ hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
492
569
  }
493
- // A direct (non-reactive) array is static SSR markup; per-item reactive
494
- // bindings aren't individually re-hydrated (use `${() => arr.map(…)}`).
495
570
  return;
496
571
  }
497
572