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

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.
Files changed (92) hide show
  1. package/MIGRATION.md +13 -0
  2. package/README.md +454 -11
  3. package/package.json +42 -42
  4. package/{cli.js → src/cli.js} +0 -0
  5. package/src/client/connection.js +227 -0
  6. package/src/client/devtools-instrument.js +201 -0
  7. package/src/client/health.js +180 -0
  8. package/src/client/internal-state.js +202 -0
  9. package/src/client/misc.js +323 -0
  10. package/src/client/rpc.js +688 -0
  11. package/src/client/stream.js +1835 -0
  12. package/src/client/upload.js +721 -0
  13. package/src/client-doc.d.ts +108 -0
  14. package/src/client-doc.svelte.js +448 -0
  15. package/src/client-multiplayer.d.ts +113 -0
  16. package/src/client-multiplayer.svelte.js +208 -0
  17. package/src/client-runtime.js +138 -0
  18. package/src/client-smooth.d.ts +79 -0
  19. package/src/client-smooth.svelte.js +207 -0
  20. package/{client.d.ts → src/client.d.ts} +40 -0
  21. package/src/client.js +38 -0
  22. package/{devtools.js → src/devtools.js} +7 -5
  23. package/src/server/admission.js +87 -0
  24. package/src/server/breaker.js +38 -0
  25. package/src/server/bus.js +25 -0
  26. package/src/server/crdt.js +554 -0
  27. package/src/server/cron-engine.js +449 -0
  28. package/src/server/cron.js +138 -0
  29. package/src/server/ctx.js +305 -0
  30. package/src/server/dev-warnings.js +362 -0
  31. package/src/server/dispatch.js +1133 -0
  32. package/src/server/env.js +9 -0
  33. package/src/server/history-compensation.js +195 -0
  34. package/src/server/idempotency.js +482 -0
  35. package/src/server/identity.js +33 -0
  36. package/src/server/interest.js +422 -0
  37. package/src/server/lagcomp.js +303 -0
  38. package/src/server/lazy.js +84 -0
  39. package/src/server/live-error.js +15 -0
  40. package/src/server/metrics.js +48 -0
  41. package/src/server/monoclock.js +70 -0
  42. package/src/server/multiplayer.js +203 -0
  43. package/src/server/presence.js +216 -0
  44. package/src/server/publish-helpers.js +198 -0
  45. package/src/server/push.js +601 -0
  46. package/src/server/rate-limit.js +201 -0
  47. package/src/server/reactive-families.js +358 -0
  48. package/src/server/reactive.js +502 -0
  49. package/src/server/replay-routing.js +138 -0
  50. package/src/server/room.js +356 -0
  51. package/src/server/rtt.js +96 -0
  52. package/src/server/runtime-fallbacks.js +40 -0
  53. package/src/server/smooth.js +1624 -0
  54. package/src/server/spatial.js +157 -0
  55. package/src/server/state.js +331 -0
  56. package/src/server/upload.js +687 -0
  57. package/src/server/validate.js +91 -0
  58. package/src/server/webhook-out.js +418 -0
  59. package/src/server/webhooks.js +101 -0
  60. package/{server.d.ts → src/server.d.ts} +551 -0
  61. package/src/server.js +3152 -0
  62. package/src/shared/assert.js +172 -0
  63. package/src/shared/color.js +79 -0
  64. package/src/shared/runtime.js +121 -0
  65. package/{test-client.d.ts → src/testing-client.d.ts} +1 -1
  66. package/{test-client.js → src/testing-client.js} +2 -2
  67. package/{test.d.ts → src/testing.d.ts} +2 -2
  68. package/{test.js → src/testing.js} +1 -1
  69. package/src/vite/codegen-client.js +510 -0
  70. package/src/vite/codegen-registry.js +595 -0
  71. package/src/vite/codegen-ssr.js +262 -0
  72. package/src/vite/codegen-types.js +365 -0
  73. package/src/vite/constants.js +5 -0
  74. package/src/vite/extract-options.js +618 -0
  75. package/src/vite/extract-types.js +570 -0
  76. package/src/vite/hmr.js +142 -0
  77. package/src/vite/patterns.js +76 -0
  78. package/src/vite/plugin.js +271 -0
  79. package/src/vite/scanner.js +387 -0
  80. package/src/vite/source-cache.js +25 -0
  81. package/src/vite.js +2 -0
  82. package/client.js +0 -4211
  83. package/server.js +0 -9142
  84. package/shared/assert.js +0 -83
  85. package/vite.js +0 -3221
  86. /package/{cli-utils.js → src/cli-utils.js} +0 -0
  87. /package/{devtools.d.ts → src/devtools.d.ts} +0 -0
  88. /package/{hooks.d.ts → src/hooks.d.ts} +0 -0
  89. /package/{hooks.js → src/hooks.js} +0 -0
  90. /package/{shared → src/shared}/merge.js +0 -0
  91. /package/{shared → src/shared}/safe-assign.js +0 -0
  92. /package/{vite.d.ts → src/vite.d.ts} +0 -0
package/MIGRATION.md CHANGED
@@ -74,6 +74,19 @@ The fix awaits the predicate before the truthiness check. The matching adapter-s
74
74
 
75
75
  These won't run cleanly until you make the change.
76
76
 
77
+ ### `svelte-realtime/test` renamed to `svelte-realtime/testing` (and `/test-client` to `/testing/client`)
78
+
79
+ **What changed.** The test-authoring entry points moved: import `createTestEnv`, `createTestContext`, and `expectGuardRejects` from `svelte-realtime/testing` (was `svelte-realtime/test`), and `subscribeAt` from `svelte-realtime/testing/client` (was `svelte-realtime/test-client`). The exported names are identical; only the subpath changed, so the framework test helpers no longer collide by name with a test runner global `test`.
80
+
81
+ **How to migrate.** Update the import specifiers:
82
+
83
+ ```diff
84
+ - import { createTestEnv, expectGuardRejects } from 'svelte-realtime/test';
85
+ + import { createTestEnv, expectGuardRejects } from 'svelte-realtime/testing';
86
+ - import { subscribeAt } from 'svelte-realtime/test-client';
87
+ + import { subscribeAt } from 'svelte-realtime/testing/client';
88
+ ```
89
+
77
90
  ### Runtime: Node.js 22+ required (was Node 20+)
78
91
 
