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 +70 -0
- package/README.md +48 -2
- package/client.d.ts +9 -0
- package/client.js +113 -2
- package/package.json +4 -4
- package/server.d.ts +175 -5
- package/server.js +573 -48
- package/vite.js +108 -1
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
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.6.0-next.1",
|
|
4
4
|
"publishConfig": {
|
|
5
|
-
"tag": "
|
|
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.
|
|
92
|
+
"svelte-adapter-uws": "^0.5.3"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
95
|
"@playwright/test": "^1.59.1",
|
|
96
96
|
"fast-check": "^4.7.0",
|
|
97
|
-
"svelte-adapter-uws-extensions": "^0.5.
|
|
97
|
+
"svelte-adapter-uws-extensions": "^0.5.3",
|
|
98
98
|
"vitest": "^4.0.18"
|
|
99
99
|
},
|
|
100
100
|
"keywords": [
|
package/server.d.ts
CHANGED
|
@@ -9,9 +9,32 @@ export interface CronContext {
|
|
|
9
9
|
platform: Platform;
|
|
10
10
|
/** Shorthand for `platform.publish` - delegates to whatever platform was passed in. */
|
|
11
11
|
publish: Platform['publish'];
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Publish a value to a topic at most once per `ms` milliseconds.
|
|
14
|
+
* The latest value always arrives (trailing edge). Outbound publish
|
|
15
|
+
* helper - does NOT gate handler execution. For per-key handler gating
|
|
16
|
+
* use `ctx.skip(key, ms)`.
|
|
17
|
+
*/
|
|
18
|
+
publishThrottled(topic: string, event: string, data: any, ms: number): void;
|
|
19
|
+
/**
|
|
20
|
+
* Publish a value to a topic after `ms` milliseconds of silence. Outbound
|
|
21
|
+
* publish helper - does NOT gate handler execution. For per-key handler
|
|
22
|
+
* gating use `ctx.skip(key, ms)`.
|
|
23
|
+
*/
|
|
24
|
+
publishDebounced(topic: string, event: string, data: any, ms: number): void;
|
|
25
|
+
/**
|
|
26
|
+
* @deprecated Renamed to `publishThrottled`. The old name reads like a
|
|
27
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
28
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
29
|
+
* a one-time dev warning fires on first call.
|
|
30
|
+
*/
|
|
13
31
|
throttle(topic: string, event: string, data: any, ms: number): void;
|
|
14
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* @deprecated Renamed to `publishDebounced`. The old name reads like a
|
|
34
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
35
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
36
|
+
* a one-time dev warning fires on first call.
|
|
37
|
+
*/
|
|
15
38
|
debounce(topic: string, event: string, data: any, ms: number): void;
|
|
16
39
|
/** Send a point-to-point signal to a specific user. */
|
|
17
40
|
signal(userId: string, event: string, data: any): void;
|
|
@@ -37,12 +60,58 @@ export interface LiveContext<UserData = unknown> {
|
|
|
37
60
|
publish: Platform['publish'];
|
|
38
61
|
/** Cursor value sent by the client for paginated stream requests. `null` if not paginated. */
|
|
39
62
|
cursor: any;
|
|
40
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Publish a value to a topic at most once per `ms` milliseconds.
|
|
65
|
+
* The latest value always arrives (trailing edge). Outbound publish
|
|
66
|
+
* helper - does NOT gate handler execution. For per-key handler gating
|
|
67
|
+
* use `ctx.skip(key, ms)`.
|
|
68
|
+
*/
|
|
69
|
+
publishThrottled(topic: string, event: string, data: any, ms: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Publish a value to a topic after `ms` milliseconds of silence. Outbound
|
|
72
|
+
* publish helper - does NOT gate handler execution. For per-key handler
|
|
73
|
+
* gating use `ctx.skip(key, ms)`.
|
|
74
|
+
*/
|
|
75
|
+
publishDebounced(topic: string, event: string, data: any, ms: number): void;
|
|
76
|
+
/**
|
|
77
|
+
* @deprecated Renamed to `publishThrottled`. The old name reads like a
|
|
78
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
79
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
80
|
+
* a one-time dev warning fires on first call.
|
|
81
|
+
*/
|
|
41
82
|
throttle(topic: string, event: string, data: any, ms: number): void;
|
|
42
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated Renamed to `publishDebounced`. The old name reads like a
|
|
85
|
+
* handler gate, but it is a publish helper. For per-key handler gating
|
|
86
|
+
* use `ctx.skip(key, ms)`. Kept as a soft-deprecated alias indefinitely;
|
|
87
|
+
* a one-time dev warning fires on first call.
|
|
88
|
+
*/
|
|
43
89
|
debounce(topic: string, event: string, data: any, ms: number): void;
|
|
44
90
|
/** Send a point-to-point signal to a specific user. */
|
|
45
91
|
signal(userId: string, event: string, data: any): void;
|
|
92
|
+
/**
|
|
93
|
+
* Per-key handler gate. Returns `true` to skip the call (key is within
|
|
94
|
+
* its cooldown window), `false` to run it (no entry, or window elapsed).
|
|
95
|
+
* Pair with an early `return` inside the handler body.
|
|
96
|
+
*
|
|
97
|
+
* State is per-replica - this is a CPU/DB shed, not a cluster-wide rate
|
|
98
|
+
* limit. For cross-replica gating use `live.rateLimit({ store: 'redis' })`
|
|
99
|
+
* or `redis/ratelimit` from svelte-adapter-uws-extensions.
|
|
100
|
+
*
|
|
101
|
+
* Throws `LiveError('INVALID_ARG', ...)` if `key` isn't a string or `ms`
|
|
102
|
+
* isn't a positive finite number. Capped at 5000 active entries with
|
|
103
|
+
* fail-open semantics on overflow (returns `false`, dev-warns once).
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```js
|
|
107
|
+
* export const moveNote = live(async (ctx, noteId, x, y) => {
|
|
108
|
+
* if (ctx.skip(`move:${noteId}`, 16)) return; // drop calls within 16ms
|
|
109
|
+
* await dbUpdateNote(noteId, x, y);
|
|
110
|
+
* ctx.publish(TOPICS.notes, 'updated', { noteId, x, y });
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
skip(key: string, ms: number): boolean;
|
|
46
115
|
/**
|
|
47
116
|
* Pressure-aware shed check. Returns `true` if a request of the given
|
|
48
117
|
* class of service should be shed under current `platform.pressure`.
|
|
@@ -500,7 +569,48 @@ export interface CreateMessageOptions {
|
|
|
500
569
|
onError?(path: string, error: unknown, ctx: LiveContext<any>): void;
|
|
501
570
|
|
|
502
571
|
/**
|
|
503
|
-
* Called when a
|
|
572
|
+
* Called when a non-RPC text frame parses as a JSON object envelope.
|
|
573
|
+
* The framework runs `TextDecoder + JSON.parse` once (or, when the
|
|
574
|
+
* adapter already parsed the frame for its own control-message routing,
|
|
575
|
+
* uses the adapter-forwarded value directly - one parse total), and
|
|
576
|
+
* hands the parsed value to this callback.
|
|
577
|
+
*
|
|
578
|
+
* Dispatch by `msg.type` inside the callback. Plugin-layer hooks
|
|
579
|
+
* (`cursor.hooks.message`, future presence/typing) consume the parsed
|
|
580
|
+
* value so user wiring doesn't re-parse on every frame.
|
|
581
|
+
*
|
|
582
|
+
* Frames that don't look like a JSON object (first byte not `{`),
|
|
583
|
+
* fail to parse, or sit at nesting depth greater than `maxJsonDepth`,
|
|
584
|
+
* fall through to `onUnhandled` with the original raw bytes.
|
|
585
|
+
*
|
|
586
|
+
* The adapter's `websocket.maxPayloadLength` already bounds the bytes
|
|
587
|
+
* `JSON.parse` ever sees, so there's no separate size cap here.
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* ```js
|
|
591
|
+
* createMessage({
|
|
592
|
+
* onJsonMessage(ws, msg, platform) {
|
|
593
|
+
* if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
|
|
594
|
+
* else if (msg.type === 'presence-snapshot') presence.hooks.message(ws, { data: msg, platform });
|
|
595
|
+
* }
|
|
596
|
+
* })
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
onJsonMessage?(ws: WebSocket<any>, msg: any, platform: Platform): void;
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Maximum nesting depth allowed in a parsed `onJsonMessage` envelope.
|
|
603
|
+
* Frames deeper than this fall through to `onUnhandled` unparsed.
|
|
604
|
+
* Mirrors `handleRpc`'s `maxEnvelopeDepth` semantics; same default.
|
|
605
|
+
*
|
|
606
|
+
* @default 64
|
|
607
|
+
*/
|
|
608
|
+
maxJsonDepth?: number;
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Called when a message is not an RPC request and either no
|
|
612
|
+
* `onJsonMessage` is set, or the frame is binary / non-JSON / parses
|
|
613
|
+
* to a non-object / exceeds `maxJsonDepth`.
|
|
504
614
|
* Use for mixing RPC with custom message handling.
|
|
505
615
|
*/
|
|
506
616
|
onUnhandled?(ws: WebSocket<any>, data: ArrayBuffer, platform: Platform): void;
|
|
@@ -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
|