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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2887,6 +2887,118 @@ 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
+
2924
+ 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.
2925
+
2926
+ ```svelte
2927
+ <script>
2928
+ import { board } from '$live/collab';
2929
+
2930
+ let { data, boardId } = $props(); // data.userId from the SvelteKit load
2931
+ board.identify(data.userId); // names self; me + self-exclusion light up
2932
+
2933
+ const room = board.room(boardId);
2934
+
2935
+ function onPointerMove(e) {
2936
+ // move() is volatile (fire-and-forget) - lossy under disconnect is the contract.
2937
+ room.move(boardId, { x: e.clientX, y: e.clientY });
2938
+ }
2939
+ </script>
2940
+
2941
+ <div onpointermove={onPointerMove}>
2942
+ <ul class="roster">
2943
+ {#each room.others as person (person.key)}
2944
+ <li style:color={person.color}>{person.name}</li>
2945
+ {/each}
2946
+ </ul>
2947
+
2948
+ {#each room.cursors as c (c.key)}
2949
+ <Cursor x={c.x} y={c.y} color={c.color} />
2950
+ {/each}
2951
+
2952
+ {#if room.me == null}
2953
+ <p>Pass a user key to <code>board.identify(...)</code> to highlight yourself.</p>
2954
+ {/if}
2955
+ </div>
2956
+
2957
+ <button onclick={() => board.addCard(boardId, 'New card')}>Add</button>
2958
+ ```
2959
+
2960
+ 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.
2961
+
2962
+ ### Raw sub-streams
2963
+
2964
+ The raw `data` / `presence` / `cursors` factory stores stay on the export for back-compat and lower-level use; `board.room(...)` composes them for you.
2965
+
2966
+ ```svelte
2967
+ <script>
2968
+ import { board } from '$live/collab';
2969
+ let { boardId } = $props();
2970
+
2971
+ const data = board.data(boardId); // main data stream
2972
+ const cursors = board.cursors(boardId); // raw cursor stream (uncolored, undeduped)
2973
+ const status = board.status; // connection status store
2974
+ </script>
2975
+
2976
+ {#each $data as card (card.id)}
2977
+ <Card {card} />
2978
+ {/each}
2979
+ ```
2980
+
2981
+ ### Reserved surfaces
2982
+
2983
+ The `typing`, `locks`, `selections`, and `reactions` views and their methods (`setTyping`, `acquireLock`, `releaseLock`, `setSelection`, `react`) are present on the export so the API shape is stable, but they are inert for now: the views read empty and the methods are no-ops that emit a single dev-mode note. They activate once the client-to-server field send path lands; declaring `typing: true`, `locks: ['cell']`, `reactions: true`, or `selections: 'offset'` reserves the surface without changing today's behavior.
2984
+
2985
+ ### Deterministic user colors
2986
+
2987
+ `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.
2988
+
2989
+ 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.
2990
+
2991
+ ```js
2992
+ import { colorForKey } from 'svelte-realtime/client';
2993
+
2994
+ const fill = colorForKey(user.id);
2995
+ // saturation and lightness vary per key, e.g.
2996
+ // colorForKey('alice') -> 'hsl(239, 70%, 45%)'
2997
+ // colorForKey('carol') -> 'hsl(2, 85%, 55%)'
2998
+ ```
2999
+
3000
+ ---
3001
+
2890
3002
  ## Webhooks
2891
3003
 
2892
3004
  Bridge external HTTP webhooks into your pub/sub topics.
@@ -3727,6 +3839,7 @@ Import from `svelte-realtime/server`.
3727
3839
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3728
3840
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
3729
3841
  | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
3842
+ | `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), connection status, and `identify(key)` |
3730
3843
  | `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
3731
3844
  | `live.gate(predicate, fn)` | Conditional stream activation |
3732
3845
  | `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
