svelte-realtime 0.5.8 → 0.6.0-next.1

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 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
@@ -2339,6 +2339,49 @@ Without a leader configured (the default), every worker fires every job. svelte-
2339
2339
 
2340
2340
  ---
2341
2341
 
2342
+ ## Feature flags
2343
+
2344
+ Use `live.flag()` to declare a server-controlled value that every client reads as a readable store. A flag is a thin wrapper over `live.stream`: it declares a `merge: 'set'` topic carrying the value, and `.set(value)` pushes a new value to every subscriber.
2345
+
2346
+ ```js
2347
+ // src/live/flags.js
2348
+ import { live } from 'svelte-realtime/server';
2349
+
2350
+ export const maintenance = live.flag('flag:maintenance', false);
2351
+ ```
2352
+
2353
+ Flip it from any handler - the `.set(value)` call publishes through the framework-owned platform, so the new value reaches every local subscriber and relays across the cluster when a bus is wired:
2354
+
2355
+ ```js
2356
+ export const toggleMaintenance = live(async (ctx, on) => {
2357
+ maintenance.set(on);
2358
+ });
2359
+ ```
2360
+
2361
+ On the client, the flag is a readable store carrying the current value:
2362
+
2363
+ ```svelte
2364
+ <script>
2365
+ import { maintenance } from '$live/flags';
2366
+ </script>
2367
+
2368
+ {#if $maintenance}
2369
+ <Banner>Down for maintenance</Banner>
2370
+ {/if}
2371
+ ```
2372
+
2373
+ `.set(value)` requires that the platform has been captured (`realtime().init`, `setCronPlatform`, or `_activateDerived` from your `hooks.ws.js` `init({ platform })` hook) - the same wiring cron and the top-level `publish()` helper need. Read the current value on the server with `.get()`.
2374
+
2375
+ Flags are cluster-consistent by default. A single-entry shared replay buffer is enabled automatically, so a `.set()` on any replica writes the cluster-shared buffer, and a client that connects fresh - to any replica, including one that never set the flag locally - is served the cluster-latest value on connect. Already-subscribed clients stay in sync wherever they connected, because `.set()` relays the update across the cluster. Pass a custom `replay` object (for example `{ replay: { size: 5 } }`) to size the buffer, or `{ replay: false }` to opt out - a single-process app loses nothing by opting out, since the locally cached value is authoritative in one process.
2376
+
2377
+ On every running replica an internal watcher keeps the cached value behind `.get()` fresh from boot. The watcher is installed when the registry module loads - the same moment `live.effect` watchers become active - so it does not wait for the flag module's first local import or subscribe: an inbound `set` relayed from any replica updates the cached value within a tick, and synchronous `.get()` reflects the cluster-latest value on any running instance. For a strict read on a replica that booted after the last `set` and has not yet received any inbound `set` - reading a flag the moment a replica comes up, before it has observed any traffic - use the asynchronous `getLatest()`, which reads the shared buffer directly:
2378
+
2379
+ ```js
2380
+ const on = await maintenance.getLatest();
2381
+ ```
2382
+
2383
+ ---
2384
+
2342
2385
  ## Derived streams
2343
2386
 
2344
2387
  Server-side computed streams that recompute when any source topic publishes.
@@ -3048,7 +3091,7 @@ With adapter 0.4.0+, the replay end marker sends `{ reqId }` (replay complete) o
3048
3091
 
3049
3092
  Once `platform.replay` is exposed (the standard install pattern is `platform.replay = createReplay(redisClient)` in your hooks), the framework auto-routes every publish to a replay-eligible topic through `platform.replay.publish` regardless of which seam the publisher sits on. `live.stream(topic, loader, { replay: true })` registers the topic at declaration time; static topics are registered up-front and dynamic topics are registered at first-subscribe time when they resolve.
3050
3093
 
