svelte-realtime 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -2
- package/client.d.ts +35 -0
- package/client.js +155 -4
- package/package.json +3 -3
- package/server.d.ts +29 -0
- package/server.js +260 -100
- package/vite.js +26 -3
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.8",
|
|
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.2"
|
|
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.2",
|
|
98
98
|
"vitest": "^4.0.18"
|
|
99
99
|
},
|
|
100
100
|
"keywords": [
|
package/server.d.ts
CHANGED
|
@@ -958,6 +958,35 @@ export namespace live {
|
|
|
958
958
|
*/
|
|
959
959
|
function middleware(fn: (ctx: LiveContext<any>, next: () => Promise<any>) => Promise<any>): void;
|
|
960
960
|
|
|
961
|
+
/**
|
|
962
|
+
* Mark a handler as fire-and-forget (volatile). The server still runs
|
|
963
|
+
* the full middleware / guard / rate-limit / validation chain, but does
|
|
964
|
+
* NOT write a response frame back. The matching client surface is
|
|
965
|
+
* `rpc.fireAndForget(...args)`, which sends a no-id wire frame and
|
|
966
|
+
* returns void synchronously.
|
|
967
|
+
*
|
|
968
|
+
* Use for high-frequency one-way RPCs where the caller has no reply
|
|
969
|
+
* to await: cursor moves, drag updates, typing indicators, telemetry
|
|
970
|
+
* beacons, heartbeats.
|
|
971
|
+
*
|
|
972
|
+
* Errors on a volatile call still run through the handler's error
|
|
973
|
+
* path (metrics, server logs) but are not transmitted - per the
|
|
974
|
+
* fire-and-forget contract.
|
|
975
|
+
*
|
|
976
|
+
* @param fn - Handler function (ctx, ...args)
|
|
977
|
+
*
|
|
978
|
+
* @example
|
|
979
|
+
* ```js
|
|
980
|
+
* export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
|
|
981
|
+
* cursor.update(ctx.ws, `board:${boardId}`, pos, ctx.platform);
|
|
982
|
+
* });
|
|
983
|
+
*
|
|
984
|
+
* // Client:
|
|
985
|
+
* moveCursor.fireAndForget('board-1', { x: 100, y: 200 });
|
|
986
|
+
* ```
|
|
987
|
+
*/
|
|
988
|
+
function volatile<T extends (ctx: LiveContext<any>, ...args: any[]) => any>(fn: T): T;
|
|
989
|
+
|
|
961
990
|
/**
|
|
962
991
|
* Wrap a stream with a server-side gate predicate.
|
|
963
992
|
* If the predicate returns false (or a `Promise` resolving to false),
|
package/server.js
CHANGED
|
@@ -2067,6 +2067,62 @@ live.public = function publicMarker(fn) {
|
|
|
2067
2067
|
return fn;
|
|
2068
2068
|
};
|
|
2069
2069
|
|
|
2070
|
+
/**
|
|
2071
|
+
* Mark a handler as fire-and-forget (volatile). The server still runs the
|
|
2072
|
+
* full middleware / guard / rate-limit / validation chain, but does NOT
|
|
2073
|
+
* write a response frame back. The client calls the handler via
|
|
2074
|
+
* `.fireAndForget(...args)`, which sends a no-id wire frame and returns
|
|
2075
|
+
* void synchronously.
|
|
2076
|
+
*
|
|
2077
|
+
* Use for high-frequency one-way RPCs where the caller has no reply to
|
|
2078
|
+
* await: cursor moves, drag updates, typing indicators, telemetry beacons,
|
|
2079
|
+
* heartbeats. The handler-level marker is intent + documentation; the wire
|
|
2080
|
+
* shape (no `id` field) is the actual contract, so calling
|
|
2081
|
+
* `.fireAndForget()` on a non-volatile handler also works (server processes
|
|
2082
|
+
* it, just skips the reply). The marker exists so reviewers can see at a
|
|
2083
|
+
* glance that a handler is intentionally one-way and that errors will not
|
|
2084
|
+
* surface to the caller.
|
|
2085
|
+
*
|
|
2086
|
+
* Errors on a volatile call still run through the handler's error path
|
|
2087
|
+
* (metrics, server logs) but are not transmitted - per the fire-and-forget
|
|
2088
|
+
* contract. Pair with `live.rateLimit` + `ctx.shed` for admission control;
|
|
2089
|
+
* shed volatile calls naturally have no caller to inform.
|
|
2090
|
+
*
|
|
2091
|
+
* @param {Function} fn - Handler function (ctx, ...args)
|
|
2092
|
+
* @returns {Function}
|
|
2093
|
+
*
|
|
2094
|
+
* @example
|
|
2095
|
+
* ```js
|
|
2096
|
+
* // src/lib/realtime/cursors.js
|
|
2097
|
+
* import { live } from 'svelte-realtime';
|
|
2098
|
+
*
|
|
2099
|
+
* export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
|
|
2100
|
+
* cursor.update(ctx.ws, `board:${boardId}`, pos, ctx.platform);
|
|
2101
|
+
* });
|
|
2102
|
+
*
|
|
2103
|
+
* // Client:
|
|
2104
|
+
* import { moveCursor } from '$live/cursors';
|
|
2105
|
+
* moveCursor.fireAndForget('board-1', { x: 100, y: 200 }); // no await, no reply
|
|
2106
|
+
* ```
|
|
2107
|
+
*/
|
|
2108
|
+
live.volatile = function volatileMarker(fn) {
|
|
2109
|
+
if (typeof fn !== 'function') {
|
|
2110
|
+
throw new Error('[svelte-realtime] live.volatile(fn) requires a handler function');
|
|
2111
|
+
}
|
|
2112
|
+
/** @type {any} */ (fn).__isLive = true;
|
|
2113
|
+
/** @type {any} */ (fn).__volatileRpc = true;
|
|
2114
|
+
return fn;
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
/**
|
|
2118
|
+
* Dev-mode warn dedup for fire-and-forget calls against non-volatile
|
|
2119
|
+
* handlers. Bounded so a script-driven barrage doesn't anchor unbounded
|
|
2120
|
+
* memory; first 256 distinct paths warn once each, then quiet.
|
|
2121
|
+
* @type {Set<string>}
|
|
2122
|
+
*/
|
|
2123
|
+
const _volatileWarnSet = new Set();
|
|
2124
|
+
const _VOLATILE_WARN_CAP = 256;
|
|
2125
|
+
|
|
2070
2126
|
/**
|
|
2071
2127
|
* Wraps a live() function with a sliding window rate limiter.
|
|
2072
2128
|
*
|
|
@@ -3461,15 +3517,6 @@ let _bus = null;
|
|
|
3461
3517
|
*/
|
|
3462
3518
|
let _cronBus = null;
|
|
3463
3519
|
|
|
3464
|
-
/**
|
|
3465
|
-
* Sentinel attached to platforms that have been wrapped with the
|
|
3466
|
-
* process-wide bus. Lets the framework detect already-wrapped inputs
|
|
3467
|
-
* and skip re-wrapping, so a user who passes a manually `bus.wrap`-ed
|
|
3468
|
-
* platform via `createMessage({ platform })` is not double-wrapped by
|
|
3469
|
-
* the auto-wrap path.
|
|
3470
|
-
*/
|
|
3471
|
-
const _BUS_WRAPPED = Symbol.for('svelte-realtime.busWrapped');
|
|
3472
|
-
|
|
3473
3520
|
/**
|
|
3474
3521
|
* Write the process-wide bus. Validated like `configureCron({ bus })`
|
|
3475
3522
|
* - must expose `.wrap(platform)` or be `null`. Mirrored into the
|
|
@@ -5166,43 +5213,73 @@ export function __registerDerived(path, fn) {
|
|
|
5166
5213
|
* triggered externally when the platform fires publish.
|
|
5167
5214
|
* @param {import('svelte-adapter-uws').Platform} platform
|
|
5168
5215
|
*/
|
|
5169
|
-
/**
|
|
5216
|
+
/**
|
|
5217
|
+
* Tracks platforms whose `publish` has been swapped to `derivedPublish`
|
|
5218
|
+
* by `_wrapPlatformPublish`. WeakSet so per-connection platform clones
|
|
5219
|
+
* inherit the mutation via prototype chain without forcing the base
|
|
5220
|
+
* platform to live longer than the adapter intends - entries clear
|
|
5221
|
+
* naturally when the platform itself becomes GC-eligible. The WeakSet
|
|
5222
|
+
* is the single source of truth for "is this platform's publish path
|
|
5223
|
+
* framework-owned?" - consulted by `_ensureWrap` (the universal idempotent
|
|
5224
|
+
* installer), referenced indirectly by every publish surface (RPC, cron,
|
|
5225
|
+
* reactive, top-level `publish()`).
|
|
5226
|
+
*
|
|
5227
|
+
* @type {WeakSet<object>}
|
|
5228
|
+
*/
|
|
5170
5229
|
const _activatedPlatforms = new WeakSet();
|
|
5171
5230
|
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5231
|
+
/**
|
|
5232
|
+
* Universal install point for the framework's publish wrap. Idempotent
|
|
5233
|
+
* against `_activatedPlatforms`, safe under HMR, called from every site
|
|
5234
|
+
* that captures or first sees a platform reference:
|
|
5235
|
+
* - `setCronPlatform(platform)` - call from `realtime().init` or
|
|
5236
|
+
* directly from `hooks.ws.js`'s `init({ platform })`.
|
|
5237
|
+
* - `_activateDerived(platform)` - same call site, alternative entry.
|
|
5238
|
+
* - The default `message` hook + `createMessage` returned hook - first
|
|
5239
|
+
* message per platform installs the wrap, so apps that wire only
|
|
5240
|
+
* `setBus(bus)` and re-export `message` (no init hook, no
|
|
5241
|
+
* `_activateDerived` call) still get cluster routing on first RPC.
|
|
5242
|
+
*
|
|
5243
|
+
* Single install site eliminates the entire class of "outer wrap stacks
|
|
5244
|
+
* on inner wrap" bugs: there is only ONE `bus.wrap(...)` call in the
|
|
5245
|
+
* whole framework (inside `_wrapPlatformPublish`'s `_refreshBusCache`)
|
|
5246
|
+
* and it's composed with everything else (reactive watchers, batched
|
|
5247
|
+
* fast path, replay routing) at publish time via the mutated
|
|
5248
|
+
* `derivedPublish` / `derivedPublishBatched`.
|
|
5249
|
+
*
|
|
5250
|
+
* @param {any} platform
|
|
5251
|
+
*/
|
|
5252
|
+
function _ensureWrap(platform) {
|
|
5253
|
+
if (!platform) return;
|
|
5193
5254
|
// svelte-adapter-uws hands hooks a per-connection platform created via
|
|
5194
|
-
// Object.create(basePlatform). Wrapping that per-connection object
|
|
5195
|
-
// every other connection's inherited publish / publishBatched
|
|
5196
|
-
// because their lookups walk the prototype chain to the
|
|
5197
|
-
// Resolve to the base prototype so the wrap is visible
|
|
5198
|
-
// that share it. Test mocks pass plain objects whose
|
|
5199
|
-
// Object.prototype - in that case wrap the object itself.
|
|
5255
|
+
// Object.create(basePlatform). Wrapping that per-connection object would
|
|
5256
|
+
// leave every other connection's inherited publish / publishBatched
|
|
5257
|
+
// untouched, because their lookups walk the prototype chain to the
|
|
5258
|
+
// original base. Resolve to the base prototype so the wrap is visible
|
|
5259
|
+
// to all connections that share it. Test mocks pass plain objects whose
|
|
5260
|
+
// proto is Object.prototype - in that case wrap the object itself.
|
|
5200
5261
|
const target = _resolveWrapTarget(platform);
|
|
5201
5262
|
if (_activatedPlatforms.has(target)) return;
|
|
5202
5263
|
_activatedPlatforms.add(target);
|
|
5203
5264
|
_wrapPlatformPublish(target);
|
|
5204
5265
|
}
|
|
5205
5266
|
|
|
5267
|
+
export function _activateDerived(platform) {
|
|
5268
|
+
_derivedPlatform = platform;
|
|
5269
|
+
_activateDerivedCalled = true;
|
|
5270
|
+
// Install the framework's publish wrap unconditionally. Pre-0.5.7 this
|
|
5271
|
+
// was gated on "any reactive primitives registered?" to avoid wrap
|
|
5272
|
+
// overhead on apps that didn't use derived/effect/aggregate. With the
|
|
5273
|
+
// wrap now also responsible for bus routing (every publish surface
|
|
5274
|
+
// consults `_getBus()` via `derivedPublish`), gating would create a
|
|
5275
|
+
// window where a publish escapes routing - the late-activation race
|
|
5276
|
+
// from the 0.5.6 audit. The per-publish overhead of an empty wrap is
|
|
5277
|
+
// one function call plus a `Map.has` check on an empty Map (`O(1)`,
|
|
5278
|
+
// branch-predicted to false); the install cost is one closure scope
|
|
5279
|
+
// per platform, paid once at init.
|
|
5280
|
+
_ensureWrap(platform);
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5206
5283
|
/**
|
|
5207
5284
|
* Install the publish wrap retroactively if `_activateDerived(platform)`
|
|
5208
5285
|
* was called against an empty registry and a registration has now landed
|
|
@@ -5221,10 +5298,7 @@ export function _activateDerived(platform) {
|
|
|
5221
5298
|
*/
|
|
5222
5299
|
function _maybeLateActivate() {
|
|
5223
5300
|
if (!_derivedPlatform) return;
|
|
5224
|
-
|
|
5225
|
-
if (_activatedPlatforms.has(target)) return;
|
|
5226
|
-
_activatedPlatforms.add(target);
|
|
5227
|
-
_wrapPlatformPublish(target);
|
|
5301
|
+
_ensureWrap(_derivedPlatform);
|
|
5228
5302
|
}
|
|
5229
5303
|
|
|
5230
5304
|
/**
|
|
@@ -5282,10 +5356,6 @@ function _wrapPlatformPublish(platform) {
|
|
|
5282
5356
|
surrogate.publish = derivedPublishLocal;
|
|
5283
5357
|
if (originalPublishBatched) surrogate.publishBatched = derivedPublishBatchedLocal;
|
|
5284
5358
|
const wrapped = bus.wrap(surrogate);
|
|
5285
|
-
// Tag so a downstream auto-wrap pass (e.g. message hook) can
|
|
5286
|
-
// detect "already wrapped by us" and skip re-wrapping. The tag
|
|
5287
|
-
// records the bus identity so a later swap re-wraps cleanly.
|
|
5288
|
-
/** @type {any} */ (wrapped)[_BUS_WRAPPED] = bus;
|
|
5289
5359
|
_busPublish = typeof wrapped.publish === 'function' ? wrapped.publish.bind(wrapped) : null;
|
|
5290
5360
|
_busPublishBatched = typeof /** @type {any} */ (wrapped).publishBatched === 'function'
|
|
5291
5361
|
? /** @type {any} */ (wrapped).publishBatched.bind(wrapped)
|
|
@@ -5648,6 +5718,12 @@ export function setCronPlatform(platform) {
|
|
|
5648
5718
|
// Re-arm the dedup so a subsequent platform-loss (defensive only --
|
|
5649
5719
|
// platform never goes null in practice) gets one fresh warning.
|
|
5650
5720
|
_cronPlatformWarnFired = false;
|
|
5721
|
+
// Install the framework's publish wrap here too: pure-cron apps that
|
|
5722
|
+
// never call `_activateDerived` (no reactive primitives wired) still
|
|
5723
|
+
// need cluster routing when a bus is configured. The wrap is idempotent
|
|
5724
|
+
// via `_activatedPlatforms`, so when `realtime().init` calls both
|
|
5725
|
+
// `setCronPlatform` and `_activateDerived` the second call is a no-op.
|
|
5726
|
+
if (platform) _ensureWrap(platform);
|
|
5651
5727
|
}
|
|
5652
5728
|
|
|
5653
5729
|
/**
|
|
@@ -6115,17 +6191,15 @@ export async function _tickCron() {
|
|
|
6115
6191
|
}
|
|
6116
6192
|
return;
|
|
6117
6193
|
}
|
|
6118
|
-
// Cluster fan-out
|
|
6119
|
-
//
|
|
6120
|
-
//
|
|
6121
|
-
//
|
|
6122
|
-
//
|
|
6123
|
-
//
|
|
6124
|
-
//
|
|
6125
|
-
//
|
|
6126
|
-
|
|
6127
|
-
// happy path).
|
|
6128
|
-
const cronPub = _cronBus ? _cronBus.wrap(_cronPlatform) : _cronPlatform;
|
|
6194
|
+
// Cluster fan-out is the framework's publish wrap's job
|
|
6195
|
+
// now (one wrap site for the whole framework, installed
|
|
6196
|
+
// by `_ensureWrap` from `setCronPlatform`). The cron tick
|
|
6197
|
+
// uses the captured `_cronPlatform` directly - its
|
|
6198
|
+
// `publish` is `derivedPublish`, which consults the
|
|
6199
|
+
// process-wide bus at publish time. No outer `bus.wrap(...)`
|
|
6200
|
+
// here, which eliminates the 0.5.6 double-relay class of
|
|
6201
|
+
// bugs by construction.
|
|
6202
|
+
const cronPub = _cronPlatform;
|
|
6129
6203
|
const _h = _getCtxHelpers(cronPub);
|
|
6130
6204
|
const ctx = _buildCtx(null, null, cronPub, _h, null);
|
|
6131
6205
|
const result = await entry.fn(ctx);
|
|
@@ -6516,7 +6590,20 @@ export function handleRpc(ws, data, platform, options) {
|
|
|
6516
6590
|
return true;
|
|
6517
6591
|
}
|
|
6518
6592
|
|
|
6519
|
-
if (typeof msg.rpc !== 'string'
|
|
6593
|
+
if (typeof msg.rpc !== 'string') return false;
|
|
6594
|
+
|
|
6595
|
+
// Volatile (fire-and-forget) RPC: frames with no `id` field signal
|
|
6596
|
+
// "no reply expected". Server runs the full handler chain but skips
|
|
6597
|
+
// the response emit. The wire shape (id absent) is the contract; the
|
|
6598
|
+
// matching client surface is `rpc.fireAndForget(...)` plus the
|
|
6599
|
+
// `live.volatile()` server-side marker.
|
|
6600
|
+
if (msg.id === undefined) {
|
|
6601
|
+
if (msg.rpc.length === 0) return false;
|
|
6602
|
+
_executeVolatileRpc(ws, msg, platform, options);
|
|
6603
|
+
return true;
|
|
6604
|
+
}
|
|
6605
|
+
|
|
6606
|
+
if (typeof msg.id !== 'string') return false;
|
|
6520
6607
|
|
|
6521
6608
|
// envelope.shape invariant: rpc and id must be non-empty for routing
|
|
6522
6609
|
assert(msg.rpc.length > 0 && msg.id.length > 0, 'realtime/handleRpc.envelope.non-empty', { rpcLen: msg.rpc.length, idLen: msg.id.length });
|
|
@@ -6537,6 +6624,58 @@ async function _executeRpc(ws, msg, platform, options) {
|
|
|
6537
6624
|
_respond(ws, platform, msg.id, result);
|
|
6538
6625
|
}
|
|
6539
6626
|
|
|
6627
|
+
/**
|
|
6628
|
+
* Execute a fire-and-forget RPC. Runs the full handler chain (middleware,
|
|
6629
|
+
* guards, rate limits, validation) but does NOT write a response frame.
|
|
6630
|
+
* `msg.id` is absent on the wire; an internal correlation id is synthesized
|
|
6631
|
+
* so metrics, devtools, and `_executeSingleRpc`'s response shape stay
|
|
6632
|
+
* uniform without leaking onto the wire.
|
|
6633
|
+
*
|
|
6634
|
+
* Dev-mode warns once per non-volatile handler that receives a fire-and-forget
|
|
6635
|
+
* call (bounded by `_VOLATILE_WARN_CAP` distinct paths) so accidental
|
|
6636
|
+
* `.fireAndForget()` calls against handlers that have a meaningful return
|
|
6637
|
+
* value surface in the server log.
|
|
6638
|
+
*
|
|
6639
|
+
* @param {any} ws
|
|
6640
|
+
* @param {{ rpc: string, args?: any[] }} msg
|
|
6641
|
+
* @param {import('svelte-adapter-uws').Platform} platform
|
|
6642
|
+
* @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
|
|
6643
|
+
*/
|
|
6644
|
+
async function _executeVolatileRpc(ws, msg, platform, options) {
|
|
6645
|
+
if (_IS_DEV) {
|
|
6646
|
+
const path = msg.rpc;
|
|
6647
|
+
const fn = await _resolveRegistryEntry(path);
|
|
6648
|
+
if (fn && !_hasVolatileMarker(fn) && !_volatileWarnSet.has(path)) {
|
|
6649
|
+
if (_volatileWarnSet.size < _VOLATILE_WARN_CAP) _volatileWarnSet.add(path);
|
|
6650
|
+
console.warn(
|
|
6651
|
+
`[svelte-realtime] handler '${path}' received a fire-and-forget call but is not marked live.volatile(). ` +
|
|
6652
|
+
"Errors will be silently dropped (no reply is sent). Wrap the handler with live.volatile() to make this intent explicit.\n See: https://svti.me/volatile"
|
|
6653
|
+
);
|
|
6654
|
+
}
|
|
6655
|
+
}
|
|
6656
|
+
/** @type {any} */ (msg).id = '__volatile';
|
|
6657
|
+
await _executeSingleRpc(ws, /** @type {any} */ (msg), platform, options);
|
|
6658
|
+
// No _respond - fire-and-forget contract.
|
|
6659
|
+
}
|
|
6660
|
+
|
|
6661
|
+
/**
|
|
6662
|
+
* Walk the `__wrappedFn` chain produced by `live.rateLimit` / `live.idempotent`
|
|
6663
|
+
* / `live.breaker` / `live.validated` / `live.lock` to find an inner
|
|
6664
|
+
* `__volatileRpc` marker. Lets users wrap a `live.volatile(handler)` core
|
|
6665
|
+
* with any combination of the other markers in any order without tripping
|
|
6666
|
+
* the dev-mode "not marked volatile" warning. Bounded walk (depth 8) so a
|
|
6667
|
+
* pathological cycle cannot loop forever.
|
|
6668
|
+
* @param {any} fn
|
|
6669
|
+
*/
|
|
6670
|
+
function _hasVolatileMarker(fn) {
|
|
6671
|
+
let cur = fn;
|
|
6672
|
+
for (let i = 0; cur && i < 8; i++) {
|
|
6673
|
+
if (cur.__volatileRpc) return true;
|
|
6674
|
+
cur = cur.__wrappedFn;
|
|
6675
|
+
}
|
|
6676
|
+
return false;
|
|
6677
|
+
}
|
|
6678
|
+
|
|
6540
6679
|
/**
|
|
6541
6680
|
* Execute a batch of RPC calls. Supports parallel (default) and sequential modes.
|
|
6542
6681
|
*
|
|
@@ -8181,50 +8320,43 @@ export function close(ws, { platform, subscriptions }) {
|
|
|
8181
8320
|
}
|
|
8182
8321
|
|
|
8183
8322
|
/**
|
|
8184
|
-
*
|
|
8185
|
-
*
|
|
8186
|
-
* the
|
|
8187
|
-
*
|
|
8188
|
-
*
|
|
8189
|
-
*
|
|
8323
|
+
* One-shot dev-mode flag for the "createMessage({ platform: callback })
|
|
8324
|
+
* is redundant" warning. A user-supplied `platform` callback in
|
|
8325
|
+
* `createMessage` was the pre-0.5.6 way to wire bus.wrap into the RPC
|
|
8326
|
+
* hook. With 0.5.7+ the framework installs a single publish wrap on the
|
|
8327
|
+
* adapter platform (via `_ensureWrap`, called from
|
|
8328
|
+
* `setCronPlatform` / `_activateDerived` / first message), and that
|
|
8329
|
+
* wrap is the sole `bus.wrap(...)` site. A manual callback that wraps
|
|
8330
|
+
* with `bus.wrap` stacks an outer relay on top of the inner one and
|
|
8331
|
+
* double-delivers every RPC publish to other replicas. We can't detect
|
|
8332
|
+
* the manual-wrap case from the callback's output (user-built wraps
|
|
8333
|
+
* don't carry our sentinel), but the input platform is the activated
|
|
8334
|
+
* adapter platform, so we warn at receive time when both conditions
|
|
8335
|
+
* hold. Module-level so a user creating multiple message hooks sees
|
|
8336
|
+
* one warning total.
|
|
8190
8337
|
*/
|
|
8191
|
-
|
|
8338
|
+
let _manualPlatformCallbackWarnFired = false;
|
|
8192
8339
|
|
|
8193
8340
|
/**
|
|
8194
|
-
*
|
|
8195
|
-
*
|
|
8196
|
-
*
|
|
8197
|
-
* platform is wrapped on first use and memoized for subsequent
|
|
8198
|
-
* messages on the same platform. When the user manually pre-wraps via
|
|
8199
|
-
* `createMessage({ platform })`, this is bypassed (their callback
|
|
8200
|
-
* runs first) so we never double-wrap.
|
|
8201
|
-
*
|
|
8202
|
-
* @param {import('svelte-adapter-uws').Platform} platform
|
|
8203
|
-
* @returns {import('svelte-adapter-uws').Platform}
|
|
8341
|
+
* Reset the one-shot dev-warn flag for tests. Production deployments
|
|
8342
|
+
* don't need this - the warning is meant to fire once per process and
|
|
8343
|
+
* the flag never needs resetting outside test isolation.
|
|
8204
8344
|
*/
|
|
8205
|
-
function
|
|
8206
|
-
|
|
8207
|
-
if (!bus) return platform;
|
|
8208
|
-
// Idempotence: if the input has already been wrapped by this
|
|
8209
|
-
// framework against the current bus, return it untouched.
|
|
8210
|
-
if (/** @type {any} */ (platform)[_BUS_WRAPPED] === bus) return platform;
|
|
8211
|
-
const entry = _rpcBusWrapCache.get(/** @type {any} */ (platform));
|
|
8212
|
-
if (entry && entry.bus === bus && entry.epoch === _busEpoch) return entry.wrapped;
|
|
8213
|
-
const wrapped = bus.wrap(platform);
|
|
8214
|
-
/** @type {any} */ (wrapped)[_BUS_WRAPPED] = bus;
|
|
8215
|
-
_rpcBusWrapCache.set(/** @type {any} */ (platform), { bus, wrapped, epoch: _busEpoch });
|
|
8216
|
-
return wrapped;
|
|
8345
|
+
export function _resetManualPlatformCallbackWarn() {
|
|
8346
|
+
_manualPlatformCallbackWarnFired = false;
|
|
8217
8347
|
}
|
|
8218
8348
|
|
|
8219
8349
|
/**
|
|
8220
|
-
* Ready-made message hook. Re-export from hooks.ws.js for zero-config
|
|
8350
|
+
* Ready-made message hook. Re-export from hooks.ws.js for zero-config
|
|
8351
|
+
* RPC routing.
|
|
8221
8352
|
*
|
|
8222
|
-
*
|
|
8223
|
-
* `
|
|
8224
|
-
*
|
|
8225
|
-
*
|
|
8226
|
-
*
|
|
8227
|
-
* overhead
|
|
8353
|
+
* First call per platform installs the framework's publish wrap via
|
|
8354
|
+
* `_ensureWrap` (idempotent), so apps that wire `setBus(bus)` and
|
|
8355
|
+
* re-export `message` but never call `_activateDerived` /
|
|
8356
|
+
* `setCronPlatform` themselves still get cluster routing on first
|
|
8357
|
+
* RPC. Subsequent calls are no-ops on the wrap path. Without a bus,
|
|
8358
|
+
* the wrap's per-publish overhead is one function call plus a
|
|
8359
|
+
* `Map.has` check on an empty Map - well below noise.
|
|
8228
8360
|
*
|
|
8229
8361
|
* Signature matches the adapter's message hook exactly.
|
|
8230
8362
|
*
|
|
@@ -8232,7 +8364,8 @@ function _autoBusWrap(platform) {
|
|
|
8232
8364
|
* @param {{ data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform }} ctx
|
|
8233
8365
|
*/
|
|
8234
8366
|
export function message(ws, { data, platform }) {
|
|
8235
|
-
|
|
8367
|
+
_ensureWrap(platform);
|
|
8368
|
+
handleRpc(ws, data, platform);
|
|
8236
8369
|
}
|
|
8237
8370
|
|
|
8238
8371
|
/**
|
|
@@ -8253,12 +8386,39 @@ export function createMessage(options) {
|
|
|
8253
8386
|
const hasRpcOpts = beforeExecute || onError;
|
|
8254
8387
|
|
|
8255
8388
|
return function customMessage(ws, { data, platform }) {
|
|
8256
|
-
//
|
|
8257
|
-
//
|
|
8258
|
-
//
|
|
8259
|
-
//
|
|
8260
|
-
//
|
|
8261
|
-
|
|
8389
|
+
// Install the framework's publish wrap on the platform (idempotent
|
|
8390
|
+
// per platform). After this returns, `platform.publish` is
|
|
8391
|
+
// `derivedPublish`, which is the single bus-routing site for the
|
|
8392
|
+
// whole framework. Done BEFORE any transform callback so the
|
|
8393
|
+
// callback sees the wrapped publish path (correct ordering for
|
|
8394
|
+
// non-bus transforms like metrics instrumentation; double-wrap
|
|
8395
|
+
// detected and warned for legacy bus.wrap callbacks).
|
|
8396
|
+
_ensureWrap(platform);
|
|
8397
|
+
let p;
|
|
8398
|
+
if (transformPlatform) {
|
|
8399
|
+
// Dev-only nudge: a `platform` callback against an
|
|
8400
|
+
// already-activated platform with a process-wide bus wired
|
|
8401
|
+
// almost always means a legacy `(p) => bus.wrap(p)` callback
|
|
8402
|
+
// is layered on top of `derivedPublish`'s inner bus.wrap,
|
|
8403
|
+
// which double-relays every RPC publish. Warn once per
|
|
8404
|
+
// process; users with a non-bus transform (e.g. metrics
|
|
8405
|
+
// instrumentation) can ignore.
|
|
8406
|
+
if (_IS_DEV
|
|
8407
|
+
&& !_manualPlatformCallbackWarnFired
|
|
8408
|
+
&& _getBus()
|
|
8409
|
+
) {
|
|
8410
|
+
_manualPlatformCallbackWarnFired = true;
|
|
8411
|
+
console.warn(
|
|
8412
|
+
"[svelte-realtime] createMessage({ platform: callback }) is redundant when `setBus(...)` is wired: " +
|
|
8413
|
+
"the framework already routes ctx.publish through the bus, so a manual `bus.wrap(p)` callback double-relays every RPC publish to other replicas. " +
|
|
8414
|
+
"Drop the `platform` option to fix. If your callback does a non-bus transform (e.g. metrics) you can ignore this warning.\n" +
|
|
8415
|
+
" See: https://svti.me/cluster-relay"
|
|
8416
|
+
);
|
|
8417
|
+
}
|
|
8418
|
+
p = transformPlatform(platform);
|
|
8419
|
+
} else {
|
|
8420
|
+
p = platform;
|
|
8421
|
+
}
|
|
8262
8422
|
const handled = handleRpc(ws, data, p, hasRpcOpts ? rpcOpts : undefined);
|
|
8263
8423
|
if (!handled && onUnhandled) {
|
|
8264
8424
|
onUnhandled(ws, data, p);
|
package/vite.js
CHANGED
|
@@ -41,6 +41,11 @@ const PUBLIC_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.public\s*\(/g;
|
|
|
41
41
|
// module are intentionally public.
|
|
42
42
|
const PUBLIC_COMMENT_RE = /(?:\/\/|\/\*)\s*realtime-allow-public\b/;
|
|
43
43
|
const IDEMPOTENT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.idempotent\s*\(/g;
|
|
44
|
+
// `live.volatile(...)` is the fire-and-forget RPC marker. From the client's
|
|
45
|
+
// perspective the export is a normal RPC stub - the `.fireAndForget()` method
|
|
46
|
+
// is attached by the `__rpc()` factory itself - so the codegen emits the same
|
|
47
|
+
// `__rpc(...)` line as a plain `live()` export.
|
|
48
|
+
const VOLATILE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.volatile\s*\(/g;
|
|
44
49
|
|
|
45
50
|
const _validSegmentReVite = /^[a-zA-Z0-9_]+$/;
|
|
46
51
|
|
|
@@ -1097,7 +1102,7 @@ function _generateClientStubs(filePath, modulePath, dir) {
|
|
|
1097
1102
|
const hasPublicComment = PUBLIC_COMMENT_RE.test(source);
|
|
1098
1103
|
// live() and the wrappers that pass through unchanged on the client
|
|
1099
1104
|
// (validated/lock/idempotent/rateLimit/public) all emit the same __rpc line.
|
|
1100
|
-
for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE, PUBLIC_EXPORT_RE]) {
|
|
1105
|
+
for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE, PUBLIC_EXPORT_RE, VOLATILE_EXPORT_RE]) {
|
|
1101
1106
|
re.lastIndex = 0;
|
|
1102
1107
|
while ((match = re.exec(source)) !== null) {
|
|
1103
1108
|
const name = match[1];
|
|
@@ -1886,8 +1891,8 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
|
|
|
1886
1891
|
const registered = new Set();
|
|
1887
1892
|
let match;
|
|
1888
1893
|
// live() and the wrappers that share the plain __register line
|
|
1889
|
-
// (validated/lock/idempotent/rateLimit).
|
|
1890
|
-
for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE]) {
|
|
1894
|
+
// (validated/lock/idempotent/rateLimit/volatile).
|
|
1895
|
+
for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE, VOLATILE_EXPORT_RE]) {
|
|
1891
1896
|
re.lastIndex = 0;
|
|
1892
1897
|
while ((match = re.exec(source)) !== null) {
|
|
1893
1898
|
const name = match[1];
|
|
@@ -2381,6 +2386,24 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
2381
2386
|
}
|
|
2382
2387
|
}
|
|
2383
2388
|
|
|
2389
|
+
// Detect live.volatile() exports - inner handler at arg index 0
|
|
2390
|
+
// (same shape as plain live()). The .fireAndForget(...) method is
|
|
2391
|
+
// attached by the __rpc(...) factory itself, so the generated stub
|
|
2392
|
+
// types as the plain RPC signature; users get the method at runtime.
|
|
2393
|
+
VOLATILE_EXPORT_RE.lastIndex = 0;
|
|
2394
|
+
while ((match = VOLATILE_EXPORT_RE.exec(source)) !== null) {
|
|
2395
|
+
const name = match[1];
|
|
2396
|
+
handledNames.add(name);
|
|
2397
|
+
if (!exports.some(e => e.includes(`export const ${name}:`))) {
|
|
2398
|
+
if (isTS) {
|
|
2399
|
+
const sig = _extractFunctionSignatureFor(source, name, 'live\\.volatile', 0);
|
|
2400
|
+
exports.push(` export const ${name}: ${sig};`);
|
|
2401
|
+
} else {
|
|
2402
|
+
exports.push(` export const ${name}: (...args: any[]) => Promise<any>;`);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2384
2407
|
// Detect live.room() exports
|
|
2385
2408
|
ROOM_EXPORT_RE.lastIndex = 0;
|
|
2386
2409
|
while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
|