@c9up/aurora 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hydrate.ts CHANGED
@@ -358,6 +358,12 @@ function hydrateTemplateResult(
358
358
  childNodes: liveNodes,
359
359
  } as unknown as ParentNode;
360
360
 
361
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
362
+ // ONE attribute value built from all of them plus the static segments in
363
+ // between. Binding each slot on its own would have the last writer win and
364
+ // wipe the statics, which is what render.ts already avoids server-side.
365
+ const multiGroups = new Map<string, MultiAttrGroup>();
366
+
361
367
  for (let i = 0; i < tpl.slots.length; i++) {
362
368
  const slot = tpl.slots[i];
363
369
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -373,6 +379,15 @@ function hydrateTemplateResult(
373
379
  }
374
380
  continue;
375
381
  }
382
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
383
+ collectMultiAttr(
384
+ slot,
385
+ liveNode as Element,
386
+ result.values[i],
387
+ multiGroups,
388
+ );
389
+ continue;
390
+ }
376
391
  hydrateSlot(
377
392
  slot,
378
393
  liveNode,
@@ -382,6 +397,65 @@ function hydrateTemplateResult(
382
397
  markerCursor,
383
398
  );
384
399
  }
400
+
401
+ for (const group of multiGroups.values()) {
402
+ applyMultiAttrGroup(group, cleanups);
403
+ }
404
+ }
405
+
406
+ /** One attribute whose value is assembled from several slots. */
407
+ interface MultiAttrGroup {
408
+ el: Element;
409
+ name: string;
410
+ staticParts: readonly string[];
411
+ values: unknown[];
412
+ }
413
+
414
+ function collectMultiAttr(
415
+ slot: AttrSlot,
416
+ el: Element,
417
+ value: unknown,
418
+ groups: Map<string, MultiAttrGroup>,
419
+ ): void {
420
+ if (!slot.staticParts) return;
421
+ const key = `${slot.name}::${(slot.path as readonly number[]).join(".")}`;
422
+ let group = groups.get(key);
423
+ if (!group) {
424
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
425
+ groups.set(key, group);
426
+ }
427
+ group.values.push(value);
428
+ }
429
+
430
+ function applyMultiAttrGroup(
431
+ group: MultiAttrGroup,
432
+ cleanups: Disposer[],
433
+ ): void {
434
+ function join(): string {
435
+ let out = group.staticParts[0] ?? "";
436
+ for (let i = 0; i < group.values.length; i++) {
437
+ const v = group.values[i];
438
+ const resolved =
439
+ isSignal(v) || typeof v === "function" ? (v as () => unknown)() : v;
440
+ out += resolved == null || resolved === false ? "" : String(resolved);
441
+ out += group.staticParts[i + 1] ?? "";
442
+ }
443
+ return out;
444
+ }
445
+
446
+ const hasReactive = group.values.some(
447
+ (v) => isSignal(v) || typeof v === "function",
448
+ );
449
+ if (hasReactive) {
450
+ // SSR already wrote the joined value; re-joining on every tick is what
451
+ // keeps the statics in place when only one part changes.
452
+ cleanups.push(
453
+ effect(() => {
454
+ group.el.setAttribute(group.name, join());
455
+ }),
456
+ );
457
+ }
458
+ // Fully static groups need nothing: SSR wrote the final value.
385
459
  }
386
460
 
387
461
  /**
package/src/index.ts CHANGED
@@ -100,6 +100,7 @@ export {
100
100
  } from "./liveRouter.js";
101
101
  export {
102
102
  DEFAULT_LIVE_EVENT_PATH,
103
+ type LiveEventBody,
103
104
  type LiveHttpContext,
104
105
  type LiveHttpRouter,
105
106
  type WireLiveEventsOptions,
package/src/live.ts CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  } from "./liveRouter.js";
40
40
  export {
41
41
  DEFAULT_LIVE_EVENT_PATH,
42
+ type LiveEventBody,
42
43
  type LiveHttpContext,
43
44
  type LiveHttpRouter,
44
45
  type WireLiveEventsOptions,
package/src/liveServer.ts CHANGED
@@ -29,9 +29,19 @@ export interface LiveHttpContext {
29
29
  export interface WireLiveEventsOptions {
30
30
  /** Route path for inbound events (must match the client transport). */