79
92
  **What changed.** `package.json#engines.node` moved from `>=20.0.0` to `>=22.0.0`. Tracks the adapter's bump, which in turn tracks `uWebSockets.js` v20.67.0 dropping Node 20 support upstream.
package/README.md CHANGED
@@ -256,6 +256,8 @@ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `re
256
256
  - [Pipes](#pipes)
257
257
  - [Binary RPC](#binary-rpc)
258
258
  - [Rooms](#rooms)
259
+ - [Multiplayer](#multiplayer)
260
+ - [Smoothed entities](#smoothed-entities)
259
261
  - [Webhooks](#webhooks)
260
262
  - [Signals](#signals)
261
263
  - [Schema evolution](#schema-evolution)
@@ -2125,12 +2127,14 @@ The six-line shim adapts realtime's options-object call shape to the extensions
2125
2127
  - **In production**, a violation increments an in-memory per-category counter, fires the Prometheus counter `svelte_realtime_assertion_violations_total{category}` (when `live.metrics(...)` is wired), and logs a single `[realtime/assert] {...}` line at `console.error`. The assert does NOT throw - a thrown exception inside a publish hot-path microtask or a subscribe callback could leave a half-applied bookkeeping update or a corrupted index. Counter + log give observability without the corruption risk.
2126
2128
  - **In test mode** (`process.env.VITEST` or `NODE_ENV === 'test'`) the assert THROWS so vitest surfaces the failure as a normal test error.
2127
2129
 
2130
+ There is also a **hard tier**, `fatal(cond, category, context)`, for genuinely unrecoverable server state where continuing risks silent misdelivery. It records the same per-category counter and Prometheus series as `assert` (the severity rides the log as `[realtime/fatal] {..., "severity":"fatal"}`), but in production it schedules a deferred worker termination with exit code 78 once the current callback frame unwinds, so the supervisor restarts the worker from clean state rather than letting it mis-route publishes; in test mode it throws. On the client it degrades to the log + counter only (there is no worker to terminate).
2131
+
2128
2132
  Categories are stable strings prefixed `realtime/<module>.<invariant>` (so the Prometheus label cardinality is bounded and won't collide with the adapter's `extensions_assertion_violations_total`). Today's categories:
2129
2133
 
2130
2134
  | Category | Where |
2131
2135
  | ----------------------------------------------------- | ------------------------------------------- |
2132
2136
  | `realtime/handleRpc.envelope.non-empty` | RPC frame has non-empty `rpc` and `id` |
2133
- | `realtime/subscription.bookkeeping.ws-was-tracked` | Unsubscribe path: ws was in the topic set |
2137
+ | `realtime/subscription.bookkeeping.ws-was-tracked` | Subscribe rollback: forward/reverse subscription indexes agree (**hard tier**: terminates the worker) |
2134
2138
  | `realtime/push-registry.entry-tracked` | Close hook: registry entry exists for userId |
2135
2139
  | `realtime/lock.waiter.shape` | Dequeued lock waiter has resolve+reject |
2136
2140
  | `realtime/optimistic.queue.serverValue-iff-nonempty` | Server-merge path: `_serverValue` set when queue non-empty |
@@ -2885,6 +2889,381 @@ import { board } from './live/collab.js';
2885
2889
  export const { message, close, unsubscribe } = board.hooks;
2886
2890
  ```
2887
2891
 
