svelte-realtime 0.5.7 → 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 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
- }, _getTimeout());
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
- * @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: 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
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.7",
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.1"
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.1",
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
  *
@@ -6534,7 +6590,20 @@ export function handleRpc(ws, data, platform, options) {
6534
6590
  return true;
6535
6591
  }
6536
6592
 
6537
- if (typeof msg.rpc !== 'string' || typeof msg.id !== 'string') return false;
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;
6538
6607
 
6539
6608
  // envelope.shape invariant: rpc and id must be non-empty for routing
6540
6609
  assert(msg.rpc.length > 0 && msg.id.length > 0, 'realtime/handleRpc.envelope.non-empty', { rpcLen: msg.rpc.length, idLen: msg.id.length });
@@ -6555,6 +6624,58 @@ async function _executeRpc(ws, msg, platform, options) {
6555
6624
  _respond(ws, platform, msg.id, result);
6556
6625
  }
6557
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
+
6558
6679
  /**
6559
6680
  * Execute a batch of RPC calls. Supports parallel (default) and sequential modes.
6560
6681
  *
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) {