3051
- This applies to every framework publish surface `ctx.publish` from RPC handlers, cron auto-publish (`live.cron('* * * * * *', topic, async (ctx) => result)` where `result !== undefined`), and `ctx.publish` from inside cron handlers without the user wiring anything beyond `replay: true`. Pre-fix, the user was responsible for wrapping the platform with a `wrapWithReplay` proxy at every seam (the docs showed it on `createMessage` only; cron was a separate `setCronPlatform(platform)` capture, and cron-published events silently bypassed the buffer because the wrap was missing there). The auto-routing makes that asymmetry impossible by construction.
3094
+ This applies to every framework publish surface - `ctx.publish` from RPC handlers, cron auto-publish (`live.cron('* * * * * *', topic, async (ctx) => result)` where `result !== undefined`), and `ctx.publish` from inside cron handlers - without the user wiring anything beyond `replay: true`. Pre-fix, the user was responsible for wrapping the platform with a `wrapWithReplay` proxy at every seam (the docs showed it on `createMessage` only; cron was a separate `setCronPlatform(platform)` capture, and cron-published events silently bypassed the buffer because the wrap was missing there). The auto-routing makes that asymmetry impossible by construction.
3052
3095
 
3053
3096
  If you need to keep your own platform-wrapping proxy (custom topic patterns, additional intercepts), set `[WRAPPED_FOR_REPLAY] = true` on the proxy:
3054
3097
 
@@ -3062,7 +3105,7 @@ function wrapWithReplay(p) {
3062
3105
  }
