svelte-realtime 0.5.7 → 0.5.10
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/MIGRATION.md +70 -0
- package/README.md +58 -2
- package/client.d.ts +35 -0
- package/client.js +155 -4
- package/package.json +3 -3
- package/server.d.ts +144 -5
- package/server.js +447 -49
- package/vite.js +26 -3
package/MIGRATION.md
CHANGED
|
@@ -287,6 +287,56 @@ Saturation behavior: entries with a pending leave timer are evicted first; if st
|
|
|
287
287
|
|
|
288
288
|
Not required. Adopting these gets you the full 0.5 experience.
|
|
289
289
|
|
|
290
|
+
### `ctx.skip(key, ms)` for per-key handler gating
|
|
291
|
+
|
|
292
|
+
**What's new.** A per-key gate primitive on `LiveContext`. Returns `true` to skip the call (key is within its cooldown window), `false` to run it. Pairs with `ctx.shed` semantically so call sites read uniformly with an early `return`:
|
|
293
|
+
|
|
294
|
+
```js
|
|
295
|
+
export const moveNote = live(async (ctx, noteId, x, y) => {
|
|
296
|
+
if (ctx.shed('background')) return; // pressure shed
|
|
297
|
+
if (ctx.skip(`move:${noteId}`, 16)) return; // per-key handler gate
|
|
298
|
+
await dbUpdateNote(noteId, x, y);
|
|
299
|
+
ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
State is per-replica (CPU/DB shed, not cluster-wide rate limit; for cross-replica gating use `live.rateLimit({ store: 'redis' })` or the `redis/ratelimit` extension). Capped at 5000 active entries with fail-open semantics on overflow (returns `false`, dev-warns once). Throws `LiveError('INVALID_ARG', ...)` on `key` not a string or `ms` not a positive finite number.
|
|
304
|
+
|
|
305
|
+
This is the primitive developers were reaching for when they wrote `ctx.throttle('move:id', 50)` thinking it gated handler execution. The old `ctx.throttle` / `ctx.debounce` are outbound publish helpers (renamed to `publishThrottled` / `publishDebounced` - see [Cosmetic](#cosmetic)); the new `ctx.skip` is the actual handler gate.
|
|
306
|
+
|
|
307
|
+
### `createMessage({ onJsonMessage(ws, msg, platform) })` for plugin-layer JSON dispatch
|
|
308
|
+
|
|
309
|
+
**What's new.** A callback on `createMessage` that receives the parsed envelope when a non-RPC text frame parses as a JSON object. Replaces the manual `TextDecoder + JSON.parse + dispatch` pattern that plugins like `cursor.hooks.message` previously required in user `hooks.ws.js`.
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// Before
|
|
313
|
+
import { createMessage } from 'svelte-realtime/server';
|
|
314
|
+
import { cursor } from '$lib/server/redis';
|
|
315
|
+
|
|
316
|
+
export const message = createMessage({
|
|
317
|
+
onUnhandled(ws, data, platform) {
|
|
318
|
+
if (!(data instanceof ArrayBuffer) || data.byteLength < 2) return;
|
|
319
|
+
let msg;
|
|
320
|
+
try { msg = JSON.parse(new TextDecoder().decode(data)); } catch { return; }
|
|
321
|
+
if (!msg || typeof msg !== 'object') return;
|
|
322
|
+
if (msg.type === 'cursor') {
|
|
323
|
+
cursor.hooks.message(ws, { data: msg, platform });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// After
|
|
329
|
+
export const message = createMessage({
|
|
330
|
+
onJsonMessage(ws, msg, platform) {
|
|
331
|
+
if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Two-tier lookup: (1) fast path uses the `msg` field forwarded by `svelte-adapter-uws@^0.5.3` (one parse total); (2) fallback parses locally for frames the adapter didn't fast-path (older adapter, > 8 KiB frame, or non-`{"ty` prefix). Frames that aren't JSON, can't parse, parse to a non-object, or exceed the depth cap (`maxJsonDepth`, default 64) fall through to `onUnhandled` with the original raw bytes. The adapter's `maxPayloadLength` (default 1 MB) is the structural size ceiling.
|
|
337
|
+
|
|
338
|
+
Both `onJsonMessage` and `onUnhandled` can be set together for mixed JSON / binary frame handling.
|
|
339
|
+
|
|
290
340
|
### Move `setCronPlatform` and `live.configurePush({ remoteRegistry })` to `init({ platform })`
|
|
291
341
|
|
|
292
342
|
**What changed.** Both functions used to be wired from `open(ws, platform)`. The recommended call site is now the adapter's `init({ platform })` lifecycle hook, which fires once per worker after the listen socket is bound and before any upgrade / open / message hook runs. This eliminates the boot-to-first-connect window where cron ticks were no-ops and `live.push` could not reach cross-instance users.
|
|
@@ -376,6 +426,26 @@ export const transport = realtimeTransport();
|
|
|
376
426
|
|
|
377
427
|
Type-only changes, deprecations, dead code removed. No action required for most apps.
|
|
378
428
|
|
|
429
|
+
### `ctx.throttle` / `ctx.debounce` renamed to `ctx.publishThrottled` / `ctx.publishDebounced`; old names accepted as soft-deprecated aliases
|
|
430
|
+
|
|
431
|
+
**What changed.** The names `throttle` / `debounce` in JS-land (lodash, RxJS, Underscore) typically mean "gate a function's execution." The realtime helpers actually scheduled outbound publishes - misreading the name as a gate led to calls like `ctx.throttle('move:noteId', 50)` (developer intent: "gate this handler") which silently published junk frames to a topic nobody subscribed to at the full client rate (`event=50` (number), `data=undefined`, `ms=undefined` -> `setTimeout(_, 0)` -> zero-ms window -> next call publishes again). The new names `publishThrottled` / `publishDebounced` put "publish" central so the misread becomes structurally impossible.
|
|
432
|
+
|
|
433
|
+
For the gate-handler use case the developer was actually after, `ctx.skip(key, ms)` is the new primitive (see [Recommended new patterns](#recommended-new-patterns)).
|
|
434
|
+
|
|
435
|
+
**How to migrate.** Optional rename for new code:
|
|
436
|
+
|
|
437
|
+
```diff
|
|
438
|
+
- ctx.throttle(topic, event, data, ms)
|
|
439
|
+
+ ctx.publishThrottled(topic, event, data, ms)
|
|
440
|
+
|
|
441
|
+
- ctx.debounce(topic, event, data, ms)
|
|
442
|
+
+ ctx.publishDebounced(topic, event, data, ms)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
The old names keep working as aliases indefinitely. A one-time dev warning per process per name fires on first call to the old name; production behaviour is unchanged. To silence the dev warning, rename. `live.cron()` and `live()` contexts both gained the new names; both keep the old aliases.
|
|
446
|
+
|
|
447
|
+
If you wrote `ctx.throttle('move:id', 50)` thinking it would gate handler execution, the fix is `if (ctx.skip('move:id', 50)) return` at the top of the handler body. See the `ctx.skip` migration entry in [Recommended new patterns](#recommended-new-patterns).
|
|
448
|
+
|
|
379
449
|
### `pushHooks.close` now drains stream-subscription bookkeeping when called with `ctx`
|
|
380
450
|
|
|
381
451
|
**What changed.** Pre-0.5, `pushHooks.close` was push-only. Apps following the JSDoc-ordained `export const close = pushHooks.close;` left stream-subscription bookkeeping (`_topicWsCounts`, silent-topic watchdogs, `__onUnsubscribe` callbacks) un-drained. A 30s flurry of `silent topic` warnings fired after every page closed.
|
package/README.md
CHANGED
|
@@ -227,6 +227,7 @@ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `re
|
|
|
227
227
|
|
|
228
228
|
**Client features**
|
|
229
229
|
- [Batching](#batching)
|
|
230
|
+
- [Volatile RPC (fire-and-forget)](#volatile-rpc-fire-and-forget)
|
|
230
231
|
- [Optimistic updates](#optimistic-updates)
|
|
231
232
|
- [Stream pagination](#stream-pagination)
|
|
232
233
|
- [Undo and redo](#undo-and-redo)
|
|
@@ -957,6 +958,8 @@ const [board, column] = await batch(() => [
|
|
|
957
958
|
|
|
958
959
|
Each call resolves or rejects independently - one failure does not cancel the others. Batches are limited to 50 calls - enforced both client-side (rejects before sending) and server-side.
|
|
959
960
|
|
|
961
|
+
A `batch()` containing only one call sends a bare RPC frame (no batch envelope) and the server replies through the normal RPC path - so the defensive "always wrap writes in batch() for symmetry" pattern pays no envelope overhead. Batches of 2+ keep the envelope.
|
|
962
|
+
|
|
960
963
|
### Server-side batching
|
|
961
964
|
|
|
962
965
|
Use `ctx.batch()` inside RPC handlers to publish multiple messages in a single call:
|
|
@@ -973,6 +976,59 @@ export const resetBoard = live(async (ctx, boardId) => {
|
|
|
973
976
|
|
|
974
977
|
---
|
|
975
978
|
|
|
979
|
+
## Volatile RPC (fire-and-forget)
|
|
980
|
+
|
|
981
|
+
For high-frequency one-way calls where the caller has no reply to await - cursor moves, drag updates, typing indicators, telemetry beacons, heartbeats - use `live.volatile(fn)` server-side + `.fireAndForget(...args)` client-side. The wire frame carries no `id`; the server runs the full handler chain (middleware, guards, rate limits, validation) but does not write a response.
|
|
982
|
+
|
|
983
|
+
```js
|
|
984
|
+
// src/lib/realtime/cursors.js
|
|
985
|
+
import { live } from 'svelte-realtime';
|
|
986
|
+
|
|
987
|
+
export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
|
|
988
|
+
// ctx.publish / ctx.shed / guards all work normally
|
|
989
|
+
ctx.publish(`board:${boardId}`, 'cursor', pos);
|
|
990
|
+
});
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
```svelte
|
|
994
|
+
<script>
|
|
995
|
+
import { moveCursor } from '$live/cursors';
|
|
996
|
+
|
|
997
|
+
function onPointerMove(e) {
|
|
998
|
+
moveCursor.fireAndForget(boardId, { x: e.clientX, y: e.clientY });
|
|
999
|
+
// returns void synchronously - no Promise, no await
|
|
1000
|
+
}
|
|
1001
|
+
</script>
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
What `.fireAndForget()` skips that a normal RPC does:
|
|
1005
|
+
- ID allocation (`_nextId()`)
|
|
1006
|
+
- Promise allocation
|
|
1007
|
+
- Dedup-Map entry + `queueMicrotask(delete)`
|
|
1008
|
+
- Pending-Map entry
|
|
1009
|
+
- Timer allocation (per-call 30s timeout)
|
|
1010
|
+
- DevTools-pending entry
|
|
1011
|
+
|
|
1012
|
+
At 60-120Hz on a single hot path that is 100K+ short-lived heap allocations per second avoided on the client.
|
|
1013
|
+
|
|
1014
|
+
**Safety:**
|
|
1015
|
+
- **Errors disappear silently from the caller.** A volatile call that fails auth, validation, or throws still runs through metrics (`_recordRpcMetrics`) and server logs - operators see the failure - but the wire carries no reply, so the caller does not. Use `live.volatile()` only when this is the intended contract.
|
|
1016
|
+
- **Backpressure drop.** Before send, the client reads `WS.bufferedAmount`; if it exceeds `volatileBackpressureBytes` (default 4 MB, configurable via `configure(...)`), the frame is dropped silently and `__devtools.volatileDropped` ticks. Dev-mode emits a one-shot `console.warn` on first drop per session.
|
|
1017
|
+
- **Offline drop.** Volatile calls made while disconnected are silently dropped. They do not enter the offline queue (which is for awaited mutations).
|
|
1018
|
+
- **Inside `batch()`.** Throws in dev, no-op in prod. Volatile bypasses batching by design.
|
|
1019
|
+
|
|
1020
|
+
**Server-side marker is recommended, not required.** The wire shape (`id` absent) is the actual contract. A `.fireAndForget()` against a plain `live()` handler also works - server processes it, just skips the reply - but dev-mode emits a one-shot warning per such path naming the handler so accidental fire-and-forget surfaces. Mark intentional one-way handlers with `live.volatile()` to silence the warning and document intent. The marker can sit at any depth inside `live.rateLimit` / `live.idempotent` / `live.breaker` / `live.validated` / `live.lock` wrappers (the framework walks `__wrappedFn` to find it).
|
|
1021
|
+
|
|
1022
|
+
**When NOT to use `.fireAndForget()`:**
|
|
1023
|
+
- The caller needs to know whether the call succeeded -> use the normal awaited RPC.
|
|
1024
|
+
- The call needs to be retried on failure -> use `.with({ idempotencyKey })` + normal RPC.
|
|
1025
|
+
- The call should survive a disconnect -> use the offline queue via the normal RPC.
|
|
1026
|
+
- The call is sometimes one-way, sometimes interesting -> keep the handler `live()` (not `live.volatile()`) and choose at each call site.
|
|
1027
|
+
|
|
1028
|
+
**`live.notify` vs `.fireAndForget()`.** Both are fire-and-forget, but they go in opposite directions: `live.notify(target, event, data)` is server -> client (server-initiated push, no client reply expected), while `.fireAndForget(...args)` is client -> server (client-initiated RPC, no server reply emitted). Different surfaces, different use cases.
|
|
1029
|
+
|
|
1030
|
+
---
|
|
1031
|
+
|
|
976
1032
|
## Optimistic updates
|
|
977
1033
|
|
|
978
1034
|
Apply changes to a stream store instantly, then roll back if the server call fails.
|
|
@@ -3663,8 +3719,8 @@ Import from `svelte-realtime/client`.
|
|
|
3663
3719
|
|---|---|
|
|
3664
3720
|
| `RpcError` | Typed error with `code` field |
|
|
3665
3721
|
| `UploadHandle<T>` | Type for `live.upload` client handles (thenable + events + cancel) |
|
|
3666
|
-
| `batch(fn, options?)` | Group RPC calls into one WebSocket frame |
|
|
3667
|
-
| `configure(config)` | Connection hooks, offline queue, upload frame size |
|
|
3722
|
+
| `batch(fn, options?)` | Group RPC calls into one WebSocket frame (or send bare frame when only one call was collected) |
|
|
3723
|
+
| `configure(config)` | Connection hooks, offline queue, upload frame size, `volatileBackpressureBytes` |
|
|
3668
3724
|
| `combine(...stores, fn)` | Multi-store composition |
|
|
3669
3725
|
| `onSignal(userId, callback)` | Listen for point-to-point signals |
|
|
3670
3726
|
| `onDerived` | Re-exported from adapter: reactive derived topic subscription |
|
package/client.d.ts
CHANGED
|
@@ -115,6 +115,31 @@ export function __rpc(path: string): ((...args: any[]) => Promise<any>) & {
|
|
|
115
115
|
| { event: string; data: any }
|
|
116
116
|
): (...callArgs: any[]) => Promise<any>;
|
|
117
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Send a fire-and-forget RPC. Returns `void` synchronously - no Promise,
|
|
120
|
+
* no pending entry, no timeout, no devtools-pending. The wire frame is
|
|
121
|
+
* `{rpc, args}` with no `id` field; the server runs the full handler
|
|
122
|
+
* chain but does not write a response. Errors are silently dropped on
|
|
123
|
+
* the wire.
|
|
124
|
+
*
|
|
125
|
+
* Pair with `live.volatile(fn)` server-side. Use for high-frequency
|
|
126
|
+
* one-way RPCs - cursor moves, drag updates, typing indicators,
|
|
127
|
+
* telemetry beacons, heartbeats.
|
|
128
|
+
*
|
|
129
|
+
* Safety:
|
|
130
|
+
* - Offline: silent no-op (no offline queue).
|
|
131
|
+
* - Backpressure: dropped when `conn.bufferedAmount` exceeds the
|
|
132
|
+
* configured `volatileBackpressureBytes` (default 4 MB);
|
|
133
|
+
* `__devtools.volatileDropped` ticks.
|
|
134
|
+
* - Inside `batch()`: throws in dev, no-op in prod.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```js
|
|
138
|
+
* import { moveCursor } from '$live/cursors';
|
|
139
|
+
* moveCursor.fireAndForget('board-1', { x, y });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
fireAndForget: (...args: any[]) => void;
|
|
118
143
|
};
|
|
119
144
|
|
|
120
145
|
/**
|
|
@@ -548,6 +573,16 @@ export function configure(config: {
|
|
|
548
573
|
* @default 60000
|
|
549
574
|
*/
|
|
550
575
|
resumeGraceMs?: number;
|
|
576
|
+
/**
|
|
577
|
+
* `WS.bufferedAmount` threshold (in bytes) at which `.fireAndForget()`
|
|
578
|
+
* sends are dropped silently and `__devtools.volatileDropped` ticks.
|
|
579
|
+
* Sized for 120Hz cursor + drag traffic; raise it if your app
|
|
580
|
+
* legitimately bursts above 4 MB of in-flight volatile traffic,
|
|
581
|
+
* lower it on mobile-constrained targets where the OS send buffer
|
|
582
|
+
* is tighter.
|
|
583
|
+
* @default 4_194_304 (4 MB)
|
|
584
|
+
*/
|
|
585
|
+
volatileBackpressureBytes?: number;
|
|
551
586
|
/** Offline mutation queue configuration. */
|
|
552
587
|
offline?: {
|
|
553
588
|
/** Enable queuing RPCs when disconnected. */
|
package/client.js
CHANGED
|
@@ -15,6 +15,11 @@ export const empty = readable(undefined);
|
|
|
15
15
|
|
|
16
16
|
const _textEncoder = new TextEncoder();
|
|
17
17
|
|
|
18
|
+
/** Dev-mode flag. True when not running under a Vite production build
|
|
19
|
+
* (and true under vitest, where `import.meta.env.PROD` is undefined).
|
|
20
|
+
* Gates dev-only warnings and devtools instrumentation. */
|
|
21
|
+
const _IS_DEV = typeof import.meta === 'undefined' || !import.meta.env || !import.meta.env.PROD;
|
|
22
|
+
|
|
18
23
|
// - Bounded-by-default capacity caps (client side) -------------------------
|
|
19
24
|
// Existing caps not re-declared (already enforced at their sites):
|
|
20
25
|
// _historyMax 50 FIFO per-stream undo/redo
|
|
@@ -540,6 +545,70 @@ export function __rpc(path) {
|
|
|
540
545
|
return _sendRpc(path, args);
|
|
541
546
|
};
|
|
542
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Send a fire-and-forget RPC. Returns `void` synchronously - no Promise,
|
|
550
|
+
* no pending entry, no timeout, no devtools-pending. The wire frame is
|
|
551
|
+
* `{rpc, args}` with no `id` field; the server runs the full handler
|
|
552
|
+
* chain (middleware, guards, rate limits, validation) but does not
|
|
553
|
+
* write a response. Errors are silently dropped on the wire.
|
|
554
|
+
*
|
|
555
|
+
* Pair with `live.volatile(fn)` server-side. Use for high-frequency
|
|
556
|
+
* one-way RPCs - cursor moves, drag updates, typing indicators,
|
|
557
|
+
* telemetry beacons, heartbeats. Skips: `_nextId()`, the Promise
|
|
558
|
+
* allocation, the dedup map, the pending Map entry, the timer
|
|
559
|
+
* allocation, the devtools-pending entry.
|
|
560
|
+
*
|
|
561
|
+
* Safety:
|
|
562
|
+
* - **Offline:** silent no-op while disconnected. No offline-queue
|
|
563
|
+
* entry. Lossy under disconnect IS the contract.
|
|
564
|
+
* - **Backpressure:** if `conn.bufferedAmount` exceeds
|
|
565
|
+
* `volatileBackpressureBytes` (default 4 MB - see `configure(...)`),
|
|
566
|
+
* the send is dropped and the drop counter ticks. Prevents the WS
|
|
567
|
+
* send queue from growing unbounded on a stuck connection.
|
|
568
|
+
* - **Inside `batch()`:** dev-mode throws; production no-op. Volatile
|
|
569
|
+
* bypasses batching by design.
|
|
570
|
+
*
|
|
571
|
+
* @param {...any} args - Arguments forwarded to the handler
|
|
572
|
+
* @returns {void}
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```js
|
|
576
|
+
* import { moveCursor } from '$live/cursors';
|
|
577
|
+
* moveCursor.fireAndForget('board-1', { x, y });
|
|
578
|
+
* ```
|
|
579
|
+
*/
|
|
580
|
+
rpcCall.fireAndForget = function fireAndForget(...args) {
|
|
581
|
+
if (_terminated) return;
|
|
582
|
+
if (_isOffline) { _volatileDropped++; return; }
|
|
583
|
+
if (_batchCollector) {
|
|
584
|
+
if (_IS_DEV) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`[svelte-realtime] '${path}'.fireAndForget() cannot be used inside batch() - volatile RPCs bypass batching.\n See: https://svti.me/volatile`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
ensureListener();
|
|
592
|
+
ensureDisconnectListener();
|
|
593
|
+
const conn = _connect();
|
|
594
|
+
const cap = _clientConfig.volatileBackpressureBytes || _DEFAULT_VOLATILE_BACKPRESSURE_BYTES;
|
|
595
|
+
if (typeof conn.bufferedAmount === 'number' && conn.bufferedAmount > cap) {
|
|
596
|
+
_volatileDropped++;
|
|
597
|
+
if (__devtools) __devtools.volatileDropped = _volatileDropped;
|
|
598
|
+
if (_IS_DEV && !_volatileBackpressureWarned) {
|
|
599
|
+
_volatileBackpressureWarned = true;
|
|
600
|
+
console.warn(
|
|
601
|
+
`[svelte-realtime] volatile RPC '${path}' dropped: WS bufferedAmount (${conn.bufferedAmount} bytes) exceeded volatileBackpressureBytes (${cap}). ` +
|
|
602
|
+
`This warning fires once per session; subsequent drops increment __devtools.volatileDropped silently. ` +
|
|
603
|
+
`Raise the threshold via configure({ volatileBackpressureBytes }) if your app legitimately bursts above 4 MB of in-flight WS traffic.\n See: https://svti.me/volatile`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
_devtoolsVolatileSent(path, args);
|
|
609
|
+
conn.sendQueued({ rpc: path, args });
|
|
610
|
+
};
|
|
611
|
+
|
|
543
612
|
/**
|
|
544
613
|
* Attach per-call options. Returns a callable bound to those options.
|
|
545
614
|
*
|
|
@@ -835,6 +904,23 @@ const _DEFAULT_UPLOAD_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
|
835
904
|
const _DEFAULT_UPLOAD_LOW_WATER_MARK = 1 * 1024 * 1024;
|
|
836
905
|
const _UPLOAD_DRAIN_POLL_MS = 50;
|
|
837
906
|
|
|
907
|
+
/** Default backpressure threshold for `.fireAndForget()`. When `conn.bufferedAmount`
|
|
908
|
+
* exceeds this, the volatile send is dropped silently and the drop counter
|
|
909
|
+
* ticks. Sized for 120Hz cursor + drag traffic (~24 KB/sec per client on
|
|
910
|
+
* volatile paths): 4 MB gives ~170s of buffer headroom before drops kick
|
|
911
|
+
* in - healthy demos never trip it; a genuinely dead connection does
|
|
912
|
+
* before browser OOM. Override via `configure({ volatileBackpressureBytes })`. */
|
|
913
|
+
const _DEFAULT_VOLATILE_BACKPRESSURE_BYTES = 4 * 1024 * 1024;
|
|
914
|
+
|
|
915
|
+
/** Volatile-send drop counter. Incremented when a `.fireAndForget()` call is
|
|
916
|
+
* dropped (offline, backpressure, terminated). Exposed to devtools as
|
|
917
|
+
* `__devtools.volatileDropped`. */
|
|
918
|
+
let _volatileDropped = 0;
|
|
919
|
+
|
|
920
|
+
/** Dev-warn dedup: one-shot warn when the first volatile backpressure drop
|
|
921
|
+
* happens, so apps notice in development that they're hitting the cap. */
|
|
922
|
+
let _volatileBackpressureWarned = false;
|
|
923
|
+
|
|
838
924
|
/** Server-discovered `platform.maxPayloadLength`. Updated whenever an upload
|
|
839
925
|
* response arrives carrying `__cap`. 0 = not yet discovered. */
|
|
840
926
|
let _discoveredUploadMaxFrameSize = 0;
|
|
@@ -3363,6 +3449,35 @@ export function batch(fn, options) {
|
|
|
3363
3449
|
return Promise.reject(new RpcError('INVALID_REQUEST', 'Batch exceeds maximum of 50 calls'));
|
|
3364
3450
|
}
|
|
3365
3451
|
|
|
3452
|
+
const conn = _connect();
|
|
3453
|
+
const effectiveTimeout = _getTimeout();
|
|
3454
|
+
|
|
3455
|
+
// Batch-of-1: send the bare RPC frame instead of wrapping in a batch
|
|
3456
|
+
// envelope. Defensive callers (single writes wrapped in batch() for API
|
|
3457
|
+
// symmetry) should not pay envelope cost or the round-trip of a batch
|
|
3458
|
+
// response. The collected entry's pending entry was created with
|
|
3459
|
+
// timer: null inside the call's __rpc path; attach a per-call timer
|
|
3460
|
+
// here so the single call still times out cleanly.
|
|
3461
|
+
if (collected.length === 1) {
|
|
3462
|
+
const call = collected[0];
|
|
3463
|
+
const _startTime = Date.now();
|
|
3464
|
+
const sleepThreshold = Math.max(effectiveTimeout * 3, 90000);
|
|
3465
|
+
const timer = setTimeout(() => {
|
|
3466
|
+
const entry = pending.get(call.id);
|
|
3467
|
+
if (!entry) return;
|
|
3468
|
+
pending.delete(call.id);
|
|
3469
|
+
if (Date.now() - _startTime > sleepThreshold) {
|
|
3470
|
+
entry.reject(new RpcError('DISCONNECTED', 'Connection interrupted (device sleep)'));
|
|
3471
|
+
} else {
|
|
3472
|
+
entry.reject(new RpcError('TIMEOUT', `RPC '${call.rpc}' timed out after ${Math.round(effectiveTimeout / 1000)}s`));
|
|
3473
|
+
}
|
|
3474
|
+
}, effectiveTimeout);
|
|
3475
|
+
const existing = pending.get(call.id);
|
|
3476
|
+
if (existing) existing.timer = timer;
|
|
3477
|
+
conn.sendQueued(call);
|
|
3478
|
+
return Promise.all(promises);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3366
3481
|
// Set a batch-level timeout (sleep-aware)
|
|
3367
3482
|
const _batchStartTime = Date.now();
|
|
3368
3483
|
const batchTimer = setTimeout(() => {
|
|
@@ -3383,10 +3498,9 @@ export function batch(fn, options) {
|
|
|
3383
3498
|
entry.reject(new RpcError('TIMEOUT', `Batch timed out after 30s`));
|
|
3384
3499
|
}
|
|
3385
3500
|
}
|
|
3386
|
-
},
|
|
3501
|
+
}, effectiveTimeout);
|
|
3387
3502
|
|
|
3388
3503
|
// Send all calls as one frame
|
|
3389
|
-
const conn = _connect();
|
|
3390
3504
|
const payload = { batch: collected };
|
|
3391
3505
|
if (options?.sequential) payload.sequential = true;
|
|
3392
3506
|
conn.sendQueued(payload);
|
|
@@ -3416,7 +3530,7 @@ function _checkArgs(path, args) {
|
|
|
3416
3530
|
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
|
|
3417
3531
|
*/
|
|
3418
3532
|
|
|
3419
|
-
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
|
|
3533
|
+
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
|
|
3420
3534
|
let _clientConfig = {};
|
|
3421
3535
|
|
|
3422
3536
|
/** @type {boolean} */
|
|
@@ -3440,7 +3554,14 @@ let _replayingQueue = false;
|
|
|
3440
3554
|
* window resumes from the retained seq/version/cursor so the server can
|
|
3441
3555
|
* gap-fill instead of cold-rehydrating. Set to 0 to disable.
|
|
3442
3556
|
*
|
|
3443
|
-
*
|
|
3557
|
+
* `volatileBackpressureBytes` (default 4 MB) is the `WS.bufferedAmount`
|
|
3558
|
+
* threshold at which `.fireAndForget()` sends are dropped silently and
|
|
3559
|
+
* `__devtools.volatileDropped` increments. Sized for 120Hz cursor + drag
|
|
3560
|
+
* traffic; raise it if your app legitimately bursts above 4 MB of in-flight
|
|
3561
|
+
* volatile traffic, lower it on mobile-constrained targets where the OS
|
|
3562
|
+
* send buffer is tighter.
|
|
3563
|
+
*
|
|
3564
|
+
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
|
|
3444
3565
|
*/
|
|
3445
3566
|
export function configure(config) {
|
|
3446
3567
|
_clientConfig = config;
|
|
@@ -3743,11 +3864,15 @@ const _DEFAULT_REDACT_KEYS = new Set([
|
|
|
3743
3864
|
|
|
3744
3865
|
const _MAX_STREAM_EVENTS = 20;
|
|
3745
3866
|
|
|
3867
|
+
const _DEVTOOLS_VOLATILE_MAX = 100;
|
|
3868
|
+
|
|
3746
3869
|
/**
|
|
3747
3870
|
* @type {{
|
|
3748
3871
|
* history: any[],
|
|
3749
3872
|
* streams: Map<string, any>,
|
|
3750
3873
|
* pending: Map<string, any>,
|
|
3874
|
+
* volatile: any[],
|
|
3875
|
+
* volatileDropped: number,
|
|
3751
3876
|
* redactKeys: Set<string>,
|
|
3752
3877
|
* paused: boolean
|
|
3753
3878
|
* } | null}
|
|
@@ -3757,11 +3882,37 @@ export const __devtools = (typeof import.meta !== 'undefined' && !import.meta.en
|
|
|
3757
3882
|
history: new Array(50).fill(null),
|
|
3758
3883
|
streams: new Map(),
|
|
3759
3884
|
pending: new Map(),
|
|
3885
|
+
volatile: new Array(_DEVTOOLS_VOLATILE_MAX).fill(null),
|
|
3886
|
+
volatileDropped: 0,
|
|
3760
3887
|
redactKeys: new Set(_DEFAULT_REDACT_KEYS),
|
|
3761
3888
|
paused: false
|
|
3762
3889
|
}
|
|
3763
3890
|
: null;
|
|
3764
3891
|
|
|
3892
|
+
/** Ring buffer index for the devtools volatile send track. */
|
|
3893
|
+
let _devtoolsVolatileIdx = 0;
|
|
3894
|
+
let _devtoolsVolatileSeq = 0;
|
|
3895
|
+
|
|
3896
|
+
/**
|
|
3897
|
+
* Record a fire-and-forget RPC send for devtools. Send-only - there is no
|
|
3898
|
+
* matching completion event because the wire shape carries no `id` and the
|
|
3899
|
+
* server never replies. Ring buffer is bounded (`_DEVTOOLS_VOLATILE_MAX`,
|
|
3900
|
+
* drop-oldest) so a high-frequency 60-120Hz mover can't anchor unbounded
|
|
3901
|
+
* dev-mode memory.
|
|
3902
|
+
* @param {string} path
|
|
3903
|
+
* @param {any[]} args
|
|
3904
|
+
*/
|
|
3905
|
+
function _devtoolsVolatileSent(path, args) {
|
|
3906
|
+
if (!__devtools) return;
|
|
3907
|
+
__devtools.volatile[_devtoolsVolatileIdx] = {
|
|
3908
|
+
path,
|
|
3909
|
+
args,
|
|
3910
|
+
time: Date.now(),
|
|
3911
|
+
seq: ++_devtoolsVolatileSeq
|
|
3912
|
+
};
|
|
3913
|
+
_devtoolsVolatileIdx = (_devtoolsVolatileIdx + 1) % _DEVTOOLS_VOLATILE_MAX;
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3765
3916
|
/**
|
|
3766
3917
|
* Walk a value, replacing matched keys with `'[REDACTED]'`. Caps recursion
|
|
3767
3918
|
* depth at 5 and array length at 50 so dev-only capture doesn't pin large
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-realtime",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"tag": "latest"
|
|
6
6
|
},
|
|
@@ -89,12 +89,12 @@
|
|
|
89
89
|
"peerDependencies": {
|
|
90
90
|
"@sveltejs/kit": "^2.0.0",
|
|
91
91
|
"svelte": "^4.0.0 || ^5.0.0",
|
|
92
|
-
"svelte-adapter-uws": "^0.5.
|
|
92
|
+
"svelte-adapter-uws": "^0.5.3"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
95
|
"@playwright/test": "^1.59.1",
|
|
96
96
|
"fast-check": "^4.7.0",
|
|
97
|
-
"svelte-adapter-uws-extensions": "^0.5.
|
|
97
|
+
"svelte-adapter-uws-extensions": "^0.5.3",
|
|
98
98
|
"vitest": "^4.0.18"
|
|
99
99
|
},
|
|
100
100
|
"keywords": [
|
package/server.d.ts
CHANGED
|
@@ -9,9 +9,32 @@ export interface CronContext {
|
|
|
9
9
|
platform: Platform;
|
|
10
10
|
/** Shorthand for `platform.publish` - delegates to whatever platform was passed in. */
|
|
11
11
|
publish: Platform['publish'];
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Publish a value to a topic at most once per `ms` milliseconds.
|
|
14
|
+
* The latest value always arrives (trailing edge). Outbound publish
|
|
15
|
+
* helper - does NOT gate handler execution. For per-key handler gating
|
|
16
|
+
* use `ctx.skip(key, ms)`.
|
|
17
|
+
*/
|
|
18
|
+
publishThrottled(topic: string, event: string, data: any, ms: number): void;
|
|
19
|
+
/**
|
|
20
|
+
* Publish a value to a topic after `ms` milliseconds of silence. Outbound
|
|
21
|
+
* publish helper - does NOT gate handler execution. For per-key handler
|
|
22
|
+
* gating use `ctx.skip(key, ms)`.
|
|
23
|
+
*/
|
|
24
|
+
publishDebounced(topic: string, event: string, data: any, ms: number): void;
|
|
25
|
+
/**
|
|
26
|
+
* @deprecated Renamed to `publishThrottled`. The old name reads like a
|
|
27
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
28
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
29
|
+
* a one-time dev warning fires on first call.
|
|
30
|
+
*/
|
|
13
31
|
throttle(topic: string, event: string, data: any, ms: number): void;
|
|
14
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* @deprecated Renamed to `publishDebounced`. The old name reads like a
|
|
34
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
35
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
36
|
+
* a one-time dev warning fires on first call.
|
|
37
|
+
*/
|
|
15
38
|
debounce(topic: string, event: string, data: any, ms: number): void;
|
|
16
39
|
/** Send a point-to-point signal to a specific user. */
|
|
17
40
|
signal(userId: string, event: string, data: any): void;
|
|
@@ -37,12 +60,58 @@ export interface LiveContext<UserData = unknown> {
|
|
|
37
60
|
publish: Platform['publish'];
|
|
38
61
|
/** Cursor value sent by the client for paginated stream requests. `null` if not paginated. */
|
|
39
62
|
cursor: any;
|
|
40
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Publish a value to a topic at most once per `ms` milliseconds.
|
|
65
|
+
* The latest value always arrives (trailing edge). Outbound publish
|
|
66
|
+
* helper - does NOT gate handler execution. For per-key handler gating
|
|
67
|
+
* use `ctx.skip(key, ms)`.
|
|
68
|
+
*/
|
|
69
|
+
publishThrottled(topic: string, event: string, data: any, ms: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Publish a value to a topic after `ms` milliseconds of silence. Outbound
|
|
72
|
+
* publish helper - does NOT gate handler execution. For per-key handler
|
|
73
|
+
* gating use `ctx.skip(key, ms)`.
|
|
74
|
+
*/
|
|
75
|
+
publishDebounced(topic: string, event: string, data: any, ms: number): void;
|
|
76
|
+
/**
|
|
77
|
+
* @deprecated Renamed to `publishThrottled`. The old name reads like a
|
|
78
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
79
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
80
|
+
* a one-time dev warning fires on first call.
|
|
81
|
+
*/
|
|
41
82
|
throttle(topic: string, event: string, data: any, ms: number): void;
|
|
42
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated Renamed to `publishDebounced`. The old name reads like a
|
|
85
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
86
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
87
|
+
* a one-time dev warning fires on first call.
|
|
88
|
+
*/
|
|
43
89
|
debounce(topic: string, event: string, data: any, ms: number): void;
|
|
44
90
|
/** Send a point-to-point signal to a specific user. */
|
|
45
91
|
signal(userId: string, event: string, data: any): void;
|
|
92
|
+
/**
|
|
93
|
+
* Per-key handler gate. Returns `true` to skip the call (key is within
|
|
94
|
+
* its cooldown window), `false` to run it (no entry, or window elapsed).
|
|
95
|
+
* Pair with an early `return` inside the handler body.
|
|
96
|
+
*
|
|
97
|
+
* State is per-replica - this is a CPU/DB shed, not a cluster-wide rate
|
|
98
|
+
* limit. For cross-replica gating use `live.rateLimit({ store: 'redis' })`
|
|
99
|
+
* or `redis/ratelimit` from svelte-adapter-uws-extensions.
|
|
100
|
+
*
|
|
101
|
+
* Throws `LiveError('INVALID_ARG', ...)` if `key` isn't a string or `ms`
|
|
102
|
+
* isn't a positive finite number. Capped at 5000 active entries with
|
|
103
|
+
* fail-open semantics on overflow (returns `false`, dev-warns once).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```js
|
|
107
|
+
* export const moveNote = live(async (ctx, noteId, x, y) => {
|
|
108
|
+
* if (ctx.skip(`move:${noteId}`, 16)) return; // drop calls within 16ms
|
|
109
|
+
* await dbUpdateNote(noteId, x, y);
|
|
110
|
+
* ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
skip(key: string, ms: number): boolean;
|
|
46
115
|
/**
|
|
47
116
|
* Pressure-aware shed check. Returns `true` if a request of the given
|
|
48
117
|
* class of service should be shed under current `platform.pressure`.
|
|
@@ -500,7 +569,48 @@ export interface CreateMessageOptions {
|
|
|
500
569
|
onError?(path: string, error: unknown, ctx: LiveContext<any>): void;
|
|
501
570
|
|
|
502
571
|
/**
|
|
503
|
-
* Called when a
|
|
572
|
+
* Called when a non-RPC text frame parses as a JSON object envelope.
|
|
573
|
+
* The framework runs `TextDecoder + JSON.parse` once (or, when the
|
|
574
|
+
* adapter already parsed the frame for its own control-message routing,
|
|
575
|
+
* uses the adapter-forwarded value directly - one parse total), and
|
|
576
|
+
* hands the parsed value to this callback.
|
|
577
|
+
*
|
|
578
|
+
* Dispatch by `msg.type` inside the callback. Plugin-layer hooks
|
|
579
|
+
* (`cursor.hooks.message`, future presence/typing) consume the parsed
|
|
580
|
+
* value so user wiring doesn't re-parse on every frame.
|
|
581
|
+
*
|
|
582
|
+
* Frames that don't look like a JSON object (first byte not `{`),
|
|
583
|
+
* fail to parse, or sit at nesting depth greater than `maxJsonDepth`,
|
|
584
|
+
* fall through to `onUnhandled` with the original raw bytes.
|
|
585
|
+
*
|
|
586
|
+
* The adapter's `websocket.maxPayloadLength` already bounds the bytes
|
|
587
|
+
* `JSON.parse` ever sees, so there's no separate size cap here.
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* ```js
|
|
591
|
+
* createMessage({
|
|
592
|
+
* onJsonMessage(ws, msg, platform) {
|
|
593
|
+
* if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
|
|
594
|
+
* else if (msg.type === 'presence-snapshot') presence.hooks.message(ws, { data: msg, platform });
|
|
595
|
+
* }
|
|
596
|
+
* })
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
onJsonMessage?(ws: WebSocket<any>, msg: any, platform: Platform): void;
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Maximum nesting depth allowed in a parsed `onJsonMessage` envelope.
|
|
603
|
+
* Frames deeper than this fall through to `onUnhandled` unparsed.
|
|
604
|
+
* Mirrors `handleRpc`'s `maxEnvelopeDepth` semantics; same default.
|
|
605
|
+
*
|
|
606
|
+
* @default 64
|
|
607
|
+
*/
|
|
608
|
+
maxJsonDepth?: number;
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Called when a message is not an RPC request and either no
|
|
612
|
+
* `onJsonMessage` is set, or the frame is binary / non-JSON / parses
|
|
613
|
+
* to a non-object / exceeds `maxJsonDepth`.
|
|
504
614
|
* Use for mixing RPC with custom message handling.
|
|
505
615
|
*/
|
|
506
616
|
onUnhandled?(ws: WebSocket<any>, data: ArrayBuffer, platform: Platform): void;
|
|
@@ -958,6 +1068,35 @@ export namespace live {
|
|
|
958
1068
|
*/
|
|
959
1069
|
function middleware(fn: (ctx: LiveContext<any>, next: () => Promise<any>) => Promise<any>): void;
|
|
960
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* Mark a handler as fire-and-forget (volatile). The server still runs
|
|
1073
|
+
* the full middleware / guard / rate-limit / validation chain, but does
|
|
1074
|
+
* NOT write a response frame back. The matching client surface is
|
|
1075
|
+
* `rpc.fireAndForget(...args)`, which sends a no-id wire frame and
|
|
1076
|
+
* returns void synchronously.
|
|
1077
|
+
*
|
|
1078
|
+
* Use for high-frequency one-way RPCs where the caller has no reply
|
|
1079
|
+
* to await: cursor moves, drag updates, typing indicators, telemetry
|
|
1080
|
+
* beacons, heartbeats.
|
|
1081
|
+
*
|
|
1082
|
+
* Errors on a volatile call still run through the handler's error
|
|
1083
|
+
* path (metrics, server logs) but are not transmitted - per the
|
|
1084
|
+
* fire-and-forget contract.
|
|
1085
|
+
*
|
|
1086
|
+
* @param fn - Handler function (ctx, ...args)
|
|
1087
|
+
*
|
|
1088
|
+
* @example
|
|
1089
|
+
* ```js
|
|
1090
|
+
* export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
|
|
1091
|
+
* cursor.update(ctx.ws, `board:${boardId}`, pos, ctx.platform);
|
|
1092
|
+
* });
|
|
1093
|
+
*
|
|
1094
|
+
* // Client:
|
|
1095
|
+
* moveCursor.fireAndForget('board-1', { x: 100, y: 200 });
|
|
1096
|
+
* ```
|
|
1097
|
+
*/
|
|
1098
|
+
function volatile<T extends (ctx: LiveContext<any>, ...args: any[]) => any>(fn: T): T;
|
|
1099
|
+
|
|
961
1100
|
/**
|
|
962
1101
|
* Wrap a stream with a server-side gate predicate.
|
|
963
1102
|
* If the predicate returns false (or a `Promise` resolving to false),
|