2892
+ ### Lag compensation
2893
+
2894
+ For real-time games, the moment a player acts and the moment the server evaluates that action are separated by network latency - by the time a shot arrives, the target has moved on the server. A room can opt into a bounded history of state snapshots and evaluate actions against the state *as it was when the player acted*: declare `history` with a `capture` function (your snapshot of your own authoritative state - the framework never guesses at state shape), and call `ctx.compensate()` inside an action with the client-stamped command time, passed as an ordinary action argument:
2895
+
2896
+ ```js
2897
+ export const game = live.room({
2898
+ topic: (ctx, gameId) => 'game:' + gameId,
2899
+ init: async (ctx, gameId) => loadWorld(gameId),
2900
+ topicArgs: 1,
2901
+ history: {
2902
+ capture: (gameId) => snapshotPlayers(gameId) // plain data, recorded after every action
2903
+ },
2904
+ actions: {
2905
+ move: async (ctx, gameId, dir) => applyMove(gameId, ctx.user.id, dir),
2906
+ shoot: async (ctx, gameId, targetId, firedAt) => {
2907
+ const result = await ctx.compensate(firedAt, (state, meta) => {
2908
+ const target = state.players[targetId];
2909
+ const hit = target && hitscan(state.players[ctx.user.id].pos, target.pos);
2910
+ return { hit, rewoundMs: meta.age };
2911
+ });
2912
+ if (result.hit) ctx.publish('hit', { by: ctx.user.id, target: targetId });
2913
+ return result;
2914
+ }
2915
+ }
2916
+ });
2917
+ ```
2918
+
2919
+ ```js
2920
+ // Client: stamp the moment the player fired with the local clock.
2921
+ game.shoot(gameId, targetId, Date.now());
2922
+ ```
2923
+
2924
+ How it behaves:
2925
+
2926
+ - **Recording.** After every successful action, `capture` runs and the snapshot joins a per-room-topic ring (`maxEntries`, default 300; `maxAgeMs` rewind window, default 2000ms; topics LRU-capped via `maxTopics`, default 100). A failed action records nothing. Snapshots are frozen - return plain data from `capture`, not live references. Worst-case retention is `maxTopics * maxEntries` snapshots per room export (a quiet topic keeps its ring until it is touched again or LRU-evicted), so size your `capture` output with that envelope in mind.
2927
+ - **Capture is room-global by design.** It receives only the room-identifying arguments, never the acting user's `ctx`: a snapshot recorded during one player's action is served to *every* room member's later evaluations, so an API that cannot see the acting user cannot accidentally record one user's private view into state another user will read.
2928
+ - **Trust model.** The client stamps when it acted; the server only honors stamps inside the window it recorded itself. A stamp older than the history window, an empty ring, or a nested `compensate` call all fail safe to *current* state - never the oldest marker - and report `meta.fallback: true`. Production game servers run windows of 500-2000ms; longer windows widen the peek advantage a high-latency client gets, so tune `maxAgeMs` to your tolerance. The ring's topic cap is per room export: if your action guard lets a user act on arbitrarily many room ids, that user can cycle honest rooms' history out of the cap - the guard is the mitigation.
2929
+ - **Clock skew matters.** The stamp is compared against the *server's* wall clock. A client clock running ahead makes every stamp look fresh (no rewind, reported as `fallback: false`); one running behind by more than the window gets permanent `fallback: true`. Both degrade safely but silently - so derive the stamp from a server-synced clock instead of raw `Date.now()`. A [smoothed-entity view](#smoothed-entities) maintains exactly that clock: stamp with `view.now()` and the compensation window and the prediction loop share one time axis.
2930
+ - **Publishes during evaluation** are ordinary room publishes, delivered immediately - the same semantics as a publish anywhere else in an action (a publish followed by a throw is delivered there too). The recommended pattern is evaluate first, publish from the result, as in the example above.
2931
+ - **Cost.** Without `history`, actions are unchanged. With it, recording adds roughly the cost of your `capture` (a 32-player snapshot measures ~2-4us per action with `NODE_ENV=production node bench/compensate.mjs`; plain dev mode reads several times that because dev deep-freezes each snapshot to catch mutations), and a ring-hit rewind is a clock read plus a binary search - the cheapest compensate path, since it reuses an already-frozen snapshot.
2932
+ - **With client prediction.** If clients predict and reconcile, capture every field the reconciliation compares (position-only snapshots suffice for hitscan, not for replaying movement), and stamp commands with the same clock the prediction loop uses - `view.now()` on a [smoothed-entity view](#smoothed-entities).
2933
+
2934
+ `tolerance` (per call: `ctx.compensate(t, fn, { tolerance: 20 })`) skips the rewind when the stamp is within that many milliseconds of now - the low-latency common case.
2935
+
2936
+ ---
2937
+
2938
+ ## Multiplayer
2939
+
2940
+ `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, scoped actions, and the `history` / `ctx.compensate()` lag-compensation surface 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.
2941
+
2942
+ ```js
2943
+ // src/live/collab.js
2944
+ import { live } from 'svelte-realtime/server';
2945
+
2946
+ export const board = live.multiplayer({
2947
+ topic: (ctx, boardId) => 'board:' + boardId,
2948
+ topicArgs: 1,
2949
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
2950
+ presence: (ctx) => ({ name: ctx.user.name, avatar: ctx.user.avatar }),
2951
+ cursors: true,
2952
+ actions: {
2953
+ addCard: async (ctx, boardId, title) => {
2954
+ const card = await db.cards.insert({ boardId, title });
2955
+ ctx.publish('created', card);
2956
+ return card;
2957
+ }
2958
+ }
2959
+ });
2960
+ ```
2961
+
2962
+ ### The roster view
2963
+
2964
+ `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:
2965
+
2966
+ - `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.
2967
+ - `cursors` - deduped by user key and colored. The local user is kept so you can render your own cursor.
2968
+ - `me` - the local user's key, or `null` until you name it.
2969
+ - `status` - the connection-status passthrough.
2970
+ - `move(...)` / `reportViewport(...)` - forward to the cursor send path.
2971
+ - `typing` / `selections` / `locks` / `reactions` and their send methods, when the matching field surfaces are declared (see [Field surfaces](#field-surfaces-typing-selections-locks-reactions)).
2972
+
2973
+ 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.
2974
+
2975
+ ```svelte
2976
+ <script>
2977
+ import { board } from '$live/collab';
2978
+
2979
+ let { data, boardId } = $props(); // data.userId from the SvelteKit load
2980
+ board.identify(data.userId); // names self; me + self-exclusion light up
2981
+
2982
+ const room = board.room(boardId);
2983
+
2984
+ function onPointerMove(e) {
2985
+ // move() is volatile (fire-and-forget) - lossy under disconnect is the contract.
2986
+ room.move(boardId, { x: e.clientX, y: e.clientY });
2987
+ }
2988
+ </script>
2989
+
2990
+ <div onpointermove={onPointerMove}>
2991
+ <ul class="roster">
2992
+ {#each room.others as person (person.key)}
2993
+ <li style:color={person.color}>{person.name}</li>
2994
+ {/each}
2995
+ </ul>
2996
+
2997
+ {#each room.cursors as c (c.key)}
2998
+ <Cursor x={c.x} y={c.y} color={c.color} />
2999
+ {/each}
3000
+
3001
+ {#if room.me == null}
3002
+ <p>Pass a user key to <code>board.identify(...)</code> to highlight yourself.</p>
3003
+ {/if}
3004
+ </div>
3005
+
3006
+ <button onclick={() => board.addCard(boardId, 'New card')}>Add</button>
3007
+ ```
3008
+
3009
+ 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.
3010
+
3011
+ ### Raw sub-streams
3012
+
3013
+ The raw `data` / `presence` / `cursors` factory stores stay on the export for back-compat and lower-level use; `board.room(...)` composes them for you.
3014
+
3015
+ ```svelte
3016
+ <script>
3017
+ import { board } from '$live/collab';
3018
+ let { boardId } = $props();
3019
+
3020
+ const data = board.data(boardId); // main data stream
3021
+ const cursors = board.cursors(boardId); // raw cursor stream (uncolored, undeduped)
3022
+ const status = board.status; // connection status store
3023
+ </script>
3024
+
3025
+ {#each $data as card (card.id)}
3026
+ <Card {card} />
3027
+ {/each}
3028
+ ```
3029
+
3030
+ ### Field surfaces: typing, selections, locks, reactions
3031
+
3032
+ 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.
3033
+
3034
+ ```js
3035
+ export const board = live.multiplayer({
3036
+ topic: (ctx, boardId) => 'board:' + boardId,
3037
+ topicArgs: 1,
3038
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
3039
+ presence: (ctx) => ({ name: ctx.user.name }),
3040
+ cursors: true,
3041
+ typing: true,
3042
+ selections: 'offset',
3043
+ locks: ['title', 'body'],
3044
+ reactions: true
3045
+ });
3046
+ ```
3047
+
3048
+ - **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.
3049
+ - **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.
3050
+ - **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.
3051
+ - **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.
3052
+
3053
+ 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.
3054
+
3055
+ ```svelte
3056
+ <script>
3057
+ import { board } from '$live/collab';
3058
+ let { boardId } = $props();
3059
+ const room = board.room(boardId);
3060
+ </script>
3061
+
3062
+ <input
3063
+ oninput={() => room.setTyping(true)}
3064
+ onblur={() => room.setTyping(false)} />
3065
+
3066
+ {#if room.typing.length}
3067
+ <p>{room.typing.length} editing...</p>
3068
+ {/if}
3069
+
3070
+ <button onclick={() => room.react('heart', { x: 100, y: 40 })}>React</button>
3071
+ ```
3072
+
3073
+ 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(...)`.
3074
+
3075
+ ### Deterministic user colors
3076
+
3077
+ `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.
3078
+
3079
+ 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.
3080
+
3081
+ ```js
3082
+ import { colorForKey } from 'svelte-realtime/client';
3083
+
3084
+ const fill = colorForKey(user.id);
3085
+ // saturation and lightness vary per key, e.g.
3086
+ // colorForKey('alice') -> 'hsl(239, 70%, 45%)'
3087
+ // colorForKey('carol') -> 'hsl(2, 85%, 55%)'
3088
+ ```
3089
+
3090
+ ---
3091
+
3092
+ ## Smoothed entities
3093
+
3094
+ `live.smooth()` is the responsiveness primitive for entities a user drives continuously - a dragged shape, an avatar, a game character. The local entity responds to input on the same frame it happens; remote entities animate smoothly between server updates; and the server remains the only authority on truth - a client can only ever send commands, never state.
3095
+
3096
+ The whole contract is one pure function the app writes once, in a plain module both sides import:
3097
+
3098
+ ```js
3099
+ // src/live/board.shared.js - no framework imports, pure (state, command) -> state.
3100
+ export function apply(state, command) {
3101
+ return { x: state.x + command.dx, y: state.y + command.dy };
3102
+ }
3103
+ ```
3104
+
3105
+ The server declares the topic with it:
3106
+
3107
+ ```js
3108
+ // src/live/board.js
3109
+ import { live } from 'svelte-realtime';
3110
+ import { apply } from './board.shared.js';
3111
+
3112
+ export const shape = live.smooth({
3113
+ topic: (ctx, boardId) => 'shape:' + boardId,
3114
+ apply,
3115
+ initial: { x: 0, y: 0 }
3116
+ });
3117
+ ```
3118
+
3119
+ And the component constructs the view with the same `apply` - the generated module can only carry paths and literals, so the shared function travels through the app's own import, which is exactly what makes client and server provably identical:
3120
+
3121
+ ```svelte
3122
+ <script>
3123
+ import { shape } from '$live/board';
3124
+ import { apply } from '$live/board.shared.js';
3125
+
3126
+ let { boardId } = $props();
3127
+ const view = shape.smooth(boardId, { apply, initial: { x: 0, y: 0 } });
3128
+ $effect(() => () => view.destroy());
3129
+
3130
+ function onpointermove(e) {
3131
+ view.command({ dx: e.movementX, dy: e.movementY });
3132
+ }
3133
+ </script>
3134
+
3135
+ <Box x={view.local.x} y={view.local.y} />
3136
+ {#each [...view.remote] as [key, s] (key)}
3137
+ <Box x={s.x} y={s.y} ghost />
3138
+ {/each}
3139
+ ```
3140
+
3141
+ How it behaves:
3142
+
3143
+ - **The local entity is predicted.** `view.command(cmd)` applies your function immediately, so `view.local` moves this frame, and transmits the command (frame-batched, fire-and-forget - loss is recovered by reconciliation, never retransmission). The server applies the same commands through the same function on its authoritative tick and acknowledges each owner with the resulting state. When the acknowledged state disagrees with the prediction, the simulation adopts the server's answer immediately and the *rendered* position eases over a short window (`smoothTimeMs`, default 100) - unless the divergence is below `errorThreshold` (default 1), where easing would smear precision for nothing and the correction snaps silently.
3144
+ - **Remote entities render slightly in the past.** Their positions interpolate between server updates on a server-synced clock, so a dropped or late frame is invisible. `interpolationMs: 'auto'` (the default) tracks twice the measured update interval and collapses toward its floor when updates arrive at display rate. State fields beyond `x`/`y` are latest-value.
3145
+ - **Echo suppression is on by default** (`noEcho`): broadcasts of an owner's own commanded updates skip that owner - the acknowledgement already carries the authoritative copy, so the echo would be wasted bytes that fight the prediction. `onMissing` motion produces no acknowledgement and broadcasts to the owner too.
3146
+ - **One entity per identity per topic**, keyed like presence rosters (`identify`-style user keys, or per-connection guest keys). A second tab takes ownership by constructing a view (its sync re-binds the entity); it never races commands against the first. Ownership is last-sync-wins: a dispossessed view's commands are silently ignored until its un-acked window overflows, and the overflow recovery resync re-takes ownership - so exactly one view per identity should be commanding at a time.
3147
+ - **Side effects in `apply` must guard on `ctx.firstTime`** - reconciliation replays commands, and the flag is true only on a command's first application. Randomness inside `apply` must come from `ctx.rng` (reseeded per command id, identical on prediction, replay, and the server); `Math.random()` there is the documented mistake that turns every random command into a misprediction.
3148
+ - **Discrete one-shot events ride the channel, not state.** A muzzle flash, a hit, a pickup is a fire-once effect, not a position. Call `ctx.emitEvent(type, data, opts?)` inside `apply` and subscribe with `const off = view.onEvent((e) => { if (e.type === 'shot') flash(e.data); })`. The owner draws it the instant its command is read (`origin:'local'`, suppressed on every reconciliation replay by the same `ctx.firstTime` gate), and the authority broadcasts it author-excluded so other clients see it (`origin:'server'`) while the owner never double-draws its own. The optimistic and authoritative copies of one event share a `<commandId>:<ordinal>` key, so a handler that receives both can correlate them. Two escape hatches on `opts`: `{ toAuthor: true }` (the owner must also receive the authoritative copy - a hit it took) and `{ global: true }` (the author needs the broadcast too). `view.onEvent` returns an unsubscribe; events are not buffered, so subscribe before the first command.
3149
+ - **`onMissing(state, lastCommand)`** runs on the server for entities with no commands that tick - the hook for genuinely simulated entities that keep moving between inputs. Omitted, an idle entity simply holds position and costs nothing.
3150
+ - **Recovery is explicit.** If the server stops acknowledging long enough that the un-acked command window overflows (`windowCap` 256 commands or `windowMaxAgeMs` 3000ms), prediction is killed rather than allowed to run away: the entity renders the last authoritative state, `view.overflowed` reads true, the shared `health` store reads `'degraded'`, and the view resyncs; the next acknowledgement re-engages prediction.
3151
+ - **`view.now()`** is the estimated server wall-clock time, maintained from the server's frame stamps and the acknowledgement round trips. It is the right stamp for lag-compensated action arguments (see below).
3152
+ - **The wire is negotiated.** Binary frames engage per connection (`smooth.protocol:1`); everything degrades to JSON additively - old clients ignore the new events, and a server without the smooth plugin fails the first sync or command with an actionable version message, never a resolution crash.
3153
+
3154
+ Knobs (all optional, on the server declaration: `tickMs` 50, `noEcho` true, `queueCap` 1024, `guard`, `onMissing`, `snapshot` false, `snapshotDebounceMs` 1000; on the client factory: `interpolationMs` 'auto', `extrapolateMs` 250, `snapGapMs` 500, `smoothTimeMs` 100, `errorThreshold` 1, `computeError`, `cmdRate` 60, `windowCap` 256, `windowMaxAgeMs` 3000). The tradeoff to know: larger `interpolationMs` survives more dropped frames but renders remote entities further in the past.
3155
+
3156
+ Requires Svelte 5 (the view is a rune class) and svelte-adapter-uws 0.6.0-next.26 or newer (`view.onEvent` needs the adapter's client-side event delivery; the prediction/reconciliation surface alone needs only next.24).
3157
+
3158
+ **Cluster-aware:** wire `platform.smooth = createSmoothCluster(redisClient)` from `svelte-adapter-uws-extensions/redis/smooth` and the smoothed-entity layer detects it and routes through it automatically. Because the authority's `apply` is order-dependent (unlike a document merge, it cannot run on every instance), the model is single-owner-per-topic: one instance holds a per-topic Redis lease and ticks that topic; the others forward their clients' commands to it and re-broadcast its updates, acknowledgements, and events to their own subscribers, so a load balancer can spread one topic's players across instances and cross-instance events still fire exactly once. On owner death the lease expires, another instance takes over with a fresh authority, and clients re-sync. Without it, `live.smooth()` runs single-instance (correct on one process, divergent across a load-balanced cluster - so wire the coordinator for any multi-instance deployment). **Warm handoff:** pass `snapshot: true` to recover gracefully from an owner crash - the owner persists a debounced state snapshot and the instance that takes over resumes each entity from its last state instead of resetting it to `initial` (snapping a player back to spawn). Off by default; tune the write cadence with `snapshotDebounceMs`. Needs `svelte-adapter-uws-extensions >= 0.6.0-next.20`.
3159
+
3160
+ **Area of interest** (an uncapped lobby): a smoothed topic broadcasts every entity's motion to every subscriber, which is right for a small room and ruinous for a lobby of thousands where each client only sees its own neighbourhood. Opt into `interest` and the tick delivers each subscriber only the entities inside its area of interest:
3161
+
3162
+ ```js
3163
+ export const arena = live.smooth({
3164
+ topic: (ctx, arenaId) => `arena:${arenaId}`,
3165
+ apply,
3166
+ initial: (key) => spawn(key),
3167
+ interest: {
3168
+ radius: 1200, // cull radius, in your position units
3169
+ position: (s) => ({ x: s.x, y: s.y }), // where an entity is; null = always-visible (a flag, an objective)
3170
+ lod: [ // optional level-of-detail bands (the outer edge is the radius)
3171
+ { within: 400, rate: 1 }, // near: every tick
3172
+ { within: 800, rate: 3 }, // mid: every 3rd tick
3173
+ { within: 1200, rate: 8 } // fringe: every 8th tick
3174
+ ]
3175
+ }
3176
+ });
3177
+ ```
3178
+
3179
+ The area-of-interest centre is the subscriber's own entity by default, so a player-centric game needs no extra wiring. A spectator or free-cam whose view is not its own entity calls `view.reportCenter(x, y)` to point culling at where the camera looks (and `view.clearCenter()` to revert); the report rides a volatile send, an unchanged centre is dropped, and it takes effect even on a still board, so panning across a paused scene reveals what is there. `lod` bands let distant motion fade at a throttled, id-staggered cadence instead of consuming the same bandwidth as a nearby duel; a small hysteresis margin on the band edges stops an entity hovering on a ring from flickering, and an entity entering range (or crossing into a nearer band) is always delivered at once. Relevancy is a delivery preference, never authorization - the `guard` stays the separate auth layer - and the polarity is to over-deliver: a subscriber with no resolvable centre is delivered the whole board. **Off by default**, gated end to end on `interest != null`, so a topic without it is byte-identical to the broadcast-all path (the gate overhead benchmarks within noise, well under 1%). Culling re-sends an entity only when it actually moved since a subscriber last saw it, which relies on `apply` returning a NEW state object on a change rather than mutating in place - the same contract the broadcast itself already depends on (an in-place mutation makes the authority think nothing changed). At arena scale the cull delivers roughly 90% less per client, and the saving grows with the population. It works single-instance and across the cluster (a topic's owner culls its own local subscribers against the full cluster-wide catalog). _A subscriber on a non-owning cluster instance is over-delivered relayed updates - never under-delivered - until the cross-instance fine cull lands._
3180
+
3181
+ ---
3182
+
3183
+ ## Shared documents
3184
+
3185
+ `live.doc()` / `live.map()` / `live.array()` are the merge primitives for state several people edit at once - a kanban board, a shared form, a todo list, collaborative text. A `live.stream` publish replaces; a document write **merges**: every client holds a local replica, every local edit applies immediately (no pending state, no rollback), and concurrent edits from any number of peers converge to the same value on every replica without a server round trip deciding a winner.
3186
+
3187
+ The server declares a document like any other live export:
3188
+
3189
+ ```js
3190
+ // src/live/board.js
3191
+ import { live } from 'svelte-realtime';
3192
+
3193
+ export const board = live.map({
3194
+ topic: (ctx, boardId) => 'board:' + boardId,
3195
+ guard({ user }) {
3196
+ const role = roleFor(user);
3197
+ return { read: role !== null, write: role === 'editor' || role === 'owner' };
3198
+ },
3199
+ persist: {
3200
+ load: (topic) => db.loadSnapshot(topic), // bytes or null for a new doc
3201
+ store: (topic, bytes) => db.saveSnapshot(topic, bytes)
3202
+ }
3203
+ });
3204
+ ```
3205
+
3206
+ And the component mutates the store directly - no transaction objects, no flush, no await:
3207
+
3208
+ ```svelte
3209
+ <script>
3210
+ import { board } from '$live/board';
3211
+
3212
+ let { boardId, selected } = $props();
3213
+ const cards = board.map(boardId);
3214
+ $effect(() => () => cards.destroy());
3215
+
3216
+ function rename(id, title) {
3217
+ cards.set(id, { ...cards.get(id), title }); // applies locally now, merges everywhere
3218
+ }
3219
+ </script>
3220
+
3221
+ <input value={cards.get(selected)?.title ?? ''} disabled={cards.readOnly}
3222
+ oninput={(e) => rename(selected, e.target.value)} />
3223
+
3224
+ {#each [...cards.entries()] as [id, card] (id)}
3225
+ <Card {...card} />
3226
+ {/each}
3227
+ ```
3228
+
3229
+ The three declarations differ only in what the component-side factory returns. `live.map` gives the keyed container directly (`get` / `set` / `delete` / `entries` / `keys` / `size`); `live.array` gives the ordered container (`at` / `push` / `insert` / `delete` / `length` - positions are stable under concurrent insert/delete because every element carries its own identity); `live.doc` gives the root handle for documents that mix shapes:
3230
+
3231
+ ```js
3232
+ const doc = board.doc(boardId);
3233
+ const layers = doc.array('layers'); // ordered
3234
+ const meta = doc.map('meta'); // keyed
3235
+ const title = doc.text('title'); // collaborative text (concurrent character edits)
3236
+ doc.transact(() => { meta.set('a', 1); meta.set('b', 2); }); // one atomic wire update
3237
+ ```
3238
+
3239
+ All named containers share the topic's single update stream. Reads are rune-backed and granular - a write to one map key re-renders only readers of that key - and multiple components mounting the same document share one replica through a reference-counted cache, so `destroy()` is safe to call per component.
3240
+
3241
+ How it behaves:
3242
+
3243
+ - **Local-first by construction.** Reads never await the network (the page renders offline); a write returns synchronously and the template re-renders against the just-written value in the same tick. The update travels as opaque merge data, never as a request that could fail and need unwinding.
3244
+ - **Reconnect and offline are one mechanism.** On every connection open the store runs one idempotent exchange: it tells the server what it has (a compact state vector), receives exactly the missing changes, and uploads exactly what the server lacks. The local replica IS the offline queue - two minutes or two hours offline reconciles in one bounded round trip, and replaying overlapping data is free because the merge is idempotent. Any lost frame in between (backpressure, anything) is detected as a dependency gap and self-heals through the same exchange.
3245
+ - **The guard returns an access record.** `{read, write, comment}` per connection per document: a boolean widens to all three rights (`guard: () => user != null` never learns the record shape), a partial record defaults missing rights to false (`{read: true}` is a viewer). The record is cached at sync time and every inbound update is checked against it server-side; a role change applies at the next sync. Read-only mounts surface `readOnly` so the UI disables inputs up front - their mutators throw rather than silently fork the local view with edits the server will reject. The `comment` right is carried in full for the coming rich-text marks surface; granting it changes nothing yet. No guard means everyone connected can read and write (a one-time production warning says so).
3246
+ - **Persistence is two hooks on a framework schedule.** `load` runs once per cold document (simultaneous joiners coalesce onto one call); `store` receives the compacted full state - debounced 2s after the last edit, forced at least every 10s under sustained editing, compacted every 200 updates, and flushed once when the last subscriber leaves, so an edit-then-disconnect is never lost. Knobs: `debounceWait`, `debounceMaxWait`, `snapshotEvery`, `persistOnEmpty`, `onError`. Omit `persist` entirely for an in-memory document.
3247
+ - **Status is on the store**: `synced` (this connection completed its exchange), `degraded` (the exchange is failing and retrying - also folded into the shared `health` store), `access`, `readOnly`. Documents survive dev-server hot reloads with their unsaved edits intact.
3248
+ - **The wire is negotiated.** Document frames ride the binary wire per connection (`crdt.protocol:1`) and degrade transparently to JSON envelopes; updates ride a reliable no-reply send (never the volatile drop tier - a dropped edit would desync the document, a buffered one merely arrives late).
3249
+
3250
+ Values are plain JSON values with replace-on-write semantics (`m.set(k, { ...m.get(k), title })`); nested collaborative structures are sibling named containers on the same document, not nested values. Presence (who is here, where their cursor is) stays a sibling concern - pair a document with `live.multiplayer()` / the cursor plugin; ephemeral state never belongs in the document.
3251
+
3252
+ ### Running across a cluster
3253
+
3254
+ On a single process, documents are correct out of the box. Behind a load balancer, two people editing one document land on different instances - so clustered deployments must wire the CRDT coordinator from the extensions package, or the instances will not converge and each will overwrite the others' persisted snapshot:
3255
+
3256
+ ```js
3257
+ // hooks.server.js (or wherever you wire the Redis bus)
3258
+ import { createCrdtCluster } from 'svelte-adapter-uws-extensions/redis/crdt';
3259
+
3260
+ platform.crdt = createCrdtCluster(redisClient); // alongside platform.redis / platform.presence
3261
+ ```
3262
+
3263
+ That is the whole wiring. The document layer detects `platform.crdt` and routes through it automatically: every instance keeps its own replica and serves its own subscribers (no extra hop), every edit relays so all replicas converge, snapshots persist single-writer per topic (no clobber), and a cold-joining instance pulls the latest from a live peer. Without it the document family still works - it just runs single-instance, which is correct on one process and divergent across a cluster.
3264
+
3265
+ Requires Svelte 5 (the stores are rune classes) and svelte-adapter-uws 0.6.0-next.25 or newer.
3266
+
2888
3267
  ---
2889
3268
 
2890
3269
  ## Webhooks
@@ -2922,6 +3301,69 @@ export async function POST({ request, platform }) {
2922
3301
  }
2923
3302
  ```
2924
3303
 
3304
+ The flat `live.webhook(...)` above is an alias for `live.webhooks.inbound(...)`; both keep working.
3305
+
3306
+ ---
3307
+
3308
+ ## Outbound webhooks
3309
+
3310
+ The mirror of inbound: `live.webhooks.outbound(sources, config)` fires an HTTP POST to an external endpoint whenever one of the `sources` topics publishes. Use it to forward your events to other systems - Slack, a partner's API, an internal pipeline.
3311
+
3312
+ ```js
3313
+ // src/live/integrations.js
3314
+ export const slackAlerts = live.webhooks.outbound(['alerts'], {
3315
+ url: 'https://hooks.slack.example/services/T000/B000/xxxx',
3316
+ secret: process.env.WEBHOOK_SECRET, // optional HMAC-SHA256 signing
3317
+ transform: (event, data) => ({ text: `[${event}] ${data.message}` })
3318
+ });
3319
+ ```
3320
+
3321
+ That's the whole wiring - no `+server.js`, no client code. It fires on every `platform.publish('alerts', ...)` (local or cluster-relayed). The body defaults to `{ event, data }`; return `null` from `transform` to skip an event.
3322
+
3323
+ #### Cluster delivery: at-least-once, leader-gated
3324
+
3325
+ In a single process every publish fires the webhook once. In a cluster every replica receives the published event, so to fire it **once** wire a leader (the same one cron uses):
3326
+
3327
+ ```js
3328
+ import { createLeader } from 'svelte-adapter-uws-extensions/redis/leader';
3329
+ configureCron({ leader: createLeader(redis).isLeader });
3330
+ ```
3331
+
3332
+ With a leader configured, only the leader replica POSTs; without one, every worker fires (correct for single-process, the same footgun cron has). Delivery is **at-least-once**, not exactly-once - strict exactly-once over HTTP is unachievable (a dropped *response* is indistinguishable from a dropped *request*, so the only safe action, retry, is also what can duplicate). Every POST therefore carries an `idempotency-key` header (stable across retries **and** across a leadership-transition double-fire), so **make your receiver idempotent** and it is effectively-once. When you set a `secret` the default key is **keyed** with it (an outsider who can trigger the same publish still cannot precompute the key to replay or suppress a delivery); without a secret it is a plain content hash (predictable - set a secret or supply your own `idempotencyKey: (event, data) => yourId`).
3333
+
3334
+ #### SSRF protection
3335
+
3336
+ Outbound delivery is a classic server-side request forgery (SSRF) surface, so the guard is **on by default and covers the whole request**, not just the configured URL:
3337
+
3338
+ - **Private/loopback/metadata ranges are blocked** (strict by default) at definition time for a static `url` and at fire time for a dynamic `url: (event, data) => ...`.
3339
+ - **DNS rebinding is closed.** A DNS-name target is resolved, **every** resolved address is range-checked, and the connection is **pinned** to the validated address - a public-looking name that resolves to `169.254.169.254` or a private host is rejected, and it cannot rebind between the check and the connection. The `Host` header and TLS certificate validation still use the original hostname.
3340
+ - **Redirects are re-checked on every hop** (up to `maxRedirects`, default 5): a `3xx` to a private host, a non-http(s) scheme, or an https->http downgrade is refused, and redirect loops are detected. (A plain `fetch` would silently follow such a redirect - this does not.)
3341
+ - A blocked URL throws at definition or calls `onFailure` at fire time; it never reaches the network.
3342
+
3343
+ To reach an internal endpoint on purpose, set `urlMode: 'allowlist'` + `allow: [...]` for a **public** host, or - for a **private** host - `urlMode: 'off'` (the explicit range opt-out; the http(s) scheme gate still applies) paired with a `validateUrl` that allows exactly that host. `validateUrl` is an **additional** restriction (logical AND): it can only narrow the allowed set, never re-open a blocked host, and it may be `async`. Supply a custom `resolve(hostname)` to use a hardened resolver.
3344
+
3345
+ #### Delivery
3346
+
3347
+ Delivery runs over `node:http`/`node:https` (no extra dependency). Each POST is retried with **jittered** exponential backoff (default 3 attempts, 100ms - 5s) on a 5xx / 429 / network error / timeout; a 4xx (other than 429) is a permanent client error and is not retried. When `secret` is set the body is signed as `x-webhook-signature: sha256=<hex>`. The per-attempt `timeoutMs` covers DNS, connect, TTFB and body; user callbacks are bounded by `callbackTimeoutMs`. Exhausted retries (and blocked URLs / bad payloads / blocked redirects) call `onFailure(err, event, data, attempts)` - the error never contains the `secret`, the signature, or URL credentials.
3348
+
3349
+ #### Options
3350
+
3351
+ | Option | Default | Description |
3352
+ |---|---|---|
3353
+ | `url` | (required) | Destination URL, or `(event, data) => string \| Promise<string>` for a per-event URL. SSRF-checked. |
3354
+ | `transform` | `{ event, data }` | Build the POST body. Return `null` to skip the event. |
3355
+ | `secret` | - | HMAC-SHA256 signing secret; adds `x-webhook-signature` and keys the default idempotency key. |
3356
+ | `idempotencyKey` | keyed/content hash | Override the `idempotency-key` header value. |
3357
+ | `retry` | `{ attempts: 3, initialDelayMs: 100, maxDelayMs: 5000, backoffMultiplier: 2 }` | Retry policy (jittered). |
3358
+ | `timeoutMs` | `10000` | Per-attempt timeout (DNS + connect + TTFB + body). |
3359
+ | `callbackTimeoutMs` | `10000` | Timeout for each user callback (`transform` / `url` / `validateUrl` / `resolve` / `idempotencyKey`). |
3360
+ | `maxRedirects` | `5` | Redirect hops to follow; every hop is re-gated. `0` refuses all redirects. |
3361
+ | `urlMode` | `'strict'` | SSRF posture: `strict` (range-check + DNS pin), `allowlist` (+ host in `allow`), or `off` (no range check / no pin; scheme gate stays on). |
3362
+ | `allow` | - | Allowlisted hostnames for `urlMode: 'allowlist'`. |
3363
+ | `validateUrl` | - | Additional restriction `(url) => boolean \| Promise<boolean>`, ANDed with the built-in guard on every hop. Narrows only. |
3364
+ | `resolve` | `node:dns` | Custom DNS resolver `(hostname) => address \| address[] \| Promise<...>` for the pin. |
3365
+ | `onFailure` | - | Called on delivery failure after retries (or on a blocked URL / payload / redirect). |
3366
+
2925
3367
  ---
2926
3368
 
2927
3369
  ## Signals
@@ -2982,12 +3424,12 @@ The Vite plugin includes the stream version in the client stub. On reconnect, th
2982
3424
 
2983
3425
  The migration codepath only fires across a real deploy boundary - a v1 server is replaced with a v2 server, a previously-connected v1 client reconnects, the cached `_schemaVersion` rides up. There's no path in a fresh tab to observe the migrate chain end-to-end, which makes demos and e2e tests awkward.
2984
3426
 
2985
- `subscribeAt(stream, { schemaVersion })` from `svelte-realtime/test-client` creates a parallel store that subscribes pretending to be a stale client at the chosen version. The wire envelope carries `schemaVersion: N`, the server runs the registered migrate chain forward, and the parallel store renders the migrated payload. Use it for side-by-side demo panels and for e2e assertions on the migrate chain output.
3427
+ `subscribeAt(stream, { schemaVersion })` from `svelte-realtime/testing/client` creates a parallel store that subscribes pretending to be a stale client at the chosen version. The wire envelope carries `schemaVersion: N`, the server runs the registered migrate chain forward, and the parallel store renders the migrated payload. Use it for side-by-side demo panels and for e2e assertions on the migrate chain output.
2986
3428
 
2987
3429
  ```svelte
2988
3430
  <script>
2989
3431
  import { todos } from '$live/todos';
2990
- import { subscribeAt } from 'svelte-realtime/test-client';
3432
+ import { subscribeAt } from 'svelte-realtime/testing/client';
2991
3433
 
2992
3434
  // Production store at the current server version (no migration on its responses).
2993
3435
  // Parallel stores pretending to be stale clients - each triggers the migrate
@@ -3007,7 +3449,7 @@ For dynamic streams, call the factory first and pass the cached store:
3007
3449
 
3008
3450
  ```js
3009
3451
  import { messages } from '$live/chat';
3010
- import { subscribeAt } from 'svelte-realtime/test-client';
3452
+ import { subscribeAt } from 'svelte-realtime/testing/client';
3011
3453
 
3012
3454
  const v1Messages = subscribeAt(messages('room-1'), { schemaVersion: 1 });
3013
3455
  ```
@@ -3570,11 +4012,11 @@ realtime({ devtools: false })
3570
4012
 
3571
4013
  ## Testing
3572
4014
 
3573
- Use `createTestEnv()` from `svelte-realtime/test` to test your live functions without a real WebSocket server.
4015
+ Use `createTestEnv()` from `svelte-realtime/testing` to test your live functions without a real WebSocket server.
3574
4016
 
3575
4017
  ```js
3576
4018
  import { describe, it, expect, afterEach } from 'vitest';
3577
- import { createTestEnv } from 'svelte-realtime/test';
4019
+ import { createTestEnv } from 'svelte-realtime/testing';
3578
4020
  import * as chat from '../src/live/chat.js';
3579
4021
 
3580
4022
  describe('chat module', () => {
@@ -3637,7 +4079,7 @@ describe('chat module', () => {
3637
4079
  `createTestEnv({ chaos: { dropRate, seed } })` enables fault injection on `platform.publish` so tests can verify resilience to message drops without spinning a real cluster.
3638
4080
 
3639
4081
  ```js
3640
- import { createTestEnv } from 'svelte-realtime/test';
4082
+ import { createTestEnv } from 'svelte-realtime/testing';
3641
4083
 
3642
4084
  // 50% drop rate, deterministic via seed
3643
4085
  const env = createTestEnv({
@@ -3672,7 +4114,7 @@ Currently models the `drop-outbound` scenario only - `platform.publish` events t
3672
4114
  `createTestContext({ user })` builds a `ctx`-shaped object suitable for direct unit tests of guards and predicates - helper methods are no-ops, the user / cursor / requestId can be overridden. Use this when the function under test takes `ctx` and synchronously returns a value; reach for `createTestEnv()` only when you need full publish/subscribe round-trips.
3673
4115
 
3674
4116
  ```js
3675
- import { createTestContext } from 'svelte-realtime/test';
4117
+ import { createTestContext } from 'svelte-realtime/testing';
3676
4118
 
3677
4119
  const adminOnly = (ctx) => ctx.user?.role === 'admin';
3678
4120
 
@@ -3688,7 +4130,7 @@ The returned shape mirrors the production `_buildCtx`: `user`, `ws`, `platform`,
3688
4130
  `expectGuardRejects(promise, expectedCode?)` is a small ergonomic wrapper for the common "this call should be denied" pattern. It awaits the promise, asserts it rejected with a `LiveError` of the expected code (default `'FORBIDDEN'`), and returns the error so further assertions can run on it.
3689
4131
 
3690
4132
  ```js
3691
- import { createTestEnv, expectGuardRejects } from 'svelte-realtime/test';
4133
+ import { createTestEnv, expectGuardRejects } from 'svelte-realtime/testing';
3692
4134
 
3693
4135
  const env = createTestEnv();
3694
4136
  env.register('admin', adminModule);
@@ -3727,6 +4169,7 @@ Import from `svelte-realtime/server`.
3727
4169
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3728
4170
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
3729
4171
  | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
4172
+ | `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
4173
  | `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
3731
4174
  | `live.gate(predicate, fn)` | Conditional stream activation |
3732
4175
  | `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
@@ -3817,7 +4260,7 @@ The benchmark suite measures the full-stack overhead added by svelte-realtime on
3817
4260
  Run with:
3818
4261
 
3819
4262
  ```bash
3820
- node bench/rpc.js
4263
+ node bench/rpc.mjs
3821
4264
  ```
3822
4265
 
3823
4266
  What gets measured:
@@ -3835,7 +4278,7 @@ With high-frequency streams (e.g. 1000 cursors at 20 updates/sec), this reduces
3835
4278
 
3836
4279
  In Node/SSR (tests, `__directCall`, etc.), events apply synchronously - no batching overhead.
3837
4280
 
3838
- See [bench/rpc.js](bench/rpc.js) for the full source.
4281
+ See [bench/rpc.mjs](bench/rpc.mjs) for the full source.
3839
4282
 
3840
4283
  ---
3841
4284