svelte-realtime 0.6.0-next.2 → 0.6.0-next.4

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/README.md CHANGED
@@ -2887,6 +2887,160 @@ export const { message, close, unsubscribe } = board.hooks;
2887
2887
 
2888
2888
  ---
2889
2889
 
2890
+ ## Multiplayer
2891
+
2892
+ `live.multiplayer()` bundles the collaborative surfaces - live cursors and presence - into a single declaration. It reuses the same data, presence, and cursor machinery a room uses, so the data stream, presence auto-join, and scoped actions behave exactly like `live.room()`. The difference is on the client: the generated export carries a connection-aware surface alongside the sub-streams - a `status` connection store, the `move` / `reportViewport` cursor methods, `identify(key)`, and a `room(...)` factory that returns the aggregated roster view.
2893
+
2894
+ ```js
2895
+ // src/live/collab.js
2896
+ import { live } from 'svelte-realtime/server';
2897
+
2898
+ export const board = live.multiplayer({
2899
+ topic: (ctx, boardId) => 'board:' + boardId,
2900
+ topicArgs: 1,
2901
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
2902
+ presence: (ctx) => ({ name: ctx.user.name, avatar: ctx.user.avatar }),
2903
+ cursors: true,
2904
+ actions: {
2905
+ addCard: async (ctx, boardId, title) => {
2906
+ const card = await db.cards.insert({ boardId, title });
2907
+ ctx.publish('created', card);
2908
+ return card;
2909
+ }
2910
+ }
2911
+ });
2912
+ ```
2913
+
2914
+ ### The roster view
2915
+
2916
+ `board.room(...)` aggregates the raw presence and cursor streams into the reactive surface an app renders. It is the recommended way to consume a multiplayer export:
2917
+
2918
+ - `others` - the presence roster, deduped by user key (latest wins), each entry stamped with a deterministic color, and with the local user excluded once it is known.
2919
+ - `cursors` - deduped by user key and colored. The local user is kept so you can render your own cursor.
2920
+ - `me` - the local user's key, or `null` until you name it.
2921
+ - `status` - the connection-status passthrough.
2922
+ - `move(...)` / `reportViewport(...)` - forward to the cursor send path.
2923
+ - `typing` / `selections` / `locks` / `reactions` and their send methods, when the matching field surfaces are declared (see [Field surfaces](#field-surfaces-typing-selections-locks-reactions)).
2924
+
2925
+ Name the current user once with `board.identify(key)` - the same identity you already supply for presence, available from the SvelteKit page load. Calling `identify(key)` after `board.room(...)` still lights up `me` and self-exclusion. If you never call it the surface degrades gracefully: `others` is the full deduped roster and `me` reads `null`, never a crash.
2926
+
2927
+ ```svelte
2928
+ <script>
2929
+ import { board } from '$live/collab';
2930
+
2931
+ let { data, boardId } = $props(); // data.userId from the SvelteKit load
2932
+ board.identify(data.userId); // names self; me + self-exclusion light up
2933
+
2934
+ const room = board.room(boardId);
2935
+
2936
+ function onPointerMove(e) {
2937
+ // move() is volatile (fire-and-forget) - lossy under disconnect is the contract.
2938
+ room.move(boardId, { x: e.clientX, y: e.clientY });
2939
+ }
2940
+ </script>
2941
+
2942
+ <div onpointermove={onPointerMove}>
2943
+ <ul class="roster">
2944
+ {#each room.others as person (person.key)}
2945
+ <li style:color={person.color}>{person.name}</li>
2946
+ {/each}
2947
+ </ul>
2948
+
2949
+ {#each room.cursors as c (c.key)}
2950
+ <Cursor x={c.x} y={c.y} color={c.color} />
2951
+ {/each}
2952
+
2953
+ {#if room.me == null}
2954
+ <p>Pass a user key to <code>board.identify(...)</code> to highlight yourself.</p>
2955
+ {/if}
2956
+ </div>
2957
+
2958
+ <button onclick={() => board.addCard(boardId, 'New card')}>Add</button>
2959
+ ```
2960
+
2961
+ The aggregated view ships from the `svelte-realtime/multiplayer` subpath and is reactive: `others` and `cursors` refresh whenever the underlying presence or cursor stream pushes. Reactivity uses Svelte 5 runes, so `board.room(...)` requires Svelte 5.
2962
+
2963
+ ### Raw sub-streams
2964
+
2965
+ The raw `data` / `presence` / `cursors` factory stores stay on the export for back-compat and lower-level use; `board.room(...)` composes them for you.
2966
+
2967
+ ```svelte
2968
+ <script>
2969
+ import { board } from '$live/collab';
2970
+ let { boardId } = $props();
2971
+
2972
+ const data = board.data(boardId); // main data stream
2973
+ const cursors = board.cursors(boardId); // raw cursor stream (uncolored, undeduped)
2974
+ const status = board.status; // connection status store
2975
+ </script>
2976
+
2977
+ {#each $data as card (card.id)}
2978
+ <Card {card} />
2979
+ {/each}
2980
+ ```
2981
+
2982
+ ### Field surfaces: typing, selections, locks, reactions
2983
+
2984
+ Declare a collaborative field surface on the export and the room view lights up a reactive projection plus a send method for it. Typing, selections, and locks are published onto the same presence roster `room.others` reads, so they cost no extra subscription; reactions ride a dedicated stream.
2985
+
2986
+ ```js
2987
+ export const board = live.multiplayer({
2988
+ topic: (ctx, boardId) => 'board:' + boardId,
2989
+ topicArgs: 1,
2990
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
2991
+ presence: (ctx) => ({ name: ctx.user.name }),
2992
+ cursors: true,
2993
+ typing: true,
2994
+ selections: 'offset',
2995
+ locks: ['title', 'body'],
2996
+ reactions: true
2997
+ });
2998
+ ```
2999
+
3000
+ - **Typing** - `room.typing` is the list of remote collaborators' user keys currently flagged as typing (self excluded). Toggle the local flag with `room.setTyping(true)` / `room.setTyping(false)`. Typing is a transient flag and is never persisted on the roster.
3001
+ - **Selections** - `room.selections` is a `{ userKey: range }` map of remote selection ranges (self excluded). Publish the local offset-mode range with `room.setSelection({ start, end, nodePath })`; pass `null` to clear it. A selection is stamped on the caller's roster entry, so a late joiner loads the current selections from the roster snapshot instead of waiting for the next change.
3002
+ - **Locks** - `room.locks` is a `{ lockKey: holderUserKey }` map. `room.acquireLock('title')` announces an advisory claim on a key and `room.releaseLock('title')` clears it. A holder disconnecting drops its claims on the next roster push. These are soft, collaborative-awareness locks (they tell collaborators who is editing what), not distributed mutual exclusion - a second caller is not blocked. A held lock is stamped on the caller's roster entry, so a late joiner sees who holds what from the roster snapshot.
3003
+ - **Reactions** - `room.reactions` is a bounded ring of recent ephemeral emotes; `room.react('heart', { x, y })` emits one. Reactions are never coalesced, so a burst of taps all arrive, and old entries fall off the ring.
3004
+
3005
+ Selections and locks persist on the roster both single-instance and across a cluster (wire `platform.redis`); typing stays ephemeral. Because a field is stamped on a roster entry that only exists once presence is set, declaring `typing`, `selections`, or `locks` requires a `presence` function - the Vite plugin and `live.multiplayer()` both reject a presence field with no presence. `reactions` are exempt: they ride their own ephemeral stream and need no presence.
3006
+
3007
+ ```svelte
3008
+ <script>
3009
+ import { board } from '$live/collab';
3010
+ let { boardId } = $props();
3011
+ const room = board.room(boardId);
3012
+ </script>
3013
+
3014
+ <input
3015
+ oninput={() => room.setTyping(true)}
3016
+ onblur={() => room.setTyping(false)} />
3017
+
3018
+ {#if room.typing.length}
3019
+ <p>{room.typing.length} editing...</p>
3020
+ {/if}
3021
+
3022
+ <button onclick={() => room.react('heart', { x: 100, y: 40 })}>React</button>
3023
+ ```
3024
+
3025
+ A multiplayer export that declares no field surface is unchanged, and calling a field method on the namespace (rather than a `room(...)` instance) is a safe no-op that points you at `room(...)`.
3026
+
3027
+ ### Deterministic user colors
3028
+
3029
+ `colorForKey(key)` and `hueForKey(key)` derive a stable color from a user key with a 32-bit FNV-1a hash, so a server render and the first client paint compute the identical color with no hydration mismatch.
3030
+
3031
+ The hue is the folded hash; saturation and lightness are each drawn from a legible band using high bits of the same hash, so the palette spans 4320 distinct swatches (360 hues x 3 saturation bands x 4 lightness bands) instead of hue alone. Two keys are far less likely to share a swatch, and every swatch keeps a legible contrast for foreground text. `hueForKey(key)` is unchanged and still returns the raw hue, so a caller building its own color expression from the hue is unaffected.
3032
+
3033
+ ```js
3034
+ import { colorForKey } from 'svelte-realtime/client';
3035
+
3036
+ const fill = colorForKey(user.id);
3037
+ // saturation and lightness vary per key, e.g.
3038
+ // colorForKey('alice') -> 'hsl(239, 70%, 45%)'
3039
+ // colorForKey('carol') -> 'hsl(2, 85%, 55%)'
3040
+ ```
3041
+
3042
+ ---
3043
+
2890
3044
  ## Webhooks
2891
3045
 
2892
3046
  Bridge external HTTP webhooks into your pub/sub topics.
@@ -3727,6 +3881,7 @@ Import from `svelte-realtime/server`.
3727
3881
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3728
3882
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
3729
3883
  | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
3884
+ | `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), field surfaces (typing / selections / advisory locks / reactions), connection status, and `identify(key)` |
3730
3885
  | `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
3731
3886
  | `live.gate(predicate, fn)` | Conditional stream activation |
3732
3887
  | `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
@@ -0,0 +1,113 @@
1
+ import type { Readable } from 'svelte/store';
2
+
3
+ /** A roster entry: a user key plus the presence payload and a stamped color. */
4
+ export interface RosterEntry {
5
+ key: string;
6
+ color: string;
7
+ [field: string]: any;
8
+ }
9
+
10
+ /** A cursor entry: a user key, position fields, and a stamped color. */
11
+ export interface CursorEntry {
12
+ key: string;
13
+ color: string;
14
+ [field: string]: any;
15
+ }
16
+
17
+ /**
18
+ * A reactive holder for the local user's key. The generated `live.multiplayer()`
19
+ * namespace owns one; the app sets it once via `namespace.identify(key)` and the
20
+ * `MultiplayerRoom` reads it through `me`. Until set it reads `null`.
21
+ */
22
+ export interface LocalKeySource {
23
+ readonly value: string | null;
24
+ set(next: string | null | undefined): void;
25
+ }
26
+
27
+ /**
28
+ * Create a reactive local-key holder. The generated namespace constructs one
29
+ * per multiplayer export; an app rarely calls this directly.
30
+ */
31
+ export function localKeySource(initial?: string | null): LocalKeySource;
32
+
33
+ /** Dependencies the generated namespace injects when it constructs a room. */
34
+ export interface MultiplayerRoomDeps {
35
+ /** The local user's key, a reactive holder, or null/undefined when unknown. */
36
+ me?: string | LocalKeySource | null;
37
+ /** Presence sub-stream (the per-room factory store). */
38
+ presence: Readable<any[]>;
39
+ /** Cursor sub-stream (the per-room factory store). */
40
+ cursors: Readable<any[]>;
41
+ /** Connection-status store. */
42
+ status: Readable<string>;
43
+ /** Reactions sub-stream (the bounded ring of recent emotes), when enabled. */
44
+ reactions?: Readable<any[]>;
45
+ /** Outbound cursor-move send callback. */
46
+ move?: (...args: any[]) => any;
47
+ /** Outbound viewport-report send callback. Falls back to `move`. */
48
+ reportViewport?: (...args: any[]) => any;
49
+ /** Outbound presence-field send callback for typing toggles. */
50
+ setTyping?: (...args: any[]) => any;
51
+ /** Outbound presence-field send callback for selection ranges. */
52
+ setSelection?: (...args: any[]) => any;
53
+ /** Outbound presence-field send callback for advisory lock acquisition. */
54
+ acquireLock?: (...args: any[]) => any;
55
+ /** Outbound presence-field send callback for advisory lock release. */
56
+ releaseLock?: (...args: any[]) => any;
57
+ /** Outbound reaction send callback. */
58
+ react?: (...args: any[]) => any;
59
+ }
60
+
61
+ /**
62
+ * Live roster aggregation for a `live.multiplayer()` room. Composes the
63
+ * generated presence / cursor / status stores into the public surface an app
64
+ * renders. The aggregated views are reactive: they refresh when the underlying
65
+ * stores push.
66
+ *
67
+ * - `others`: the presence roster, deduped by user key (latest wins), each
68
+ * entry stamped with a deterministic color, excluding the local user when
69
+ * `me` is known. When `me` is unknown it is the full deduped roster.
70
+ * - `cursors`: deduped by user key (latest wins) and colored. Self is not
71
+ * excluded so the local user can render its own cursor.
72
+ * - `me`: the local user's key, or `null` when the app never supplied one.
73
+ * - `status`: the connection-status passthrough.
74
+ *
75
+ * The `typing` / `locks` / `selections` views are reactive projections of the
76
+ * presence roster, driven by the field-send methods; `reactions` is the bounded
77
+ * ring of recent ephemeral emotes.
78
+ */
79
+ export class MultiplayerRoom {
80
+ constructor(deps: MultiplayerRoomDeps);
81
+ /** The presence roster: deduped, colored, self-excluded when `me` is known. */
82
+ get others(): RosterEntry[];
83
+ /** The cursor roster: deduped and colored (self not excluded). */
84
+ get cursors(): CursorEntry[];
85
+ /** The local user's key, or `null` when unknown. */
86
+ get me(): string | null;
87
+ /** The connection status. */
88
+ get status(): string;
89
+ /** The user keys of remote collaborators currently flagged as typing. */
90
+ get typing(): string[];
91
+ /** Advisory lock holders keyed by lock key: `{ lockKey: holderUserKey }`. */
92
+ get locks(): Record<string, any>;
93
+ /** Remote selection ranges keyed by user (self excluded). */
94
+ get selections(): Record<string, any>;
95
+ /** The bounded ring of recent reactions. */
96
+ get reactions(): any[];
97
+ /** Forward a cursor move to the injected send callback. */
98
+ move(...args: any[]): any;
99
+ /** Forward a viewport report to the injected send callback. */
100
+ reportViewport(...args: any[]): any;
101
+ /** Emit an ephemeral reaction (a token at an optional point). */
102
+ react(token: any, at?: any): any;
103
+ /** Toggle the local typing flag, published onto the presence roster. */
104
+ setTyping(on: boolean): any;
105
+ /** Claim an advisory lock on a key (collaborative awareness, not exclusion). */
106
+ acquireLock(lockKey: string): any;
107
+ /** Release an advisory lock on a key. */
108
+ releaseLock(lockKey: string): any;
109
+ /** Publish the local selection range; pass `null` to clear it. */
110
+ setSelection(selection: any): any;
111
+ /** Unsubscribe from the injected stores. Call when the room unmounts. */
112
+ destroy(): void;
113
+ }
@@ -0,0 +1,208 @@
1
+ // @ts-check
2
+ import { colorForKey } from './shared/color.js';
3
+
4
+ /**
5
+ * A tiny reactive holder for the local user's key. The generated namespace
6
+ * owns one and the app sets it once with `namespace.identify(key)`; the
7
+ * `MultiplayerRoom` reads it through `me` so calling `identify(key)` after the
8
+ * room is created still lights up self-exclusion. Until set it is `null`, which
9
+ * the room treats as "self unknown" (the full deduped roster, no crash).
10
+ *
11
+ * @param {string | null} [initial]
12
+ */
13
+ export function localKeySource(initial) {
14
+ let key = $state(initial ?? null);
15
+ return {
16
+ get value() { return key; },
17
+ /** @param {string | null | undefined} next */
18
+ set(next) { key = next ?? null; }
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Dedupe a roster list by user key, keeping the latest entry per key. Entries
24
+ * without a key are dropped (they cannot be addressed or colored).
25
+ *
26
+ * @param {Array<{ key?: string }> | null | undefined} list
27
+ * @returns {Array<any>}
28
+ */
29
+ function dedupeByUser(list) {
30
+ const seen = new Map();
31
+ for (const item of list || []) {
32
+ if (item && item.key != null) seen.set(item.key, item);
33
+ }
34
+ return [...seen.values()];
35
+ }
36
+
37
+ /**
38
+ * Live roster aggregation for a `live.multiplayer()` room. Composes the
39
+ * generated presence / cursor / status stores into the public surface an app
40
+ * renders: `others` (the presence roster, deduped by user key, each entry
41
+ * stamped with a deterministic color, excluding the local user when known),
42
+ * `cursors` (deduped + colored), `me` (the local user key, or `null` when the
43
+ * app never supplied one), and `status` (the connection-status passthrough).
44
+ *
45
+ * When `me` is unknown the surface degrades gracefully: `others` is the full
46
+ * deduped roster (no self-exclusion) and `me` reads `null`, never a crash.
47
+ *
48
+ * `me` is supplied either as a plain key or as a reactive holder (the
49
+ * `localKeySource` the generated namespace owns). Reading it through a getter
50
+ * means `identify(key)` called after the room is constructed updates self-
51
+ * exclusion live.
52
+ *
53
+ * The collaborative field surfaces - `typing`, `selections`, `locks` - are
54
+ * projections of the same presence roster: a caller stamps fields onto its own
55
+ * entry through the injected send callbacks, the presence merge layers them in,
56
+ * and these views read them back. `reactions` is the bounded ring of recent
57
+ * ephemeral emotes from the dedicated reactions stream.
58
+ *
59
+ * The views are `$derived` over `$state` snapshots of the injected stores, so a
60
+ * store push followed by a reactive flush refreshes every view.
61
+ */
62
+ export class MultiplayerRoom {
63
+ #meSource;
64
+ #presence = $state([]);
65
+ #cursors = $state([]);
66
+ #reactions = $state([]);
67
+ #status = $state('idle');
68
+ #move;
69
+ #reportViewport;
70
+ #setTyping;
71
+ #setSelection;
72
+ #acquireLock;
73
+ #releaseLock;
74
+ #react;
75
+ #unsubs = [];
76
+
77
+ constructor(deps) {
78
+ this.#meSource = deps.me;
79
+ this.#move = deps.move;
80
+ this.#reportViewport = deps.reportViewport || deps.move;
81
+ this.#setTyping = deps.setTyping;
82
+ this.#setSelection = deps.setSelection;
83
+ this.#acquireLock = deps.acquireLock;
84
+ this.#releaseLock = deps.releaseLock;
85
+ this.#react = deps.react;
86
+ this.#unsubs.push(deps.presence.subscribe((v) => { this.#presence = v || []; }));
87
+ this.#unsubs.push(deps.cursors.subscribe((v) => { this.#cursors = v || []; }));
88
+ this.#unsubs.push(deps.status.subscribe((v) => { this.#status = v; }));
89
+ if (deps.reactions) {
90
+ this.#unsubs.push(deps.reactions.subscribe((v) => { this.#reactions = v || []; }));
91
+ }
92
+ }
93
+
94
+ // Reads the reactive holder when one was injected (so identify(key) after
95
+ // construction lights up self-exclusion), else the plain value, else null.
96
+ get me() {
97
+ const m = this.#meSource;
98
+ const raw = (m && typeof m === 'object' && 'value' in m) ? m.value : m;
99
+ // The server stamps every presence/cursor key as String(id), so coerce
100
+ // the local key the same way - a numeric identify(42) still self-excludes
101
+ // against the stored "42" instead of silently showing the local user.
102
+ return raw == null ? null : String(raw);
103
+ }
104
+ get status() { return this.#status; }
105
+
106
+ #othersDerived = $derived(
107
+ dedupeByUser(this.#presence)
108
+ .filter((p) => this.me == null || p.key !== this.me)
109
+ .map((p) => ({ ...p, color: colorForKey(p.key) }))
110
+ );
111
+ get others() { return this.#othersDerived; }
112
+
113
+ #cursorsDerived = $derived(
114
+ dedupeByUser(this.#cursors).map((c) => ({ ...c, color: colorForKey(c.key) }))
115
+ );
116
+ get cursors() { return this.#cursorsDerived; }
117
+
118
+ // Field surfaces are projections of the same presence roster `others` reads:
119
+ // a caller stamps fields onto its own roster entry through the send path, the
120
+ // presence merge layers them onto the entry, and these views read them back.
121
+ // They add no new store subscription - one derive pass over the roster the
122
+ // room already aggregates.
123
+
124
+ // The keys of remote collaborators currently flagged as typing.
125
+ #typingDerived = $derived(
126
+ dedupeByUser(this.#presence)
127
+ .filter((p) => p.typing === true && (this.me == null || p.key !== this.me))
128
+ .map((p) => p.key)
129
+ );
130
+ get typing() { return this.#typingDerived; }
131
+
132
+ // Advisory lock holders, keyed by lock key. A roster entry carries a held key
133
+ // as `lock:<key>` (truthy while held, cleared on release); the holder is the
134
+ // entry's own user key. This collapses the roster into a { lockKey: holder }
135
+ // map. A holder leaving drops its entry, so its locks recompute to absent on
136
+ // the next push - no stale grant survives.
137
+ #locksDerived = $derived(this.#deriveLocks(this.#presence));
138
+ get locks() { return this.#locksDerived; }
139
+
140
+ // Remote selections, keyed by user. Self is excluded so an app renders only
141
+ // collaborators' ranges. Each value is the selection payload the holder sent.
142
+ #selectionsDerived = $derived(
143
+ Object.fromEntries(
144
+ dedupeByUser(this.#presence)
145
+ .filter((p) => p.selection != null && (this.me == null || p.key !== this.me))
146
+ .map((p) => [p.key, p.selection])
147
+ )
148
+ );
149
+ get selections() { return this.#selectionsDerived; }
150
+
151
+ // The bounded ring of recent reactions. The send stream caps and GCs old
152
+ // taps, so an app renders the current window and lets entries fall off.
153
+ get reactions() { return this.#reactions; }
154
+
155
+ /** @param {Array<Record<string, any>>} roster */
156
+ #deriveLocks(roster) {
157
+ const out = /** @type {Record<string, any>} */ ({});
158
+ for (const entry of dedupeByUser(roster)) {
159
+ for (const field in entry) {
160
+ if (field.startsWith('lock:') && entry[field] != null && entry[field] !== false) {
161
+ out[field.slice(5)] = entry.key;
162
+ }
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ move(...args) { return this.#move ? this.#move(...args) : undefined; }
169
+ reportViewport(...args) { return this.#reportViewport ? this.#reportViewport(...args) : undefined; }
170
+
171
+ // Toggle the local typing flag. Publishes a `{ typing }` delta onto the
172
+ // caller's presence entry so every collaborator's `typing` view updates.
173
+ setTyping(on) {
174
+ return this.#setTyping ? this.#setTyping({ typing: !!on }) : undefined;
175
+ }
176
+
177
+ // Publish the local selection range. `null` clears it. Offset selections are
178
+ // a plain `{ start, end, nodePath }` object; the value is sent verbatim.
179
+ setSelection(selection) {
180
+ return this.#setSelection ? this.#setSelection({ selection: selection ?? null }) : undefined;
181
+ }
182
+
183
+ // Claim an advisory lock on a key: stamps `lock:<key>` on the caller's
184
+ // presence entry, which the server keys by the caller's identity. Advisory
185
+ // only - it announces intent, it does not block another claimant.
186
+ acquireLock(lockKey) {
187
+ if (!this.#acquireLock || lockKey == null) return undefined;
188
+ return this.#acquireLock({ ['lock:' + lockKey]: true });
189
+ }
190
+
191
+ // Release an advisory lock: clears `lock:<key>` on the caller's entry.
192
+ releaseLock(lockKey) {
193
+ if (!this.#releaseLock || lockKey == null) return undefined;
194
+ return this.#releaseLock({ ['lock:' + lockKey]: null });
195
+ }
196
+
197
+ // Emit an ephemeral reaction (an emote token at an optional point). Rides the
198
+ // dedicated reactions stream, never the roster, so it is a one-off event.
199
+ react(token, at) {
200
+ if (!this.#react) return undefined;
201
+ return at !== undefined ? this.#react(token, at) : this.#react(token);
202
+ }
203
+
204
+ destroy() {
205
+ for (const off of this.#unsubs) off();
206
+ this.#unsubs = [];
207
+ }
208
+ }
@@ -0,0 +1,138 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Injectable runtime environment for the clock, RNG, and timers - browser build.
5
+ *
6
+ * Browser client code reads time, randomness, and schedules timers exclusively
7
+ * through the named helpers exported here instead of touching `Date.now`,
8
+ * `Math.random`, `crypto`, or `setTimeout` directly. The helper names and the
9
+ * environment shape are kept byte-identical with the node `shared/runtime.js`
10
+ * copy so the two never drift and callers are interchangeable. The difference is
11
+ * the binding: this copy is backed entirely by browser globals (no `node:`
12
+ * imports), so it bundles cleanly for the browser, while a controlled simulation
13
+ * harness running the same client code under node can still swap in seeded
14
+ * virtual implementations via `setRuntimeEnv`.
15
+ *
16
+ * In production the helpers bind the native globals with zero measurable
17
+ * overhead: one read over a frozen, never-swapped environment object that V8
18
+ * keeps monomorphic and inlines straight through to the native calls.
19
+ *
20
+ * @module svelte-realtime/client-runtime
21
+ */
22
+
23
+ // The browser clock is a direct wall read: the clients never had a 1Hz cache,
24
+ // so reading `Date.now()` per call preserves their existing behavior (and lets a
25
+ // test's fake-timer Date mock propagate directly). `performance.now()` drives
26
+ // monotonic duration math when present, falling back to the wall clock when not.
27
+ const _hasPerf = typeof globalThis.performance !== 'undefined' && typeof globalThis.performance.now === 'function';
28
+ const _processStartEpoch = _hasPerf ? Date.now() - globalThis.performance.now() : 0;
29
+ const _webcrypto = (typeof globalThis.crypto !== 'undefined') ? globalThis.crypto : undefined;
30
+
31
+ // v4-shaped fallback when Web Crypto randomUUID is unavailable (non-secure
32
+ // context / old browser). Uses the env RNG so a seeded run still reproduces it.
33
+ function _uuidFallback(rngFloat) {
34
+ let out = '';
35
+ for (let i = 0; i < 36; i++) {
36
+ if (i === 8 || i === 13 || i === 18 || i === 23) { out += '-'; continue; }
37
+ if (i === 14) { out += '4'; continue; }
38
+ const r = (rngFloat() * 16) | 0;
39
+ out += (i === 19 ? ((r & 0x3) | 0x8) : r).toString(16);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // One frozen environment object, one stable hidden class. In production
45
+ // `current === defaultEnv` for the whole page lifetime (no override ever
46
+ // installs), so V8 sees a monomorphic shape and inlines the helpers to the
47
+ // native primitives - zero measurable overhead on the hot path.
48
+ const defaultEnv = Object.freeze({
49
+ clock: Object.freeze({
50
+ now: () => Date.now(), // wall, direct read
51
+ monotonic: _hasPerf ? () => _processStartEpoch + globalThis.performance.now() : () => Date.now(), // strictly-forward duration math
52
+ wallEpoch: () => Date.now() // exact wall clock; identity baseline
53
+ }),
54
+ rng: Object.freeze({
55
+ float: () => Math.random(),
56
+ u32: () => (Math.random() * 0x100000000) >>> 0,
57
+ uuid: (_webcrypto && typeof _webcrypto.randomUUID === 'function')
58
+ ? () => _webcrypto.randomUUID()
59
+ : () => _uuidFallback(() => Math.random()),
60
+ bytes: (_webcrypto && typeof _webcrypto.getRandomValues === 'function')
61
+ ? (n) => _webcrypto.getRandomValues(new Uint8Array(n))
62
+ : (n) => { const a = new Uint8Array(n); for (let i = 0; i < n; i++) a[i] = (Math.random() * 256) | 0; return a; }
63
+ }),
64
+ timers: Object.freeze({
65
+ set: (cb, ms, ...a) => setTimeout(cb, ms, ...a),
66
+ setInterval: (cb, ms, ...a) => setInterval(cb, ms, ...a),
67
+ // No setImmediate in the browser: a zero-delay macrotask is the closest.
68
+ setImmediate: (cb, ...a) => setTimeout(cb, 0, ...a),
69
+ clear: (h) => clearTimeout(h),
70
+ clearInterval: (h) => clearInterval(h),
71
+ queueMicrotask: (typeof globalThis.queueMicrotask === 'function')
72
+ ? (cb) => globalThis.queueMicrotask(cb)
73
+ : (cb) => Promise.resolve().then(cb)
74
+ }),
75
+ tz: undefined // effective timezone for cron evaluation; undefined = real local TZ
76
+ });
77
+
78
+ let current = defaultEnv;
79
+
80
+ // The named helpers are the ONLY thing browser client code imports. Each is a
81
+ // one-line read over `current` - monomorphic in prod, inlined by V8.
82
+ export const now = () => current.clock.now();
83
+ export const monotonicNow = () => current.clock.monotonic();
84
+ export const wallEpoch = () => current.clock.wallEpoch();
85
+ export const randomFloat = () => current.rng.float();
86
+ export const randomU32 = () => current.rng.u32();
87
+ export const randomUuid = () => current.rng.uuid();
88
+ export const randomBytes = (n) => current.rng.bytes(n);
89
+ export const setTimer = (cb, ms, ...a) => current.timers.set(cb, ms, ...a);
90
+ export const setIntervalTimer = (cb, ms, ...a) => current.timers.setInterval(cb, ms, ...a);
91
+ export const setImmediateTimer = (cb, ...a) => current.timers.setImmediate(cb, ...a);
92
+ export const clearTimer = (h) => current.timers.clear(h);
93
+ export const clearIntervalTimer = (h) => current.timers.clearInterval(h);
94
+ export const microtask = (cb) => current.timers.queueMicrotask(cb);
95
+ export const effectiveTimeZone = () => current.tz;
96
+
97
+ /**
98
+ * Install a virtual environment (the simulator / test harness only). Refuses in
99
+ * a node production build unless explicitly forced, so a stray call can never
100
+ * swap the clock under a live deployment; in a real browser there is no
101
+ * `process` and nothing calls this anyway. A partial env merges over the native
102
+ * defaults, so a harness can override just the clock and keep native rng / timers.
103
+ *
104
+ * @param {object} [env] - Partial environment. Any of `clock`, `rng`, `timers`
105
+ * may carry a subset of their fields; provided fields override the native
106
+ * default, omitted fields fall through to native. `tz` overrides only when the
107
+ * property is present (including an explicit `undefined`).
108
+ * @param {object} [opts]
109
+ * @param {boolean} [opts.force] - Allow the swap even when
110
+ * `process.env.NODE_ENV === 'production'`. Use only inside a controlled
111
+ * simulation harness.
112
+ * @returns {object} The newly installed frozen environment.
113
+ */
114
+ export function setRuntimeEnv(env, opts) {
115
+ const force = opts && opts.force === true;
116
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production' && !force) {
117
+ throw new Error('client runtime: setRuntimeEnv refused in production (pass { force: true } only inside a controlled simulation harness)');
118
+ }
119
+ current = Object.freeze({
120
+ clock: Object.freeze({ ...defaultEnv.clock, ...(env && env.clock) }),
121
+ rng: Object.freeze({ ...defaultEnv.rng, ...(env && env.rng) }),
122
+ timers: Object.freeze({ ...defaultEnv.timers, ...(env && env.timers) }),
123
+ tz: env && Object.prototype.hasOwnProperty.call(env, 'tz') ? env.tz : defaultEnv.tz
124
+ });
125
+ return current;
126
+ }
127
+
128
+ /**
129
+ * Restore the native environment. Cheap wholesale reassignment (no per-field
130
+ * mutation), so the hidden class stays stable.
131
+ */
132
+ export function resetRuntimeEnv() { current = defaultEnv; }
133
+
134
+ /**
135
+ * Read-only accessor for the active environment (test / sim introspection only).
136
+ * @returns {object} The active frozen environment.
137
+ */
138
+ export function getRuntimeEnv() { return current; }
package/client.d.ts CHANGED
@@ -390,6 +390,18 @@ export function __stream(
390
390
  isDynamic: true
391
391
  ): (...args: any[]) => StreamStore;
392
392
 
393
+ /**
394
+ * Build the reserved field-surface members of a generated `live.multiplayer()`
395
+ * namespace: the empty `typing` / `locks` / `selections` / `reactions` views
396
+ * and the no-op `setTyping` / `acquireLock` / `releaseLock` / `setSelection` /
397
+ * `react` methods. The codegen spreads the result into the namespace object and
398
+ * adds the live `data` / `presence` / `cursors` / `status` / `move` /
399
+ * `reportViewport` members on top.
400
+ *
401
+ * @internal
402
+ */
403
+ export function __mpFields(): Record<string, any>;
404
+
393
405
  /**
394
406
  * Group multiple RPC calls into a single WebSocket frame.
395
407
  * All calls are sent together and responses come back in one frame.
@@ -783,6 +795,23 @@ export type FailureClass = 'TERMINAL' | 'EXHAUSTED' | 'THROTTLE' | 'RETRY' | 'AU
783
795
  */
784
796
  export const failure: Readable<Failure | null>;
785
797
 
798
+ /**
799
+ * Reactive store holding the connection status. Re-exported from
800
+ * `svelte-adapter-uws/client`. The generated `live.multiplayer()` namespace
801
+ * exposes this as its `status` view.
802
+ */
803
+ export const status: Readable<string>;
804
+
805
+ /**
806
+ * Deterministic `hsl(...)` color for a stable user key. The same key yields
807
+ * the same color on the server and on every client, so server-rendered markup
808
+ * and the first client paint agree without a hydration mismatch. Use it to
809
+ * stamp a per-user color on a roster or cursor entry.
810
+ */
811
+ export function colorForKey(key: string): string;
812
+ /** Raw deterministic hue (0..359) for a stable user key. */
813
+ export function hueForKey(key: string): number;
814
+
786
815
  /**
787
816
  * Reactive store that emits `true` when every active stream has
788
817
  * finished loading (or errored) and `false` while at least one is