31
31
  path?: string;
32
+ /**
33
+ * Optional per-request guard. Return `false` to reject the event with 403.
34
+ * Use this to enforce the same auth/CSRF/owner policy as the page that mounted
35
+ * the live session. When omitted, aurora preserves the framework-agnostic
36
+ * legacy behavior and expects the host route/middleware to guard the endpoint.
37
+ */
38
+ authorize?: (
39
+ ctx: LiveHttpContext,
40
+ body: LiveEventBody,
41
+ ) => boolean | Promise<boolean>;
32
42
  }
33
43
 
34
- interface LiveEventBody {
44
+ export interface LiveEventBody {
35
45
  id: string;
36
46
  event: string;
37
47
  payload?: unknown;
@@ -57,13 +67,26 @@ export function wireLiveEvents(
57
67
  options: WireLiveEventsOptions = {},
58
68
  ): void {
59
69
  const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
60
- router.post(path, (ctx) => {
70
+ router.post(path, async (ctx) => {
61
71
  const body = ctx.request.body();
62
72
  if (!isLiveEventBody(body)) {
63
73
  ctx.response.status(400);
64
74
  ctx.response.json({ error: "live event requires { id, event }" });
65
75
  return;
66
76
  }
77
+ let authorized = true;
78
+ if (options.authorize) {
79
+ try {
80
+ authorized = await options.authorize(ctx, body);
81
+ } catch {
82
+ authorized = false;
83
+ }
84
+ }
85
+ if (!authorized) {
86
+ ctx.response.status(403);
87
+ ctx.response.json({ error: "forbidden live event" });
88
+ return;
89
+ }
67
90
  const handled = live.event(body.id, body.event, body.payload);
68
91
  if (!handled) {
69
92
  ctx.response.status(404);
package/src/relay.ts CHANGED
@@ -345,7 +345,15 @@ async function postHandshake(url: string, channel: string): Promise<void> {
345
345
  function retrieveXsrfToken(): string | null {
346
346
  if (typeof document === "undefined") return null;
347
347
  const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
348
- return match ? decodeURIComponent(match[1]) : null;
348
+ if (!match) return null;
349
+ try {
350
+ return decodeURIComponent(match[1]);
351
+ } catch {
352
+ // A malformed cookie must not break subscribe/unsubscribe handshakes. The
353
+ // server will reject an invalid token normally; the client should not throw
354
+ // before it even sends the request.
355
+ return match[1];
356
+ }
349
357
  }
350
358
 
351
359
  function safeJson<T>(raw: unknown): T | null {
package/src/render.ts CHANGED
@@ -104,10 +104,20 @@ export function mount(
104
104
  // after.
105
105
  const multiGroups = new Map<string, MultiAttrGroup>();
106
106
 
107
+ // Resolve EVERY slot's node before applying any of them. Applying a text
108
+ // slot inserts nodes into the fragment, which shifts the child indices the
109
+ // remaining paths were computed against — so a slot sitting after a nested
110
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
111
+ // none, and silently never bound. Fragments exist precisely so a component
112
+ // needs no wrapper element; they must not cost the slots that follow them.
113
+ const resolved: Array<Node | null> = tpl.slots.map((slot) =>
114
+ resolvePath(fragment, slot.path),
115
+ );
116
+
107
117
  for (let i = 0; i < tpl.slots.length; i++) {
108
118
  const slot = tpl.slots[i];
109
- const node = resolvePath(fragment, slot.path);
110
- if (node === null) {
119
+ const node = resolved[i];
120
+ if (node === null || node === undefined) {
111
121
  // Path didn't resolve — skip this binding rather than crash (see
112
122
  // resolvePath). Degrades to a dead binding; the surrounding render
113
123
  // (and any command driving it) survives.
package/src/route.ts CHANGED
@@ -72,10 +72,19 @@ const DEFAULT_SHELL = (body: string, entry: string): string =>
72
72
  </head>
73
73
  <body>
74
74
  <div id="aurora-root">${body}</div>
75
- <script type="module" src="${entry}"></script>
75
+ <script type="module" src="${escapeAttr(entry)}"></script>
76
76
  </body>
77
77
  </html>`;
78
78
 
79
+ function escapeAttr(value: string): string {
80
+ return value
81
+ .replace(/&/g, "&amp;")
82
+ .replace(/"/g, "&quot;")
83
+ .replace(/'/g, "&#39;")
84
+ .replace(/</g, "&lt;")
85
+ .replace(/>/g, "&gt;");
86
+ }
87
+
79
88
  /**
80
89
  * Build a Ream-compatible route handler that SSR-renders the given
81
90
  * factory and serves the full HTML document.
@@ -23,10 +23,11 @@
23
23
  * </body>
24
24
  */
25
25
 
26
- import { setCookieStore } from "../browser.js";
26
+ import { AsyncLocalStorage } from "node:async_hooks";
27
+ import { setCookieStoreReader } from "../browser.js";
27
28
  import type { Pages } from "../Pages.js";
28
29
  import { renderToString } from "../ssr.js";
29
- import { setRouteManifest } from "../url.js";
30
+ import { setRouteManifestReader } from "../url.js";
30
31
 
31
32
  /**
32
33
  * Structural slice of the host framework's response. Same shape
@@ -70,6 +71,29 @@ export interface RenderPageOptions {
70
71
  * targets.
71
72
  */
72
73
  rootId?: string;
74
+ /**
75
+ * Root element tag for the SSR + hydrated tree. Defaults to `div`.
76
+ * Mirrors Inertia's root tag customization (`@inertia({ as: ... })`) while
77
+ * keeping aurora independent from a template engine.
78
+ */
79
+ rootTag?: string;
80
+ /**
81
+ * Optional class attribute on the root element. Mirrors
82
+ * `@inertia({ class: ... })`.
83
+ */
84
+ rootClass?: string;
85
+ /**
86
+ * Shared props merged into every page render before invoking the page factory.
87
+ * Use this for global data such as user, flash and validation errors. A
88
+ * function receives the current HTTP context and may be async, matching
89
+ * Adonis/Inertia's request middleware `share()` model.
90
+ */
91
+ shared?: SharedProps | SharedPropsResolver;
92
+ /**
93
+ * Asset/version marker serialized with the page payload. Apps can use this to
94
+ * detect stale client state when their frontend build changes.
95
+ */
96
+ assetsVersion?: string;
73
97
  /**
74
98
  * Named-route manifest (`name → path-pattern`) for the isomorphic
75
99
  * `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
@@ -92,11 +116,26 @@ export interface RenderPageOptions {
92
116
  cookies?: string[];
93
117
  }
94
118
 
119
+ export type SharedProps = Record<string, unknown>;
120
+ export type SharedPropsResolver = (
121
+ ctx: RenderHttpContext,
122
+ ) => SharedProps | Promise<SharedProps>;
123
+
95
124
  /** A request that can read a cookie by name — the structural slice we need. */
96
125
  interface CookieReadableRequest {
97
126
  cookie(name: string): string | null;
98
127
  }
99
128
 
129
+ interface RenderScope {
130
+ cookies: Record<string, string>;
131
+ routes: Record<string, string>;
132
+ }
133
+
134
+ const renderScope = new AsyncLocalStorage<RenderScope>();
135
+
136
+ setCookieStoreReader(() => renderScope.getStore()?.cookies);
137
+ setRouteManifestReader(() => renderScope.getStore()?.routes);
138
+
100
139
  function isCookieReadable(request: unknown): request is CookieReadableRequest {
101
140
  return (
102
141
  typeof request === "object" &&
@@ -127,21 +166,31 @@ export async function renderPage<P>(
127
166
  props: P,
128
167
  options: RenderPageOptions = {},
129
168
  ): Promise<void> {
130
- // Install the route manifest BEFORE rendering so a page calling `urlFor`
131
- // during SSR resolves against the same map the client will get.
132
- if (options.routes) setRouteManifest(options.routes);
133
-
134
- // Seed the request's UI cookies so the page reads the SAME state server-side
135
- // that the browser will after hydration. Set synchronously right before the
136
- // (synchronous) render — read cookie signals at the top of the page.
137
- setCookieStore(
138
- options.cookies ? readRequestCookies(ctx.request, options.cookies) : {},
169
+ const scope: RenderScope = {
170
+ cookies: options.cookies
171
+ ? readRequestCookies(ctx.request, options.cookies)
172
+ : {},
173
+ routes: options.routes ?? {},
174
+ };
175
+
176
+ return renderScope.run(scope, () =>
177
+ renderPageInScope(ctx, pages, name, props, options),
139
178
  );
179
+ }
140
180
 
181
+ async function renderPageInScope<P>(
182
+ ctx: RenderHttpContext,
183
+ pages: Pages,
184
+ name: string,
185
+ props: P,
186
+ options: RenderPageOptions,
187
+ ): Promise<void> {
141
188
  const factory = await pages.resolve(name);
189
+ const shared = await resolveSharedProps(ctx, options.shared);
190
+ const pageProps = mergeProps(shared, props);
142
191
  // The factory must be invoked the SAME way client-side for hydrate
143
192
  // to find matching slots — `Page(props)` is the contract.
144
- const tree = await factory(props as never);
193
+ const tree = await factory(pageProps as never);
145
194
  const body = renderToString(tree);
146
195
 
147
196
  const importmap = {
@@ -149,6 +198,8 @@ export async function renderPage<P>(
149
198
  ...options.importmap,
150
199
  };
151
200
  const rootId = options.rootId ?? "aurora-root";
201
+ const rootTag = normalizeRootTag(options.rootTag ?? "div");
202
+ const rootClass = options.rootClass;
152
203
  const lang = options.lang ?? "en";
153
204
  const pageUrl = pages.urlFor(name);
154
205
 
@@ -161,13 +212,14 @@ export async function renderPage<P>(
161
212
  ${options.headExtra ?? ""}
162
213
  </head>
163
214
  <body>
164
- <div id="${escapeAttr(rootId)}">${body}</div>
215
+ <${rootTag}${rootAttrs(rootId, rootClass)}>${body}</${rootTag}>
165
216
  <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
166
217
  name,
167
- props,
218
+ props: pageProps,
168
219
  url: pageUrl,
169
220
  rootId,
170
221
  routes: options.routes ?? {},
222
+ version: options.assetsVersion ?? null,
171
223
  })}</script>
172
224
  <script type="module">
173
225
  import { hydrate, setRouteManifest } from '@c9up/aurora'
@@ -183,6 +235,40 @@ hydrate(document.getElementById(data.rootId), () => Page(data.props))
183
235
  ctx.response.send(doc);
184
236
  }
185
237
 
238
+ async function resolveSharedProps(
239
+ ctx: RenderHttpContext,
240
+ shared: RenderPageOptions["shared"],
241
+ ): Promise<SharedProps> {
242
+ if (!shared) return {};
243
+ return typeof shared === "function" ? shared(ctx) : shared;
244
+ }
245
+
246
+ function mergeProps<P>(shared: SharedProps, props: P): P | SharedProps {
247
+ if (Object.keys(shared).length === 0) return props;
248
+ if (isPlainRecord(props)) return { ...shared, ...props };
249
+ return { ...shared, page: props };
250
+ }
251
+
252
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
253
+ return (
254
+ typeof value === "object" &&
255
+ value !== null &&
256
+ !Array.isArray(value) &&
257
+ Object.getPrototypeOf(value) === Object.prototype
258
+ );
259
+ }
260
+
261
+ function normalizeRootTag(tag: string): string {
262
+ if (/^[a-z][a-z0-9-]*$/i.test(tag)) return tag.toLowerCase();
263
+ throw new Error(`[aurora] illegal root tag: ${JSON.stringify(tag)}`);
264
+ }
265
+
266
+ function rootAttrs(id: string, className: string | undefined): string {
267
+ const attrs = [`id="${escapeAttr(id)}"`];
268
+ if (className) attrs.push(`class="${escapeAttr(className)}"`);
269
+ return ` ${attrs.join(" ")}`;
270
+ }
271
+
186
272
  function escapeAttr(value: string): string {
187
273
  return value
188
274
  .replace(/&/g, "&amp;")
package/src/server.ts CHANGED
@@ -16,6 +16,8 @@ export {
16
16
  type RenderPageOptions,
17
17
  type RenderResponse,
18
18
  renderPage,
19
+ type SharedProps,
20
+ type SharedPropsResolver,
19
21
  } from "./server/renderPage.js";
20
22
  export {
21
23
  type AssetsHttpContext,
@@ -26,6 +26,15 @@ export function getAurora(): AuroraManager | undefined {
26
26
 
27
27
  const aurora: AuroraManager = new Proxy({} as AuroraManager, {
28
28
  get(_target, prop) {
29
+ // A module loader inspects what it imports before anyone uses it: it reads
30
+ // `then` to decide whether the namespace is thenable, and various symbols
31
+ // for interop and formatting. Throwing on those turns a plain
32
+ // `import { setX } from ".../services/main"` into a crash at import time,
33
+ // far from any real use. They are not members of what this stands in for,
34
+ // so answer undefined and let a genuine access be the one that reports.
35
+ if (typeof prop === "symbol" || prop === "then") {
36
+ return undefined;
37
+ }
29
38
  if (!instance) {
30
39
  throw new Error(
31
40
  "[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
package/src/url.ts CHANGED
@@ -19,6 +19,23 @@
19
19
 
20
20
  let manifest: Record<string, string> = {};
21
21
 
22
+ type RouteManifestReader = () => Record<string, string> | undefined;
23
+ let routeManifestReader: RouteManifestReader | undefined;
24
+
25
+ /**
26
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
27
+ * route manifest without importing Node built-ins from this browser-safe module.
28
+ */
29
+ export function setRouteManifestReader(
30
+ reader: RouteManifestReader | undefined,
31
+ ): void {
32
+ routeManifestReader = reader;
33
+ }
34
+
35
+ function activeManifest(): Record<string, string> {
36
+ return routeManifestReader?.() ?? manifest;
37
+ }
38
+
22
39
  /**
23
40
  * Install the `name → path-pattern` map `urlFor` resolves against (e.g.
24
41
  * `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
@@ -31,7 +48,7 @@ export function setRouteManifest(routes: Record<string, string>): void {
31
48
 
32
49
  /** The currently-installed route manifest (mainly for tests/introspection). */
33
50
  export function getRouteManifest(): Record<string, string> {
34
- return { ...manifest };
51
+ return { ...activeManifest() };
35
52
  }
36
53
 
37
54
  /**
@@ -44,9 +61,10 @@ export function urlFor(
44
61
  params?: Record<string, string | number>,
45
62
  query?: Record<string, string | number>,
46
63
  ): string {
47
- const pattern = manifest[name];
64
+ const routes = activeManifest();
65
+ const pattern = routes[name];
48
66
  if (pattern === undefined) {
49
- const known = Object.keys(manifest);
67
+ const known = Object.keys(routes);
50
68
  throw new Error(
51
69
  `[aurora] urlFor: unknown route '${name}'. ${
52
70
  known.length > 0