3063
3106
  ```
3064
3107
 
3065
- The framework defers entirely when this marker is present (no double-write to Redis). Without the marker, the framework's auto-routing runs alongside the user proxy's routing and will issue duplicate Redis writes explicit opt-out is required.
3108
+ The framework defers entirely when this marker is present (no double-write to Redis). Without the marker, the framework's auto-routing runs alongside the user proxy's routing and will issue duplicate Redis writes - explicit opt-out is required.
3066
3109
 
3067
3110
  If `replay: true` is declared but `platform.replay` is never set, dev-mode logs a one-time `console.warn` per topic on the first publish, with the install pointer for the replay extension. Production runs silently (no per-publish overhead) and the local broadcast still happens.
3068
3111
 
@@ -3401,6 +3444,8 @@ live.publishRateWarning(false);
3401
3444
 
3402
3445
  Production builds constant-fold the activation branch to dead code - zero overhead. The sampler runs once per platform on the first ctx-helpers cache miss; per-publish cost is unchanged. Topics already in `_topicCoalesce` or `_topicVolatile` are skipped (the user has already addressed them).
3403
3446
 
3447
+ The client emits the same hint from the receiving side: in development it measures each stream's inbound frame rate and logs one warning per topic when it crosses the threshold, suggesting `coalesceBy` / `volatile` and linking the same guide. Streams declared with `coalesceBy` are suppressed; silence it everywhere with `configure({ publishRateHint: false })`. Production builds strip the client hint entirely (`import.meta.env`-gated), leaving zero residue on the inbound dispatch path.
3448
+
3404
3449
  ### Dev-mode silent-topic warning
3405
3450
 
3406
3451
  In development, the framework arms a one-shot timer when a stream first subscribes to a topic. If no events arrive within `thresholdMs` (default `30000`), it logs a warning naming the topic and the common causes:
@@ -3677,6 +3722,7 @@ Import from `svelte-realtime/server`.
3677
3722
  | `live.upload(fn, options?)` | Streaming upload handler (chunked, abortable async-iterable; `maxSize` 100MB, `maxConcurrentPerSession` 4, `maxBufferedChunks` 64) |
3678
3723
  | `live.validated(schema, fn)` | RPC with [Standard Schema](https://standardschema.dev/) input validation (Zod, ArkType, Valibot, etc.) |
3679
3724
  | `live.cron(schedule, topic, fn)` | Server-side scheduled function |
3725
+ | `live.flag(topic, initialValue?, options?)` | Feature flag exposed as a readable stream with a server-side `.set(value)` |
3680
3726
  | `live.derived(sources, fn, options?)` | Server-side computed stream (static or dynamic sources) |
3681
3727
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3682
3728
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
package/client.d.ts CHANGED
@@ -583,6 +583,15 @@ export function configure(config: {
583
583
  * @default 4_194_304 (4 MB)
584
584
  */
585
585
  volatileBackpressureBytes?: number;
586
+ /**
587
+ * Enable the dev-mode publish-rate hint: a one-shot console warning logged
588
+ * when an inbound stream's frame rate crosses the high-frequency threshold,
589
+ * suggesting `coalesceBy` / `volatile`. Set `false` to silence it.
590
+ * Production builds strip the hint entirely, so this flag only has an effect
591
+ * in development.
592
+ * @default true
593
+ */
594
+ publishRateHint?: boolean;
586
595
  /** Offline mutation queue configuration. */
587
596
  offline?: {
588
597
  /** Enable queuing RPCs when disconnected. */
package/client.js CHANGED
@@ -121,6 +121,32 @@ const _dedupMap = new Map();
121
121
  */
122
122
  const _dedupCoalesceWarned = new Set();
123
123
 
124
+ // - Dev-mode publish-rate hint (client half) -------------------------------
125
+ // Mirrors the server-side sampler in `svelte-realtime/server.js`, but the
126
+ // signal source differs. The server reads `platform.pressure.topPublishers`
127
+ // (rates the adapter already computes); the client has no such snapshot, so
128
+ // it measures inbound frame rate directly at the dispatch hook. A per-topic
129
+ // fixed window counts frames; when a window closes over threshold, one warn
130
+ // fires per topic per session with the SAME wording, threshold (200), and
131
+ // `svti.me/highfreq` link as the server. The whole feature is gated by the
132
+ // `import.meta.env`-folded `_IS_DEV` const so a production build strips it to
133
+ // dead code, leaving zero residue on the inbound dispatch hot path.
134
+
135
+ /** Inbound events/sec at which a topic is considered high-frequency. Matches the server sampler default. */
136
+ const _PUBLISH_RATE_HINT_THRESHOLD = 200;
137
+
138
+ /** Measurement window for the client frame-rate counter, in ms. Rate = frames-in-window / window-seconds. */
139
+ const _PUBLISH_RATE_HINT_WINDOW_MS = 1000;
140
+
141
+ /** Max distinct topics tracked in the warned dedup set. FIFO-evict on cap: dropping the oldest entry just lets that topic re-warn on its next over-threshold window. Mirrors the server `PUBLISH_RATE_WARN_DEDUP_MAX` eviction shape. */
142
+ const _PUBLISH_RATE_HINT_DEDUP_MAX = 1_000_000;
143
+
144
+ /** @type {Map<string, { start: number, count: number }>} Per-topic fixed-window frame counter. */
145
+ const _publishRateWindows = new Map();
146
+
147
+ /** @type {Set<string>} One-shot warned topics. FIFO-evicted at the dedup cap. */
148
+ const _publishRateHintWarned = new Set();
149
+
124
150
  /**
125
151
  * Dev-mode check, mirrored from the `process.env.NODE_ENV` pattern
126
152
  * used elsewhere in this file. Cached once at first call so the hot
@@ -494,6 +520,85 @@ function _warnCoalesceOnce(path) {
494
520
  );
495
521
  }
496
522
 
523
+ /**
524
+ * Count one inbound frame for a topic and, if its measured rate crosses the
525
+ * high-frequency threshold, emit a one-shot dev hint. Counterpart to the
526
+ * server-side sampler: same threshold (200), same `coalesceBy` / `volatile`
527
+ * suggestions, same `svti.me/highfreq` link. The server reads rates the
528
+ * adapter pre-computes; the client has none, so it counts frames over a fixed
529
+ * window and derives the rate locally.
530
+ *
531
+ * Suppressed when the stream was declared with `coalesceBy` - that is the user
532
+ * already choosing the latest-value-wins mitigation, so the hint would be
533
+ * noise. (There is no client-side `volatile` declaration to read; `volatile`
534
+ * is a per-call RPC concern, not a stream option, so only `coalesceBy`
535
+ * suppresses here.) Opt out entirely with `configure({ publishRateHint: false })`.
536
+ *
537
+ * The whole function is dead code in production: the only caller is gated by
538
+ * the `import.meta.env`-folded `_IS_DEV` const, and this body re-checks it so a
539
+ * direct call from a test still no-ops under a production build.
540
+ *
541
+ * @param {string} topic - the stream path (the identity available at dispatch)
542
+ * @param {any} options - the per-stream options object (read for `coalesceBy`)
543
+ */
544
+ function _maybeHintPublishRate(topic, options) {
545
+ if (!_IS_DEV) return;
546
+ if (_clientConfig.publishRateHint === false) return;
547
+ if (_publishRateHintWarned.has(topic)) return;
548
+ // A declared-coalesced stream already picked latest-value-wins, so the hint
549
+ // would be noise: skip the counting work entirely, mirroring the server which
550
+ // marks such topics handled up front.
551
+ if (options && options.coalesceBy) return;
552
+
553
+ const now = Date.now();
554
+ let win = _publishRateWindows.get(topic);
555
+ if (win === undefined) {
556
+ _publishRateWindows.set(topic, { start: now, count: 1 });
557
+ return;
558
+ }
559
+ win.count++;
560
+ const elapsed = now - win.start;
561
+ if (elapsed < _PUBLISH_RATE_HINT_WINDOW_MS) return;
562
+
563
+ // Window closed: derive events/sec and reset for the next window. A short
564
+ // final window (e.g. the stream unsubscribed mid-window) still scales to a
565
+ // per-second rate, so a genuine burst is not under-counted.
566
+ const rate = (win.count * 1000) / elapsed;
567
+ win.start = now;
568
+ win.count = 0;
569
+
570
+ if (rate < _PUBLISH_RATE_HINT_THRESHOLD) return;
571
+
572
+ if (_publishRateHintWarned.size >= _PUBLISH_RATE_HINT_DEDUP_MAX) {
573
+ const oldest = _publishRateHintWarned.values().next().value;
574
+ if (oldest !== undefined) _publishRateHintWarned.delete(oldest);
575
+ }
576
+ _publishRateHintWarned.add(topic);
577
+ // The window counter is never re-read once a topic has warned (the warned
578
+ // set short-circuits at the top), so drop it to keep the window map bounded
579
+ // by live unwarned topics, mirroring the server sampler's symmetry.
580
+ _publishRateWindows.delete(topic);
581
+ console.warn(
582
+ `[svelte-realtime] Topic '${topic}' is receiving ` +
583
+ `${Math.round(rate)} events/sec.\n` +
584
+ ` For high-frequency streams, consider one of:\n` +
585
+ ` live.stream(topic, loader, { coalesceBy: (data) => data.userId }) // latest-value-wins, queued per subscriber\n` +
586
+ ` live.stream(topic, loader, { volatile: true }) // drop on backpressure, best-effort\n` +
587
+ ` See: https://svti.me/highfreq`
588
+ );
589
+ }
590
+
591
+ /**
592
+ * Reset the dev-mode client publish-rate hint state. Tests only. Clears the
593
+ * one-shot warned set and the per-topic window counters so a previously seen
594
+ * topic can warn again.
595
+ * @internal
596
+ */
597
+ export function _resetClientPublishRateWarning() {
598
+ _publishRateHintWarned.clear();
599
+ _publishRateWindows.clear();
600
+ }
601
+
497
602
  /**
498
603
  * Build a dedup key from path and args, avoiding JSON.stringify for common cases.
499
604
  * @param {string} path
@@ -2424,6 +2529,7 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
2424
2529
  }
2425
2530
 
2426
2531
  _devtoolsStreamEvent(path, envelope.event, envelope.data);
2532
+ if (_IS_DEV) _maybeHintPublishRate(path, options);
2427
2533
 
2428
2534
  if (_useRAF) {
2429
2535
  _activeBuf.push(envelope);
@@ -3530,7 +3636,7 @@ function _checkArgs(path, args) {
3530
3636
  * @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
3531
3637
  */
