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