@c9up/aurora 0.1.37 → 0.1.39
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/dist/AuroraManager.d.ts +6 -0
- package/dist/AuroraManager.js +12 -1
- package/dist/AuroraProvider.js +18 -4
- package/dist/form.d.ts +1 -1
- package/dist/hydrate.js +4 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +9 -1
- package/dist/render.d.ts +17 -1
- package/dist/render.js +58 -22
- package/dist/server/serveAssets.d.ts +10 -0
- package/dist/server/serveAssets.js +38 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +6 -0
- package/dist/services/main.d.ts +9 -0
- package/dist/services/main.js +11 -0
- package/package.json +1 -1
- package/src/AuroraManager.ts +12 -1
- package/src/AuroraProvider.ts +18 -4
- package/src/form.ts +1 -1
- package/src/hydrate.ts +4 -1
- package/src/index.ts +9 -1
- package/src/render.ts +76 -32
- package/src/server/serveAssets.ts +48 -0
- package/src/server.ts +6 -0
- package/src/services/main.ts +12 -0
package/dist/AuroraManager.d.ts
CHANGED
|
@@ -105,6 +105,12 @@ export declare class AuroraManager {
|
|
|
105
105
|
/**
|
|
106
106
|
* Handler for the app's pages directory. Mount on
|
|
107
107
|
* `GET /__assets/pages/*`.
|
|
108
|
+
*
|
|
109
|
+
* `no-cache` while developing — which does NOT mean "do not cache", it
|
|
110
|
+
* means "always ask". These are source files: served with the default
|
|
111
|
+
* 60-second TTL and no validator, an edited page was handed back stale for
|
|
112
|
+
* a minute with no way for the browser even to enquire. The ETag makes the
|
|
113
|
+
* question cheap; production keeps the TTL.
|
|
108
114
|
*/
|
|
109
115
|
pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void>;
|
|
110
116
|
/**
|
package/dist/AuroraManager.js
CHANGED
|
@@ -129,9 +129,20 @@ export class AuroraManager {
|
|
|
129
129
|
/**
|
|
130
130
|
* Handler for the app's pages directory. Mount on
|
|
131
131
|
* `GET /__assets/pages/*`.
|
|
132
|
+
*
|
|
133
|
+
* `no-cache` while developing — which does NOT mean "do not cache", it
|
|
134
|
+
* means "always ask". These are source files: served with the default
|
|
135
|
+
* 60-second TTL and no validator, an edited page was handed back stale for
|
|
136
|
+
* a minute with no way for the browser even to enquire. The ETag makes the
|
|
137
|
+
* question cheap; production keeps the TTL.
|
|
132
138
|
*/
|
|
133
139
|
pageAssetsHandler() {
|
|
134
|
-
return serveAssets({
|
|
140
|
+
return serveAssets({
|
|
141
|
+
root: this.pages.root,
|
|
142
|
+
...(process.env.NODE_ENV === "production"
|
|
143
|
+
? {}
|
|
144
|
+
: { cacheControl: "no-cache" }),
|
|
145
|
+
});
|
|
135
146
|
}
|
|
136
147
|
/**
|
|
137
148
|
* Handler for `@c9up/comet`'s runtime (the RPC client). Mount on
|
package/dist/AuroraProvider.js
CHANGED
|
@@ -20,10 +20,12 @@ import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { AuroraManager } from "./AuroraManager.js";
|
|
22
22
|
import { auroraRoute } from "./route.js";
|
|
23
|
-
import { setAurora } from "./services/main.js";
|
|
23
|
+
import { clearAurora, getAurora, setAurora } from "./services/main.js";
|
|
24
24
|
import { renderToString } from "./ssr.js";
|
|
25
25
|
export default class AuroraProvider {
|
|
26
26
|
app;
|
|
27
|
+
/** What this provider bound, so shutdown only clears its own. */
|
|
28
|
+
#owned;
|
|
27
29
|
constructor(app) {
|
|
28
30
|
this.app = app;
|
|
29
31
|
}
|
|
@@ -46,11 +48,15 @@ export default class AuroraProvider {
|
|
|
46
48
|
// Force-resolve so `setAurora` runs even if the app never
|
|
47
49
|
// touches the singleton from a preload.
|
|
48
50
|
const manager = await this.app.container.resolve(AuroraManager);
|
|
51
|
+
this.#owned = manager;
|
|
49
52
|
setAurora(manager);
|
|
50
53
|
}
|
|
51
54
|
async start() {
|
|
52
|
-
// Asset routes are registered in `start()
|
|
53
|
-
//
|
|
55
|
+
// Asset routes are registered in `start()`, which runs BEFORE the
|
|
56
|
+
// preloads — providers start, then the `starting` hooks, then the
|
|
57
|
+
// preloads are imported. An earlier version of this comment claimed the
|
|
58
|
+
// opposite; an app that wanted to swap aurora's pages root has to do it
|
|
59
|
+
// from a provider, not from a preload.
|
|
54
60
|
//
|
|
55
61
|
// Resolve the host router from the container, where Ream registers it as
|
|
56
62
|
// `'router'` (Ignitor). Reading it from the container — instead of
|
|
@@ -77,7 +83,15 @@ export default class AuroraProvider {
|
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
async ready() { }
|
|
80
|
-
async shutdown() {
|
|
86
|
+
async shutdown() {
|
|
87
|
+
// Release the module-level singleton, while it is still ours. A stopped
|
|
88
|
+
// application left a dead Aurora manager reachable through `services/main`, and
|
|
89
|
+
// with two applications in one process the survivor's binding must not
|
|
90
|
+
// be the one cleared.
|
|
91
|
+
if (this.#owned !== undefined && getAurora() === this.#owned)
|
|
92
|
+
clearAurora();
|
|
93
|
+
this.#owned = undefined;
|
|
94
|
+
}
|
|
81
95
|
/**
|
|
82
96
|
* Resolve the user-supplied config:
|
|
83
97
|
* - relative `pages.root` (e.g. `./resources/pages`) is joined to
|
package/dist/form.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface FormValidationOutcome {
|
|
|
43
43
|
* Anything schema-shaped (a `@c9up/rune` schema satisfies this).
|
|
44
44
|
*
|
|
45
45
|
* Both spellings are accepted, and `validateResult` wins when present: rune
|
|
46
|
-
* reserves `validate()` for the
|
|
46
|
+
* reserves `validate()` for the async, throwing contract, and reading
|
|
47
47
|
* `.valid` off a Promise yields `undefined` — the form would then report itself
|
|
48
48
|
* invalid with no error to show.
|
|
49
49
|
*/
|
package/dist/hydrate.js
CHANGED
|
@@ -91,7 +91,10 @@ function renderValueToNodes(value, cleanups, mountHooks, doc) {
|
|
|
91
91
|
return out;
|
|
92
92
|
}
|
|
93
93
|
if (isTemplateResult(value)) {
|
|
94
|
-
|
|
94
|
+
// The renderer collects hooks in a queue now; hydrate keeps its own
|
|
95
|
+
// flat list, so it hands one over and takes back what was collected.
|
|
96
|
+
const nested = { hooks: mountHooks, flushed: false };
|
|
97
|
+
const frag = mount(value, cleanups, [], nested);
|
|
95
98
|
return Array.from(frag.childNodes);
|
|
96
99
|
}
|
|
97
100
|
if (value instanceof Node)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import "./augmentations.js";
|
|
2
1
|
export type { CookieCodec, CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
|
|
3
2
|
export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
4
3
|
export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
5
|
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
6
|
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// `./augmentations.js` is NOT loaded here either, and for a related reason: it
|
|
9
|
+
// augments ream's HttpContext and ContainerBindings, so it names `@c9up/ream`
|
|
10
|
+
// and drags ream's whole source into any program that imports this barrel. A
|
|
11
|
+
// component library that only wants `component`/`html` was compiling ream's
|
|
12
|
+
// decorated console commands under its own tsconfig, which does not enable
|
|
13
|
+
// decorators — a build failure, in a package that never mentions ream. The
|
|
14
|
+
// augmentations load from `@c9up/aurora/server` and `@c9up/aurora/provider`,
|
|
15
|
+
// which is where every type they describe is reachable from anyway.
|
|
8
16
|
export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
9
17
|
export { clsx, cn, twMerge } from "./cn.js";
|
|
10
18
|
export { command } from "./command.js";
|
package/dist/render.d.ts
CHANGED
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type EffectCallback, type TemplateResult } from "./types.js";
|
|
11
11
|
export type Disposer = () => void;
|
|
12
|
+
/**
|
|
13
|
+
* The `onMount` hooks collected while a tree is built, and whether they have
|
|
14
|
+
* already been run.
|
|
15
|
+
*
|
|
16
|
+
* A bare array was not enough. The root flushes it once, after the fragment is
|
|
17
|
+
* in the document; a reactive slot that swapped its content LATER kept
|
|
18
|
+
* appending to that same array, and nothing flushed it again — the component's
|
|
19
|
+
* setup ran, its `onMount` never did, and the hooks piled up for the life of
|
|
20
|
+
* the page. The flag is what lets a later update tell "collect these, the root
|
|
21
|
+
* will run them" from "the root is done, run mine myself".
|
|
22
|
+
*/
|
|
23
|
+
interface MountQueue {
|
|
24
|
+
hooks: Array<EffectCallback>;
|
|
25
|
+
flushed: boolean;
|
|
26
|
+
}
|
|
12
27
|
/**
|
|
13
28
|
* Mount a TemplateResult into `container`. Returns a `Disposer` that
|
|
14
29
|
* stops every reactive effect and removes the mounted nodes. Calling it
|
|
@@ -22,4 +37,5 @@ export declare function render(result: TemplateResult, container: Element | Docu
|
|
|
22
37
|
* nested-template subtree when the signal changes after hydration
|
|
23
38
|
* (the swap path — see `hydrateTextSlot`).
|
|
24
39
|
*/
|
|
25
|
-
export declare function mount(result: TemplateResult, cleanups: Disposer[], mounted: ChildNode[],
|
|
40
|
+
export declare function mount(result: TemplateResult, cleanups: Disposer[], mounted: ChildNode[], queue: MountQueue): DocumentFragment;
|
|
41
|
+
export {};
|
package/dist/render.js
CHANGED
|
@@ -16,6 +16,24 @@ import { readComponentLifecycle } from "./component.js";
|
|
|
16
16
|
import { getTemplate } from "./html.js";
|
|
17
17
|
import { effect, isSignal } from "./reactive.js";
|
|
18
18
|
import { isTemplateResult, } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Run hooks, sending any teardown to `cleanups`.
|
|
21
|
+
*
|
|
22
|
+
* Failures are swallowed per hook: one component's bad `onMount` must not stop
|
|
23
|
+
* its siblings from mounting.
|
|
24
|
+
*/
|
|
25
|
+
function runMountHooks(hooks, cleanups) {
|
|
26
|
+
for (const hook of hooks) {
|
|
27
|
+
try {
|
|
28
|
+
const teardown = hook();
|
|
29
|
+
if (typeof teardown === "function")
|
|
30
|
+
cleanups.push(teardown);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* swallow — one bad onMount should not block sibling components */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
38
|
* Mount a TemplateResult into `container`. Returns a `Disposer` that
|
|
21
39
|
* stops every reactive effect and removes the mounted nodes. Calling it
|
|
@@ -24,22 +42,17 @@ import { isTemplateResult, } from "./types.js";
|
|
|
24
42
|
export function render(result, container) {
|
|
25
43
|
const cleanups = [];
|
|
26
44
|
const mountedNodes = [];
|
|
27
|
-
const
|
|
28
|
-
const fragment = mount(result, cleanups, mountedNodes,
|
|
45
|
+
const queue = { hooks: [], flushed: false };
|
|
46
|
+
const fragment = mount(result, cleanups, mountedNodes, queue);
|
|
29
47
|
container.appendChild(fragment);
|
|
30
48
|
// `onMount` hooks fire after the fragment is live in the document so
|
|
31
49
|
// callbacks that measure / focus / observe see a real DOM. A returned
|
|
32
50
|
// cleanup function joins the unmount queue.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
/* swallow — one bad onMount should not block sibling components */
|
|
41
|
-
}
|
|
42
|
-
}
|
|
51
|
+
runMountHooks(queue.hooks, cleanups);
|
|
52
|
+
queue.hooks = [];
|
|
53
|
+
// From here on the root never flushes again, so a slot that mounts
|
|
54
|
+
// something later has to run its own hooks.
|
|
55
|
+
queue.flushed = true;
|
|
43
56
|
let disposed = false;
|
|
44
57
|
return () => {
|
|
45
58
|
if (disposed)
|
|
@@ -58,14 +71,14 @@ export function render(result, container) {
|
|
|
58
71
|
* nested-template subtree when the signal changes after hydration
|
|
59
72
|
* (the swap path — see `hydrateTextSlot`).
|
|
60
73
|
*/
|
|
61
|
-
export function mount(result, cleanups, mounted,
|
|
74
|
+
export function mount(result, cleanups, mounted, queue) {
|
|
62
75
|
const tpl = getTemplate(result.strings);
|
|
63
76
|
const fragment = tpl.element.content.cloneNode(true);
|
|
64
77
|
// Forward any component()-attached lifecycle from this result.
|
|
65
78
|
const lifecycle = readComponentLifecycle(result);
|
|
66
79
|
if (lifecycle) {
|
|
67
80
|
for (const hook of lifecycle.mountHooks)
|
|
68
|
-
|
|
81
|
+
queue.hooks.push(hook);
|
|
69
82
|
for (const c of lifecycle.cleanups)
|
|
70
83
|
cleanups.push(c);
|
|
71
84
|
}
|
|
@@ -106,7 +119,7 @@ export function mount(result, cleanups, mounted, mountHooks) {
|
|
|
106
119
|
collectMultiAttr(slot, node, result.values[i], multiGroups);
|
|
107
120
|
}
|
|
108
121
|
else {
|
|
109
|
-
applySlot(slot, node, result.values[i], cleanups, mounted,
|
|
122
|
+
applySlot(slot, node, result.values[i], cleanups, mounted, queue);
|
|
110
123
|
}
|
|
111
124
|
}
|
|
112
125
|
for (const group of multiGroups.values()) {
|
|
@@ -134,10 +147,10 @@ function resolvePath(root, path) {
|
|
|
134
147
|
}
|
|
135
148
|
return node;
|
|
136
149
|
}
|
|
137
|
-
function applySlot(slot, node, value, cleanups, mounted,
|
|
150
|
+
function applySlot(slot, node, value, cleanups, mounted, queue) {
|
|
138
151
|
switch (slot.kind) {
|
|
139
152
|
case "text":
|
|
140
|
-
applyTextSlot(slot, node, value, cleanups, mounted,
|
|
153
|
+
applyTextSlot(slot, node, value, cleanups, mounted, queue);
|
|
141
154
|
return;
|
|
142
155
|
case "attr":
|
|
143
156
|
applyAttrSlot(slot, node, value, cleanups);
|
|
@@ -159,7 +172,7 @@ function applySlot(slot, node, value, cleanups, mounted, mountHooks) {
|
|
|
159
172
|
* inserted before it, and each re-render swaps out only the nodes it
|
|
160
173
|
* previously inserted.
|
|
161
174
|
*/
|
|
162
|
-
function applyTextSlot(_slot, anchor, value, cleanups, mounted,
|
|
175
|
+
function applyTextSlot(_slot, anchor, value, cleanups, mounted, queue) {
|
|
163
176
|
let currentNodes = [];
|
|
164
177
|
// Per-render disposers for whatever the slot currently shows. A
|
|
165
178
|
// reactive slot that swaps a nested TemplateResult for another must
|
|
@@ -179,7 +192,16 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
|
|
|
179
192
|
for (const n of currentNodes)
|
|
180
193
|
n.remove();
|
|
181
194
|
currentNodes = [];
|
|
182
|
-
|
|
195
|
+
// This render's hooks are collected apart from the shared queue,
|
|
196
|
+
// whichever phase we are in, because their TEARDOWNS belong to this
|
|
197
|
+
// slot: the next swap must dispose what it replaces. Left on the root's
|
|
198
|
+
// list, a component mounted per row kept its subscription until the
|
|
199
|
+
// whole page unmounted.
|
|
200
|
+
//
|
|
201
|
+
// `flushed` is inherited so a deeper slot built during this render
|
|
202
|
+
// still knows whether the root has a flush coming.
|
|
203
|
+
const own = { hooks: [], flushed: queue.flushed };
|
|
204
|
+
const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, own);
|
|
183
205
|
const parent = anchor.parentNode;
|
|
184
206
|
if (!parent)
|
|
185
207
|
return;
|
|
@@ -187,6 +209,20 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
|
|
|
187
209
|
parent.insertBefore(n, anchor);
|
|
188
210
|
currentNodes.push(n);
|
|
189
211
|
}
|
|
212
|
+
if (queue.flushed) {
|
|
213
|
+
// After insertion, exactly as at the root: an `onMount` that
|
|
214
|
+
// measures, focuses or observes has to see a live node.
|
|
215
|
+
runMountHooks(own.hooks, localCleanups);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Still building the initial tree: the nodes are in a detached
|
|
219
|
+
// fragment, so defer to the root's flush. One wrapper carries them, and
|
|
220
|
+
// it returns nothing — the real teardowns go to `localCleanups`, not to
|
|
221
|
+
// the root.
|
|
222
|
+
const pending = own.hooks;
|
|
223
|
+
queue.hooks.push(() => {
|
|
224
|
+
runMountHooks(pending.splice(0), localCleanups);
|
|
225
|
+
});
|
|
190
226
|
}
|
|
191
227
|
if (isSignal(value) || typeof value === "function") {
|
|
192
228
|
const dispose = effect(() => {
|
|
@@ -206,18 +242,18 @@ function applyTextSlot(_slot, anchor, value, cleanups, mounted, mountHooks) {
|
|
|
206
242
|
cleanups.push(disposeLocal);
|
|
207
243
|
}
|
|
208
244
|
}
|
|
209
|
-
function renderValueIntoNodes(value, cleanups, mounted,
|
|
245
|
+
function renderValueIntoNodes(value, cleanups, mounted, queue) {
|
|
210
246
|
if (value === null || value === undefined || value === false)
|
|
211
247
|
return [];
|
|
212
248
|
if (Array.isArray(value)) {
|
|
213
249
|
const out = [];
|
|
214
250
|
for (const item of value) {
|
|
215
|
-
out.push(...renderValueIntoNodes(item, cleanups, mounted,
|
|
251
|
+
out.push(...renderValueIntoNodes(item, cleanups, mounted, queue));
|
|
216
252
|
}
|
|
217
253
|
return out;
|
|
218
254
|
}
|
|
219
255
|
if (isTemplateResult(value)) {
|
|
220
|
-
const frag = mount(value, cleanups, mounted,
|
|
256
|
+
const frag = mount(value, cleanups, mounted, queue);
|
|
221
257
|
return Array.from(frag.childNodes);
|
|
222
258
|
}
|
|
223
259
|
if (value instanceof Node) {
|
|
@@ -28,6 +28,12 @@ export interface AssetsRequest {
|
|
|
28
28
|
* convention.
|
|
29
29
|
*/
|
|
30
30
|
param(name: string): unknown;
|
|
31
|
+
/**
|
|
32
|
+
* Read a request header. OPTIONAL, so a host that only implements
|
|
33
|
+
* `param()` keeps working: without it there is no conditional request and
|
|
34
|
+
* every response is a full 200, which is what happened before.
|
|
35
|
+
*/
|
|
36
|
+
header?(name: string): string | undefined;
|
|
31
37
|
}
|
|
32
38
|
export interface AssetsResponse {
|
|
33
39
|
status(code: number): AssetsResponse;
|
|
@@ -48,6 +54,10 @@ export interface ServeAssetsOptions {
|
|
|
48
54
|
* `Cache-Control` value to emit. Defaults to a dev-friendly
|
|
49
55
|
* 60-second TTL. Production deployments should hash the asset
|
|
50
56
|
* name and switch to `public, max-age=31536000, immutable`.
|
|
57
|
+
*
|
|
58
|
+
* Note that a TTL alone tells the browser not to ASK for 60 seconds. Pair
|
|
59
|
+
* it with `no-cache` while developing — the ETag below then makes the
|
|
60
|
+
* revalidation nearly free.
|
|
51
61
|
*/
|
|
52
62
|
cacheControl?: string;
|
|
53
63
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* and writes to `ctx.response`. Any context that satisfies
|
|
10
10
|
* `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
|
|
11
11
|
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { readFile, realpath } from "node:fs/promises";
|
|
13
14
|
import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,6 +26,27 @@ import { fileURLToPath } from "node:url";
|
|
|
25
26
|
export function packageAssetDir(specifier) {
|
|
26
27
|
return dirname(fileURLToPath(import.meta.resolve(specifier)));
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Does `If-None-Match` cover this entity? (RFC 9110 §13.1.2)
|
|
31
|
+
*
|
|
32
|
+
* A strict `===` answered 200 for three shapes a real client sends: `*`, a
|
|
33
|
+
* comma-separated list of tags, and the weak form `W/"…"` — so a browser
|
|
34
|
+
* holding the exact bytes re-downloaded them anyway, which is most of what the
|
|
35
|
+
* validator exists to prevent. Written here rather than imported because aurora
|
|
36
|
+
* does not depend on ream; the same function lives in `ream/src/http/etag.ts`.
|
|
37
|
+
*/
|
|
38
|
+
function matchesIfNoneMatch(header, tag) {
|
|
39
|
+
if (header === undefined || header === "" || tag === "")
|
|
40
|
+
return false;
|
|
41
|
+
// `*` means "any current representation", so a stored copy always matches.
|
|
42
|
+
if (header.trim() === "*")
|
|
43
|
+
return true;
|
|
44
|
+
const bare = (value) => value.startsWith("W/") ? value.slice(2) : value;
|
|
45
|
+
const current = bare(tag);
|
|
46
|
+
return header
|
|
47
|
+
.split(",")
|
|
48
|
+
.some((candidate) => bare(candidate.trim()) === current);
|
|
49
|
+
}
|
|
28
50
|
const CONTENT_TYPES = {
|
|
29
51
|
".js": "text/javascript; charset=utf-8",
|
|
30
52
|
".mjs": "text/javascript; charset=utf-8",
|
|
@@ -98,9 +120,25 @@ export function serveAssets(options) {
|
|
|
98
120
|
ctx.response.status(404).send("asset not found");
|
|
99
121
|
return;
|
|
100
122
|
}
|
|
123
|
+
// A validator, so a cached copy can be CHECKED rather than only trusted
|
|
124
|
+
// for a fixed time. Without one an edited module was served stale for
|
|
125
|
+
// the whole TTL with no way for the browser to ask whether it changed —
|
|
126
|
+
// in development that is a source file, and the answer is usually yes.
|
|
127
|
+
//
|
|
128
|
+
// Hashed from the bytes actually being sent rather than from mtime and
|
|
129
|
+
// size: a checkout, a rebuild or a touched file all move the metadata
|
|
130
|
+
// without changing the content, and each would needlessly re-download.
|
|
131
|
+
const etag = `"${createHash("sha1").update(body).digest("base64url")}"`;
|
|
101
132
|
const type = CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
|
|
102
133
|
ctx.response.header("content-type", type);
|
|
103
134
|
ctx.response.header("cache-control", cacheControl);
|
|
135
|
+
ctx.response.header("etag", etag);
|
|
136
|
+
if (matchesIfNoneMatch(ctx.request.header?.("if-none-match"), etag)) {
|
|
137
|
+
// 304 carries no body, and must not: the browser reuses the copy it
|
|
138
|
+
// already has.
|
|
139
|
+
ctx.response.status(304).send("");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
104
142
|
ctx.response.send(body);
|
|
105
143
|
};
|
|
106
144
|
}
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// importing client primitives (component/html/hydrate/render) never pulls the
|
|
5
5
|
// Node built-ins through the import graph. Server code imports from
|
|
6
6
|
// `@c9up/aurora/server`; the client `.` entry stays node-free.
|
|
7
|
+
//
|
|
8
|
+
// The ream augmentations (`ctx.aurora`, the `aurora` container binding) load
|
|
9
|
+
// here rather than from the client barrel: everything they describe is
|
|
10
|
+
// server-side, and naming `@c9up/ream` from the browser entry pulled ream's
|
|
11
|
+
// source into every consumer's program — including ones that never use it.
|
|
12
|
+
import "./augmentations.js";
|
|
7
13
|
export { AuroraManager } from "./AuroraManager.js";
|
|
8
14
|
export { auroraContext, } from "./middleware.js";
|
|
9
15
|
export { Pages } from "./Pages.js";
|
package/dist/services/main.d.ts
CHANGED
|
@@ -14,5 +14,14 @@ import type { AuroraManager } from "../AuroraManager.js";
|
|
|
14
14
|
export declare function setAurora(value: AuroraManager): void;
|
|
15
15
|
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
16
16
|
export declare function getAurora(): AuroraManager | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* @internal Release the singleton, so a shut-down application does not leave a
|
|
19
|
+
* dead Aurora manager reachable through `services/main`.
|
|
20
|
+
*
|
|
21
|
+
* The caller checks ownership first (`getAurora() === mine`): two applications
|
|
22
|
+
* share this module in one process, and the one shutting down must not clear
|
|
23
|
+
* what the other has since bound.
|
|
24
|
+
*/
|
|
25
|
+
export declare function clearAurora(): void;
|
|
17
26
|
declare const aurora: AuroraManager;
|
|
18
27
|
export default aurora;
|
package/dist/services/main.js
CHANGED
|
@@ -18,6 +18,17 @@ export function setAurora(value) {
|
|
|
18
18
|
export function getAurora() {
|
|
19
19
|
return instance;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* @internal Release the singleton, so a shut-down application does not leave a
|
|
23
|
+
* dead Aurora manager reachable through `services/main`.
|
|
24
|
+
*
|
|
25
|
+
* The caller checks ownership first (`getAurora() === mine`): two applications
|
|
26
|
+
* share this module in one process, and the one shutting down must not clear
|
|
27
|
+
* what the other has since bound.
|
|
28
|
+
*/
|
|
29
|
+
export function clearAurora() {
|
|
30
|
+
instance = undefined;
|
|
31
|
+
}
|
|
21
32
|
const aurora = new Proxy({}, {
|
|
22
33
|
get(_target, prop) {
|
|
23
34
|
// A module loader inspects what it imports before anyone uses it: it reads
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/AuroraManager.ts
CHANGED
|
@@ -209,9 +209,20 @@ export class AuroraManager {
|
|
|
209
209
|
/**
|
|
210
210
|
* Handler for the app's pages directory. Mount on
|
|
211
211
|
* `GET /__assets/pages/*`.
|
|
212
|
+
*
|
|
213
|
+
* `no-cache` while developing — which does NOT mean "do not cache", it
|
|
214
|
+
* means "always ask". These are source files: served with the default
|
|
215
|
+
* 60-second TTL and no validator, an edited page was handed back stale for
|
|
216
|
+
* a minute with no way for the browser even to enquire. The ETag makes the
|
|
217
|
+
* question cheap; production keeps the TTL.
|
|
212
218
|
*/
|
|
213
219
|
pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void> {
|
|
214
|
-
return serveAssets({
|
|
220
|
+
return serveAssets({
|
|
221
|
+
root: this.pages.root,
|
|
222
|
+
...(process.env.NODE_ENV === "production"
|
|
223
|
+
? {}
|
|
224
|
+
: { cacheControl: "no-cache" }),
|
|
225
|
+
});
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
/**
|
package/src/AuroraProvider.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
AssetsRequest,
|
|
27
27
|
AssetsResponse,
|
|
28
28
|
} from "./server/serveAssets.js";
|
|
29
|
-
import { setAurora } from "./services/main.js";
|
|
29
|
+
import { clearAurora, getAurora, setAurora } from "./services/main.js";
|
|
30
30
|
import { renderToString } from "./ssr.js";
|
|
31
31
|
|
|
32
32
|
interface AuroraContainer {
|
|
@@ -50,6 +50,9 @@ interface ReamRouter {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export default class AuroraProvider {
|
|
53
|
+
/** What this provider bound, so shutdown only clears its own. */
|
|
54
|
+
#owned: AuroraManager | undefined;
|
|
55
|
+
|
|
53
56
|
constructor(protected app: AuroraAppContext) {}
|
|
54
57
|
|
|
55
58
|
register(): void {
|
|
@@ -75,12 +78,16 @@ export default class AuroraProvider {
|
|
|
75
78
|
// touches the singleton from a preload.
|
|
76
79
|
const manager =
|
|
77
80
|
await this.app.container.resolve<AuroraManager>(AuroraManager);
|
|
81
|
+
this.#owned = manager;
|
|
78
82
|
setAurora(manager);
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
async start(): Promise<void> {
|
|
82
|
-
// Asset routes are registered in `start()
|
|
83
|
-
//
|
|
86
|
+
// Asset routes are registered in `start()`, which runs BEFORE the
|
|
87
|
+
// preloads — providers start, then the `starting` hooks, then the
|
|
88
|
+
// preloads are imported. An earlier version of this comment claimed the
|
|
89
|
+
// opposite; an app that wanted to swap aurora's pages root has to do it
|
|
90
|
+
// from a provider, not from a preload.
|
|
84
91
|
//
|
|
85
92
|
// Resolve the host router from the container, where Ream registers it as
|
|
86
93
|
// `'router'` (Ignitor). Reading it from the container — instead of
|
|
@@ -114,7 +121,14 @@ export default class AuroraProvider {
|
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
async ready(): Promise<void> {}
|
|
117
|
-
async shutdown(): Promise<void> {
|
|
124
|
+
async shutdown(): Promise<void> {
|
|
125
|
+
// Release the module-level singleton, while it is still ours. A stopped
|
|
126
|
+
// application left a dead Aurora manager reachable through `services/main`, and
|
|
127
|
+
// with two applications in one process the survivor's binding must not
|
|
128
|
+
// be the one cleared.
|
|
129
|
+
if (this.#owned !== undefined && getAurora() === this.#owned) clearAurora();
|
|
130
|
+
this.#owned = undefined;
|
|
131
|
+
}
|
|
118
132
|
|
|
119
133
|
/**
|
|
120
134
|
* Resolve the user-supplied config:
|
package/src/form.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface FormValidationOutcome {
|
|
|
45
45
|
* Anything schema-shaped (a `@c9up/rune` schema satisfies this).
|
|
46
46
|
*
|
|
47
47
|
* Both spellings are accepted, and `validateResult` wins when present: rune
|
|
48
|
-
* reserves `validate()` for the
|
|
48
|
+
* reserves `validate()` for the async, throwing contract, and reading
|
|
49
49
|
* `.valid` off a Promise yields `undefined` — the form would then report itself
|
|
50
50
|
* invalid with no error to show.
|
|
51
51
|
*/
|
package/src/hydrate.ts
CHANGED
|
@@ -143,7 +143,10 @@ function renderValueToNodes(
|
|
|
143
143
|
return out;
|
|
144
144
|
}
|
|
145
145
|
if (isTemplateResult(value)) {
|
|
146
|
-
|
|
146
|
+
// The renderer collects hooks in a queue now; hydrate keeps its own
|
|
147
|
+
// flat list, so it hands one over and takes back what was collected.
|
|
148
|
+
const nested = { hooks: mountHooks, flushed: false };
|
|
149
|
+
const frag = mount(value, cleanups, [], nested);
|
|
147
150
|
return Array.from(frag.childNodes);
|
|
148
151
|
}
|
|
149
152
|
if (value instanceof Node) return [value as ChildNode];
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
5
|
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
6
|
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// `./augmentations.js` is NOT loaded here either, and for a related reason: it
|
|
9
|
+
// augments ream's HttpContext and ContainerBindings, so it names `@c9up/ream`
|
|
10
|
+
// and drags ream's whole source into any program that imports this barrel. A
|
|
11
|
+
// component library that only wants `component`/`html` was compiling ream's
|
|
12
|
+
// decorated console commands under its own tsconfig, which does not enable
|
|
13
|
+
// decorators — a build failure, in a package that never mentions ream. The
|
|
14
|
+
// augmentations load from `@c9up/aurora/server` and `@c9up/aurora/provider`,
|
|
15
|
+
// which is where every type they describe is reachable from anyway.
|
|
8
16
|
|
|
9
17
|
export type {
|
|
10
18
|
CookieCodec,
|
package/src/render.ts
CHANGED
|
@@ -31,6 +31,42 @@ import {
|
|
|
31
31
|
|
|
32
32
|
export type Disposer = () => void;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The `onMount` hooks collected while a tree is built, and whether they have
|
|
36
|
+
* already been run.
|
|
37
|
+
*
|
|
38
|
+
* A bare array was not enough. The root flushes it once, after the fragment is
|
|
39
|
+
* in the document; a reactive slot that swapped its content LATER kept
|
|
40
|
+
* appending to that same array, and nothing flushed it again — the component's
|
|
41
|
+
* setup ran, its `onMount` never did, and the hooks piled up for the life of
|
|
42
|
+
* the page. The flag is what lets a later update tell "collect these, the root
|
|
43
|
+
* will run them" from "the root is done, run mine myself".
|
|
44
|
+
*/
|
|
45
|
+
interface MountQueue {
|
|
46
|
+
hooks: Array<EffectCallback>;
|
|
47
|
+
flushed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run hooks, sending any teardown to `cleanups`.
|
|
52
|
+
*
|
|
53
|
+
* Failures are swallowed per hook: one component's bad `onMount` must not stop
|
|
54
|
+
* its siblings from mounting.
|
|
55
|
+
*/
|
|
56
|
+
function runMountHooks(
|
|
57
|
+
hooks: Array<EffectCallback>,
|
|
58
|
+
cleanups: Disposer[],
|
|
59
|
+
): void {
|
|
60
|
+
for (const hook of hooks) {
|
|
61
|
+
try {
|
|
62
|
+
const teardown = hook();
|
|
63
|
+
if (typeof teardown === "function") cleanups.push(teardown);
|
|
64
|
+
} catch {
|
|
65
|
+
/* swallow — one bad onMount should not block sibling components */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
/**
|
|
35
71
|
* Mount a TemplateResult into `container`. Returns a `Disposer` that
|
|
36
72
|
* stops every reactive effect and removes the mounted nodes. Calling it
|
|
@@ -42,22 +78,19 @@ export function render(
|
|
|
42
78
|
): Disposer {
|
|
43
79
|
const cleanups: Disposer[] = [];
|
|
44
80
|
const mountedNodes: ChildNode[] = [];
|
|
45
|
-
const
|
|
81
|
+
const queue: MountQueue = { hooks: [], flushed: false };
|
|
46
82
|
|
|
47
|
-
const fragment = mount(result, cleanups, mountedNodes,
|
|
83
|
+
const fragment = mount(result, cleanups, mountedNodes, queue);
|
|
48
84
|
container.appendChild(fragment);
|
|
49
85
|
|
|
50
86
|
// `onMount` hooks fire after the fragment is live in the document so
|
|
51
87
|
// callbacks that measure / focus / observe see a real DOM. A returned
|
|
52
88
|
// cleanup function joins the unmount queue.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/* swallow — one bad onMount should not block sibling components */
|
|
59
|
-
}
|
|
60
|
-
}
|
|
89
|
+
runMountHooks(queue.hooks, cleanups);
|
|
90
|
+
queue.hooks = [];
|
|
91
|
+
// From here on the root never flushes again, so a slot that mounts
|
|
92
|
+
// something later has to run its own hooks.
|
|
93
|
+
queue.flushed = true;
|
|
61
94
|
|
|
62
95
|
let disposed = false;
|
|
63
96
|
return () => {
|
|
@@ -92,7 +125,7 @@ export function mount(
|
|
|
92
125
|
result: TemplateResult,
|
|
93
126
|
cleanups: Disposer[],
|
|
94
127
|
mounted: ChildNode[],
|
|
95
|
-
|
|
128
|
+
queue: MountQueue,
|
|
96
129
|
): DocumentFragment {
|
|
97
130
|
const tpl = getTemplate(result.strings);
|
|
98
131
|
const fragment = tpl.element.content.cloneNode(true) as DocumentFragment;
|
|
@@ -100,7 +133,7 @@ export function mount(
|
|
|
100
133
|
// Forward any component()-attached lifecycle from this result.
|
|
101
134
|
const lifecycle = readComponentLifecycle(result);
|
|
102
135
|
if (lifecycle) {
|
|
103
|
-
for (const hook of lifecycle.mountHooks)
|
|
136
|
+
for (const hook of lifecycle.mountHooks) queue.hooks.push(hook);
|
|
104
137
|
for (const c of lifecycle.cleanups) cleanups.push(c);
|
|
105
138
|
}
|
|
106
139
|
|
|
@@ -148,7 +181,7 @@ export function mount(
|
|
|
148
181
|
if (slot.kind === "attr" && slot.staticParts !== undefined) {
|
|
149
182
|
collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
|
|
150
183
|
} else {
|
|
151
|
-
applySlot(slot, node, result.values[i], cleanups, mounted,
|
|
184
|
+
applySlot(slot, node, result.values[i], cleanups, mounted, queue);
|
|
152
185
|
}
|
|
153
186
|
}
|
|
154
187
|
|
|
@@ -185,18 +218,11 @@ function applySlot(
|
|
|
185
218
|
value: unknown,
|
|
186
219
|
cleanups: Disposer[],
|
|
187
220
|
mounted: ChildNode[],
|
|
188
|
-
|
|
221
|
+
queue: MountQueue,
|
|
189
222
|
): void {
|
|
190
223
|
switch (slot.kind) {
|
|
191
224
|
case "text":
|
|
192
|
-
applyTextSlot(
|
|
193
|
-
slot,
|
|
194
|
-
node as Comment,
|
|
195
|
-
value,
|
|
196
|
-
cleanups,
|
|
197
|
-
mounted,
|
|
198
|
-
mountHooks,
|
|
199
|
-
);
|
|
225
|
+
applyTextSlot(slot, node as Comment, value, cleanups, mounted, queue);
|
|
200
226
|
return;
|
|
201
227
|
case "attr":
|
|
202
228
|
applyAttrSlot(slot, node as Element, value, cleanups);
|
|
@@ -225,7 +251,7 @@ function applyTextSlot(
|
|
|
225
251
|
value: unknown,
|
|
226
252
|
cleanups: Disposer[],
|
|
227
253
|
mounted: ChildNode[],
|
|
228
|
-
|
|
254
|
+
queue: MountQueue,
|
|
229
255
|
): void {
|
|
230
256
|
let currentNodes: ChildNode[] = [];
|
|
231
257
|
// Per-render disposers for whatever the slot currently shows. A
|
|
@@ -246,18 +272,36 @@ function applyTextSlot(
|
|
|
246
272
|
disposeLocal();
|
|
247
273
|
for (const n of currentNodes) n.remove();
|
|
248
274
|
currentNodes = [];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
275
|
+
// This render's hooks are collected apart from the shared queue,
|
|
276
|
+
// whichever phase we are in, because their TEARDOWNS belong to this
|
|
277
|
+
// slot: the next swap must dispose what it replaces. Left on the root's
|
|
278
|
+
// list, a component mounted per row kept its subscription until the
|
|
279
|
+
// whole page unmounted.
|
|
280
|
+
//
|
|
281
|
+
// `flushed` is inherited so a deeper slot built during this render
|
|
282
|
+
// still knows whether the root has a flush coming.
|
|
283
|
+
const own: MountQueue = { hooks: [], flushed: queue.flushed };
|
|
284
|
+
const nodes = renderValueIntoNodes(newValue, localCleanups, mounted, own);
|
|
255
285
|
const parent = anchor.parentNode;
|
|
256
286
|
if (!parent) return;
|
|
257
287
|
for (const n of nodes) {
|
|
258
288
|
parent.insertBefore(n, anchor);
|
|
259
289
|
currentNodes.push(n);
|
|
260
290
|
}
|
|
291
|
+
if (queue.flushed) {
|
|
292
|
+
// After insertion, exactly as at the root: an `onMount` that
|
|
293
|
+
// measures, focuses or observes has to see a live node.
|
|
294
|
+
runMountHooks(own.hooks, localCleanups);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Still building the initial tree: the nodes are in a detached
|
|
298
|
+
// fragment, so defer to the root's flush. One wrapper carries them, and
|
|
299
|
+
// it returns nothing — the real teardowns go to `localCleanups`, not to
|
|
300
|
+
// the root.
|
|
301
|
+
const pending = own.hooks;
|
|
302
|
+
queue.hooks.push(() => {
|
|
303
|
+
runMountHooks(pending.splice(0), localCleanups);
|
|
304
|
+
});
|
|
261
305
|
}
|
|
262
306
|
|
|
263
307
|
if (isSignal(value) || typeof value === "function") {
|
|
@@ -282,18 +326,18 @@ function renderValueIntoNodes(
|
|
|
282
326
|
value: unknown,
|
|
283
327
|
cleanups: Disposer[],
|
|
284
328
|
mounted: ChildNode[],
|
|
285
|
-
|
|
329
|
+
queue: MountQueue,
|
|
286
330
|
): ChildNode[] {
|
|
287
331
|
if (value === null || value === undefined || value === false) return [];
|
|
288
332
|
if (Array.isArray(value)) {
|
|
289
333
|
const out: ChildNode[] = [];
|
|
290
334
|
for (const item of value) {
|
|
291
|
-
out.push(...renderValueIntoNodes(item, cleanups, mounted,
|
|
335
|
+
out.push(...renderValueIntoNodes(item, cleanups, mounted, queue));
|
|
292
336
|
}
|
|
293
337
|
return out;
|
|
294
338
|
}
|
|
295
339
|
if (isTemplateResult(value)) {
|
|
296
|
-
const frag = mount(value, cleanups, mounted,
|
|
340
|
+
const frag = mount(value, cleanups, mounted, queue);
|
|
297
341
|
return Array.from(frag.childNodes);
|
|
298
342
|
}
|
|
299
343
|
if (value instanceof Node) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import { readFile, realpath } from "node:fs/promises";
|
|
14
15
|
import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -28,6 +29,27 @@ export function packageAssetDir(specifier: string): string {
|
|
|
28
29
|
return dirname(fileURLToPath(import.meta.resolve(specifier)));
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Does `If-None-Match` cover this entity? (RFC 9110 §13.1.2)
|
|
34
|
+
*
|
|
35
|
+
* A strict `===` answered 200 for three shapes a real client sends: `*`, a
|
|
36
|
+
* comma-separated list of tags, and the weak form `W/"…"` — so a browser
|
|
37
|
+
* holding the exact bytes re-downloaded them anyway, which is most of what the
|
|
38
|
+
* validator exists to prevent. Written here rather than imported because aurora
|
|
39
|
+
* does not depend on ream; the same function lives in `ream/src/http/etag.ts`.
|
|
40
|
+
*/
|
|
41
|
+
function matchesIfNoneMatch(header: string | undefined, tag: string): boolean {
|
|
42
|
+
if (header === undefined || header === "" || tag === "") return false;
|
|
43
|
+
// `*` means "any current representation", so a stored copy always matches.
|
|
44
|
+
if (header.trim() === "*") return true;
|
|
45
|
+
const bare = (value: string): string =>
|
|
46
|
+
value.startsWith("W/") ? value.slice(2) : value;
|
|
47
|
+
const current = bare(tag);
|
|
48
|
+
return header
|
|
49
|
+
.split(",")
|
|
50
|
+
.some((candidate) => bare(candidate.trim()) === current);
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
const CONTENT_TYPES: Record<string, string> = {
|
|
32
54
|
".js": "text/javascript; charset=utf-8",
|
|
33
55
|
".mjs": "text/javascript; charset=utf-8",
|
|
@@ -44,6 +66,12 @@ export interface AssetsRequest {
|
|
|
44
66
|
* convention.
|
|
45
67
|
*/
|
|
46
68
|
param(name: string): unknown;
|
|
69
|
+
/**
|
|
70
|
+
* Read a request header. OPTIONAL, so a host that only implements
|
|
71
|
+
* `param()` keeps working: without it there is no conditional request and
|
|
72
|
+
* every response is a full 200, which is what happened before.
|
|
73
|
+
*/
|
|
74
|
+
header?(name: string): string | undefined;
|
|
47
75
|
}
|
|
48
76
|
export interface AssetsResponse {
|
|
49
77
|
status(code: number): AssetsResponse;
|
|
@@ -65,6 +93,10 @@ export interface ServeAssetsOptions {
|
|
|
65
93
|
* `Cache-Control` value to emit. Defaults to a dev-friendly
|
|
66
94
|
* 60-second TTL. Production deployments should hash the asset
|
|
67
95
|
* name and switch to `public, max-age=31536000, immutable`.
|
|
96
|
+
*
|
|
97
|
+
* Note that a TTL alone tells the browser not to ASK for 60 seconds. Pair
|
|
98
|
+
* it with `no-cache` while developing — the ETag below then makes the
|
|
99
|
+
* revalidation nearly free.
|
|
68
100
|
*/
|
|
69
101
|
cacheControl?: string;
|
|
70
102
|
}
|
|
@@ -144,10 +176,26 @@ export function serveAssets(
|
|
|
144
176
|
return;
|
|
145
177
|
}
|
|
146
178
|
|
|
179
|
+
// A validator, so a cached copy can be CHECKED rather than only trusted
|
|
180
|
+
// for a fixed time. Without one an edited module was served stale for
|
|
181
|
+
// the whole TTL with no way for the browser to ask whether it changed —
|
|
182
|
+
// in development that is a source file, and the answer is usually yes.
|
|
183
|
+
//
|
|
184
|
+
// Hashed from the bytes actually being sent rather than from mtime and
|
|
185
|
+
// size: a checkout, a rebuild or a touched file all move the metadata
|
|
186
|
+
// without changing the content, and each would needlessly re-download.
|
|
187
|
+
const etag = `"${createHash("sha1").update(body).digest("base64url")}"`;
|
|
147
188
|
const type =
|
|
148
189
|
CONTENT_TYPES[extname(canonicalAbsolute)] ?? "application/octet-stream";
|
|
149
190
|
ctx.response.header("content-type", type);
|
|
150
191
|
ctx.response.header("cache-control", cacheControl);
|
|
192
|
+
ctx.response.header("etag", etag);
|
|
193
|
+
if (matchesIfNoneMatch(ctx.request.header?.("if-none-match"), etag)) {
|
|
194
|
+
// 304 carries no body, and must not: the browser reuses the copy it
|
|
195
|
+
// already has.
|
|
196
|
+
ctx.response.status(304).send("");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
151
199
|
ctx.response.send(body);
|
|
152
200
|
};
|
|
153
201
|
}
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// importing client primitives (component/html/hydrate/render) never pulls the
|
|
5
5
|
// Node built-ins through the import graph. Server code imports from
|
|
6
6
|
// `@c9up/aurora/server`; the client `.` entry stays node-free.
|
|
7
|
+
//
|
|
8
|
+
// The ream augmentations (`ctx.aurora`, the `aurora` container binding) load
|
|
9
|
+
// here rather than from the client barrel: everything they describe is
|
|
10
|
+
// server-side, and naming `@c9up/ream` from the browser entry pulled ream's
|
|
11
|
+
// source into every consumer's program — including ones that never use it.
|
|
12
|
+
import "./augmentations.js";
|
|
7
13
|
|
|
8
14
|
export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
|
|
9
15
|
export {
|
package/src/services/main.ts
CHANGED
|
@@ -24,6 +24,18 @@ export function getAurora(): AuroraManager | undefined {
|
|
|
24
24
|
return instance;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @internal Release the singleton, so a shut-down application does not leave a
|
|
29
|
+
* dead Aurora manager reachable through `services/main`.
|
|
30
|
+
*
|
|
31
|
+
* The caller checks ownership first (`getAurora() === mine`): two applications
|
|
32
|
+
* share this module in one process, and the one shutting down must not clear
|
|
33
|
+
* what the other has since bound.
|
|
34
|
+
*/
|
|
35
|
+
export function clearAurora(): void {
|
|
36
|
+
instance = undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const aurora: AuroraManager = new Proxy({} as AuroraManager, {
|
|
28
40
|
get(_target, prop) {
|
|
29
41
|
// A module loader inspects what it imports before anyone uses it: it reads
|