@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/dist/render.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Render a `TemplateResult` to the DOM and keep it reactive.
3
+ *
4
+ * `render(template, container)` clones the parsed `<template>`, walks to
5
+ * each slot, attaches the corresponding value (with `effect()` for any
6
+ * reactive expression), and appends the result to the container. The
7
+ * returned dispose function tears down every effect and removes the
8
+ * mounted nodes.
9
+ */
10
+ import { readComponentLifecycle } from "./component.js";
11
+ import { getTemplate } from "./html.js";
12
+ import { effect, isSignal } from "./reactive.js";
13
+ import { isTemplateResult, } from "./types.js";
14
+ /**
15
+ * Mount a TemplateResult into `container`. Returns a `Disposer` that
16
+ * stops every reactive effect and removes the mounted nodes. Calling it
17
+ * twice is a no-op.
18
+ */
19
+ export function render(result, container) {
20
+ const cleanups = [];
21
+ const mountedNodes = [];
22
+ const mountHooks = [];
23
+ const fragment = mount(result, cleanups, mountedNodes, mountHooks);
24
+ container.appendChild(fragment);
25
+ // `onMount` hooks fire after the fragment is live in the document so
26
+ // callbacks that measure / focus / observe see a real DOM. A returned
27
+ // cleanup function joins the unmount queue.
28
+ for (const hook of mountHooks) {
29
+ try {
30
+ const teardown = hook();
31
+ if (typeof teardown === "function")
32
+ cleanups.push(teardown);
33
+ }
34
+ catch {
35
+ /* swallow — one bad onMount should not block sibling components */
36
+ }
37
+ }
38
+ let disposed = false;
39
+ return () => {
40
+ if (disposed)
41
+ return;
42
+ disposed = true;
43
+ for (const c of cleanups.splice(0))
44
+ c();
45
+ for (const node of mountedNodes)
46
+ node.remove();
47
+ };
48
+ }
49
+ /**
50
+ * Build a fragment for a TemplateResult and register cleanups.
51
+ *
52
+ * @internal Exported so `hydrate.ts` can client-render a reactive
53
+ * nested-template subtree when the signal changes after hydration
54
+ * (the swap path — see `hydrateTextSlot`).
55
+ */
56
+ export function mount(result, cleanups, mounted, mountHooks) {
57
+ const tpl = getTemplate(result.strings);
58
+ const fragment = tpl.element.content.cloneNode(true);
59
+ // Forward any component()-attached lifecycle from this result.
60
+ const lifecycle = readComponentLifecycle(result);
61
+ if (lifecycle) {
62
+ for (const hook of lifecycle.mountHooks)
63
+ mountHooks.push(hook);
64
+ for (const c of lifecycle.cleanups)
65
+ cleanups.push(c);
66
+ }
67
+ // Multi-slot attrs need every contributing value before we can join
68
+ // the final string. Collect them in a first pass, attach effects
69
+ // after.
70
+ const multiGroups = new Map();
71
+ for (let i = 0; i < tpl.slots.length; i++) {
72
+ const slot = tpl.slots[i];
73
+ const node = resolvePath(fragment, slot.path);
74
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
75
+ collectMultiAttr(slot, node, result.values[i], multiGroups);
76
+ }
77
+ else {
78
+ applySlot(slot, node, result.values[i], cleanups, mounted, mountHooks);
79
+ }
80
+ }
81
+ for (const group of multiGroups.values()) {
82
+ applyMultiAttrGroup(group, cleanups);
83
+ }
84
+ for (const child of Array.from(fragment.childNodes)) {
85
+ mounted.push(child);
86
+ }
87
+ return fragment;
88
+ }
89
+ function resolvePath(root, path) {
90
+ let node = root;
91
+ for (const i of path)
92
+ node = node.childNodes[i];
93
+ return node;
94
+ }
95
+ function applySlot(slot, node, value, cleanups, mounted, mountHooks) {
96
+ switch (slot.kind) {
97
+ case "text":
98
+ applyTextSlot(slot, node, value, cleanups, mounted, mountHooks);
99
+ return;
100
+ case "attr":
101
+ applyAttrSlot(slot, node, value, cleanups);
102
+ return;
103
+ case "boolean-attr":
104
+ applyBooleanAttrSlot(slot, node, value, cleanups);
105
+ return;
106
+ case "prop":
107
+ applyPropSlot(slot, node, value, cleanups);
108
+ return;
109
+ case "event":
110
+ applyEventSlot(slot, node, value, cleanups);
111
+ return;
112
+ }
113
+ }
114
+ /**
115
+ * Text slot — replace the marker comment with whatever the value
116
+ * resolves to. The anchor comment stays in place; new content is
117
+ * inserted before it, and each re-render swaps out only the nodes it
118
+ * previously inserted.
119
+ */
120
+ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
121
+ let currentNodes = [];
122
+ // Per-render disposers for whatever the slot currently shows. A
123
+ // reactive slot that swaps a nested TemplateResult for another must
124
+ // dispose the OLD subtree's effects + event listeners — otherwise
125
+ // they'd live in the shared `cleanups` array until the whole root
126
+ // disposes, leaking a stale subscription/listener on every branch
127
+ // change. We hand `localCleanups` (not `cleanups`) to the per-render
128
+ // mount and tear it down at the top of each `set()`.
129
+ let localCleanups = [];
130
+ function disposeLocal() {
131
+ for (const d of localCleanups)
132
+ d();
133
+ localCleanups = [];
134
+ }
135
+ function set(newValue) {
136
+ disposeLocal();
137
+ for (const n of currentNodes)
138
+ n.remove();
139
+ currentNodes = [];
140
+ const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, mountHooks);
141
+ const parent = anchor.parentNode;
142
+ if (!parent)
143
+ return;
144
+ for (const n of nodes) {
145
+ parent.insertBefore(n, anchor);
146
+ currentNodes.push(n);
147
+ }
148
+ }
149
+ if (isSignal(value) || typeof value === "function") {
150
+ const dispose = effect(() => {
151
+ set(value());
152
+ });
153
+ // Root disposal tears down the slot's own effect AND whatever
154
+ // subtree is currently mounted.
155
+ cleanups.push(() => {
156
+ dispose();
157
+ disposeLocal();
158
+ });
159
+ }
160
+ else {
161
+ set(value);
162
+ // Static value never re-runs, but its subtree (e.g. a one-shot
163
+ // nested template) still needs to be disposed with the root.
164
+ cleanups.push(disposeLocal);
165
+ }
166
+ }
167
+ function renderValueIntoNodes(value, cleanups, mounted, mountHooks) {
168
+ if (value === null || value === undefined || value === false)
169
+ return [];
170
+ if (Array.isArray(value)) {
171
+ const out = [];
172
+ for (const item of value) {
173
+ out.push(...renderValueIntoNodes(item, cleanups, mounted, mountHooks));
174
+ }
175
+ return out;
176
+ }
177
+ if (isTemplateResult(value)) {
178
+ const frag = mount(value, cleanups, mounted, mountHooks);
179
+ return Array.from(frag.childNodes);
180
+ }
181
+ if (value instanceof Node) {
182
+ return [value];
183
+ }
184
+ return [document.createTextNode(String(value))];
185
+ }
186
+ function applyAttrSlot(slot, el, value, cleanups) {
187
+ function apply(v) {
188
+ if (v === null || v === undefined || v === false) {
189
+ el.removeAttribute(slot.name);
190
+ }
191
+ else if (v === true) {
192
+ el.setAttribute(slot.name, "");
193
+ }
194
+ else {
195
+ el.setAttribute(slot.name, String(v));
196
+ }
197
+ }
198
+ if (isSignal(value) || typeof value === "function") {
199
+ const dispose = effect(() => apply(value()));
200
+ cleanups.push(dispose);
201
+ }
202
+ else {
203
+ apply(value);
204
+ }
205
+ }
206
+ function collectMultiAttr(slot, el, value, groups) {
207
+ if (!slot.staticParts)
208
+ return;
209
+ const key = `${slot.name}::${slot.path.join(".")}`;
210
+ let group = groups.get(key);
211
+ if (!group) {
212
+ group = {
213
+ el,
214
+ name: slot.name,
215
+ staticParts: slot.staticParts,
216
+ values: [],
217
+ };
218
+ groups.set(key, group);
219
+ }
220
+ group.values.push(value);
221
+ }
222
+ function applyMultiAttrGroup(group, cleanups) {
223
+ function join() {
224
+ let out = group.staticParts[0] ?? "";
225
+ for (let i = 0; i < group.values.length; i++) {
226
+ const v = resolveReactive(group.values[i]);
227
+ out += v == null || v === false ? "" : String(v);
228
+ out += group.staticParts[i + 1] ?? "";
229
+ }
230
+ return out;
231
+ }
232
+ const hasReactive = group.values.some((v) => isSignal(v) || typeof v === "function");
233
+ if (hasReactive) {
234
+ const dispose = effect(() => {
235
+ group.el.setAttribute(group.name, join());
236
+ });
237
+ cleanups.push(dispose);
238
+ }
239
+ else {
240
+ group.el.setAttribute(group.name, join());
241
+ }
242
+ }
243
+ function resolveReactive(value) {
244
+ if (isSignal(value))
245
+ return value();
246
+ if (typeof value === "function")
247
+ return value();
248
+ return value;
249
+ }
250
+ function applyBooleanAttrSlot(slot, el, value, cleanups) {
251
+ function apply(v) {
252
+ if (v)
253
+ el.setAttribute(slot.name, "");
254
+ else
255
+ el.removeAttribute(slot.name);
256
+ }
257
+ if (isSignal(value) || typeof value === "function") {
258
+ const dispose = effect(() => apply(value()));
259
+ cleanups.push(dispose);
260
+ }
261
+ else {
262
+ apply(value);
263
+ }
264
+ }
265
+ function applyPropSlot(slot, el, value, cleanups) {
266
+ function apply(v) {
267
+ el[slot.name] = v;
268
+ }
269
+ if (isSignal(value) || typeof value === "function") {
270
+ const dispose = effect(() => apply(value()));
271
+ cleanups.push(dispose);
272
+ }
273
+ else {
274
+ apply(value);
275
+ }
276
+ }
277
+ function applyEventSlot(slot, el, value, cleanups) {
278
+ if (typeof value !== "function")
279
+ return;
280
+ const handler = value;
281
+ el.addEventListener(slot.event, handler);
282
+ cleanups.push(() => el.removeEventListener(slot.event, handler));
283
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `auroraRoute` — Ream route handler that SSR-renders an aurora
3
+ * component and ships the markup plus the bytes needed for client-side
4
+ * hydration.
5
+ *
6
+ * Usage:
7
+ *
8
+ * import { auroraRoute } from '@c9up/aurora/provider'
9
+ * router.get('/dashboard', auroraRoute({
10
+ * entry: '/app/pages/dashboard.client.js',
11
+ * render: () => Dashboard({ user: ... })
12
+ * }))
13
+ *
14
+ * The handler:
15
+ * 1. invokes `render()` and stringifies the result via @c9up/aurora's SSR
16
+ * 2. wraps the markup in the page shell from `shell` (default: a minimal
17
+ * <!doctype html> document)
18
+ * 3. embeds a `<script type="module">` that imports the user-supplied
19
+ * `entry` module — the client bundle is expected to call
20
+ * `hydrate(document.getElementById('aurora-root'), factory)` itself.
21
+ */
22
+ import type { TemplateResult } from "./types.js";
23
+ /**
24
+ * Structural slice of Ream's HttpContext — the response surface we need.
25
+ * Declaring it locally keeps aurora's bundle free of a `@c9up/ream`
26
+ * import; any framework whose context exposes `response.header()` and
27
+ * `response.send()` satisfies this contract via structural subtyping.
28
+ */
29
+ export interface AuroraResponse {
30
+ status(code: number): AuroraResponse;
31
+ header(name: string, value: string): AuroraResponse;
32
+ send(data: string): void;
33
+ }
34
+ export interface AuroraHttpContext {
35
+ request: unknown;
36
+ response: AuroraResponse;
37
+ }
38
+ /** Configuration for a single auroraRoute call. */
39
+ export interface AuroraRouteConfig {
40
+ /**
41
+ * SSR factory. Called once per request. Returning a TemplateResult is
42
+ * the common case; returning a Promise<TemplateResult> works too
43
+ * (data-loading components).
44
+ */
45
+ render: (ctx: AuroraHttpContext) => TemplateResult | Promise<TemplateResult>;
46
+ /**
47
+ * Path to the ES module the browser should import to hydrate. The
48
+ * value is inlined into a `<script type="module" src="...">` tag —
49
+ * make sure the route exists on the Ream router (typically served
50
+ * statically). Defaults to `/aurora-client.js`.
51
+ */
52
+ entry?: string;
53
+ /**
54
+ * Page shell. Defaults to a minimal HTML5 doctype with a `<div
55
+ * id="aurora-root">` that wraps the SSR markup. Apps swap in their
56
+ * own to control `<head>` (title, meta, fonts, css).
57
+ */
58
+ shell?: (body: string, entry: string) => string;
59
+ }
60
+ /**
61
+ * Build a Ream-compatible route handler that SSR-renders the given
62
+ * factory and serves the full HTML document.
63
+ */
64
+ export declare function auroraRoute(config: AuroraRouteConfig): (ctx: AuroraHttpContext) => Promise<void>;
package/dist/route.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `auroraRoute` — Ream route handler that SSR-renders an aurora
3
+ * component and ships the markup plus the bytes needed for client-side
4
+ * hydration.
5
+ *
6
+ * Usage:
7
+ *
8
+ * import { auroraRoute } from '@c9up/aurora/provider'
9
+ * router.get('/dashboard', auroraRoute({
10
+ * entry: '/app/pages/dashboard.client.js',
11
+ * render: () => Dashboard({ user: ... })
12
+ * }))
13
+ *
14
+ * The handler:
15
+ * 1. invokes `render()` and stringifies the result via @c9up/aurora's SSR
16
+ * 2. wraps the markup in the page shell from `shell` (default: a minimal
17
+ * <!doctype html> document)
18
+ * 3. embeds a `<script type="module">` that imports the user-supplied
19
+ * `entry` module — the client bundle is expected to call
20
+ * `hydrate(document.getElementById('aurora-root'), factory)` itself.
21
+ */
22
+ import { renderToString } from "./ssr.js";
23
+ const DEFAULT_SHELL = (body, entry) => `<!doctype html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
28
+ <title>Aurora</title>
29
+ </head>
30
+ <body>
31
+ <div id="aurora-root">${body}</div>
32
+ <script type="module" src="${entry}"></script>
33
+ </body>
34
+ </html>`;
35
+ /**
36
+ * Build a Ream-compatible route handler that SSR-renders the given
37
+ * factory and serves the full HTML document.
38
+ */
39
+ export function auroraRoute(config) {
40
+ const entry = config.entry ?? "/aurora-client.js";
41
+ const shell = config.shell ?? DEFAULT_SHELL;
42
+ return async (ctx) => {
43
+ const tree = await config.render(ctx);
44
+ const body = renderToString(tree);
45
+ const html = shell(body, entry);
46
+ ctx.response.header("content-type", "text/html; charset=utf-8");
47
+ ctx.response.send(html);
48
+ };
49
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `renderPage` — server-side helper that turns a page name + props into
3
+ * a full HTML document, ready to ship from a route handler.
4
+ *
5
+ * The output carries everything the browser needs to hydrate against
6
+ * the SSR markup with zero app-side scripting:
7
+ *
8
+ * <head>
9
+ * ...
10
+ * <script type="importmap">
11
+ * { "imports": { "@c9up/aurora": "/_assets/aurora/index.js" } }
12
+ * </script>
13
+ * </head>
14
+ * <body>
15
+ * <div id="aurora-root">…SSR markup…</div>
16
+ * <script id="aurora-page-data" type="application/json">{…}</script>
17
+ * <script type="module">
18
+ * import { hydrate } from '@c9up/aurora'
19
+ * import Page from '/_assets/pages/ProjectPage.js'
20
+ * const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
21
+ * hydrate(document.getElementById('aurora-root'), () => Page(data.props))
22
+ * </script>
23
+ * </body>
24
+ */
25
+ import type { Pages } from "../Pages.js";
26
+ /**
27
+ * Structural slice of the host framework's response. Same shape
28
+ * `auroraRoute()` uses — keeps aurora free of a `@c9up/ream` import.
29
+ */
30
+ export interface RenderResponse {
31
+ status(code: number): RenderResponse;
32
+ header(name: string, value: string): RenderResponse;
33
+ send(body: string): void;
34
+ }
35
+ export interface RenderHttpContext {
36
+ request: unknown;
37
+ response: RenderResponse;
38
+ }
39
+ export interface RenderPageOptions {
40
+ /**
41
+ * Importmap entries injected into `<head>`. Defaults to mapping
42
+ * `@c9up/aurora` to `/_assets/aurora/index.js`. Override to point
43
+ * at a different mount or to add app-side aliases.
44
+ */
45
+ importmap?: Record<string, string>;
46
+ /**
47
+ * Extra markup spliced into `<head>` after the importmap. Use to
48
+ * inject `<title>`, meta tags, stylesheets.
49
+ */
50
+ headExtra?: string;
51
+ /**
52
+ * Outer language tag on the `<html>` element. Defaults to `en`.
53
+ */
54
+ lang?: string;
55
+ /**
56
+ * Mount root id for the SSR + hydrated tree. Defaults to
57
+ * `aurora-root`. Matches the id the client-side hydrate script
58
+ * targets.
59
+ */
60
+ rootId?: string;
61
+ }
62
+ export declare function renderPage<P>(ctx: RenderHttpContext, pages: Pages, name: string, props: P, options?: RenderPageOptions): Promise<void>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `renderPage` — server-side helper that turns a page name + props into
3
+ * a full HTML document, ready to ship from a route handler.
4
+ *
5
+ * The output carries everything the browser needs to hydrate against
6
+ * the SSR markup with zero app-side scripting:
7
+ *
8
+ * <head>
9
+ * ...
10
+ * <script type="importmap">
11
+ * { "imports": { "@c9up/aurora": "/_assets/aurora/index.js" } }
12
+ * </script>
13
+ * </head>
14
+ * <body>
15
+ * <div id="aurora-root">…SSR markup…</div>
16
+ * <script id="aurora-page-data" type="application/json">{…}</script>
17
+ * <script type="module">
18
+ * import { hydrate } from '@c9up/aurora'
19
+ * import Page from '/_assets/pages/ProjectPage.js'
20
+ * const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
21
+ * hydrate(document.getElementById('aurora-root'), () => Page(data.props))
22
+ * </script>
23
+ * </body>
24
+ */
25
+ import { renderToString } from "../ssr.js";
26
+ export async function renderPage(ctx, pages, name, props, options = {}) {
27
+ const factory = await pages.resolve(name);
28
+ // The factory must be invoked the SAME way client-side for hydrate
29
+ // to find matching slots — `Page(props)` is the contract.
30
+ const tree = await factory(props);
31
+ const body = renderToString(tree);
32
+ const importmap = {
33
+ "@c9up/aurora": "/_assets/aurora/index.js",
34
+ ...options.importmap,
35
+ };
36
+ const rootId = options.rootId ?? "aurora-root";
37
+ const lang = options.lang ?? "en";
38
+ const pageUrl = pages.urlFor(name);
39
+ const doc = `<!doctype html>
40
+ <html lang="${escapeAttr(lang)}">
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
44
+ <script type="importmap">${JSON.stringify({ imports: importmap })}</script>
45
+ ${options.headExtra ?? ""}
46
+ </head>
47
+ <body>
48
+ <div id="${escapeAttr(rootId)}">${body}</div>
49
+ <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
50
+ name,
51
+ props,
52
+ url: pageUrl,
53
+ rootId,
54
+ })}</script>
55
+ <script type="module">
56
+ import { hydrate } from '@c9up/aurora'
57
+ import Page from ${JSON.stringify(pageUrl)}
58
+ const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
59
+ hydrate(document.getElementById(data.rootId), () => Page(data.props))
60
+ </script>
61
+ </body>
62
+ </html>`;
63
+ ctx.response.header("content-type", "text/html; charset=utf-8");
64
+ ctx.response.send(doc);
65
+ }
66
+ function escapeAttr(value) {
67
+ return value
68
+ .replace(/&/g, "&amp;")
69
+ .replace(/"/g, "&quot;")
70
+ .replace(/</g, "&lt;");
71
+ }
72
+ /**
73
+ * Escape a JSON payload for safe embedding inside a `<script>` block.
74
+ * The HTML parser closes the script on `</script>` regardless of JSON
75
+ * quoting, so we slash-escape the `/`. We also escape `<!--` and `-->`
76
+ * to dodge HTML-comment interpretation inside the script body.
77
+ */
78
+ function escapeJsonForScript(value) {
79
+ return JSON.stringify(value)
80
+ .replace(/<\/(script)/gi, "<\\/$1")
81
+ .replace(/<!--/g, "<\\!--")
82
+ .replace(/-->/g, "--\\>");
83
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `serveAssets` — generic static-file handler exposed by aurora so an
3
+ * app can mount the runtime + the pages dist with a couple of routes:
4
+ *
5
+ * router.get('/_assets/aurora/*', serveAssets({ root: auroraDistPath }))
6
+ * router.get('/_assets/pages/*', serveAssets({ root: pagesPath }))
7
+ *
8
+ * The handler is framework-agnostic: it reads `ctx.request.param('*')`
9
+ * and writes to `ctx.response`. Any context that satisfies
10
+ * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
+ */
12
+ export interface AssetsRequest {
13
+ /**
14
+ * Read the wildcard `*` segment of the matched route. Most routers
15
+ * (Ream, AdonisJS, fastify with params) expose this as
16
+ * `params['*']` — the duck-typed helper below accepts either
17
+ * convention.
18
+ */
19
+ param(name: string): unknown;
20
+ }
21
+ export interface AssetsResponse {
22
+ status(code: number): AssetsResponse;
23
+ header(name: string, value: string): AssetsResponse;
24
+ send(body: string | Buffer): void;
25
+ }
26
+ export interface AssetsHttpContext {
27
+ request: AssetsRequest;
28
+ response: AssetsResponse;
29
+ }
30
+ export interface ServeAssetsOptions {
31
+ /**
32
+ * Absolute filesystem root the handler is allowed to serve from.
33
+ * Requests resolving outside this root return 403.
34
+ */
35
+ root: string;
36
+ /**
37
+ * `Cache-Control` value to emit. Defaults to a dev-friendly
38
+ * 60-second TTL. Production deployments should hash the asset
39
+ * name and switch to `public, max-age=31536000, immutable`.
40
+ */
41
+ cacheControl?: string;
42
+ }
43
+ export declare function serveAssets(options: ServeAssetsOptions): (ctx: AssetsHttpContext) => Promise<void>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `serveAssets` — generic static-file handler exposed by aurora so an
3
+ * app can mount the runtime + the pages dist with a couple of routes:
4
+ *
5
+ * router.get('/_assets/aurora/*', serveAssets({ root: auroraDistPath }))
6
+ * router.get('/_assets/pages/*', serveAssets({ root: pagesPath }))
7
+ *
8
+ * The handler is framework-agnostic: it reads `ctx.request.param('*')`
9
+ * and writes to `ctx.response`. Any context that satisfies
10
+ * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
+ */
12
+ import { readFile, realpath } from "node:fs/promises";
13
+ import { extname, join, resolve as resolvePath, sep } from "node:path";
14
+ const CONTENT_TYPES = {
15
+ ".js": "text/javascript; charset=utf-8",
16
+ ".mjs": "text/javascript; charset=utf-8",
17
+ ".map": "application/json; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".json": "application/json; charset=utf-8",
20
+ };
21
+ export function serveAssets(options) {
22
+ const root = options.root;
23
+ const cacheControl = options.cacheControl ?? "public, max-age=60";
24
+ // Canonicalize the root ONCE at handler creation. The realpath check
25
+ // below compares against this canonical form so a symlinked root
26
+ // (e.g. `/var/www/current → /var/www/release-42`) still resolves
27
+ // requests correctly. `realpath` failure at construction means the
28
+ // configured root doesn't exist yet — we fall back to the lexical
29
+ // resolve so the first request emits a clean 404 instead of a boot
30
+ // crash. The realpath re-check at request time handles that case.
31
+ let canonicalRoot;
32
+ realpath(root).then((p) => {
33
+ canonicalRoot = p;
34
+ }, () => {
35
+ /* root not yet on disk — request-time realpath will surface it */
36
+ });
37
+ return async (ctx) => {
38
+ const rest = ctx.request.param("*");
39
+ if (typeof rest !== "string" || rest.length === 0) {
40
+ ctx.response.status(400).send("missing asset path");
41
+ return;
42
+ }
43
+ // First gate: lexical containment check. `resolve()` collapses
44
+ // `../` segments; we assert the resolved path still starts with
45
+ // `root + sep`. This blocks the "../../../etc/passwd" class of
46
+ // requests before we ever touch the filesystem.
47
+ const absolute = resolvePath(join(root, rest));
48
+ if (!absolute.startsWith(root + sep) && absolute !== root) {
49
+ ctx.response.status(403).send("forbidden");
50
+ return;
51
+ }
52
+ // Second gate: dereference any symlinks under the root and
53
+ // re-check containment against the canonical root. Without this
54
+ // step a symlink planted at `<root>/legit → /etc/secrets` would
55
+ // pass the lexical check above and be served. We re-canonicalize
56
+ // the root each request when the constructor-time realpath
57
+ // hadn't resolved yet (root mounted after boot).
58
+ let canonicalAbsolute;
59
+ let canonicalRootNow;
60
+ try {
61
+ canonicalRootNow = canonicalRoot ?? (await realpath(root));
62
+ canonicalAbsolute = await realpath(absolute);
63
+ }
64
+ catch {
65
+ // realpath fails if the target doesn't exist — emit a normal
66
+ // 404 here so symlink-escape probes can't be distinguished
67
+ // from genuine misses via response timing or status.
68
+ ctx.response.status(404).send("asset not found");
69
+ return;
70
+ }
71
+ if (!canonicalAbsolute.startsWith(canonicalRootNow + sep) &&
72
+ canonicalAbsolute !== canonicalRootNow) {
73
+ ctx.response.status(403).send("forbidden");
74
+ return;
75
+ }
76
+ let body;
77
+ try {
78
+ body = await readFile(canonicalAbsolute);
79
+ }
80
+ catch {
81
+ ctx.response.status(404).send("asset not found");
82
+ return;
83
+ }
84
+ const type = CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
85
+ ctx.response.header("content-type", type);
86
+ ctx.response.header("cache-control", cacheControl);
87
+ ctx.response.send(body);
88
+ };
89
+ }