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 +113 -0
- package/client-multiplayer.d.ts +100 -0
- package/client-multiplayer.svelte.js +118 -0
- package/client.d.ts +29 -0
- package/client.js +49 -0
- package/package.json +7 -1
- package/server.d.ts +79 -0
- package/server.js +87 -0
- package/shared/color.js +79 -0
- package/vite.js +241 -2
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.
|
|
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
|
|
package/shared/color.js
ADDED
|
@@ -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);
|