@@ -0,0 +1,100 @@
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
+ /** Outbound cursor-move send callback. */
44
+ move?: (...args: any[]) => any;
45
+ /** Outbound viewport-report send callback. Falls back to `move`. */
46
+ reportViewport?: (...args: any[]) => any;
47
+ }
48
+
49
+ /**
50
+ * Live roster aggregation for a `live.multiplayer()` room. Composes the
51
+ * generated presence / cursor / status stores into the public surface an app
52
+ * renders. The aggregated views are reactive: they refresh when the underlying
53
+ * stores push.
54
+ *
55
+ * - `others`: the presence roster, deduped by user key (latest wins), each
56
+ * entry stamped with a deterministic color, excluding the local user when
57
+ * `me` is known. When `me` is unknown it is the full deduped roster.
58
+ * - `cursors`: deduped by user key (latest wins) and colored. Self is not
59
+ * excluded so the local user can render its own cursor.
60
+ * - `me`: the local user's key, or `null` when the app never supplied one.
61
+ * - `status`: the connection-status passthrough.
62
+ *
63
+ * The `typing` / `locks` / `selections` / `reactions` field surfaces and their
64
+ * methods are reserved and inert.
65
+ */
66
+ export class MultiplayerRoom {
67
+ constructor(deps: MultiplayerRoomDeps);
68
+ /** The presence roster: deduped, colored, self-excluded when `me` is known. */
69
+ get others(): RosterEntry[];
70
+ /** The cursor roster: deduped and colored (self not excluded). */
71
+ get cursors(): CursorEntry[];
72
+ /** The local user's key, or `null` when unknown. */
73
+ get me(): string | null;
74
+ /** The connection status. */
75
+ get status(): string;
76
+ /** Reserved field surface. Inert. */
77
+ get typing(): any[];
78
+ /** Reserved field surface. Inert. */
79
+ get locks(): Record<string, any>;
80
+ /** Reserved field surface. Inert. */
81
+ get selections(): Record<string, any>;
82
+ /** Reserved field surface. Inert. */
83
+ get reactions(): any[];
84
+ /** Forward a cursor move to the injected send callback. */
85
+ move(...args: any[]): any;
86
+ /** Forward a viewport report to the injected send callback. */
87
+ reportViewport(...args: any[]): any;
88
+ /** Reserved no-op. */
89
+ react(...args: any[]): void;
90
+ /** Reserved no-op. */
91
+ setTyping(...args: any[]): void;
92
+ /** Reserved no-op. */
93
+ acquireLock(...args: any[]): void;
94
+ /** Reserved no-op. */
95
+ releaseLock(...args: any[]): void;
96
+ /** Reserved no-op. */
97
+ setSelection(...args: any[]): void;
98
+ /** Unsubscribe from the injected stores. Call when the room unmounts. */
99
+ destroy(): void;
100
+ }
@@ -0,0 +1,118 @@
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 views are `$derived` over `$state` snapshots of the injected stores, so a
54
+ * store push followed by a reactive flush refreshes every view.
55
+ */
56
+ export class MultiplayerRoom {
57
+ #meSource;
58
+ #presence = $state([]);
59
+ #cursors = $state([]);
60
+ #status = $state('idle');
61
+ #move;
62
+ #reportViewport;
63
+ #unsubs = [];
64
+
65
+ constructor(deps) {
66
+ this.#meSource = deps.me;
67
+ this.#move = deps.move;
68
+ this.#reportViewport = deps.reportViewport || deps.move;
69
+ this.#unsubs.push(deps.presence.subscribe((v) => { this.#presence = v || []; }));
70
+ this.#unsubs.push(deps.cursors.subscribe((v) => { this.#cursors = v || []; }));
71
+ this.#unsubs.push(deps.status.subscribe((v) => { this.#status = v; }));
72
+ }
73
+
74
+ // Reads the reactive holder when one was injected (so identify(key) after
75
+ // construction lights up self-exclusion), else the plain value, else null.
76
+ get me() {
77
+ const m = this.#meSource;
78
+ const raw = (m && typeof m === 'object' && 'value' in m) ? m.value : m;
79
+ // The server stamps every presence/cursor key as String(id), so coerce
80
+ // the local key the same way - a numeric identify(42) still self-excludes
81
+ // against the stored "42" instead of silently showing the local user.
82
+ return raw == null ? null : String(raw);
83
+ }
84
+ get status() { return this.#status; }
85
+
86
+ #othersDerived = $derived(
87
+ dedupeByUser(this.#presence)
88
+ .filter((p) => this.me == null || p.key !== this.me)
89
+ .map((p) => ({ ...p, color: colorForKey(p.key) }))
90
+ );
91
+ get others() { return this.#othersDerived; }
92
+
93
+ #cursorsDerived = $derived(
94
+ dedupeByUser(this.#cursors).map((c) => ({ ...c, color: colorForKey(c.key) }))
95
+ );
96
+ get cursors() { return this.#cursorsDerived; }
97
+
98
+ // Stubbed field surfaces: present so the API shape is stable, inert until
99
+ // the client-to-server presence-field send path lands.
100
+ get typing() { return []; }
101
+ get locks() { return {}; }
102
+ get selections() { return {}; }
103
+ get reactions() { return []; }
104
+
105
+ move(...args) { return this.#move ? this.#move(...args) : undefined; }
106
+ reportViewport(...args) { return this.#reportViewport ? this.#reportViewport(...args) : undefined; }
107
+
108
+ react() {}
109
+ setTyping() {}
110
+ acquireLock() {}
111
+ releaseLock() {}
112
+ setSelection() {}
113
+
114
+ destroy() {
115
+ for (const off of this.#unsubs) off();
116
+ this.#unsubs = [];
117
+ }
118
+ }
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
package/client.js CHANGED
@@ -3,6 +3,7 @@ import { connect as _connect, on, status, denials, onRequest as _adapterOnReques
3
3
  import { writable, readable } from 'svelte/store';
4
4
  import { assert } from './shared/assert.js';
5
5
  export { assert, getAssertionCounters, _resetAssertCounters } from './shared/assert.js';
6
+ export { colorForKey, hueForKey } from './shared/color.js';
6
7
  import { mergeKeyField, rebuildIndex } from './shared/merge.js';
7
8
  import { sanitizeRowData } from './shared/safe-assign.js';
8
9
  // Namespace import lets .rune() access fromStore (Svelte 5 only) without
@@ -841,6 +842,45 @@ export function __rpc(path) {
841
842
  return rpcCall;
842
843
  }
843
844
 
845
+ let _mpStubNoteFired = false;
846
+
847
+ /**
848
+ * Build the reserved field-surface members of a generated `live.multiplayer()`
849
+ * namespace: the `typing` / `locks` / `selections` / `reactions` views (empty)
850
+ * and the `setTyping` / `acquireLock` / `releaseLock` / `setSelection` / `react`
851
+ * methods (no-op). These keep the namespace shape stable for the follow-up that
852
+ * wires the client->server send path; calling a method is a safe no-op and
853
+ * emits one dev-only note so a render loop does not spam the console.
854
+ *
855
+ * Spread into the generated namespace object: the live `data` / `presence` /
856
+ * `cursors` / `status` / `move` / `reportViewport` members and the room actions
857
+ * are added by the codegen and override nothing here.
858
+ *
859
+ * @returns {Record<string, any>}
860
+ */
861
+ export function __mpFields() {
862
+ const note = (method) => {
863
+ if (_mpStubNoteFired) return;
864
+ _mpStubNoteFired = true;
865
+ if (typeof console !== 'undefined' && console.warn) {
866
+ console.warn(
867
+ `[svelte-realtime] multiplayer.${method}() is reserved and currently a no-op; the field surface is not yet active.\n See: https://svti.me/multiplayer`
868
+ );
869
+ }
870
+ };
871
+ return {
872
+ typing: [],
873
+ locks: {},
874
+ selections: {},
875
+ reactions: [],
876
+ setTyping() { note('setTyping'); },
877
+ acquireLock() { note('acquireLock'); },
878
+ releaseLock() { note('releaseLock'); },
879
+ setSelection() { note('setSelection'); },
880
+ react() { note('react'); }
881
+ };
882
+ }
883
+
844
884
  /**
845
885
  * Internal: send an RPC request over the WebSocket.
846
886
  * @param {string} path
@@ -4209,3 +4249,12 @@ export { onDerived } from 'svelte-adapter-uws/client';
4209
4249
  * successful `'open'`. Not set on intentional `close()`.
4210
4250
  */
4211
4251
  export { failure } from 'svelte-adapter-uws/client';
4252
+
4253
+ /**
4254
+ * Re-export `status` from the adapter client.
4255
+ * Reactive store holding the connection status: 'loading', 'connected',
4256
+ * 'reconnecting', or 'error'. The generated multiplayer namespace exposes this
4257
+ * as its `status` view so a collaborative surface can react to connectivity
4258
+ * without a separate adapter import.
4259
+ */
4260
+ export { status } from 'svelte-adapter-uws/client';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.2",
3
+ "version": "0.6.0-next.3",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -30,6 +30,10 @@
30
30
  "types": "./client.d.ts",
31
31
  "default": "./client.js"
32
32
  },
33
+ "./multiplayer": {
34
+ "types": "./client-multiplayer.d.ts",
35
+ "default": "./client-multiplayer.svelte.js"
36
+ },
33
37
  "./vite": {
34
38
  "types": "./vite.d.ts",
35
39
  "default": "./vite.js"
@@ -56,6 +60,8 @@
56
60
  "server.d.ts",
57
61
  "client.js",
58
62
  "client.d.ts",
63
+ "client-multiplayer.svelte.js",
64
+ "client-multiplayer.d.ts",
59
65
  "vite.js",
60
66
  "vite.d.ts",
61
67
  "test.js",
package/server.d.ts CHANGED
@@ -722,6 +722,15 @@ export const combineMin: (...buckets: number[]) => number;
722
722
  export const combineCounts: (...buckets: Array<Record<string, number> | undefined>) => Record<string, number>;
723
723
  export const combineMerge: <T extends object>(...buckets: Array<T | undefined>) => T;
724
724
 
725
+ /**
726
+ * Deterministic `hsl(...)` color for a stable user key. The same key yields
727
+ * the same color on the server and on every client, so server-rendered markup
728
+ * and the first client paint agree without a hydration mismatch.
729
+ */
730
+ export function colorForKey(key: string): string;
731
+ /** Raw deterministic hue (0..359) for a stable user key. */
732
+ export function hueForKey(key: string): number;
733
+
725
734
  /**
726
735
  * Maximum number of hop buckets a single sliding window may allocate.
727
736
  * Sliding state is `O(bucketCount * per-bucket state)`. Default 1000;
@@ -1885,6 +1894,26 @@ export namespace live {
1885
1894
  */
1886
1895
  function room(config: RoomConfig): RoomExport;
1887
1896
 
1897
+ /**
1898
+ * Bundle the collaborative surfaces (live cursors and presence) into one
1899
+ * declaration whose codegen produces a connection-aware client object.
1900
+ * Reuses the room sub-stream machinery for the `data` / `presence` /
1901
+ * `cursors` streams, then adds the `status` connection store and the
1902
+ * `move` / `reportViewport` cursor methods on the client. The cursor
1903
+ * methods publish a keyed update to the room's cursor sub-topic, so the
1904
+ * `cursors` stream reflects every connected client's position.
1905
+ *
1906
+ * @example
1907
+ * ```js
1908
+ * export const board = live.multiplayer({
1909
+ * topic: (ctx, id) => 'board:' + id,
1910
+ * presence: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),
1911
+ * cursors: true
1912
+ * });
1913
+ * ```
1914
+ */
1915
+ function multiplayer(config: MultiplayerConfig): MultiplayerExport;
1916
+
1888
1917
  /**
1889
1918
  * Create a webhook-to-stream bridge.
1890
1919
  * The returned handler can be used in a SvelteKit +server.js POST endpoint.
@@ -2132,6 +2161,56 @@ export interface RoomExport {
2132
2161
  __actions?: Record<string, any>;
2133
2162
  }
2134
2163
 
2164
+ /**
2165
+ * Configuration for `live.multiplayer()`. Mirrors `RoomConfig` and adds the
2166
+ * reserved field-surface declarations.
2167
+ */
2168
+ export interface MultiplayerConfig {
2169
+ /** Function that computes the room topic from context and args. */
2170
+ topic: (ctx: LiveContext<any>, ...args: any[]) => string;
2171
+ /** Initial data for the room's data stream. Defaults to an empty list. */
2172
+ init?: (ctx: LiveContext<any>, ...args: any[]) => Promise<any>;
2173
+ /** Presence payload for the connecting user. Drives the roster. */
2174
+ presence?: (ctx: LiveContext<any>) => any;
2175
+ /** Enable cursor tracking. Pass `true` or `{ throttle: ms }`. */
2176
+ cursors?: boolean | { throttle?: number };
2177
+ /** Room-scoped RPC actions on the data stream. */
2178
+ actions?: Record<string, (ctx: LiveContext<any>, ...args: any[]) => any>;
2179
+ /** Guard run before data access and actions. */
2180
+ guard?: (ctx: LiveContext<any>, ...args: any[]) => void | Promise<void>;
2181
+ /** Called when a user joins. */
2182
+ onJoin?: (ctx: LiveContext<any>, ...args: any[]) => void | Promise<void>;
2183
+ /** Called when a user leaves. */
2184
+ onLeave?: (ctx: LiveContext<any>, topic: string) => void | Promise<void>;
2185
+ /** Merge strategy for the data stream. @default 'crud' */
2186
+ merge?: string;
2187
+ /** Key field for the data stream. @default 'id' */
2188
+ key?: string;
2189
+ /** Number of room-identifying args the topic function expects (excluding ctx). */
2190
+ topicArgs?: number;
2191
+ /** Reserve a typing indicator surface. Not yet active. */
2192
+ typing?: boolean;
2193
+ /** Reserve advisory single-holder lock surfaces by key. Not yet active. */
2194
+ locks?: string[];
2195
+ /** Reserve an ephemeral reactions surface. Not yet active. */
2196
+ reactions?: boolean;
2197
+ /** Reserve a remote-selection surface. Not yet active. */
2198
+ selections?: 'offset' | 'crdt';
2199
+ }
2200
+
2201
+ /**
2202
+ * Return type of `live.multiplayer()`. A superset of `RoomExport`.
2203
+ */
2204
+ export interface MultiplayerExport extends RoomExport {
2205
+ __isMultiplayer: true;
2206
+ __fields: {
2207
+ typing: boolean;
2208
+ locks: string[] | null;
2209
+ reactions: boolean;
2210
+ selections: 'offset' | 'crdt' | null;
2211
+ };
2212
+ }
2213
+
2135
2214
  /**
2136
2215
  * Webhook configuration for `live.webhook()`.
2137
2216
  */
package/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { assert, wireAssertionMetrics } from './shared/assert.js';
3
3
  import { safeAssign as _safeAssignSnapshot } from './shared/safe-assign.js';
4
4
  export { assert, getAssertionCounters, _resetAssertCounters } from './shared/assert.js';
5
+ export { colorForKey, hueForKey } from './shared/color.js';
5
6
 
6
7
  const textDecoder = new TextDecoder();
7
8
  const _validPathRe = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)+$/;
@@ -5347,6 +5348,92 @@ live.room = function room(config) {
5347
5348
  return roomExport;
5348
5349
  };
5349
5350
 
5351
+ live.multiplayer = function multiplayer(config) {
5352
+ const topicFn = config && config.topic;
5353
+ if (typeof topicFn !== 'function') {
5354
+ throw new Error(
5355
+ `[svelte-realtime] live.multiplayer() requires a topic function (ctx, ...args) => string\n See: https://svti.me/multiplayer`
5356
+ );
5357
+ }
5358
+
5359
+ // A multiplayer export is a room export with a marker stamped on top. It
5360
+ // reuses live.room's sub-stream construction verbatim so the data /
5361
+ // presence / cursor streams, the presence-ref auto-join, and the scoped
5362
+ // actions are byte-identical to a room. The codegen and the dev-direct
5363
+ // loader dispatch on __isRoom for the sub-streams; the __isMultiplayer
5364
+ // marker only adds the collaborative client surface.
5365
+ const roomExport = live.room({
5366
+ topic: topicFn,
5367
+ init: config.init ? config.init : async () => [],
5368
+ presence: config.presence,
5369
+ cursors: config.cursors,
5370
+ guard: config.guard,
5371
+ onJoin: config.onJoin,
5372
+ onLeave: config.onLeave,
5373
+ merge: config.merge,
5374
+ key: config.key,
5375
+ actions: config.actions,
5376
+ topicArgs: config.topicArgs
5377
+ });
5378
+
5379
+ /** @type {any} */ (roomExport).__isMultiplayer = true;
5380
+
5381
+ // Cursor send path. The client `move` / `reportViewport` methods are
5382
+ // volatile RPCs (fire-and-forget, lossy under disconnect is the contract)
5383
+ // that publish an `update` frame keyed by the caller's identity onto the
5384
+ // room's `:cursors` sub-topic - the same topic the cursor stream loads and
5385
+ // merges with `merge: 'cursor'`. The leading args identify the room (the
5386
+ // same count the topic function and room actions use); the trailing args
5387
+ // are the cursor payload, normalized to a flat object the cursor merge can
5388
+ // key by `.key`.
5389
+ const _cursorArgCount = config.topicArgs !== undefined
5390
+ ? config.topicArgs
5391
+ : Math.max(0, topicFn.length - 1);
5392
+
5393
+ /**
5394
+ * @param {any} ctx
5395
+ * @param {any[]} args
5396
+ * @param {Record<string, any>} extra
5397
+ */
5398
+ const _publishCursor = (ctx, args, extra) => {
5399
+ const roomArgs = args.slice(0, _cursorArgCount);
5400
+ const payload = args.slice(_cursorArgCount);
5401
+ const cursorTopic = _callTopicFn(topicFn, ctx, roomArgs) + ':cursors';
5402
+ const key = _getIdentityKey(ctx);
5403
+ const frame = { key, ...extra };
5404
+ const cur = payload[0];
5405
+ if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
5406
+ Object.assign(frame, cur);
5407
+ } else if (payload.length > 0) {
5408
+ frame.value = payload.length === 1 ? cur : payload;
5409
+ }
5410
+ ctx.publish(cursorTopic, 'update', frame);
5411
+ };
5412
+
5413
+ const _cursorGuard = config.guard;
5414
+ /** @type {any} */ (roomExport).__cursorMove = live.volatile(async (ctx, ...args) => {
5415
+ if (_cursorGuard) await _cursorGuard(ctx, ...args.slice(0, _cursorArgCount));
5416
+ _publishCursor(ctx, args, {});
5417
+ });
5418
+ /** @type {any} */ (roomExport).__cursorReportViewport = live.volatile(async (ctx, ...args) => {
5419
+ if (_cursorGuard) await _cursorGuard(ctx, ...args.slice(0, _cursorArgCount));
5420
+ _publishCursor(ctx, args, { viewport: true });
5421
+ });
5422
+
5423
+ // Record the declared field surfaces. The typing / locks / selections /
5424
+ // reactions surfaces are reserved here so the returned shape is stable for
5425
+ // the follow-up that wires the client->server send path; they produce no
5426
+ // live sub-stream yet.
5427
+ /** @type {any} */ (roomExport).__fields = {
5428
+ typing: !!config.typing,
5429
+ locks: Array.isArray(config.locks) ? config.locks.slice() : (config.locks ? [] : null),
5430
+ reactions: !!config.reactions,
5431
+ selections: config.selections === 'crdt' ? 'crdt' : (config.selections ? 'offset' : null)
5432
+ };
5433
+
5434
+ return roomExport;
5435
+ };
5436
+
5350
5437
  /** @type {{ rpcCount?: any, rpcDuration?: any, rpcErrors?: any, streamGauge?: any, cronCount?: any, cronErrors?: any, assertions?: any } | null} */
5351
5438
  let _metricsInstruments = null;
5352
5439
 
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+
3
+ // Deterministic per-user color. Hue is an FNV-1a hash folded to 0..359.
4
+ // Saturation and lightness are each chosen from a small band set using HIGH
5
+ // bits of the same 32-bit hash, drawn from bit windows disjoint from the low
6
+ // bits that `% 360` consumes. The result is 360 hues * 3 sat bands * 4 light
7
+ // bands = 4320 distinct, perceptually separable swatches. The first entry of
8
+ // each band set is the prior single value (70% sat, 55% light), so the palette
9
+ // is a strict superset of the earlier single band. Every operation stays in
10
+ // unsigned 32-bit integer space (`Math.imul` + `>>> 0`), so the value is
11
+ // byte-identical on the server render and the first client paint. No
12
+ // randomness, no Date.
13
+
14
+ // Band sets. Index 0 of each is the legacy value, so the old single swatch
15
+ // remains reachable and the palette only grows. Bands are spaced far enough
16
+ // apart to read as distinct chips while every combination keeps a legible
17
+ // contrast for foreground text (S in [60, 85], L in [38, 65] - never
18
+ // near-white, never near-black).
19
+ const SAT_BANDS = [70, 60, 85];
20
+ const LIGHT_BANDS = [55, 45, 65, 38];
21
+
22
+ /**
23
+ * Deterministic color for a stable per-user key. The same input always yields
24
+ * the same `hsl(...)` on the server and on every client, so server-rendered
25
+ * markup and the first client paint agree with no hydration mismatch.
26
+ *
27
+ * The hue is an FNV-1a hash of the key folded into 0..359; saturation and
28
+ * lightness are each drawn from a legible band using high bits of the same
29
+ * hash, widening the palette to 4320 distinct swatches without sacrificing
30
+ * foreground contrast. Returns an `hsl(...)` string usable directly as a CSS
31
+ * color or a custom property.
32
+ *
33
+ * @param {string} key - a stable per-user key
34
+ * @returns {string} an `hsl(h, s%, l%)` color string
35
+ */
36
+ export function colorForKey(key) {
37
+ const h = hash32(key);
38
+ const hue = h % 360;
39
+ // Band selectors come from high bit windows. `% 360` effectively consumes
40
+ // the low ~9 bits (360 < 512), so reading from bit 17 and bit 23 keeps the
41
+ // bands statistically independent of the hue. The two windows (bits 17..
42
+ // and 23..) and the hue's low region do not overlap, so no selector is a
43
+ // slave of another.
44
+ const light = LIGHT_BANDS[(h >>> 17) % LIGHT_BANDS.length];
45
+ const sat = SAT_BANDS[(h >>> 23) % SAT_BANDS.length];
46
+ return `hsl(${hue}, ${sat}%, ${light}%)`;
47
+ }
48
+
49
+ /**
50
+ * The raw hue (0..359) for a key. Exposed separately so a caller can build a
51
+ * different color expression (for example a translucent selection fill) from
52
+ * the same deterministic hue.
53
+ *
54
+ * @param {string} key
55
+ * @returns {number} integer hue in [0, 360)
56
+ */
57
+ export function hueForKey(key) {
58
+ return hash32(key) % 360;
59
+ }
60
+
61
+ /**
62
+ * FNV-1a, 32-bit. Offset basis 2166136261, prime 16777619. All math is kept in
63
+ * 32-bit unsigned space via `Math.imul` + `>>> 0` so the result is identical
64
+ * across engines (server Node and every browser). A naive `h * 16777619`
65
+ * overflows into float territory and diverges per engine, which would break the
66
+ * server/client color agreement.
67
+ *
68
+ * @param {string} key
69
+ * @returns {number} unsigned 32-bit hash
70
+ */
71
+ function hash32(key) {
72
+ const s = typeof key === 'string' ? key : String(key);
73
+ let h = 2166136261;
74
+ for (let i = 0; i < s.length; i++) {
75
+ h ^= s.charCodeAt(i);
76
+ h = Math.imul(h, 16777619);
77
+ }
78
+ return h >>> 0;
79
+ }
package/vite.js CHANGED
@@ -15,6 +15,12 @@ const UPLOAD_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.upload\s*\(/g;
15
15
  const DERIVED_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.derived\s*\(/g;
16
16
  const DYNAMIC_DERIVED_RE = /export\s+const\s+(\w+)\s*=\s*live\.derived\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>/g;
17
17
  const ROOM_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.room\s*\(/g;
18
+ // `live.multiplayer(...)` is a room export with a collaborative client surface.
19
+ // At runtime it carries `__isRoom` plus the same __data/__presence/__cursors
20
+ // sub-streams, so its registry registration is identical to a room; the client
21
+ // stub adds the aggregated `status` view and the `move`/`reportViewport`
22
+ // cursor methods on top of the room namespace.
23
+ const MULTIPLAYER_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.multiplayer\s*\(/g;
18
24
  const WEBHOOK_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.webhook\s*\(/g;
19
25
  const CHANNEL_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(/g;
20
26
  const DYNAMIC_CHANNEL_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>/g;
@@ -989,12 +995,25 @@ function _generateSsrStubs(filePath, modulePath) {
989
995
  rooms.push({ name, info: _extractRoomInfo(source, name) });
990
996
  }
991
997
 
998
+ // Collect live.multiplayer() exports - the SSR stub mirrors the room
999
+ // namespace (factory-shaped sub-streams, no-op actions) plus the empty
1000
+ // collaborative views and no-op cursor methods, so a page that renders
1001
+ // `board.status` / `board.move(...)` during SSR does not crash.
1002
+ /** @type {Array<{ name: string, info: ReturnType<typeof _extractMultiplayerInfo> }>} */
1003
+ const multiplayers = [];
1004
+ MULTIPLAYER_EXPORT_RE.lastIndex = 0;
1005
+ while ((match = MULTIPLAYER_EXPORT_RE.exec(source)) !== null) {
1006
+ const name = match[1];
1007
+ if (!/^\w+$/.test(name)) continue;
1008
+ multiplayers.push({ name, info: _extractMultiplayerInfo(source, name) });
1009
+ }
1010
+
992
1011
  // Escape paths for safe embedding in generated code
993
1012
  const safePath = JSON.stringify(normalized);
994
1013
  const safeModulePath = (name) => JSON.stringify(modulePath + '/' + name);
995
1014
 
996
- // If no store-like / room / windowed-aggregate exports, simple re-export
997
- if (storeNames.length === 0 && rooms.length === 0 && windowedAggregates.length === 0) {
1015
+ // If no store-like / room / multiplayer / windowed-aggregate exports, simple re-export
1016
+ if (storeNames.length === 0 && rooms.length === 0 && multiplayers.length === 0 && windowedAggregates.length === 0) {
998
1017
  return `export * from ${safePath};\n`;
999
1018
  }
1000
1019
 
@@ -1062,6 +1081,49 @@ function _generateSsrStubs(filePath, modulePath) {
1062
1081
  lines.push(`export { _${name} as ${name} };`);
1063
1082
  }
1064
1083
 
1084
+ for (const { name, info } of multiplayers) {
1085
+ // Multiplayer namespace: the same factory-shaped sub-streams a room
1086
+ // uses, plus an empty connection-status readable and no-op cursor
1087
+ // methods. The client factory replaces all of this on hydration.
1088
+ lines.push(`const _${name}_data = (...args) => { const s = readable(undefined); s.hydrate = (d) => readable(d); return s; };`);
1089
+ lines.push(`_${name}_data.load = (platform, options) => __directCall(${JSON.stringify(modulePath + '/' + name + '/__data')}, options?.args || [], platform, options);`);
1090
+ const mpFactories = [`data: _${name}_data`];
1091
+ if (info.hasPresence) {
1092
+ lines.push(`const _${name}_presence = (...args) => readable(undefined);`);
1093
+ mpFactories.push(`presence: _${name}_presence`);
1094
+ }
1095
+ if (info.hasCursors) {
1096
+ lines.push(`const _${name}_cursors = (...args) => readable(undefined);`);
1097
+ mpFactories.push(`cursors: _${name}_cursors`);
1098
+ }
1099
+ mpFactories.push(`status: readable('connecting')`);
1100
+ mpFactories.push(`move: () => {}`);
1101
+ mpFactories.push(`reportViewport: () => {}`);
1102
+ // Reserved field-surface members rendered as their empty SSR state.
1103
+ mpFactories.push(`typing: []`);
1104
+ mpFactories.push(`locks: {}`);
1105
+ mpFactories.push(`selections: {}`);
1106
+ mpFactories.push(`reactions: []`);
1107
+ mpFactories.push(`setTyping: () => {}`);
1108
+ mpFactories.push(`acquireLock: () => {}`);
1109
+ mpFactories.push(`releaseLock: () => {}`);
1110
+ mpFactories.push(`setSelection: () => {}`);
1111
+ mpFactories.push(`react: () => {}`);
1112
+ // identify(...) and room(...) render their empty collaborative state on
1113
+ // the server so a page that names self or reads the aggregated roster
1114
+ // during SSR does not crash before hydration. No rune import on the
1115
+ // server: room() returns a plain object, not a MultiplayerRoom.
1116
+ if (info.hasPresence || info.hasCursors) {
1117
+ mpFactories.push(`identify: () => {}`);
1118
+ mpFactories.push(`room: () => ({ others: [], cursors: [], me: null, status: 'connecting', typing: [], locks: {}, selections: {}, reactions: [], move: () => {}, reportViewport: () => {}, setTyping: () => {}, acquireLock: () => {}, releaseLock: () => {}, setSelection: () => {}, react: () => {}, destroy: () => {} })`);
1119
+ }
1120
+ for (const action of info.actions) {
1121
+ mpFactories.push(`${action}: () => Promise.resolve(undefined)`);
1122
+ }
1123
+ lines.push(`const _${name} = { ${mpFactories.join(', ')} };`);
1124
+ lines.push(`export { _${name} as ${name} };`);
1125
+ }
1126
+
1065
1127
  return lines.join('\n') + '\n';
1066
1128
  }
1067
1129
 
@@ -1200,6 +1262,79 @@ function _generateClientStubs(filePath, modulePath, dir) {
1200
1262
  }
1201
1263
  }
1202
1264
 
1265
+ // Detect live.multiplayer() exports - the room namespace plus the
1266
+ // aggregated connection status and the cursor move / reportViewport
1267
+ // methods. Runs before the room loop and marks the name so the room loop
1268
+ // (which guards on exportedNames) skips it - a single export is never
1269
+ // emitted twice.
1270
+ // Emit the rune-class import at most once per stub even when a module
1271
+ // declares several multiplayer exports, so the generated module never has a
1272
+ // duplicate import declaration.
1273
+ let mpRuntimeImported = false;
1274
+ MULTIPLAYER_EXPORT_RE.lastIndex = 0;
1275
+ while ((match = MULTIPLAYER_EXPORT_RE.exec(source)) !== null) {
1276
+ const name = match[1];
1277
+ if (!/^\w+$/.test(name)) continue;
1278
+ if (!exportedNames.has(name)) {
1279
+ exportedNames.add(name);
1280
+ imports.add('__stream');
1281
+ imports.add('__rpc');
1282
+ imports.add('status');
1283
+ imports.add('__mpFields');
1284
+ const mpInfo = _extractMultiplayerInfo(source, name);
1285
+ // `others` / `cursors` / `me` aggregation needs the rune-class. It
1286
+ // composes the generated presence / cursor / status sub-streams, so
1287
+ // it is only constructed when a roster surface exists (presence or
1288
+ // cursors). The class lives in a separate rune-aware subpath, not in
1289
+ // svelte-realtime/client, so its import is a standalone line.
1290
+ const hasRoster = mpInfo.hasPresence || mpInfo.hasCursors;
1291
+ if (hasRoster) {
1292
+ if (!mpRuntimeImported) {
1293
+ lines.push(`import { MultiplayerRoom, localKeySource } from 'svelte-realtime/multiplayer';`);
1294
+ mpRuntimeImported = true;
1295
+ }
1296
+ lines.push(`const _${name}_me = localKeySource();`);
1297
+ }
1298
+ const mpLines = [];
1299
+ mpLines.push(`export const ${name} = {`);
1300
+ // Reserved field-surface members (typing / locks / selections /
1301
+ // reactions + their no-op methods). The live members below override
1302
+ // nothing here.
1303
+ mpLines.push(` ...__mpFields(),`);
1304
+ mpLines.push(` data: __stream(${JSON.stringify(modulePath + '/' + name + '/__data')}, ${JSON.stringify(mpInfo.dataOpts)}, true),`);
1305
+ if (mpInfo.hasPresence) {
1306
+ mpLines.push(` presence: __stream(${JSON.stringify(modulePath + '/' + name + '/__presence')}, ${JSON.stringify({ merge: 'presence' })}, true),`);
1307
+ }
1308
+ if (mpInfo.hasCursors) {
1309
+ mpLines.push(` cursors: __stream(${JSON.stringify(modulePath + '/' + name + '/__cursors')}, ${JSON.stringify({ merge: 'cursor' })}, true),`);
1310
+ }
1311
+ mpLines.push(` status: status,`);
1312
+ mpLines.push(` move: __rpc(${JSON.stringify(modulePath + '/' + name + '/__cursor/move')}),`);
1313
+ mpLines.push(` reportViewport: __rpc(${JSON.stringify(modulePath + '/' + name + '/__cursor/reportViewport')}),`);
1314
+ for (const action of mpInfo.actions) {
1315
+ mpLines.push(` ${action}: __rpc(${JSON.stringify(modulePath + '/' + name + '/__action/' + action)}),`);
1316
+ }
1317
+ if (hasRoster) {
1318
+ // identify(key) names the local user once; me + self-exclusion
1319
+ // light up. room(...args) builds the aggregated reactive view
1320
+ // over the per-room presence / cursor sub-streams and forwards
1321
+ // the room args to each. The object is fully assigned before
1322
+ // room() can be called, so self-referencing ${name} is safe.
1323
+ // A roster surface may declare presence without cursors (or the
1324
+ // reverse); the missing sub-stream falls back to a store that
1325
+ // pushes an empty list once, so the room always has both stores
1326
+ // to compose without a dangling member reference.
1327
+ const emptyStore = `{ subscribe: (fn) => { fn([]); return () => {}; } }`;
1328
+ const presenceArg = mpInfo.hasPresence ? `${name}.presence(...args)` : emptyStore;
1329
+ const cursorsArg = mpInfo.hasCursors ? `${name}.cursors(...args)` : emptyStore;
1330
+ mpLines.push(` identify(key) { _${name}_me.set(key); },`);
1331
+ mpLines.push(` room(...args) { return new MultiplayerRoom({ me: _${name}_me, presence: ${presenceArg}, cursors: ${cursorsArg}, status: status, move: (...a) => ${name}.move(...args, ...a), reportViewport: (...a) => ${name}.reportViewport(...args, ...a) }); },`);
1332
+ }
1333
+ mpLines.push(`};`);
1334
+ lines.push(mpLines.join('\n'));
1335
+ }
1336
+ }
1337
+
1203
1338
  // Detect live.room() exports - generates data stream + presence stream + cursor stream + actions
1204
1339
  ROOM_EXPORT_RE.lastIndex = 0;
1205
1340
  while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
@@ -1890,6 +2025,58 @@ function _extractRoomInfo(source, name) {
1890
2025
  return info;
1891
2026
  }
1892
2027
 
2028
+ /**
2029
+ * Extract multiplayer config for client-stub generation. The config is
2030
+ * room-shaped (topic / init / presence / cursors / actions / merge / key), so
2031
+ * the room extractor supplies the data options and the presence / cursor /
2032
+ * action discriminators; the field-surface keys are read separately so a
2033
+ * future client surface can be told which surfaces were declared.
2034
+ * @param {string} source
2035
+ * @param {string} name
2036
+ * @returns {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[], typing: boolean, hasLocks: boolean, reactions: boolean, selections: string | null }}
2037
+ */
2038
+ function _extractMultiplayerInfo(source, name) {
2039
+ const startPattern = new RegExp(
2040
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.multiplayer\\s*\\(`
2041
+ );
2042
+ const startMatch = startPattern.exec(source);
2043
+
2044
+ const info = {
2045
+ dataOpts: { merge: 'crud', key: 'id' },
2046
+ hasPresence: false,
2047
+ hasCursors: false,
2048
+ actions: [],
2049
+ typing: false,
2050
+ hasLocks: false,
2051
+ reactions: false,
2052
+ selections: /** @type {string | null} */ (null)
2053
+ };
2054
+ if (!startMatch) return info;
2055
+
2056
+ const afterOpen = source.slice(startMatch.index + startMatch[0].length);
2057
+ const body = _extractBraceContent(afterOpen);
2058
+ if (!body) return info;
2059
+
2060
+ const configKeys = new Set(_extractTopLevelKeys(body));
2061
+ info.hasPresence = configKeys.has('presence');
2062
+ info.hasCursors = configKeys.has('cursors');
2063
+ info.typing = configKeys.has('typing');
2064
+ info.hasLocks = configKeys.has('locks');
2065
+ info.reactions = configKeys.has('reactions');
2066
+ info.selections = _extractTopLevelStringProp(body, 'selections') || null;
2067
+
2068
+ const mergeVal = _extractTopLevelStringProp(body, 'merge');
2069
+ if (mergeVal) info.dataOpts.merge = mergeVal;
2070
+ const keyVal = _extractTopLevelStringProp(body, 'key');
2071
+ if (keyVal) info.dataOpts.key = keyVal;
2072
+ if (info.dataOpts.merge !== 'crud' && keyVal === undefined) delete info.dataOpts.key;
2073
+
2074
+ const actionsBody = _extractTopLevelBraceProp(body, 'actions');
2075
+ if (actionsBody) info.actions = _extractTopLevelKeys(actionsBody);
2076
+
2077
+ return info;
2078
+ }
2079
+
1893
2080
  /**
1894
2081
  * Extract content between matching braces { ... } respecting nesting.
1895
2082
  * Input should start at or before the opening brace.
@@ -2055,6 +2242,27 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
2055
2242
  }
2056
2243
  }
2057
2244
 
2245
+ // Register live.multiplayer() exports - a multiplayer export reuses the
2246
+ // room sub-streams at runtime, so it registers the same
2247
+ // __data/__presence/__cursors paths plus its scoped actions lazily.
2248
+ // Running before the room loop and marking the name means the room loop
2249
+ // (which guards on registered) skips it - never double-registered.
2250
+ MULTIPLAYER_EXPORT_RE.lastIndex = 0;
2251
+ while ((match = MULTIPLAYER_EXPORT_RE.exec(source)) !== null) {
2252
+ const name = match[1];
2253
+ if (!/^\w+$/.test(name)) continue;
2254
+ if (!registered.has(name)) {
2255
+ registered.add(name);
2256
+ const importPath = JSON.stringify(normalizedPath);
2257
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__data')}, __L(() => import(${importPath}).then(m => m.${name}.__dataStream)), ${JSON.stringify(rel)});`);
2258
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__presence')}, __L(() => import(${importPath}).then(m => m.${name}.__presenceStream)), ${JSON.stringify(rel)});`);
2259
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__cursors')}, __L(() => import(${importPath}).then(m => m.${name}.__cursorStream)), ${JSON.stringify(rel)});`);
2260
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__cursor/move')}, __L(() => import(${importPath}).then(m => m.${name}.__cursorMove)), ${JSON.stringify(rel)});`);
2261
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__cursor/reportViewport')}, __L(() => import(${importPath}).then(m => m.${name}.__cursorReportViewport)), ${JSON.stringify(rel)});`);
2262
+ lines.push(`__registerRoomActions(${JSON.stringify(rel + '/' + name)}, ${_lazy(name)});`);
2263
+ }
2264
+ }
2265
+
2058
2266
  // Register live.room() exports - register sub-streams and actions lazily
2059
2267
  ROOM_EXPORT_RE.lastIndex = 0;
2060
2268
  while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
@@ -2511,6 +2719,23 @@ function _generateTypeDeclarations(liveDir, dir) {
2511
2719
  }
2512
2720
  }
2513
2721
 
2722
+ // Detect live.multiplayer() exports - the room namespace plus the
2723
+ // connection-status view and the cursor methods. Runs before the room
2724
+ // branch and claims the name so the room branch skips it.
2725
+ MULTIPLAYER_EXPORT_RE.lastIndex = 0;
2726
+ while ((match = MULTIPLAYER_EXPORT_RE.exec(source)) !== null) {
2727
+ const name = match[1];
2728
+ handledNames.add(name);
2729
+ if (!exports.some(e => e.includes(`export const ${name}:`))) {
2730
+ needsStreamStore = true;
2731
+ const mpInfo = _extractMultiplayerInfo(source, name);
2732
+ const rosterMembers = (mpInfo.hasPresence || mpInfo.hasCursors)
2733
+ ? `, identify: (key: string) => void, room: (...args: any[]) => import('svelte-realtime/multiplayer').MultiplayerRoom`
2734
+ : '';
2735
+ exports.push(` export const ${name}: { data: (...args: any[]) => StreamStore<any>, presence?: (...args: any[]) => StreamStore<any>, cursors?: (...args: any[]) => StreamStore<any>, status: import('svelte/store').Readable<string>, move: (...args: any[]) => void, reportViewport: (...args: any[]) => void${rosterMembers}, [action: string]: any };`);
2736
+ }
2737
+ }
2738
+
2514
2739
  // Detect live.room() exports
2515
2740
  ROOM_EXPORT_RE.lastIndex = 0;
2516
2741
  while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
@@ -3181,6 +3406,20 @@ async function _loadRegistryDirect(server, liveDir, dir) {
3181
3406
  for (const [name, fn] of Object.entries(mod)) {
3182
3407
  if (name === '_guard' && /** @type {any} */ (fn)?.__isGuard) {
3183
3408
  __registerGuard(rel, fn);
3409
+ } else if (/** @type {any} */ (fn)?.__isMultiplayer) {
3410
+ // A multiplayer export reuses the room sub-streams; register
3411
+ // them, the cursor send handlers, and its scoped actions the
3412
+ // same way a room does.
3413
+ if (fn.__dataStream) __register(rel + '/' + name + '/__data', fn.__dataStream, rel);
3414
+ if (fn.__presenceStream) __register(rel + '/' + name + '/__presence', fn.__presenceStream, rel);
3415
+ if (fn.__cursorStream) __register(rel + '/' + name + '/__cursors', fn.__cursorStream, rel);
3416
+ if (fn.__cursorMove) __register(rel + '/' + name + '/__cursor/move', fn.__cursorMove, rel);
3417
+ if (fn.__cursorReportViewport) __register(rel + '/' + name + '/__cursor/reportViewport', fn.__cursorReportViewport, rel);
3418
+ if (fn.__actions) {
3419
+ for (const [k, v] of Object.entries(fn.__actions)) {
3420
+ __register(rel + '/' + name + '/__action/' + k, v, rel);
3421
+ }
3422
+ }
3184
3423
  } else if (/** @type {any} */ (fn)?.__isRoom) {
3185
3424
  if (fn.__dataStream) __register(rel + '/' + name + '/__data', fn.__dataStream, rel);
3186
3425
  if (fn.__presenceStream) __register(rel + '/' + name + '/__presence', fn.__presenceStream, rel);