@c9up/aurora 0.1.4 → 0.1.6
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/browser.d.ts +28 -0
- package/dist/browser.js +69 -0
- package/dist/component.js +7 -0
- package/dist/hydrate.js +11 -1
- package/dist/index.d.ts +1 -4
- package/dist/index.js +7 -5
- package/dist/reactive.d.ts +7 -0
- package/dist/reactive.js +25 -1
- package/dist/relay.js +16 -13
- package/dist/server/renderPage.js +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.js +10 -0
- package/dist/ssr.js +11 -1
- package/package.json +5 -1
- package/src/browser.ts +68 -0
- package/src/component.ts +7 -0
- package/src/hydrate.ts +15 -1
- package/src/index.ts +7 -20
- package/src/reactive.ts +29 -1
- package/src/relay.ts +19 -11
- package/src/server/renderPage.ts +1 -1
- package/src/server.ts +22 -0
- package/src/ssr.ts +10 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser DX helpers — navigation + a typed `localStorage` wrapper.
|
|
3
|
+
*
|
|
4
|
+
* All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
|
|
5
|
+
* a no-op and storage reads return `null`, so the same code runs during SSR
|
|
6
|
+
* without `typeof window` guards at every call site. Node-free — part of the
|
|
7
|
+
* client barrel.
|
|
8
|
+
*/
|
|
9
|
+
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
10
|
+
export declare function redirect(url: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Navigate to `url`, replacing the current history entry (no back-button trap —
|
|
13
|
+
* use after a login/logout so "back" doesn't return to the form). No-op on SSR.
|
|
14
|
+
*/
|
|
15
|
+
export declare function replace(url: string): void;
|
|
16
|
+
/** Reload the current page. No-op during SSR. */
|
|
17
|
+
export declare function reload(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
|
|
20
|
+
* return `null` on the server, on a missing key, or on malformed JSON. Writes
|
|
21
|
+
* swallow quota / private-mode errors so a full storage never crashes the app.
|
|
22
|
+
*/
|
|
23
|
+
export declare const storage: {
|
|
24
|
+
get<T>(key: string): T | null;
|
|
25
|
+
set(key: string, value: unknown): void;
|
|
26
|
+
remove(key: string): void;
|
|
27
|
+
clear(): void;
|
|
28
|
+
};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser DX helpers — navigation + a typed `localStorage` wrapper.
|
|
3
|
+
*
|
|
4
|
+
* All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
|
|
5
|
+
* a no-op and storage reads return `null`, so the same code runs during SSR
|
|
6
|
+
* without `typeof window` guards at every call site. Node-free — part of the
|
|
7
|
+
* client barrel.
|
|
8
|
+
*/
|
|
9
|
+
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
10
|
+
export function redirect(url) {
|
|
11
|
+
if (typeof window !== "undefined") {
|
|
12
|
+
window.location.href = url;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Navigate to `url`, replacing the current history entry (no back-button trap —
|
|
17
|
+
* use after a login/logout so "back" doesn't return to the form). No-op on SSR.
|
|
18
|
+
*/
|
|
19
|
+
export function replace(url) {
|
|
20
|
+
if (typeof window !== "undefined") {
|
|
21
|
+
window.location.replace(url);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Reload the current page. No-op during SSR. */
|
|
25
|
+
export function reload() {
|
|
26
|
+
if (typeof window !== "undefined") {
|
|
27
|
+
window.location.reload();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
|
|
32
|
+
* return `null` on the server, on a missing key, or on malformed JSON. Writes
|
|
33
|
+
* swallow quota / private-mode errors so a full storage never crashes the app.
|
|
34
|
+
*/
|
|
35
|
+
export const storage = {
|
|
36
|
+
get(key) {
|
|
37
|
+
if (typeof localStorage === "undefined")
|
|
38
|
+
return null;
|
|
39
|
+
const raw = localStorage.getItem(key);
|
|
40
|
+
if (raw === null)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
set(key, value) {
|
|
50
|
+
if (typeof localStorage === "undefined")
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// QuotaExceededError / Safari private mode — best-effort write.
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
remove(key) {
|
|
60
|
+
if (typeof localStorage !== "undefined") {
|
|
61
|
+
localStorage.removeItem(key);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
clear() {
|
|
65
|
+
if (typeof localStorage !== "undefined") {
|
|
66
|
+
localStorage.clear();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
package/dist/component.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* signals the setup function captures. The compiled template is what
|
|
19
19
|
* actually moves on screen.
|
|
20
20
|
*/
|
|
21
|
+
import { setOwner } from "./reactive.js";
|
|
21
22
|
const contextStack = [];
|
|
22
23
|
function activeContext() {
|
|
23
24
|
const ctx = contextStack[contextStack.length - 1];
|
|
@@ -41,11 +42,17 @@ export function component(setup) {
|
|
|
41
42
|
mountHooks: [],
|
|
42
43
|
};
|
|
43
44
|
contextStack.push(ctx);
|
|
45
|
+
// Own the reactive scope of setup: effects/memos created here register
|
|
46
|
+
// their disposer into ctx.cleanups and tear down at unmount, instead of
|
|
47
|
+
// leaking their signal subscriptions. Restored after setup so the outer
|
|
48
|
+
// scope (or none) resumes. Save/restore handles nested component() calls.
|
|
49
|
+
const prevOwner = setOwner(ctx.cleanups);
|
|
44
50
|
try {
|
|
45
51
|
const result = setup((props ?? {}));
|
|
46
52
|
return wrapWithLifecycle(result, ctx);
|
|
47
53
|
}
|
|
48
54
|
finally {
|
|
55
|
+
setOwner(prevOwner);
|
|
49
56
|
contextStack.pop();
|
|
50
57
|
}
|
|
51
58
|
};
|
package/dist/hydrate.js
CHANGED
|
@@ -307,11 +307,21 @@ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCurso
|
|
|
307
307
|
}
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
310
|
-
|
|
310
|
+
let textNode = commentMarker.nodeType === 3 /* TEXT */
|
|
311
311
|
? commentMarker
|
|
312
312
|
: commentMarker.previousSibling?.nodeType === 3
|
|
313
313
|
? commentMarker.previousSibling
|
|
314
314
|
: null;
|
|
315
|
+
if (!textNode &&
|
|
316
|
+
commentMarker.nodeType === 8 /* Comment */ &&
|
|
317
|
+
commentMarker.parentNode) {
|
|
318
|
+
// Empty SSR text slot: a `<!---->` placeholder holds the position
|
|
319
|
+
// (see ssr.ts). Materialize the reactive text node there — node
|
|
320
|
+
// count stays 1, so sibling slot paths remain aligned.
|
|
321
|
+
const fresh = (commentMarker.ownerDocument ?? document).createTextNode("");
|
|
322
|
+
commentMarker.parentNode.replaceChild(fresh, commentMarker);
|
|
323
|
+
textNode = fresh;
|
|
324
|
+
}
|
|
315
325
|
if (!textNode)
|
|
316
326
|
return;
|
|
317
327
|
const dispose = effect(() => {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { redirect, reload, replace, storage } from "./browser.js";
|
|
2
2
|
export { component, onMount, onUnmount } from "./component.js";
|
|
3
3
|
export { html, isTemplateResult } from "./html.js";
|
|
4
4
|
export { hydrate } from "./hydrate.js";
|
|
5
|
-
export { type PageFactory, Pages, type PagesConfig, } from "./Pages.js";
|
|
6
5
|
export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
|
|
7
6
|
export { type Disposer, render } from "./render.js";
|
|
8
7
|
export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
|
|
9
|
-
export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
|
|
10
|
-
export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
|
|
11
8
|
export { renderToString } from "./ssr.js";
|
|
12
9
|
export type { TemplateResult } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// ───
|
|
2
|
-
|
|
1
|
+
// ─── Client surface (node-free — safe to bundle for the browser) ──────
|
|
2
|
+
//
|
|
3
|
+
// Server-only exports (AuroraManager, Pages, renderPage, serveAssets) that pull
|
|
4
|
+
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
|
+
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
|
+
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
+
export { redirect, reload, replace, storage } from "./browser.js";
|
|
3
8
|
export { component, onMount, onUnmount } from "./component.js";
|
|
4
9
|
export { html, isTemplateResult } from "./html.js";
|
|
5
10
|
export { hydrate } from "./hydrate.js";
|
|
6
|
-
export { Pages, } from "./Pages.js";
|
|
7
11
|
export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
|
|
8
12
|
export { render } from "./render.js";
|
|
9
13
|
export { auroraRoute, } from "./route.js";
|
|
10
|
-
export { renderPage, } from "./server/renderPage.js";
|
|
11
|
-
export { serveAssets, } from "./server/serveAssets.js";
|
|
12
14
|
export { renderToString } from "./ssr.js";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface Signal<T> {
|
|
|
28
28
|
* sites need to import this directly.
|
|
29
29
|
*/
|
|
30
30
|
export declare const SIGNAL_BRAND: unique symbol;
|
|
31
|
+
/**
|
|
32
|
+
* @internal Swap the ambient owner, returning the previous one so the
|
|
33
|
+
* caller can restore it. `component()` uses this to own the effects and
|
|
34
|
+
* memos a setup function creates, so they dispose at unmount instead of
|
|
35
|
+
* keeping their signal subscriptions alive forever.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setOwner(owner: Array<() => void> | undefined): Array<() => void> | undefined;
|
|
31
38
|
/**
|
|
32
39
|
* Create a writable signal seeded with `initial`. Reads register the
|
|
33
40
|
* current observer; writes notify every observer that previously read.
|
package/dist/reactive.js
CHANGED
|
@@ -23,6 +23,24 @@ const pendingNotifications = new Set();
|
|
|
23
23
|
function activeObserver() {
|
|
24
24
|
return observerStack[observerStack.length - 1];
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Ambient disposal owner. A non-reactive scope (e.g. a component's setup
|
|
28
|
+
* run) registers an array here so effects/memos created during its
|
|
29
|
+
* execution push their disposer into it and are torn down when the scope
|
|
30
|
+
* ends. `undefined` at top level — no scope, no auto-disposal.
|
|
31
|
+
*/
|
|
32
|
+
let currentOwner;
|
|
33
|
+
/**
|
|
34
|
+
* @internal Swap the ambient owner, returning the previous one so the
|
|
35
|
+
* caller can restore it. `component()` uses this to own the effects and
|
|
36
|
+
* memos a setup function creates, so they dispose at unmount instead of
|
|
37
|
+
* keeping their signal subscriptions alive forever.
|
|
38
|
+
*/
|
|
39
|
+
export function setOwner(owner) {
|
|
40
|
+
const prev = currentOwner;
|
|
41
|
+
currentOwner = owner;
|
|
42
|
+
return prev;
|
|
43
|
+
}
|
|
26
44
|
/**
|
|
27
45
|
* Create a writable signal seeded with `initial`. Reads register the
|
|
28
46
|
* current observer; writes notify every observer that previously read.
|
|
@@ -129,7 +147,13 @@ export function effect(fn) {
|
|
|
129
147
|
},
|
|
130
148
|
};
|
|
131
149
|
eff.run();
|
|
132
|
-
|
|
150
|
+
const dispose = () => eff.dispose();
|
|
151
|
+
// Register with the ambient owner (e.g. a component's setup scope) so the
|
|
152
|
+
// effect is torn down when that scope ends. `memo()` builds on this — its
|
|
153
|
+
// internal recompute effect inherits the same ownership, which is what
|
|
154
|
+
// stops a memo created in component setup from leaking after unmount.
|
|
155
|
+
currentOwner?.push(dispose);
|
|
156
|
+
return dispose;
|
|
133
157
|
}
|
|
134
158
|
/**
|
|
135
159
|
* Register a cleanup callback against the currently-running effect.
|
package/dist/relay.js
CHANGED
|
@@ -21,7 +21,6 @@ const STATE = {
|
|
|
21
21
|
sse: null,
|
|
22
22
|
uid: null,
|
|
23
23
|
channels: new Map(),
|
|
24
|
-
pending: [],
|
|
25
24
|
};
|
|
26
25
|
let CONFIG = {
|
|
27
26
|
sseUrl: "/__relay/events",
|
|
@@ -60,17 +59,15 @@ const CLIENT = {
|
|
|
60
59
|
}
|
|
61
60
|
const adapted = handler;
|
|
62
61
|
handlers.add(adapted);
|
|
63
|
-
// Subscribe over POST as soon as we have a uid.
|
|
64
|
-
//
|
|
65
|
-
|
|
62
|
+
// Subscribe over POST as soon as we have a uid. Before the first uid (or
|
|
63
|
+
// during an auto-reconnect) the channel already lives in STATE.channels
|
|
64
|
+
// and is (re-)subscribed by the `connected` handler — so the server,
|
|
65
|
+
// which assigns a fresh uid per connection, always learns every channel.
|
|
66
|
+
if (STATE.uid) {
|
|
66
67
|
postSubscribe(channel).catch((err) => {
|
|
67
68
|
console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
|
|
68
69
|
});
|
|
69
|
-
}
|
|
70
|
-
if (STATE.uid)
|
|
71
|
-
doSubscribe();
|
|
72
|
-
else
|
|
73
|
-
STATE.pending.push(doSubscribe);
|
|
70
|
+
}
|
|
74
71
|
// Detacher — only removes the local listener. The server-side
|
|
75
72
|
// subscription stays open; closing it would interrupt other
|
|
76
73
|
// listeners on the same channel.
|
|
@@ -85,7 +82,6 @@ const CLIENT = {
|
|
|
85
82
|
}
|
|
86
83
|
STATE.uid = null;
|
|
87
84
|
STATE.channels.clear();
|
|
88
|
-
STATE.pending.length = 0;
|
|
89
85
|
},
|
|
90
86
|
};
|
|
91
87
|
function open() {
|
|
@@ -95,9 +91,16 @@ function open() {
|
|
|
95
91
|
const data = safeJson(ev.data);
|
|
96
92
|
if (data && typeof data.uid === "string") {
|
|
97
93
|
STATE.uid = data.uid;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
95
|
+
// assigns a fresh uid per connection and has no memory of prior
|
|
96
|
+
// subscriptions, so both the first connect AND browser auto-reconnects
|
|
97
|
+
// must re-POST every live channel — otherwise the client silently
|
|
98
|
+
// stops receiving after a reconnect.
|
|
99
|
+
for (const channel of STATE.channels.keys()) {
|
|
100
|
+
postSubscribe(channel).catch((err) => {
|
|
101
|
+
console.warn(`[aurora/relay] re-subscribe to ${channel} failed:`, err);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
101
104
|
}
|
|
102
105
|
});
|
|
103
106
|
sse.onmessage = (ev) => {
|
|
@@ -41,7 +41,7 @@ export async function renderPage(ctx, pages, name, props, options = {}) {
|
|
|
41
41
|
<head>
|
|
42
42
|
<meta charset="utf-8" />
|
|
43
43
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
44
|
-
<script type="importmap">${
|
|
44
|
+
<script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
|
|
45
45
|
${options.headExtra ?? ""}
|
|
46
46
|
</head>
|
|
47
47
|
<body>
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
|
|
2
|
+
export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
|
|
3
|
+
export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
|
|
4
|
+
export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ─── Server-only surface (imports node:fs / node:path / node:url) ─────
|
|
2
|
+
//
|
|
3
|
+
// Kept OUT of the package's main barrel (`@c9up/aurora`) so a browser bundle
|
|
4
|
+
// importing client primitives (component/html/hydrate/render) never pulls the
|
|
5
|
+
// Node built-ins through the import graph. Server code imports from
|
|
6
|
+
// `@c9up/aurora/server`; the client `.` entry stays node-free.
|
|
7
|
+
export { AuroraManager } from "./AuroraManager.js";
|
|
8
|
+
export { Pages } from "./Pages.js";
|
|
9
|
+
export { renderPage, } from "./server/renderPage.js";
|
|
10
|
+
export { serveAssets, } from "./server/serveAssets.js";
|
package/dist/ssr.js
CHANGED
|
@@ -75,8 +75,18 @@ function stringifyTemplateResult(result) {
|
|
|
75
75
|
out += stringifyValue(value, false);
|
|
76
76
|
out += `<!--${SLOT_END}-->`;
|
|
77
77
|
}
|
|
78
|
+
else if (inAttr) {
|
|
79
|
+
out += stringifyValue(value, true);
|
|
80
|
+
}
|
|
78
81
|
else {
|
|
79
|
-
|
|
82
|
+
// Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
|
|
83
|
+
// would emit NO node and desync the path-based hydration of the
|
|
84
|
+
// following sibling slots (their @input/@submit bindings break).
|
|
85
|
+
// Emit an empty-comment placeholder so the position is preserved
|
|
86
|
+
// — lit-html / Solid do the same; hydration materializes the text
|
|
87
|
+
// node there.
|
|
88
|
+
const text = stringifyValue(value, false);
|
|
89
|
+
out += text === "" ? "<!---->" : text;
|
|
80
90
|
}
|
|
81
91
|
}
|
|
82
92
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"./hydrate": {
|
|
31
31
|
"types": "./dist/hydrate.d.ts",
|
|
32
32
|
"import": "./dist/hydrate.js"
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"types": "./dist/server.d.ts",
|
|
36
|
+
"import": "./dist/server.js"
|
|
33
37
|
}
|
|
34
38
|
},
|
|
35
39
|
"peerDependencies": {
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser DX helpers — navigation + a typed `localStorage` wrapper.
|
|
3
|
+
*
|
|
4
|
+
* All are SSR-safe: on the server (no `window` / `localStorage`) navigation is
|
|
5
|
+
* a no-op and storage reads return `null`, so the same code runs during SSR
|
|
6
|
+
* without `typeof window` guards at every call site. Node-free — part of the
|
|
7
|
+
* client barrel.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
11
|
+
export function redirect(url: string): void {
|
|
12
|
+
if (typeof window !== "undefined") {
|
|
13
|
+
window.location.href = url;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Navigate to `url`, replacing the current history entry (no back-button trap —
|
|
19
|
+
* use after a login/logout so "back" doesn't return to the form). No-op on SSR.
|
|
20
|
+
*/
|
|
21
|
+
export function replace(url: string): void {
|
|
22
|
+
if (typeof window !== "undefined") {
|
|
23
|
+
window.location.replace(url);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reload the current page. No-op during SSR. */
|
|
28
|
+
export function reload(): void {
|
|
29
|
+
if (typeof window !== "undefined") {
|
|
30
|
+
window.location.reload();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
|
|
36
|
+
* return `null` on the server, on a missing key, or on malformed JSON. Writes
|
|
37
|
+
* swallow quota / private-mode errors so a full storage never crashes the app.
|
|
38
|
+
*/
|
|
39
|
+
export const storage = {
|
|
40
|
+
get<T>(key: string): T | null {
|
|
41
|
+
if (typeof localStorage === "undefined") return null;
|
|
42
|
+
const raw = localStorage.getItem(key);
|
|
43
|
+
if (raw === null) return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
set(key: string, value: unknown): void {
|
|
51
|
+
if (typeof localStorage === "undefined") return;
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
54
|
+
} catch {
|
|
55
|
+
// QuotaExceededError / Safari private mode — best-effort write.
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
remove(key: string): void {
|
|
59
|
+
if (typeof localStorage !== "undefined") {
|
|
60
|
+
localStorage.removeItem(key);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
clear(): void {
|
|
64
|
+
if (typeof localStorage !== "undefined") {
|
|
65
|
+
localStorage.clear();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
package/src/component.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* actually moves on screen.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { setOwner } from "./reactive.js";
|
|
22
23
|
import type { Disposer } from "./render.js";
|
|
23
24
|
import type { EffectCallback, TemplateResult } from "./types.js";
|
|
24
25
|
|
|
@@ -63,10 +64,16 @@ export function component<P = Record<string, never>>(
|
|
|
63
64
|
mountHooks: [],
|
|
64
65
|
};
|
|
65
66
|
contextStack.push(ctx);
|
|
67
|
+
// Own the reactive scope of setup: effects/memos created here register
|
|
68
|
+
// their disposer into ctx.cleanups and tear down at unmount, instead of
|
|
69
|
+
// leaking their signal subscriptions. Restored after setup so the outer
|
|
70
|
+
// scope (or none) resumes. Save/restore handles nested component() calls.
|
|
71
|
+
const prevOwner = setOwner(ctx.cleanups);
|
|
66
72
|
try {
|
|
67
73
|
const result = setup((props ?? ({} as P)) as P);
|
|
68
74
|
return wrapWithLifecycle(result, ctx);
|
|
69
75
|
} finally {
|
|
76
|
+
setOwner(prevOwner);
|
|
70
77
|
contextStack.pop();
|
|
71
78
|
}
|
|
72
79
|
};
|
package/src/hydrate.ts
CHANGED
|
@@ -422,12 +422,26 @@ function hydrateTextSlot(
|
|
|
422
422
|
}
|
|
423
423
|
return;
|
|
424
424
|
}
|
|
425
|
-
|
|
425
|
+
let textNode =
|
|
426
426
|
commentMarker.nodeType === 3 /* TEXT */
|
|
427
427
|
? (commentMarker as Text)
|
|
428
428
|
: commentMarker.previousSibling?.nodeType === 3
|
|
429
429
|
? (commentMarker.previousSibling as Text)
|
|
430
430
|
: null;
|
|
431
|
+
if (
|
|
432
|
+
!textNode &&
|
|
433
|
+
commentMarker.nodeType === 8 /* Comment */ &&
|
|
434
|
+
commentMarker.parentNode
|
|
435
|
+
) {
|
|
436
|
+
// Empty SSR text slot: a `<!---->` placeholder holds the position
|
|
437
|
+
// (see ssr.ts). Materialize the reactive text node there — node
|
|
438
|
+
// count stays 1, so sibling slot paths remain aligned.
|
|
439
|
+
const fresh = (commentMarker.ownerDocument ?? document).createTextNode(
|
|
440
|
+
"",
|
|
441
|
+
);
|
|
442
|
+
commentMarker.parentNode.replaceChild(fresh, commentMarker);
|
|
443
|
+
textNode = fresh;
|
|
444
|
+
}
|
|
431
445
|
if (!textNode) return;
|
|
432
446
|
const dispose = effect(() => {
|
|
433
447
|
const v = fn();
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// ───
|
|
2
|
-
|
|
1
|
+
// ─── Client surface (node-free — safe to bundle for the browser) ──────
|
|
2
|
+
//
|
|
3
|
+
// Server-only exports (AuroraManager, Pages, renderPage, serveAssets) that pull
|
|
4
|
+
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
|
+
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
|
+
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
+
export { redirect, reload, replace, storage } from "./browser.js";
|
|
3
8
|
export { component, onMount, onUnmount } from "./component.js";
|
|
4
9
|
export { html, isTemplateResult } from "./html.js";
|
|
5
10
|
export { hydrate } from "./hydrate.js";
|
|
6
|
-
export {
|
|
7
|
-
type PageFactory,
|
|
8
|
-
Pages,
|
|
9
|
-
type PagesConfig,
|
|
10
|
-
} from "./Pages.js";
|
|
11
11
|
export {
|
|
12
12
|
batch,
|
|
13
13
|
effect,
|
|
@@ -26,18 +26,5 @@ export {
|
|
|
26
26
|
type AuroraRouteConfig,
|
|
27
27
|
auroraRoute,
|
|
28
28
|
} from "./route.js";
|
|
29
|
-
export {
|
|
30
|
-
type RenderHttpContext,
|
|
31
|
-
type RenderPageOptions,
|
|
32
|
-
type RenderResponse,
|
|
33
|
-
renderPage,
|
|
34
|
-
} from "./server/renderPage.js";
|
|
35
|
-
export {
|
|
36
|
-
type AssetsHttpContext,
|
|
37
|
-
type AssetsRequest,
|
|
38
|
-
type AssetsResponse,
|
|
39
|
-
type ServeAssetsOptions,
|
|
40
|
-
serveAssets,
|
|
41
|
-
} from "./server/serveAssets.js";
|
|
42
29
|
export { renderToString } from "./ssr.js";
|
|
43
30
|
export type { TemplateResult } from "./types.js";
|
package/src/reactive.ts
CHANGED
|
@@ -64,6 +64,28 @@ function activeObserver(): Effect | undefined {
|
|
|
64
64
|
return observerStack[observerStack.length - 1];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Ambient disposal owner. A non-reactive scope (e.g. a component's setup
|
|
69
|
+
* run) registers an array here so effects/memos created during its
|
|
70
|
+
* execution push their disposer into it and are torn down when the scope
|
|
71
|
+
* ends. `undefined` at top level — no scope, no auto-disposal.
|
|
72
|
+
*/
|
|
73
|
+
let currentOwner: Array<() => void> | undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @internal Swap the ambient owner, returning the previous one so the
|
|
77
|
+
* caller can restore it. `component()` uses this to own the effects and
|
|
78
|
+
* memos a setup function creates, so they dispose at unmount instead of
|
|
79
|
+
* keeping their signal subscriptions alive forever.
|
|
80
|
+
*/
|
|
81
|
+
export function setOwner(
|
|
82
|
+
owner: Array<() => void> | undefined,
|
|
83
|
+
): Array<() => void> | undefined {
|
|
84
|
+
const prev = currentOwner;
|
|
85
|
+
currentOwner = owner;
|
|
86
|
+
return prev;
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Create a writable signal seeded with `initial`. Reads register the
|
|
69
91
|
* current observer; writes notify every observer that previously read.
|
|
@@ -178,7 +200,13 @@ export function effect(fn: EffectCallback): () => void {
|
|
|
178
200
|
},
|
|
179
201
|
};
|
|
180
202
|
eff.run();
|
|
181
|
-
|
|
203
|
+
const dispose = () => eff.dispose();
|
|
204
|
+
// Register with the ambient owner (e.g. a component's setup scope) so the
|
|
205
|
+
// effect is torn down when that scope ends. `memo()` builds on this — its
|
|
206
|
+
// internal recompute effect inherits the same ownership, which is what
|
|
207
|
+
// stops a memo created in component setup from leaking after unmount.
|
|
208
|
+
currentOwner?.push(dispose);
|
|
209
|
+
return dispose;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
212
|
/**
|
package/src/relay.ts
CHANGED
|
@@ -27,14 +27,12 @@ interface RelayState {
|
|
|
27
27
|
sse: EventSource | null;
|
|
28
28
|
uid: string | null;
|
|
29
29
|
channels: Map<string, Set<(event: unknown) => void>>;
|
|
30
|
-
pending: Array<() => void>;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const STATE: RelayState = {
|
|
34
33
|
sse: null,
|
|
35
34
|
uid: null,
|
|
36
35
|
channels: new Map(),
|
|
37
|
-
pending: [],
|
|
38
36
|
};
|
|
39
37
|
|
|
40
38
|
export interface RelayOptions {
|
|
@@ -87,15 +85,15 @@ const CLIENT: RelayClient = {
|
|
|
87
85
|
const adapted = handler as (event: unknown) => void;
|
|
88
86
|
handlers.add(adapted);
|
|
89
87
|
|
|
90
|
-
// Subscribe over POST as soon as we have a uid.
|
|
91
|
-
//
|
|
92
|
-
|
|
88
|
+
// Subscribe over POST as soon as we have a uid. Before the first uid (or
|
|
89
|
+
// during an auto-reconnect) the channel already lives in STATE.channels
|
|
90
|
+
// and is (re-)subscribed by the `connected` handler — so the server,
|
|
91
|
+
// which assigns a fresh uid per connection, always learns every channel.
|
|
92
|
+
if (STATE.uid) {
|
|
93
93
|
postSubscribe(channel).catch((err: unknown) => {
|
|
94
94
|
console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
|
|
95
95
|
});
|
|
96
|
-
}
|
|
97
|
-
if (STATE.uid) doSubscribe();
|
|
98
|
-
else STATE.pending.push(doSubscribe);
|
|
96
|
+
}
|
|
99
97
|
|
|
100
98
|
// Detacher — only removes the local listener. The server-side
|
|
101
99
|
// subscription stays open; closing it would interrupt other
|
|
@@ -112,7 +110,6 @@ const CLIENT: RelayClient = {
|
|
|
112
110
|
}
|
|
113
111
|
STATE.uid = null;
|
|
114
112
|
STATE.channels.clear();
|
|
115
|
-
STATE.pending.length = 0;
|
|
116
113
|
},
|
|
117
114
|
};
|
|
118
115
|
|
|
@@ -124,8 +121,19 @@ function open(): void {
|
|
|
124
121
|
const data = safeJson<{ uid?: string }>((ev as MessageEvent).data);
|
|
125
122
|
if (data && typeof data.uid === "string") {
|
|
126
123
|
STATE.uid = data.uid;
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
125
|
+
// assigns a fresh uid per connection and has no memory of prior
|
|
126
|
+
// subscriptions, so both the first connect AND browser auto-reconnects
|
|
127
|
+
// must re-POST every live channel — otherwise the client silently
|
|
128
|
+
// stops receiving after a reconnect.
|
|
129
|
+
for (const channel of STATE.channels.keys()) {
|
|
130
|
+
postSubscribe(channel).catch((err: unknown) => {
|
|
131
|
+
console.warn(
|
|
132
|
+
`[aurora/relay] re-subscribe to ${channel} failed:`,
|
|
133
|
+
err,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
129
137
|
}
|
|
130
138
|
});
|
|
131
139
|
|
package/src/server/renderPage.ts
CHANGED
|
@@ -90,7 +90,7 @@ export async function renderPage<P>(
|
|
|
90
90
|
<head>
|
|
91
91
|
<meta charset="utf-8" />
|
|
92
92
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
93
|
-
<script type="importmap">${
|
|
93
|
+
<script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
|
|
94
94
|
${options.headExtra ?? ""}
|
|
95
95
|
</head>
|
|
96
96
|
<body>
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ─── Server-only surface (imports node:fs / node:path / node:url) ─────
|
|
2
|
+
//
|
|
3
|
+
// Kept OUT of the package's main barrel (`@c9up/aurora`) so a browser bundle
|
|
4
|
+
// importing client primitives (component/html/hydrate/render) never pulls the
|
|
5
|
+
// Node built-ins through the import graph. Server code imports from
|
|
6
|
+
// `@c9up/aurora/server`; the client `.` entry stays node-free.
|
|
7
|
+
|
|
8
|
+
export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
|
|
9
|
+
export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
|
|
10
|
+
export {
|
|
11
|
+
type RenderHttpContext,
|
|
12
|
+
type RenderPageOptions,
|
|
13
|
+
type RenderResponse,
|
|
14
|
+
renderPage,
|
|
15
|
+
} from "./server/renderPage.js";
|
|
16
|
+
export {
|
|
17
|
+
type AssetsHttpContext,
|
|
18
|
+
type AssetsRequest,
|
|
19
|
+
type AssetsResponse,
|
|
20
|
+
type ServeAssetsOptions,
|
|
21
|
+
serveAssets,
|
|
22
|
+
} from "./server/serveAssets.js";
|
package/src/ssr.ts
CHANGED
|
@@ -78,8 +78,17 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
78
78
|
out += `<!--${SLOT_START}-->`;
|
|
79
79
|
out += stringifyValue(value, false);
|
|
80
80
|
out += `<!--${SLOT_END}-->`;
|
|
81
|
+
} else if (inAttr) {
|
|
82
|
+
out += stringifyValue(value, true);
|
|
81
83
|
} else {
|
|
82
|
-
|
|
84
|
+
// Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
|
|
85
|
+
// would emit NO node and desync the path-based hydration of the
|
|
86
|
+
// following sibling slots (their @input/@submit bindings break).
|
|
87
|
+
// Emit an empty-comment placeholder so the position is preserved
|
|
88
|
+
// — lit-html / Solid do the same; hydration materializes the text
|
|
89
|
+
// node there.
|
|
90
|
+
const text = stringifyValue(value, false);
|
|
91
|
+
out += text === "" ? "<!---->" : text;
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
}
|