@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,18 @@
1
+ /**
2
+ * Default `AuroraManager` singleton — Adonis-style:
3
+ *
4
+ * import aurora from '@c9up/aurora/services/main'
5
+ *
6
+ * await aurora.render(ctx, 'ProjectPage', { project, tasks })
7
+ *
8
+ * Populated either by `AuroraProvider.boot()` (when the app uses
9
+ * `() => import('@c9up/aurora/provider')`) or by the app itself via
10
+ * `setAurora(myManager)`.
11
+ */
12
+ import type { AuroraManager } from "../AuroraManager.js";
13
+ /** @internal Bind the singleton (called by AuroraProvider or by the app). */
14
+ export declare function setAurora(value: AuroraManager): void;
15
+ /** @internal Read the singleton (or `undefined` pre-boot). */
16
+ export declare function getAurora(): AuroraManager | undefined;
17
+ declare const aurora: AuroraManager;
18
+ export default aurora;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Default `AuroraManager` singleton — Adonis-style:
3
+ *
4
+ * import aurora from '@c9up/aurora/services/main'
5
+ *
6
+ * await aurora.render(ctx, 'ProjectPage', { project, tasks })
7
+ *
8
+ * Populated either by `AuroraProvider.boot()` (when the app uses
9
+ * `() => import('@c9up/aurora/provider')`) or by the app itself via
10
+ * `setAurora(myManager)`.
11
+ */
12
+ let instance;
13
+ /** @internal Bind the singleton (called by AuroraProvider or by the app). */
14
+ export function setAurora(value) {
15
+ instance = value;
16
+ }
17
+ /** @internal Read the singleton (or `undefined` pre-boot). */
18
+ export function getAurora() {
19
+ return instance;
20
+ }
21
+ const aurora = new Proxy({}, {
22
+ get(_target, prop) {
23
+ if (!instance) {
24
+ throw new Error("[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
25
+ "or `setAurora(myManager)` was called. Wire one of them first.");
26
+ }
27
+ const value = Reflect.get(instance, prop, instance);
28
+ return typeof value === "function" ? value.bind(instance) : value;
29
+ },
30
+ });
31
+ export default aurora;
package/dist/ssr.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Server-side rendering — produces an HTML string from a `TemplateResult`
3
+ * without ever touching the DOM.
4
+ *
5
+ * Reads each slot's value eagerly (signals get a one-shot snapshot,
6
+ * functions are invoked, nested TemplateResults recurse). Event handlers
7
+ * are dropped server-side; hydration re-binds them once the markup
8
+ * lands in the browser.
9
+ */
10
+ import { type TemplateResult } from "./types.js";
11
+ declare const VOID_ELEMENTS: Set<string>;
12
+ /**
13
+ * Stringify a TemplateResult into HTML. Returns the markup ready to be
14
+ * shipped over the wire — no surrounding `<html>`/`<head>`/`<body>`
15
+ * unless the template includes them.
16
+ *
17
+ * The function walks the `strings` array directly; it does NOT depend
18
+ * on the DOM-side template cache, so it works in any JS runtime (Node,
19
+ * Cloudflare Workers, Bun, Deno).
20
+ */
21
+ export declare function renderToString(result: TemplateResult): string;
22
+ export { VOID_ELEMENTS };
package/dist/ssr.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Server-side rendering — produces an HTML string from a `TemplateResult`
3
+ * without ever touching the DOM.
4
+ *
5
+ * Reads each slot's value eagerly (signals get a one-shot snapshot,
6
+ * functions are invoked, nested TemplateResults recurse). Event handlers
7
+ * are dropped server-side; hydration re-binds them once the markup
8
+ * lands in the browser.
9
+ */
10
+ import { isSignal } from "./reactive.js";
11
+ import { isTemplateResult } from "./types.js";
12
+ const VOID_ELEMENTS = new Set([
13
+ "area",
14
+ "base",
15
+ "br",
16
+ "col",
17
+ "embed",
18
+ "hr",
19
+ "img",
20
+ "input",
21
+ "keygen",
22
+ "link",
23
+ "meta",
24
+ "source",
25
+ "track",
26
+ "wbr",
27
+ ]);
28
+ /**
29
+ * Stringify a TemplateResult into HTML. Returns the markup ready to be
30
+ * shipped over the wire — no surrounding `<html>`/`<head>`/`<body>`
31
+ * unless the template includes them.
32
+ *
33
+ * The function walks the `strings` array directly; it does NOT depend
34
+ * on the DOM-side template cache, so it works in any JS runtime (Node,
35
+ * Cloudflare Workers, Bun, Deno).
36
+ */
37
+ export function renderToString(result) {
38
+ return stringifyTemplateResult(result);
39
+ }
40
+ function stringifyTemplateResult(result) {
41
+ const { strings, values } = result;
42
+ let out = "";
43
+ // When a segment ends with a directive (` @click="`, ` ?disabled="`,
44
+ // ` .value="`), we drop the directive prefix from that segment, skip
45
+ // the matching value, and consume the closing `"` from the next
46
+ // segment. This three-step coordination is why the loop holds a
47
+ // `pendingClosingQuote` flag.
48
+ let pendingClosingQuote = false;
49
+ for (let i = 0; i < strings.length; i++) {
50
+ let segment = strings[i];
51
+ if (pendingClosingQuote) {
52
+ segment = segment.replace(/^"/, "");
53
+ pendingClosingQuote = false;
54
+ }
55
+ const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
56
+ const skipValue = directiveMatch !== null;
57
+ if (directiveMatch) {
58
+ segment = segment.slice(0, segment.length - directiveMatch[0].length);
59
+ pendingClosingQuote = true;
60
+ }
61
+ out += segment;
62
+ if (i < values.length && !skipValue) {
63
+ const value = values[i];
64
+ const inAttr = isInsideAttribute(out);
65
+ if (!inAttr && isReactiveStructuredSlot(value)) {
66
+ // Reactive text slot whose value is a nested template /
67
+ // array — wrap the rendered content in boundary markers so
68
+ // hydration can locate the exact node range and SWAP it when
69
+ // the signal changes client-side. Without these markers a
70
+ // nested-template slot hydrates once and then goes stale
71
+ // (no way to find where the subtree starts/ends). Scalar
72
+ // reactive slots (`${signal}` → text) are NOT wrapped: their
73
+ // hydration updates the text node in place, no range needed.
74
+ out += `<!--${SLOT_START}-->`;
75
+ out += stringifyValue(value, false);
76
+ out += `<!--${SLOT_END}-->`;
77
+ }
78
+ else {
79
+ out += stringifyValue(value, inAttr);
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ /** Boundary-marker comment payloads (kept in sync with hydrate.ts). */
86
+ const SLOT_START = "$";
87
+ const SLOT_END = "/$";
88
+ /**
89
+ * True when `value` is a reactive expression (signal / function) whose
90
+ * current evaluation is a structured node payload (a nested
91
+ * TemplateResult, or an array). These are the slots that can SWAP their
92
+ * subtree on a client-side change and therefore need boundary markers
93
+ * for hydration to find the range. A reactive slot resolving to a
94
+ * scalar (string / number) is updated in place and needs no markers.
95
+ */
96
+ function isReactiveStructuredSlot(value) {
97
+ if (!isSignal(value) && typeof value !== "function")
98
+ return false;
99
+ let evaluated;
100
+ try {
101
+ evaluated = isSignal(value)
102
+ ? value()
103
+ : value();
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ return isTemplateResult(evaluated) || Array.isArray(evaluated);
109
+ }
110
+ /**
111
+ * Returns true if the position at the end of `htmlSoFar` lives inside
112
+ * the value region of an HTML tag (between `<` and `>`). The check
113
+ * walks backwards from the end, which is the smallest hint we need to
114
+ * decide between text-region and attribute-region escaping.
115
+ */
116
+ function isInsideAttribute(htmlSoFar) {
117
+ for (let i = htmlSoFar.length - 1; i >= 0; i--) {
118
+ const c = htmlSoFar.charCodeAt(i);
119
+ if (c === 60 /* '<' */)
120
+ return true;
121
+ if (c === 62 /* '>' */)
122
+ return false;
123
+ }
124
+ return false;
125
+ }
126
+ function stringifyValue(value, inAttribute) {
127
+ if (value === null || value === undefined || value === false)
128
+ return "";
129
+ if (value === true)
130
+ return inAttribute ? "" : "true";
131
+ if (isSignal(value))
132
+ return stringifyValue(value(), inAttribute);
133
+ if (typeof value === "function") {
134
+ // In attribute position: directive handlers (`@click`, `?disabled`,
135
+ // `.prop`) have already been stripped by `stripDirectiveBefore`.
136
+ // A function reaching this point is a reactive-expression text
137
+ // slot (`${() => ...}`), which we evaluate eagerly server-side.
138
+ try {
139
+ return stringifyValue(value(), inAttribute);
140
+ }
141
+ catch {
142
+ return "";
143
+ }
144
+ }
145
+ if (Array.isArray(value)) {
146
+ let out = "";
147
+ for (const item of value)
148
+ out += stringifyValue(item, inAttribute);
149
+ return out;
150
+ }
151
+ if (isTemplateResult(value))
152
+ return stringifyTemplateResult(value);
153
+ // Plain value — escape HTML entities (text) or attribute special
154
+ // characters (attribute value).
155
+ return inAttribute ? escapeAttr(String(value)) : escapeText(String(value));
156
+ }
157
+ function escapeText(s) {
158
+ return s
159
+ .replaceAll("&", "&amp;")
160
+ .replaceAll("<", "&lt;")
161
+ .replaceAll(">", "&gt;");
162
+ }
163
+ function escapeAttr(s) {
164
+ // Escape BOTH quote styles: the engine doesn't force double-quoted
165
+ // attributes (the classifier only tracks `<`/`>`), so a template author
166
+ // writing `id='${x}'` must still be safe — without escaping `'` a value
167
+ // like `' onmouseover='alert(1)` would break out of a single-quoted
168
+ // attribute. `>` isn't strictly required inside a quoted value but is
169
+ // escaped to stay safe under stray scanners that hunt tag boundaries
170
+ // before resolving the quote context.
171
+ return s
172
+ .replaceAll("&", "&amp;")
173
+ .replaceAll('"', "&quot;")
174
+ .replaceAll("'", "&#39;")
175
+ .replaceAll("<", "&lt;")
176
+ .replaceAll(">", "&gt;");
177
+ }
178
+ // VOID_ELEMENTS exported for downstream tooling (hydration heuristics).
179
+ export { VOID_ELEMENTS };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared types for aurora templates.
3
+ *
4
+ * A `TemplateResult` is what `html\`...\`` returns. It carries the raw
5
+ * pieces (strings, values) and a stable reference to the parsed
6
+ * `Template` (memoised by `strings`). Templates are framework-internal —
7
+ * apps only ever see `TemplateResult`.
8
+ */
9
+ /**
10
+ * A lifecycle/effect callback that runs and OPTIONALLY returns a cleanup
11
+ * function — exactly React's `EffectCallback = () => void | (() => void)`.
12
+ *
13
+ * The `void` member of the return union is load-bearing: it is what makes a
14
+ * plain `() => {}` (whose return type is `void`) assignable here. Replacing it
15
+ * with `(() => void) | undefined` makes every void-returning callback a type
16
+ * error (a `() => void` is NOT assignable to `() => (() => void) | undefined`).
17
+ * So `noConfusingVoidType` is suppressed once, at this single definition,
18
+ * instead of being worked around at ~15 call sites with broken types.
19
+ */
20
+ export type EffectCallback = () => void | (() => void);
21
+ /** Slot descriptor — where a `${value}` lives inside a parsed template. */
22
+ export type SlotKind = "text" | "attr" | "event" | "boolean-attr" | "prop";
23
+ /**
24
+ * Path to the binding point inside the cloned template. Each step is a
25
+ * child index. We never walk by query selectors because attribute and text
26
+ * placements would need synthetic markers in markup an app provided.
27
+ */
28
+ export type NodePath = readonly number[];
29
+ export interface TextSlot {
30
+ kind: "text";
31
+ path: NodePath;
32
+ }
33
+ export interface AttrSlot {
34
+ kind: "attr";
35
+ path: NodePath;
36
+ name: string;
37
+ /**
38
+ * When the attribute interpolates more than one `${...}` slot, every
39
+ * slot shares the same `name` and the static segments are stored under
40
+ * `staticParts`. The render step joins them back together each tick.
41
+ */
42
+ staticParts?: readonly string[];
43
+ staticPartIndex?: number;
44
+ }
45
+ export interface EventSlot {
46
+ kind: "event";
47
+ path: NodePath;
48
+ event: string;
49
+ }
50
+ export interface BooleanAttrSlot {
51
+ kind: "boolean-attr";
52
+ path: NodePath;
53
+ name: string;
54
+ }
55
+ export interface PropSlot {
56
+ kind: "prop";
57
+ path: NodePath;
58
+ name: string;
59
+ }
60
+ export type Slot = TextSlot | AttrSlot | EventSlot | BooleanAttrSlot | PropSlot;
61
+ /** Compiled artefact — produced once per unique `strings` array. */
62
+ export interface Template {
63
+ /**
64
+ * A `<template>` element whose `content` fragment is cloned on every
65
+ * render. Cloning is much cheaper than re-parsing the HTML string.
66
+ */
67
+ readonly element: HTMLTemplateElement;
68
+ /** Slot descriptors in source order (same order as the `${...}` values). */
69
+ readonly slots: readonly Slot[];
70
+ }
71
+ /** Tagged-template return value — what `html\`\`` produces. */
72
+ export interface TemplateResult {
73
+ readonly strings: TemplateStringsArray;
74
+ readonly values: readonly unknown[];
75
+ readonly [TEMPLATE_RESULT_BRAND]: true;
76
+ }
77
+ export declare const TEMPLATE_RESULT_BRAND: unique symbol;
78
+ export declare function isTemplateResult(value: unknown): value is TemplateResult;
package/dist/types.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared types for aurora templates.
3
+ *
4
+ * A `TemplateResult` is what `html\`...\`` returns. It carries the raw
5
+ * pieces (strings, values) and a stable reference to the parsed
6
+ * `Template` (memoised by `strings`). Templates are framework-internal —
7
+ * apps only ever see `TemplateResult`.
8
+ */
9
+ export const TEMPLATE_RESULT_BRAND = Symbol.for("aurora:template-result");
10
+ export function isTemplateResult(value) {
11
+ return (typeof value === "object" &&
12
+ value !== null &&
13
+ value[TEMPLATE_RESULT_BRAND] ===
14
+ true);
15
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@c9up/aurora",
3
+ "version": "0.1.3",
4
+ "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./provider": {
15
+ "types": "./dist/AuroraProvider.d.ts",
16
+ "import": "./dist/AuroraProvider.js"
17
+ },
18
+ "./services/main": {
19
+ "types": "./dist/services/main.d.ts",
20
+ "import": "./dist/services/main.js"
21
+ },
22
+ "./relay": {
23
+ "types": "./dist/relay.d.ts",
24
+ "import": "./dist/relay.js"
25
+ },
26
+ "./ssr": {
27
+ "types": "./dist/ssr.d.ts",
28
+ "import": "./dist/ssr.js"
29
+ },
30
+ "./hydrate": {
31
+ "types": "./dist/hydrate.d.ts",
32
+ "import": "./dist/hydrate.js"
33
+ }
34
+ },
35
+ "peerDependencies": {
36
+ "@c9up/ream": "^0.1.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@c9up/ream": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.19.15",
45
+ "happy-dom": "^15.11.7",
46
+ "typescript": "^6.0.2",
47
+ "vitest": "^4.1.2"
48
+ },
49
+ "files": [
50
+ "LICENSE",
51
+ "README.md",
52
+ "dist",
53
+ "src"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/C9up/aurora.git"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.build.json",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "lint": "biome check src/",
67
+ "test:coverage": "vitest run --coverage"
68
+ }
69
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * AuroraManager — the singleton behind `@c9up/aurora/services/main`.
3
+ *
4
+ * import aurora from '@c9up/aurora/services/main'
5
+ *
6
+ * async show(ctx) {
7
+ * await aurora.render(ctx, 'ProjectPage', { project, tasks })
8
+ * }
9
+ *
10
+ * The manager pairs a `Pages` registry with the SSR pipeline and the
11
+ * dist-asset handler. The provider builds it from `config/aurora.ts`;
12
+ * the app can also instantiate one manually for tests.
13
+ */
14
+
15
+ import { dirname, resolve as resolvePath } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { Pages, type PagesConfig } from "./Pages.js";
18
+ import {
19
+ type RenderHttpContext,
20
+ type RenderPageOptions,
21
+ renderPage,
22
+ } from "./server/renderPage.js";
23
+ import { type AssetsHttpContext, serveAssets } from "./server/serveAssets.js";
24
+
25
+ export interface AuroraManagerConfig {
26
+ pages: PagesConfig;
27
+ /**
28
+ * Filesystem path to aurora's pre-built `dist/`. Defaults to the
29
+ * dist directory shipped with the installed `@c9up/aurora` package.
30
+ * Override only if you want to serve a custom build.
31
+ */
32
+ auroraDistRoot?: string;
33
+ }
34
+
35
+ const DEFAULT_AURORA_DIST = resolvePath(
36
+ dirname(fileURLToPath(import.meta.url)),
37
+ "../dist",
38
+ );
39
+
40
+ export class AuroraManager {
41
+ readonly pages: Pages;
42
+ readonly auroraDistRoot: string;
43
+
44
+ constructor(config: AuroraManagerConfig) {
45
+ this.pages = new Pages(config.pages);
46
+ this.auroraDistRoot = config.auroraDistRoot ?? DEFAULT_AURORA_DIST;
47
+ }
48
+
49
+ /**
50
+ * SSR + hydrate + ship the document.
51
+ */
52
+ render(
53
+ ctx: RenderHttpContext,
54
+ name: string,
55
+ props: unknown,
56
+ options?: RenderPageOptions,
57
+ ): Promise<void> {
58
+ return renderPage(ctx, this.pages, name, props, options);
59
+ }
60
+
61
+ /**
62
+ * Handler for aurora's pre-built ESM runtime. Mount on
63
+ * `GET /_assets/aurora/*`.
64
+ */
65
+ auroraAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void> {
66
+ return serveAssets({ root: this.auroraDistRoot });
67
+ }
68
+
69
+ /**
70
+ * Handler for the app's pages directory. Mount on
71
+ * `GET /_assets/pages/*`.
72
+ */
73
+ pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void> {
74
+ return serveAssets({ root: this.pages.root });
75
+ }
76
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * AuroraProvider — registers the AuroraManager singleton and auto-mounts
3
+ * the two asset routes the browser needs:
4
+ *
5
+ * GET /_assets/aurora/* → packages/@c9up/aurora/dist/*
6
+ * GET /_assets/pages/* → resources/pages/*
7
+ *
8
+ * Config (in `config/aurora.ts`):
9
+ *
10
+ * export default {
11
+ * pages: { root: new URL('../resources/pages', import.meta.url).pathname },
12
+ * }
13
+ *
14
+ * The duck-typed `AuroraAppContext` keeps this provider usable in any
15
+ * framework with a container — non-Ream hosts get the singleton bindings
16
+ * and skip the route auto-registration silently.
17
+ */
18
+
19
+ import { isAbsolute, resolve as resolvePath } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
22
+ import { auroraRoute } from "./route.js";
23
+ import type {
24
+ AssetsHttpContext,
25
+ AssetsRequest,
26
+ AssetsResponse,
27
+ } from "./server/serveAssets.js";
28
+ import { setAurora } from "./services/main.js";
29
+ import { renderToString } from "./ssr.js";
30
+
31
+ interface AuroraContainer {
32
+ singleton(token: unknown, factory: () => unknown): void;
33
+ resolve<T = unknown>(token: unknown): T;
34
+ }
35
+ interface AuroraConfigStore {
36
+ get<T = unknown>(key: string): T | undefined;
37
+ }
38
+ export interface AuroraAppContext {
39
+ container: AuroraContainer;
40
+ config: AuroraConfigStore;
41
+ }
42
+
43
+ interface ReamRouter {
44
+ get(
45
+ path: string,
46
+ handler: (ctx: AssetsHttpContext) => Promise<void> | void,
47
+ ): unknown;
48
+ }
49
+
50
+ export default class AuroraProvider {
51
+ constructor(protected app: AuroraAppContext) {}
52
+
53
+ register(): void {
54
+ this.app.container.singleton(AuroraManager, () => {
55
+ const raw = this.app.config.get<AuroraManagerConfig>("aurora");
56
+ const config = this.resolveConfig(raw);
57
+ const manager = new AuroraManager(config);
58
+ setAurora(manager);
59
+ return manager;
60
+ });
61
+ this.app.container.singleton("aurora", () =>
62
+ this.app.container.resolve<AuroraManager>(AuroraManager),
63
+ );
64
+ // Legacy stateless bindings — kept so existing apps that
65
+ // `container.resolve('aurora.render')` still get a working
66
+ // function. New code should use the singleton.
67
+ this.app.container.singleton("aurora.renderToString", () => renderToString);
68
+ this.app.container.singleton("aurora.route", () => auroraRoute);
69
+ }
70
+
71
+ async boot(): Promise<void> {
72
+ // Force-resolve so `setAurora` runs even if the app never
73
+ // touches the singleton from a preload.
74
+ const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
75
+ setAurora(manager);
76
+ }
77
+
78
+ async start(): Promise<void> {
79
+ // Asset routes are registered in `start()` — after preloads —
80
+ // so apps can swap aurora's pages root in a preload if they
81
+ // wanted to. Non-Ream hosts (no `@c9up/ream/services/router`)
82
+ // AND pre-`setRouter` boots (router proxy uninit) both
83
+ // silent-return; ANY other error (slug collision, AuroraManager
84
+ // crash, factory bug) propagates so real regressions surface
85
+ // with a stack instead of "the asset routes just stopped
86
+ // mounting".
87
+ // Variable specifier so tsc does not statically resolve the optional
88
+ // `@c9up/ream` peer at build time (keeps aurora agnostic /
89
+ // standalone-buildable). Resolved to the host router only at runtime
90
+ // when aurora actually runs inside Ream.
91
+ const routerSpecifier = "@c9up/ream/services/router";
92
+ let routerMod: { default: ReamRouter };
93
+ try {
94
+ routerMod = await import(routerSpecifier);
95
+ } catch (err) {
96
+ if (isModuleNotFound(err)) return;
97
+ throw err;
98
+ }
99
+ const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
100
+ try {
101
+ routerMod.default.get(
102
+ "/_assets/aurora/*",
103
+ adaptHandler(manager.auroraAssetsHandler()),
104
+ );
105
+ routerMod.default.get(
106
+ "/_assets/pages/*",
107
+ adaptHandler(manager.pageAssetsHandler()),
108
+ );
109
+ } catch (err) {
110
+ if (isRouterProxyUninit(err)) return;
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ async ready(): Promise<void> {}
116
+ async shutdown(): Promise<void> {}
117
+
118
+ /**
119
+ * Resolve the user-supplied config:
120
+ * - relative `pages.root` (e.g. `./resources/pages`) is joined to
121
+ * the project's `appRoot` URL — same convention `modules.path`
122
+ * uses;
123
+ * - absolute paths are passed through;
124
+ * - missing config falls back to `<appRoot>/resources/pages`.
125
+ *
126
+ * `appRoot` is fetched from the container if the host registered
127
+ * one (Ream does, since v0.x — see Ignitor); other hosts get the
128
+ * `process.cwd()` fallback.
129
+ */
130
+ private resolveConfig(
131
+ raw: AuroraManagerConfig | undefined,
132
+ ): AuroraManagerConfig {
133
+ const appRoot = this.readAppRoot();
134
+ const userRoot = raw?.pages?.root;
135
+ const root =
136
+ typeof userRoot === "string" && userRoot.length > 0
137
+ ? isAbsolute(userRoot)
138
+ ? userRoot
139
+ : resolvePath(appRoot, userRoot)
140
+ : resolvePath(appRoot, "resources/pages");
141
+ return {
142
+ ...(raw ?? {}),
143
+ pages: { ...(raw?.pages ?? {}), root },
144
+ };
145
+ }
146
+
147
+ private readAppRoot(): string {
148
+ try {
149
+ const raw = this.app.container.resolve<unknown>("appRoot");
150
+ if (raw instanceof URL) return fileURLToPath(raw);
151
+ if (typeof raw === "string") return raw;
152
+ } catch {
153
+ // Host doesn't expose appRoot — fall through.
154
+ }
155
+ return process.cwd();
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Adapter: serveAssets() returns a handler that takes our duck-typed
161
+ * AssetsHttpContext. Ream's router passes its own HttpContext. The
162
+ * two are structurally compatible (request.param + response.{status,
163
+ * header, send}) but TypeScript needs the bridge made explicit.
164
+ */
165
+ function adaptHandler(
166
+ handler: (ctx: AssetsHttpContext) => Promise<void>,
167
+ ): (ctx: {
168
+ request: AssetsRequest;
169
+ response: AssetsResponse;
170
+ }) => Promise<void> {
171
+ return (ctx) => handler(ctx);
172
+ }
173
+
174
+ /** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
175
+ function isModuleNotFound(err: unknown): boolean {
176
+ if (err === null || typeof err !== "object" || !("code" in err)) return false;
177
+ const { code } = err;
178
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
179
+ }
180
+
181
+ /** Ream's router proxy throws this exact string before Ignitor wires it. */
182
+ function isRouterProxyUninit(err: unknown): boolean {
183
+ return (
184
+ err instanceof Error &&
185
+ err.message.includes("Router accessed before initialization")
186
+ );
187
+ }