@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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/AuroraManager.d.ts +44 -0
- package/dist/AuroraManager.js +47 -0
- package/dist/AuroraProvider.d.ts +52 -0
- package/dist/AuroraProvider.js +145 -0
- package/dist/Pages.d.ts +78 -0
- package/dist/Pages.js +116 -0
- package/dist/component.d.ts +55 -0
- package/dist/component.js +97 -0
- package/dist/html.d.ts +30 -0
- package/dist/html.js +246 -0
- package/dist/hydrate.d.ts +29 -0
- package/dist/hydrate.js +379 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/reactive.d.ts +83 -0
- package/dist/reactive.js +217 -0
- package/dist/relay.d.ts +43 -0
- package/dist/relay.js +144 -0
- package/dist/render.d.ts +25 -0
- package/dist/render.js +283 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +49 -0
- package/dist/server/renderPage.d.ts +62 -0
- package/dist/server/renderPage.js +83 -0
- package/dist/server/serveAssets.d.ts +43 -0
- package/dist/server/serveAssets.js +89 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.js +31 -0
- package/dist/ssr.d.ts +22 -0
- package/dist/ssr.js +179 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +15 -0
- package/package.json +69 -0
- package/src/AuroraManager.ts +76 -0
- package/src/AuroraProvider.ts +187 -0
- package/src/Pages.ts +164 -0
- package/src/component.ts +138 -0
- package/src/html.ts +296 -0
- package/src/hydrate.ts +518 -0
- package/src/index.ts +43 -0
- package/src/reactive.ts +265 -0
- package/src/relay.ts +171 -0
- package/src/render.ts +378 -0
- package/src/route.ts +96 -0
- package/src/server/renderPage.ts +135 -0
- package/src/server/serveAssets.ts +135 -0
- package/src/services/main.ts +40 -0
- package/src/ssr.ts +179 -0
- 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
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -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, "&")
|
|
69
|
+
.replace(/"/g, """)
|
|
70
|
+
.replace(/</g, "<");
|
|
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
|
+
}
|