3532
3638
 
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 } }} */
3639
+ /** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, publishRateHint?: boolean, 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 } }} */
3534
3640
  let _clientConfig = {};
3535
3641
 
3536
3642
  /** @type {boolean} */
@@ -3561,7 +3667,12 @@ let _replayingQueue = false;
3561
3667
  * volatile traffic, lower it on mobile-constrained targets where the OS
3562
3668
  * send buffer is tighter.
3563
3669
  *
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
3670
+ * `publishRateHint` (default enabled in dev) controls the one-shot console
3671
+ * hint logged when an inbound stream's frame rate crosses the high-frequency
3672
+ * threshold. Set `false` to silence it. Production builds strip the hint
3673
+ * regardless, so this only matters in development.
3674
+ *
3675
+ * @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, publishRateHint?: boolean, 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
3565
3676
  */
3566
3677
  export function configure(config) {
3567
3678
  _clientConfig = config;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.8",
3
+ "version": "0.6.0-next.1",
4
4
  "publishConfig": {
5
- "tag": "latest"
5
+ "tag": "next"
6
6
  },
7
7
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
8
8
  "author": "Kevin Radziszewski",
@@ -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.2"
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.2",
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
- /** Throttled publish - sends at most once per `ms` milliseconds. */
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
- /** Debounced publish - sends after `ms` milliseconds of silence. */
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
- /** Throttled publish - sends at most once per `ms` milliseconds. */
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
- /** Debounced publish - sends after `ms` milliseconds of silence. */
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 message is not an RPC request.
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;
@@ -1549,6 +1659,57 @@ export namespace live {
1549
1659
  fn: T
1550
1660
  ): T;
1551
1661
 
1662
+ /**
1663
+ * Declare a server-side feature flag exposed as a readable stream.
1664
+ *
1665
+ * A flag is a thin wrapper over `live.stream`: it declares a
1666
+ * `merge: 'set'` topic carrying the flag value, and any `.set(value)`
1667
+ * pushes the new value to every subscriber. On the client,
1668
+ * `$live/<module>` exposes the export as a readable store carrying the
1669
+ * current value.
1670
+ *
1671
+ * Flags are cluster-consistent by default: a single-entry shared replay
1672
+ * buffer is enabled, so `.set()` writes the cluster-shared buffer and a
1673
+ * subscriber that connects fresh - to any replica, including one that
1674
+ * never set the flag locally - is served the cluster-latest value.
1675
+ * Already-subscribed clients stay in sync across the cluster as `.set()`
1676
+ * relays the update. Pass a custom `replay` object to size the buffer, or
1677
+ * `replay: false` to opt out (single-process apps lose nothing, since the
1678
+ * locally cached value is authoritative in one process).
1679
+ *
1680
+ * `.set(value)` publishes through the framework-owned platform (the same
1681
+ * path as the top-level `publish()` helper), so the new value reaches
1682
+ * every local subscriber and relays across the cluster when a bus is
1683
+ * wired. Call it from any server context after the platform has been
1684
+ * captured. `.get()` reads the current value on the server synchronously;
1685
+ * an internal watcher - installed when the registry module loads, the same
1686
+ * lifecycle that activates `live.effect` watchers - keeps it fresh from boot
1687
+ * within a tick of any inbound `set` on a running replica, without waiting
1688
+ * for the flag module's first local import. `getLatest()` reads the
1689
+ * cluster-latest value asynchronously from the shared buffer for the strict
1690
+ * read on a replica that booted after the last `set` and has not yet
1691
+ * received any inbound `set`.
1692
+ *
1693
+ * @param topic - Topic carrying the flag value
1694
+ * @param initialValue - Value served to subscribers before the first `.set`
1695
+ * @param options - Optional `replay` to size the shared buffer, or `replay: false` to opt out
1696
+ *
1697
+ * @example
1698
+ * ```js
1699
+ * // src/live/flags.js
1700
+ * export const maintenance = live.flag('flag:maintenance', false);
1701
+ *
1702
+ * export const toggleMaintenance = live(async (ctx, on) => {
1703
+ * maintenance.set(on);
1704
+ * });
1705
+ * ```
1706
+ */
1707
+ function flag<V = any>(
1708
+ topic: string,
1709
+ initialValue?: V,
1710
+ options?: { replay?: boolean | { size?: number } }
1711
+ ): Function & { set(value: V): any; get(): V; getLatest(): Promise<V> };
1712
+
1552
1713
  /**
1553
1714
  * Create a real-time incremental aggregation over a source topic.
1554
1715
  *
@@ -2385,6 +2546,15 @@ export function __registerEffect(path: string, fn: Function): void;
2385
2546
  */
2386
2547
  export function __registerAggregate(path: string, fn: Function): void;
2387
2548
 
2549
+ /**
2550
+ * Install a flag's refresh watcher eagerly at registry-module load, keyed by
2551
+ * topic, so a server-side `.get()` reflects cluster-latest sets from boot
2552
+ * without waiting for the flag module's first local import. Called by the
2553
+ * Vite-generated registry module.
2554
+ * @internal
2555
+ */
2556
+ export function __registerFlag(topic: string, initialValue?: any): void;
2557
+
2388
2558
  /**
2389
2559
  * Register room actions lazily. Called by the Vite-generated registry module.
2390
2560
  * @internal