@c9up/aurora 0.1.21 → 0.1.23
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 +29 -7
- package/dist/browser.js +43 -16
- package/dist/live.d.ts +11 -51
- package/dist/live.js +11 -99
- package/dist/liveBroadcast.d.ts +1 -1
- package/dist/liveBroadcast.js +1 -1
- package/dist/liveClient.d.ts +1 -1
- package/dist/liveRegistry.d.ts +1 -1
- package/dist/liveRegistry.js +1 -1
- package/dist/liveSession.d.ts +56 -0
- package/dist/liveSession.js +97 -0
- package/package.json +1 -1
- package/src/browser.ts +47 -18
- package/src/live.ts +16 -135
- package/src/liveBroadcast.ts +1 -1
- package/src/liveClient.ts +6 -2
- package/src/liveRegistry.ts +1 -1
- package/src/liveSession.ts +133 -0
package/dist/browser.d.ts
CHANGED
|
@@ -31,10 +31,14 @@ export interface WebStorageOptions {
|
|
|
31
31
|
area?: StorageArea;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* SSR-safe key/value store over `localStorage` / `sessionStorage`.
|
|
35
35
|
*
|
|
36
|
-
* -
|
|
37
|
-
*
|
|
36
|
+
* - {@link get} / {@link set} are a thin pass-through — raw strings, no JSON,
|
|
37
|
+
* exactly like the native `localStorage` API (so a token round-trips as-is,
|
|
38
|
+
* not double-encoded). {@link getJSON} / {@link setJSON} are the opt-in
|
|
39
|
+
* variants for structured values.
|
|
40
|
+
* - Reads return `null` on the server or a missing key (`getJSON` also on
|
|
41
|
+
* malformed JSON).
|
|
38
42
|
* - Writes swallow quota / private-mode errors so a full store never crashes
|
|
39
43
|
* the app (best-effort).
|
|
40
44
|
* - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
|
|
@@ -45,13 +49,31 @@ export declare class WebStorage {
|
|
|
45
49
|
constructor(options?: WebStorageOptions);
|
|
46
50
|
/** The on-disk key for `key`, namespaced by the configured prefix. */
|
|
47
51
|
fullKey(key: string): string;
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Read the raw string at `key` — a thin, SSR-safe pass-through over
|
|
54
|
+
* `localStorage.getItem` (returns `null` on the server or a missing key).
|
|
55
|
+
* No JSON: what you {@link set} is what you get. Use {@link getJSON} for
|
|
56
|
+
* structured values.
|
|
57
|
+
*/
|
|
58
|
+
get(key: string): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Write the raw string `value` at `key` (SSR no-op; quota / private-mode
|
|
61
|
+
* errors are swallowed — best-effort). No encoding, like
|
|
62
|
+
* `localStorage.setItem`. Use {@link setJSON} for objects/arrays/etc.
|
|
63
|
+
*/
|
|
64
|
+
set(key: string, value: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* Read `key` and JSON-parse it into `T`. Returns `null` on the server, a
|
|
67
|
+
* missing key, or malformed JSON. Pair with {@link setJSON}.
|
|
68
|
+
*/
|
|
69
|
+
getJSON<T>(key: string): T | null;
|
|
70
|
+
/** JSON-serialise `value` and store it at `key`. Pair with {@link getJSON}. */
|
|
71
|
+
setJSON(key: string, value: unknown): void;
|
|
50
72
|
/** Whether `key` is present (and not the server). */
|
|
51
73
|
has(key: string): boolean;
|
|
52
74
|
remove(key: string): void;
|
|
53
|
-
/** Read `key`, or compute + persist `factory()` on a miss, returning the
|
|
54
|
-
getOrSet
|
|
75
|
+
/** Read `key`, or compute + persist `factory()` on a miss, returning the string. */
|
|
76
|
+
getOrSet(key: string, factory: () => string): string;
|
|
55
77
|
/** Keys in this store, prefix stripped. Empty array during SSR. */
|
|
56
78
|
keys(): string[];
|
|
57
79
|
/** Remove this store's keys. With no prefix this clears the whole area. */
|
package/dist/browser.js
CHANGED
|
@@ -29,10 +29,14 @@ export function reload() {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* SSR-safe key/value store over `localStorage` / `sessionStorage`.
|
|
33
33
|
*
|
|
34
|
-
* -
|
|
35
|
-
*
|
|
34
|
+
* - {@link get} / {@link set} are a thin pass-through — raw strings, no JSON,
|
|
35
|
+
* exactly like the native `localStorage` API (so a token round-trips as-is,
|
|
36
|
+
* not double-encoded). {@link getJSON} / {@link setJSON} are the opt-in
|
|
37
|
+
* variants for structured values.
|
|
38
|
+
* - Reads return `null` on the server or a missing key (`getJSON` also on
|
|
39
|
+
* malformed JSON).
|
|
36
40
|
* - Writes swallow quota / private-mode errors so a full store never crashes
|
|
37
41
|
* the app (best-effort).
|
|
38
42
|
* - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
|
|
@@ -56,31 +60,53 @@ export class WebStorage {
|
|
|
56
60
|
fullKey(key) {
|
|
57
61
|
return this.#prefix + key;
|
|
58
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the raw string at `key` — a thin, SSR-safe pass-through over
|
|
65
|
+
* `localStorage.getItem` (returns `null` on the server or a missing key).
|
|
66
|
+
* No JSON: what you {@link set} is what you get. Use {@link getJSON} for
|
|
67
|
+
* structured values.
|
|
68
|
+
*/
|
|
59
69
|
get(key) {
|
|
60
70
|
const backend = this.#backend();
|
|
61
71
|
if (!backend)
|
|
62
72
|
return null;
|
|
63
|
-
|
|
64
|
-
if (raw === null)
|
|
65
|
-
return null;
|
|
66
|
-
try {
|
|
67
|
-
return JSON.parse(raw);
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
73
|
+
return backend.getItem(this.fullKey(key));
|
|
72
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Write the raw string `value` at `key` (SSR no-op; quota / private-mode
|
|
77
|
+
* errors are swallowed — best-effort). No encoding, like
|
|
78
|
+
* `localStorage.setItem`. Use {@link setJSON} for objects/arrays/etc.
|
|
79
|
+
*/
|
|
73
80
|
set(key, value) {
|
|
74
81
|
const backend = this.#backend();
|
|
75
82
|
if (!backend)
|
|
76
83
|
return;
|
|
77
84
|
try {
|
|
78
|
-
backend.setItem(this.fullKey(key),
|
|
85
|
+
backend.setItem(this.fullKey(key), value);
|
|
79
86
|
}
|
|
80
87
|
catch {
|
|
81
88
|
// QuotaExceededError / Safari private mode — best-effort write.
|
|
82
89
|
}
|
|
83
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Read `key` and JSON-parse it into `T`. Returns `null` on the server, a
|
|
93
|
+
* missing key, or malformed JSON. Pair with {@link setJSON}.
|
|
94
|
+
*/
|
|
95
|
+
getJSON(key) {
|
|
96
|
+
const raw = this.get(key);
|
|
97
|
+
if (raw === null)
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** JSON-serialise `value` and store it at `key`. Pair with {@link getJSON}. */
|
|
107
|
+
setJSON(key, value) {
|
|
108
|
+
this.set(key, JSON.stringify(value));
|
|
109
|
+
}
|
|
84
110
|
/** Whether `key` is present (and not the server). */
|
|
85
111
|
has(key) {
|
|
86
112
|
const backend = this.#backend();
|
|
@@ -91,7 +117,7 @@ export class WebStorage {
|
|
|
91
117
|
remove(key) {
|
|
92
118
|
this.#backend()?.removeItem(this.fullKey(key));
|
|
93
119
|
}
|
|
94
|
-
/** Read `key`, or compute + persist `factory()` on a miss, returning the
|
|
120
|
+
/** Read `key`, or compute + persist `factory()` on a miss, returning the string. */
|
|
95
121
|
getOrSet(key, factory) {
|
|
96
122
|
const existing = this.get(key);
|
|
97
123
|
if (existing !== null)
|
|
@@ -148,11 +174,12 @@ export const session = new WebStorage({ area: "session" });
|
|
|
148
174
|
*/
|
|
149
175
|
export function persistedSignal(key, initial, options = {}) {
|
|
150
176
|
const store = new WebStorage(options);
|
|
151
|
-
const stored = store.
|
|
177
|
+
const stored = store.getJSON(key);
|
|
152
178
|
const sig = signal(stored !== null ? stored : initial);
|
|
153
179
|
// Mirror every change back to storage; runs once immediately, then on change.
|
|
180
|
+
// JSON so any T (object, number, boolean, string) round-trips.
|
|
154
181
|
effect(() => {
|
|
155
|
-
store.
|
|
182
|
+
store.setJSON(key, sig());
|
|
156
183
|
});
|
|
157
184
|
if ((options.crossTab ?? true) &&
|
|
158
185
|
(options.area ?? "local") === "local" &&
|
package/dist/live.d.ts
CHANGED
|
@@ -1,60 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* as ordinary aurora signals; this module turns a fine-grained signal change
|
|
4
|
-
* into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering the
|
|
5
|
-
* whole component. That precision — each signal already knows which template
|
|
6
|
-
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
2
|
+
* `@c9up/aurora/live` barrel — the whole Live surface behind ONE subpath.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and the
|
|
4
|
+
* The transport-agnostic core (`mountLiveSession` + its types) lives in
|
|
5
|
+
* `./liveSession.js`; the transport stack (broadcast / client / registry /
|
|
6
|
+
* router / server) builds on it. Re-exporting everything here keeps the niche
|
|
7
|
+
* surface behind a single subpath and keeps the main `.` barrel lean (it no
|
|
8
|
+
* longer eager-pulls these into every browser graph), consistent with how
|
|
9
|
+
* `./ssr`, `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The core lives in a SEPARATE module (not inline here) so the transport files
|
|
12
|
+
* import it directly from `./liveSession.js` rather than through this barrel —
|
|
13
|
+
* which would otherwise form an import cycle (barrel → transport → barrel).
|
|
15
14
|
*/
|
|
16
|
-
import type { TemplateResult } from "./types.js";
|
|
17
|
-
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
18
|
-
export interface SlotPatch {
|
|
19
|
-
slot: number;
|
|
20
|
-
value: string;
|
|
21
|
-
}
|
|
22
|
-
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
23
|
-
export interface LiveComponentDefinition {
|
|
24
|
-
/** The reactive view. Slots reading signals become live-patchable. */
|
|
25
|
-
view: TemplateResult;
|
|
26
|
-
/**
|
|
27
|
-
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
28
|
-
* dispatches one of these; it mutates the component's signals, which the
|
|
29
|
-
* patch tracker turns into a `{slot, value}` patch.
|
|
30
|
-
*/
|
|
31
|
-
handlers?: Record<string, (payload?: unknown) => void>;
|
|
32
|
-
}
|
|
33
|
-
/** A mounted live component instance — one per connected client session. */
|
|
34
|
-
export interface LiveSession {
|
|
35
|
-
/** Initial server-side render (the first full-HTML response). */
|
|
36
|
-
renderToString(): string;
|
|
37
|
-
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
38
|
-
dispatch(event: string, payload?: unknown): void;
|
|
39
|
-
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
40
|
-
drainPatches(): SlotPatch[];
|
|
41
|
-
/**
|
|
42
|
-
* Subscribe to patches as they are produced (push model — what the relay
|
|
43
|
-
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
44
|
-
*/
|
|
45
|
-
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
46
|
-
/** Stop every effect — call on client disconnect to free the session. */
|
|
47
|
-
dispose(): void;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Mount a live session from a definition factory. Call once per connected
|
|
51
|
-
* client: the factory's signals become that session's private state. To SHARE
|
|
52
|
-
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
53
|
-
* every session then reads the same signal and patches on its change.
|
|
54
|
-
*/
|
|
55
|
-
export declare function mountLiveSession(factory: () => LiveComponentDefinition): LiveSession;
|
|
56
15
|
export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } from "./liveBroadcast.js";
|
|
57
16
|
export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
|
|
58
17
|
export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
|
|
59
18
|
export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
|
|
60
19
|
export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
|
|
20
|
+
export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./liveSession.js";
|
package/dist/live.js
CHANGED
|
@@ -1,108 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* as ordinary aurora signals; this module turns a fine-grained signal change
|
|
4
|
-
* into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering the
|
|
5
|
-
* whole component. That precision — each signal already knows which template
|
|
6
|
-
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
2
|
+
* `@c9up/aurora/live` barrel — the whole Live surface behind ONE subpath.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and the
|
|
4
|
+
* The transport-agnostic core (`mountLiveSession` + its types) lives in
|
|
5
|
+
* `./liveSession.js`; the transport stack (broadcast / client / registry /
|
|
6
|
+
* router / server) builds on it. Re-exporting everything here keeps the niche
|
|
7
|
+
* surface behind a single subpath and keeps the main `.` barrel lean (it no
|
|
8
|
+
* longer eager-pulls these into every browser graph), consistent with how
|
|
9
|
+
* `./ssr`, `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The core lives in a SEPARATE module (not inline here) so the transport files
|
|
12
|
+
* import it directly from `./liveSession.js` rather than through this barrel —
|
|
13
|
+
* which would otherwise form an import cycle (barrel → transport → barrel).
|
|
15
14
|
*/
|
|
16
|
-
import { effect, isSignal } from "./reactive.js";
|
|
17
|
-
import { renderToString } from "./ssr.js";
|
|
18
|
-
/**
|
|
19
|
-
* Mount a live session from a definition factory. Call once per connected
|
|
20
|
-
* client: the factory's signals become that session's private state. To SHARE
|
|
21
|
-
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
22
|
-
* every session then reads the same signal and patches on its change.
|
|
23
|
-
*/
|
|
24
|
-
export function mountLiveSession(factory) {
|
|
25
|
-
const { view, handlers = {} } = factory();
|
|
26
|
-
const listeners = new Set();
|
|
27
|
-
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
28
|
-
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
29
|
-
// one batch collapse to the last value.
|
|
30
|
-
const pending = new Map();
|
|
31
|
-
const buffer = new Map();
|
|
32
|
-
let priming = true;
|
|
33
|
-
let flushScheduled = false;
|
|
34
|
-
const flush = () => {
|
|
35
|
-
flushScheduled = false;
|
|
36
|
-
if (pending.size === 0)
|
|
37
|
-
return;
|
|
38
|
-
const patch = [];
|
|
39
|
-
for (const [slot, value] of pending) {
|
|
40
|
-
patch.push({ slot, value });
|
|
41
|
-
buffer.set(slot, value);
|
|
42
|
-
}
|
|
43
|
-
pending.clear();
|
|
44
|
-
for (const listener of listeners)
|
|
45
|
-
listener(patch);
|
|
46
|
-
};
|
|
47
|
-
const scheduleFlush = () => {
|
|
48
|
-
if (flushScheduled)
|
|
49
|
-
return;
|
|
50
|
-
flushScheduled = true;
|
|
51
|
-
queueMicrotask(flush);
|
|
52
|
-
};
|
|
53
|
-
// One fine-grained effect per reactive slot. The priming run only
|
|
54
|
-
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
55
|
-
const stops = view.values.map((value, slot) => {
|
|
56
|
-
if (!isSignal(value) && typeof value !== "function")
|
|
57
|
-
return () => { };
|
|
58
|
-
return effect(() => {
|
|
59
|
-
const next = String(value());
|
|
60
|
-
if (priming)
|
|
61
|
-
return;
|
|
62
|
-
pending.set(slot, next);
|
|
63
|
-
scheduleFlush();
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
priming = false;
|
|
67
|
-
return {
|
|
68
|
-
renderToString: () => renderToString(view),
|
|
69
|
-
dispatch(event, payload) {
|
|
70
|
-
const handler = handlers[event];
|
|
71
|
-
if (!handler)
|
|
72
|
-
return;
|
|
73
|
-
handler(payload);
|
|
74
|
-
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
75
|
-
},
|
|
76
|
-
drainPatches() {
|
|
77
|
-
flush();
|
|
78
|
-
const patch = [];
|
|
79
|
-
for (const [slot, value] of buffer)
|
|
80
|
-
patch.push({ slot, value });
|
|
81
|
-
buffer.clear();
|
|
82
|
-
return patch;
|
|
83
|
-
},
|
|
84
|
-
onPatch(listener) {
|
|
85
|
-
listeners.add(listener);
|
|
86
|
-
return () => listeners.delete(listener);
|
|
87
|
-
},
|
|
88
|
-
dispose() {
|
|
89
|
-
for (const stop of stops)
|
|
90
|
-
stop();
|
|
91
|
-
listeners.clear();
|
|
92
|
-
pending.clear();
|
|
93
|
-
buffer.clear();
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
// ─── `@c9up/aurora/live` barrel ──────────────────────────────────────
|
|
98
|
-
//
|
|
99
|
-
// The Live stack (broadcast / client / registry / router / server) is
|
|
100
|
-
// re-exported here so the whole niche surface lives behind ONE subpath,
|
|
101
|
-
// `@c9up/aurora/live` — keeping the main `.` barrel lean (it no longer
|
|
102
|
-
// eager-pulls these into every browser graph), consistent with how `./ssr`,
|
|
103
|
-
// `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
104
15
|
export { connectPatches, liveStore, } from "./liveBroadcast.js";
|
|
105
16
|
export { buildLiveTransport, liveClient, } from "./liveClient.js";
|
|
106
17
|
export { createLiveRegistry, } from "./liveRegistry.js";
|
|
107
18
|
export { createLiveRouter, } from "./liveRouter.js";
|
|
108
19
|
export { DEFAULT_LIVE_EVENT_PATH, wireLiveEvents, } from "./liveServer.js";
|
|
20
|
+
export { mountLiveSession, } from "./liveSession.js";
|
package/dist/liveBroadcast.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* one mutation → one patch computed once → relay fans it out to all subscribers
|
|
15
15
|
* (O(1) compute, O(N) network).
|
|
16
16
|
*/
|
|
17
|
-
import { type LiveComponentDefinition, type LiveSession } from "./
|
|
17
|
+
import { type LiveComponentDefinition, type LiveSession } from "./liveSession.js";
|
|
18
18
|
/**
|
|
19
19
|
* Minimal relay surface aurora needs. The real `@c9up/relay` `Relay` satisfies
|
|
20
20
|
* it (`broadcast(channel, data) → recipient count`); aurora never imports it.
|
package/dist/liveBroadcast.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* one mutation → one patch computed once → relay fans it out to all subscribers
|
|
15
15
|
* (O(1) compute, O(N) network).
|
|
16
16
|
*/
|
|
17
|
-
import { mountLiveSession, } from "./
|
|
17
|
+
import { mountLiveSession, } from "./liveSession.js";
|
|
18
18
|
/**
|
|
19
19
|
* Pipe a session's patches onto a relay channel as they are produced. Returns
|
|
20
20
|
* an unsubscribe. Each patch becomes one `broadcast(channel, patch)`.
|
package/dist/liveClient.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `subscribe` to `@c9up/aurora/relay`'s `relay().subscribe` and `post` to an
|
|
15
15
|
* `HttpClient`. Browser-only (uses the DOM) — part of the client barrel.
|
|
16
16
|
*/
|
|
17
|
-
import type { SlotPatch } from "./
|
|
17
|
+
import type { SlotPatch } from "./liveSession.js";
|
|
18
18
|
import type { TemplateResult } from "./types.js";
|
|
19
19
|
/** The transport the live client needs: a patch subscription + an event POST. */
|
|
20
20
|
export interface LiveClientTransport {
|
package/dist/liveRegistry.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `get(id)?.dispatch(...)`; disconnect → `disposeOwner(uid)`. Transport-agnostic
|
|
11
11
|
* and node-free (only `mountLiveSession` + `crypto.randomUUID`, both isomorphic).
|
|
12
12
|
*/
|
|
13
|
-
import { type LiveComponentDefinition, type LiveSession } from "./
|
|
13
|
+
import { type LiveComponentDefinition, type LiveSession } from "./liveSession.js";
|
|
14
14
|
/** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
|
|
15
15
|
export interface LiveSessionHandle {
|
|
16
16
|
id: string;
|
package/dist/liveRegistry.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `get(id)?.dispatch(...)`; disconnect → `disposeOwner(uid)`. Transport-agnostic
|
|
11
11
|
* and node-free (only `mountLiveSession` + `crypto.randomUUID`, both isomorphic).
|
|
12
12
|
*/
|
|
13
|
-
import { mountLiveSession, } from "./
|
|
13
|
+
import { mountLiveSession, } from "./liveSession.js";
|
|
14
14
|
/** Create an isolated live-session registry (one per app / per relay instance). */
|
|
15
15
|
export function createLiveRegistry() {
|
|
16
16
|
const defs = new Map();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live components — server-resident reactive UI CORE. The state lives on the
|
|
3
|
+
* server as ordinary aurora signals; this module turns a fine-grained signal
|
|
4
|
+
* change into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering
|
|
5
|
+
* the whole component. That precision — each signal already knows which template
|
|
6
|
+
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
7
|
+
*
|
|
8
|
+
* This is the transport-agnostic core: mount a session, render its initial HTML,
|
|
9
|
+
* dispatch events that mutate signals, and drain / subscribe to the patches
|
|
10
|
+
* produced. The transport stack (broadcast / client / registry / router /
|
|
11
|
+
* server) builds ON this and re-exports through the `./live.js` barrel — kept in
|
|
12
|
+
* a separate module so those transport files import the core WITHOUT forming an
|
|
13
|
+
* import cycle back through the barrel.
|
|
14
|
+
*
|
|
15
|
+
* Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`.
|
|
16
|
+
*/
|
|
17
|
+
import type { TemplateResult } from "./types.js";
|
|
18
|
+
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
19
|
+
export interface SlotPatch {
|
|
20
|
+
slot: number;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
24
|
+
export interface LiveComponentDefinition {
|
|
25
|
+
/** The reactive view. Slots reading signals become live-patchable. */
|
|
26
|
+
view: TemplateResult;
|
|
27
|
+
/**
|
|
28
|
+
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
29
|
+
* dispatches one of these; it mutates the component's signals, which the
|
|
30
|
+
* patch tracker turns into a `{slot, value}` patch.
|
|
31
|
+
*/
|
|
32
|
+
handlers?: Record<string, (payload?: unknown) => void>;
|
|
33
|
+
}
|
|
34
|
+
/** A mounted live component instance — one per connected client session. */
|
|
35
|
+
export interface LiveSession {
|
|
36
|
+
/** Initial server-side render (the first full-HTML response). */
|
|
37
|
+
renderToString(): string;
|
|
38
|
+
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
39
|
+
dispatch(event: string, payload?: unknown): void;
|
|
40
|
+
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
41
|
+
drainPatches(): SlotPatch[];
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to patches as they are produced (push model — what the relay
|
|
44
|
+
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
45
|
+
*/
|
|
46
|
+
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
47
|
+
/** Stop every effect — call on client disconnect to free the session. */
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
52
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
53
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
54
|
+
* every session then reads the same signal and patches on its change.
|
|
55
|
+
*/
|
|
56
|
+
export declare function mountLiveSession(factory: () => LiveComponentDefinition): LiveSession;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live components — server-resident reactive UI CORE. The state lives on the
|
|
3
|
+
* server as ordinary aurora signals; this module turns a fine-grained signal
|
|
4
|
+
* change into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering
|
|
5
|
+
* the whole component. That precision — each signal already knows which template
|
|
6
|
+
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
7
|
+
*
|
|
8
|
+
* This is the transport-agnostic core: mount a session, render its initial HTML,
|
|
9
|
+
* dispatch events that mutate signals, and drain / subscribe to the patches
|
|
10
|
+
* produced. The transport stack (broadcast / client / registry / router /
|
|
11
|
+
* server) builds ON this and re-exports through the `./live.js` barrel — kept in
|
|
12
|
+
* a separate module so those transport files import the core WITHOUT forming an
|
|
13
|
+
* import cycle back through the barrel.
|
|
14
|
+
*
|
|
15
|
+
* Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`.
|
|
16
|
+
*/
|
|
17
|
+
import { effect, isSignal } from "./reactive.js";
|
|
18
|
+
import { renderToString } from "./ssr.js";
|
|
19
|
+
/**
|
|
20
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
21
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
22
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
23
|
+
* every session then reads the same signal and patches on its change.
|
|
24
|
+
*/
|
|
25
|
+
export function mountLiveSession(factory) {
|
|
26
|
+
const { view, handlers = {} } = factory();
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
29
|
+
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
30
|
+
// one batch collapse to the last value.
|
|
31
|
+
const pending = new Map();
|
|
32
|
+
const buffer = new Map();
|
|
33
|
+
let priming = true;
|
|
34
|
+
let flushScheduled = false;
|
|
35
|
+
const flush = () => {
|
|
36
|
+
flushScheduled = false;
|
|
37
|
+
if (pending.size === 0)
|
|
38
|
+
return;
|
|
39
|
+
const patch = [];
|
|
40
|
+
for (const [slot, value] of pending) {
|
|
41
|
+
patch.push({ slot, value });
|
|
42
|
+
buffer.set(slot, value);
|
|
43
|
+
}
|
|
44
|
+
pending.clear();
|
|
45
|
+
for (const listener of listeners)
|
|
46
|
+
listener(patch);
|
|
47
|
+
};
|
|
48
|
+
const scheduleFlush = () => {
|
|
49
|
+
if (flushScheduled)
|
|
50
|
+
return;
|
|
51
|
+
flushScheduled = true;
|
|
52
|
+
queueMicrotask(flush);
|
|
53
|
+
};
|
|
54
|
+
// One fine-grained effect per reactive slot. The priming run only
|
|
55
|
+
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
56
|
+
const stops = view.values.map((value, slot) => {
|
|
57
|
+
if (!isSignal(value) && typeof value !== "function")
|
|
58
|
+
return () => { };
|
|
59
|
+
return effect(() => {
|
|
60
|
+
const next = String(value());
|
|
61
|
+
if (priming)
|
|
62
|
+
return;
|
|
63
|
+
pending.set(slot, next);
|
|
64
|
+
scheduleFlush();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
priming = false;
|
|
68
|
+
return {
|
|
69
|
+
renderToString: () => renderToString(view),
|
|
70
|
+
dispatch(event, payload) {
|
|
71
|
+
const handler = handlers[event];
|
|
72
|
+
if (!handler)
|
|
73
|
+
return;
|
|
74
|
+
handler(payload);
|
|
75
|
+
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
76
|
+
},
|
|
77
|
+
drainPatches() {
|
|
78
|
+
flush();
|
|
79
|
+
const patch = [];
|
|
80
|
+
for (const [slot, value] of buffer)
|
|
81
|
+
patch.push({ slot, value });
|
|
82
|
+
buffer.clear();
|
|
83
|
+
return patch;
|
|
84
|
+
},
|
|
85
|
+
onPatch(listener) {
|
|
86
|
+
listeners.add(listener);
|
|
87
|
+
return () => listeners.delete(listener);
|
|
88
|
+
},
|
|
89
|
+
dispose() {
|
|
90
|
+
for (const stop of stops)
|
|
91
|
+
stop();
|
|
92
|
+
listeners.clear();
|
|
93
|
+
pending.clear();
|
|
94
|
+
buffer.clear();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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/browser.ts
CHANGED
|
@@ -50,10 +50,14 @@ export interface WebStorageOptions {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
*
|
|
53
|
+
* SSR-safe key/value store over `localStorage` / `sessionStorage`.
|
|
54
54
|
*
|
|
55
|
-
* -
|
|
56
|
-
*
|
|
55
|
+
* - {@link get} / {@link set} are a thin pass-through — raw strings, no JSON,
|
|
56
|
+
* exactly like the native `localStorage` API (so a token round-trips as-is,
|
|
57
|
+
* not double-encoded). {@link getJSON} / {@link setJSON} are the opt-in
|
|
58
|
+
* variants for structured values.
|
|
59
|
+
* - Reads return `null` on the server or a missing key (`getJSON` also on
|
|
60
|
+
* malformed JSON).
|
|
57
61
|
* - Writes swallow quota / private-mode errors so a full store never crashes
|
|
58
62
|
* the app (best-effort).
|
|
59
63
|
* - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
|
|
@@ -80,28 +84,52 @@ export class WebStorage {
|
|
|
80
84
|
return this.#prefix + key;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Read the raw string at `key` — a thin, SSR-safe pass-through over
|
|
89
|
+
* `localStorage.getItem` (returns `null` on the server or a missing key).
|
|
90
|
+
* No JSON: what you {@link set} is what you get. Use {@link getJSON} for
|
|
91
|
+
* structured values.
|
|
92
|
+
*/
|
|
93
|
+
get(key: string): string | null {
|
|
84
94
|
const backend = this.#backend();
|
|
85
95
|
if (!backend) return null;
|
|
86
|
-
|
|
87
|
-
if (raw === null) return null;
|
|
88
|
-
try {
|
|
89
|
-
return JSON.parse(raw) as T;
|
|
90
|
-
} catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
96
|
+
return backend.getItem(this.fullKey(key));
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Write the raw string `value` at `key` (SSR no-op; quota / private-mode
|
|
101
|
+
* errors are swallowed — best-effort). No encoding, like
|
|
102
|
+
* `localStorage.setItem`. Use {@link setJSON} for objects/arrays/etc.
|
|
103
|
+
*/
|
|
104
|
+
set(key: string, value: string): void {
|
|
96
105
|
const backend = this.#backend();
|
|
97
106
|
if (!backend) return;
|
|
98
107
|
try {
|
|
99
|
-
backend.setItem(this.fullKey(key),
|
|
108
|
+
backend.setItem(this.fullKey(key), value);
|
|
100
109
|
} catch {
|
|
101
110
|
// QuotaExceededError / Safari private mode — best-effort write.
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Read `key` and JSON-parse it into `T`. Returns `null` on the server, a
|
|
116
|
+
* missing key, or malformed JSON. Pair with {@link setJSON}.
|
|
117
|
+
*/
|
|
118
|
+
getJSON<T>(key: string): T | null {
|
|
119
|
+
const raw = this.get(key);
|
|
120
|
+
if (raw === null) return null;
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(raw) as T;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** JSON-serialise `value` and store it at `key`. Pair with {@link getJSON}. */
|
|
129
|
+
setJSON(key: string, value: unknown): void {
|
|
130
|
+
this.set(key, JSON.stringify(value));
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/** Whether `key` is present (and not the server). */
|
|
106
134
|
has(key: string): boolean {
|
|
107
135
|
const backend = this.#backend();
|
|
@@ -113,9 +141,9 @@ export class WebStorage {
|
|
|
113
141
|
this.#backend()?.removeItem(this.fullKey(key));
|
|
114
142
|
}
|
|
115
143
|
|
|
116
|
-
/** Read `key`, or compute + persist `factory()` on a miss, returning the
|
|
117
|
-
getOrSet
|
|
118
|
-
const existing = this.get
|
|
144
|
+
/** Read `key`, or compute + persist `factory()` on a miss, returning the string. */
|
|
145
|
+
getOrSet(key: string, factory: () => string): string {
|
|
146
|
+
const existing = this.get(key);
|
|
119
147
|
if (existing !== null) return existing;
|
|
120
148
|
const value = factory();
|
|
121
149
|
this.set(key, value);
|
|
@@ -183,12 +211,13 @@ export function persistedSignal<T>(
|
|
|
183
211
|
options: PersistedSignalOptions = {},
|
|
184
212
|
): Signal<T> {
|
|
185
213
|
const store = new WebStorage(options);
|
|
186
|
-
const stored = store.
|
|
214
|
+
const stored = store.getJSON<T>(key);
|
|
187
215
|
const sig = signal<T>(stored !== null ? stored : initial);
|
|
188
216
|
|
|
189
217
|
// Mirror every change back to storage; runs once immediately, then on change.
|
|
218
|
+
// JSON so any T (object, number, boolean, string) round-trips.
|
|
190
219
|
effect(() => {
|
|
191
|
-
store.
|
|
220
|
+
store.setJSON(key, sig());
|
|
192
221
|
});
|
|
193
222
|
|
|
194
223
|
if (
|
package/src/live.ts
CHANGED
|
@@ -1,143 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* as ordinary aurora signals; this module turns a fine-grained signal change
|
|
4
|
-
* into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering the
|
|
5
|
-
* whole component. That precision — each signal already knows which template
|
|
6
|
-
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
2
|
+
* `@c9up/aurora/live` barrel — the whole Live surface behind ONE subpath.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and the
|
|
4
|
+
* The transport-agnostic core (`mountLiveSession` + its types) lives in
|
|
5
|
+
* `./liveSession.js`; the transport stack (broadcast / client / registry /
|
|
6
|
+
* router / server) builds on it. Re-exporting everything here keeps the niche
|
|
7
|
+
* surface behind a single subpath and keeps the main `.` barrel lean (it no
|
|
8
|
+
* longer eager-pulls these into every browser graph), consistent with how
|
|
9
|
+
* `./ssr`, `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The core lives in a SEPARATE module (not inline here) so the transport files
|
|
12
|
+
* import it directly from `./liveSession.js` rather than through this barrel —
|
|
13
|
+
* which would otherwise form an import cycle (barrel → transport → barrel).
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
|
-
import { effect, isSignal } from "./reactive.js";
|
|
18
|
-
import { renderToString } from "./ssr.js";
|
|
19
|
-
import type { TemplateResult } from "./types.js";
|
|
20
|
-
|
|
21
|
-
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
22
|
-
export interface SlotPatch {
|
|
23
|
-
slot: number;
|
|
24
|
-
value: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
28
|
-
export interface LiveComponentDefinition {
|
|
29
|
-
/** The reactive view. Slots reading signals become live-patchable. */
|
|
30
|
-
view: TemplateResult;
|
|
31
|
-
/**
|
|
32
|
-
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
33
|
-
* dispatches one of these; it mutates the component's signals, which the
|
|
34
|
-
* patch tracker turns into a `{slot, value}` patch.
|
|
35
|
-
*/
|
|
36
|
-
handlers?: Record<string, (payload?: unknown) => void>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** A mounted live component instance — one per connected client session. */
|
|
40
|
-
export interface LiveSession {
|
|
41
|
-
/** Initial server-side render (the first full-HTML response). */
|
|
42
|
-
renderToString(): string;
|
|
43
|
-
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
44
|
-
dispatch(event: string, payload?: unknown): void;
|
|
45
|
-
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
46
|
-
drainPatches(): SlotPatch[];
|
|
47
|
-
/**
|
|
48
|
-
* Subscribe to patches as they are produced (push model — what the relay
|
|
49
|
-
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
50
|
-
*/
|
|
51
|
-
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
52
|
-
/** Stop every effect — call on client disconnect to free the session. */
|
|
53
|
-
dispose(): void;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Mount a live session from a definition factory. Call once per connected
|
|
58
|
-
* client: the factory's signals become that session's private state. To SHARE
|
|
59
|
-
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
60
|
-
* every session then reads the same signal and patches on its change.
|
|
61
|
-
*/
|
|
62
|
-
export function mountLiveSession(
|
|
63
|
-
factory: () => LiveComponentDefinition,
|
|
64
|
-
): LiveSession {
|
|
65
|
-
const { view, handlers = {} } = factory();
|
|
66
|
-
const listeners = new Set<(patch: SlotPatch[]) => void>();
|
|
67
|
-
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
68
|
-
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
69
|
-
// one batch collapse to the last value.
|
|
70
|
-
const pending = new Map<number, string>();
|
|
71
|
-
const buffer = new Map<number, string>();
|
|
72
|
-
let priming = true;
|
|
73
|
-
let flushScheduled = false;
|
|
74
|
-
|
|
75
|
-
const flush = (): void => {
|
|
76
|
-
flushScheduled = false;
|
|
77
|
-
if (pending.size === 0) return;
|
|
78
|
-
const patch: SlotPatch[] = [];
|
|
79
|
-
for (const [slot, value] of pending) {
|
|
80
|
-
patch.push({ slot, value });
|
|
81
|
-
buffer.set(slot, value);
|
|
82
|
-
}
|
|
83
|
-
pending.clear();
|
|
84
|
-
for (const listener of listeners) listener(patch);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const scheduleFlush = (): void => {
|
|
88
|
-
if (flushScheduled) return;
|
|
89
|
-
flushScheduled = true;
|
|
90
|
-
queueMicrotask(flush);
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// One fine-grained effect per reactive slot. The priming run only
|
|
94
|
-
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
95
|
-
const stops = view.values.map((value, slot) => {
|
|
96
|
-
if (!isSignal(value) && typeof value !== "function") return () => {};
|
|
97
|
-
return effect(() => {
|
|
98
|
-
const next = String((value as () => unknown)());
|
|
99
|
-
if (priming) return;
|
|
100
|
-
pending.set(slot, next);
|
|
101
|
-
scheduleFlush();
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
priming = false;
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
renderToString: () => renderToString(view),
|
|
108
|
-
dispatch(event, payload) {
|
|
109
|
-
const handler = handlers[event];
|
|
110
|
-
if (!handler) return;
|
|
111
|
-
handler(payload);
|
|
112
|
-
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
113
|
-
},
|
|
114
|
-
drainPatches() {
|
|
115
|
-
flush();
|
|
116
|
-
const patch: SlotPatch[] = [];
|
|
117
|
-
for (const [slot, value] of buffer) patch.push({ slot, value });
|
|
118
|
-
buffer.clear();
|
|
119
|
-
return patch;
|
|
120
|
-
},
|
|
121
|
-
onPatch(listener) {
|
|
122
|
-
listeners.add(listener);
|
|
123
|
-
return () => listeners.delete(listener);
|
|
124
|
-
},
|
|
125
|
-
dispose() {
|
|
126
|
-
for (const stop of stops) stop();
|
|
127
|
-
listeners.clear();
|
|
128
|
-
pending.clear();
|
|
129
|
-
buffer.clear();
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── `@c9up/aurora/live` barrel ──────────────────────────────────────
|
|
135
|
-
//
|
|
136
|
-
// The Live stack (broadcast / client / registry / router / server) is
|
|
137
|
-
// re-exported here so the whole niche surface lives behind ONE subpath,
|
|
138
|
-
// `@c9up/aurora/live` — keeping the main `.` barrel lean (it no longer
|
|
139
|
-
// eager-pulls these into every browser graph), consistent with how `./ssr`,
|
|
140
|
-
// `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
141
16
|
export {
|
|
142
17
|
connectPatches,
|
|
143
18
|
type LiveStore,
|
|
@@ -169,3 +44,9 @@ export {
|
|
|
169
44
|
type WireLiveEventsOptions,
|
|
170
45
|
wireLiveEvents,
|
|
171
46
|
} from "./liveServer.js";
|
|
47
|
+
export {
|
|
48
|
+
type LiveComponentDefinition,
|
|
49
|
+
type LiveSession,
|
|
50
|
+
mountLiveSession,
|
|
51
|
+
type SlotPatch,
|
|
52
|
+
} from "./liveSession.js";
|
package/src/liveBroadcast.ts
CHANGED
package/src/liveClient.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { hydrate } from "./hydrate.js";
|
|
19
|
+
import type { SlotPatch } from "./liveSession.js";
|
|
19
20
|
import { isSignal } from "./reactive.js";
|
|
20
|
-
import type { SlotPatch } from "./live.js";
|
|
21
21
|
import type { TemplateResult } from "./types.js";
|
|
22
22
|
|
|
23
23
|
/** The transport the live client needs: a patch subscription + an event POST. */
|
|
@@ -67,7 +67,11 @@ export function liveClient(opts: LiveClientOptions): () => void {
|
|
|
67
67
|
const target = event.target;
|
|
68
68
|
if (!(target instanceof Element)) return;
|
|
69
69
|
const el = target.closest("[data-live-click]");
|
|
70
|
-
if (el)
|
|
70
|
+
if (el)
|
|
71
|
+
opts.transport.post(
|
|
72
|
+
opts.mount.id,
|
|
73
|
+
el.getAttribute("data-live-click") ?? "",
|
|
74
|
+
);
|
|
71
75
|
};
|
|
72
76
|
opts.container.addEventListener("click", onClick);
|
|
73
77
|
|
package/src/liveRegistry.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type LiveComponentDefinition,
|
|
16
16
|
type LiveSession,
|
|
17
17
|
mountLiveSession,
|
|
18
|
-
} from "./
|
|
18
|
+
} from "./liveSession.js";
|
|
19
19
|
|
|
20
20
|
/** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
|
|
21
21
|
export interface LiveSessionHandle {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live components — server-resident reactive UI CORE. The state lives on the
|
|
3
|
+
* server as ordinary aurora signals; this module turns a fine-grained signal
|
|
4
|
+
* change into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering
|
|
5
|
+
* the whole component. That precision — each signal already knows which template
|
|
6
|
+
* slot it feeds — is the angle that beats HTML-diffing live-view libraries.
|
|
7
|
+
*
|
|
8
|
+
* This is the transport-agnostic core: mount a session, render its initial HTML,
|
|
9
|
+
* dispatch events that mutate signals, and drain / subscribe to the patches
|
|
10
|
+
* produced. The transport stack (broadcast / client / registry / router /
|
|
11
|
+
* server) builds ON this and re-exports through the `./live.js` barrel — kept in
|
|
12
|
+
* a separate module so those transport files import the core WITHOUT forming an
|
|
13
|
+
* import cycle back through the barrel.
|
|
14
|
+
*
|
|
15
|
+
* Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { effect, isSignal } from "./reactive.js";
|
|
19
|
+
import { renderToString } from "./ssr.js";
|
|
20
|
+
import type { TemplateResult } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
23
|
+
export interface SlotPatch {
|
|
24
|
+
slot: number;
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
29
|
+
export interface LiveComponentDefinition {
|
|
30
|
+
/** The reactive view. Slots reading signals become live-patchable. */
|
|
31
|
+
view: TemplateResult;
|
|
32
|
+
/**
|
|
33
|
+
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
34
|
+
* dispatches one of these; it mutates the component's signals, which the
|
|
35
|
+
* patch tracker turns into a `{slot, value}` patch.
|
|
36
|
+
*/
|
|
37
|
+
handlers?: Record<string, (payload?: unknown) => void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A mounted live component instance — one per connected client session. */
|
|
41
|
+
export interface LiveSession {
|
|
42
|
+
/** Initial server-side render (the first full-HTML response). */
|
|
43
|
+
renderToString(): string;
|
|
44
|
+
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
45
|
+
dispatch(event: string, payload?: unknown): void;
|
|
46
|
+
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
47
|
+
drainPatches(): SlotPatch[];
|
|
48
|
+
/**
|
|
49
|
+
* Subscribe to patches as they are produced (push model — what the relay
|
|
50
|
+
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
51
|
+
*/
|
|
52
|
+
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
53
|
+
/** Stop every effect — call on client disconnect to free the session. */
|
|
54
|
+
dispose(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
59
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
60
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
61
|
+
* every session then reads the same signal and patches on its change.
|
|
62
|
+
*/
|
|
63
|
+
export function mountLiveSession(
|
|
64
|
+
factory: () => LiveComponentDefinition,
|
|
65
|
+
): LiveSession {
|
|
66
|
+
const { view, handlers = {} } = factory();
|
|
67
|
+
const listeners = new Set<(patch: SlotPatch[]) => void>();
|
|
68
|
+
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
69
|
+
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
70
|
+
// one batch collapse to the last value.
|
|
71
|
+
const pending = new Map<number, string>();
|
|
72
|
+
const buffer = new Map<number, string>();
|
|
73
|
+
let priming = true;
|
|
74
|
+
let flushScheduled = false;
|
|
75
|
+
|
|
76
|
+
const flush = (): void => {
|
|
77
|
+
flushScheduled = false;
|
|
78
|
+
if (pending.size === 0) return;
|
|
79
|
+
const patch: SlotPatch[] = [];
|
|
80
|
+
for (const [slot, value] of pending) {
|
|
81
|
+
patch.push({ slot, value });
|
|
82
|
+
buffer.set(slot, value);
|
|
83
|
+
}
|
|
84
|
+
pending.clear();
|
|
85
|
+
for (const listener of listeners) listener(patch);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const scheduleFlush = (): void => {
|
|
89
|
+
if (flushScheduled) return;
|
|
90
|
+
flushScheduled = true;
|
|
91
|
+
queueMicrotask(flush);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// One fine-grained effect per reactive slot. The priming run only
|
|
95
|
+
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
96
|
+
const stops = view.values.map((value, slot) => {
|
|
97
|
+
if (!isSignal(value) && typeof value !== "function") return () => {};
|
|
98
|
+
return effect(() => {
|
|
99
|
+
const next = String((value as () => unknown)());
|
|
100
|
+
if (priming) return;
|
|
101
|
+
pending.set(slot, next);
|
|
102
|
+
scheduleFlush();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
priming = false;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
renderToString: () => renderToString(view),
|
|
109
|
+
dispatch(event, payload) {
|
|
110
|
+
const handler = handlers[event];
|
|
111
|
+
if (!handler) return;
|
|
112
|
+
handler(payload);
|
|
113
|
+
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
114
|
+
},
|
|
115
|
+
drainPatches() {
|
|
116
|
+
flush();
|
|
117
|
+
const patch: SlotPatch[] = [];
|
|
118
|
+
for (const [slot, value] of buffer) patch.push({ slot, value });
|
|
119
|
+
buffer.clear();
|
|
120
|
+
return patch;
|
|
121
|
+
},
|
|
122
|
+
onPatch(listener) {
|
|
123
|
+
listeners.add(listener);
|
|
124
|
+
return () => listeners.delete(listener);
|
|
125
|
+
},
|
|
126
|
+
dispose() {
|
|
127
|
+
for (const stop of stops) stop();
|
|
128
|
+
listeners.clear();
|
|
129
|
+
pending.clear();
|
|
130
|
+
buffer.clear();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|