svelte-realtime 0.5.10 → 0.6.0-next.2
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 +48 -2
- package/client.d.ts +9 -0
- package/client.js +149 -5
- package/package.json +2 -2
- package/server.d.ts +60 -0
- package/server.js +248 -0
- package/vite.js +108 -1
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
|
|
@@ -227,13 +253,37 @@ const _healthStore = writable(/** @type {'healthy' | 'degraded'} */ ('healthy'))
|
|
|
227
253
|
/** @type {(() => void) | null} */
|
|
228
254
|
let _healthUnsub = null;
|
|
229
255
|
|
|
256
|
+
// Two independent inputs OR into the single health state: a server-pushed
|
|
257
|
+
// degraded/recovered event on the system topic, and the connection's local
|
|
258
|
+
// internal flow-control pressure (a queued/refused flow-controlled send).
|
|
259
|
+
// Tracked separately so neither input clobbers the other - health is degraded
|
|
260
|
+
// while EITHER is degraded, healthy only when BOTH are clear.
|
|
261
|
+
let _healthServerDegraded = false;
|
|
262
|
+
let _healthFlowDegraded = false;
|
|
263
|
+
|
|
264
|
+
function _recomputeHealth() {
|
|
265
|
+
_healthStore.set(_healthServerDegraded || _healthFlowDegraded ? 'degraded' : 'healthy');
|
|
266
|
+
}
|
|
267
|
+
|
|
230
268
|
function _ensureHealthSubscription() {
|
|
231
269
|
if (_healthUnsub) return;
|
|
232
|
-
|
|
270
|
+
const offTopic = on(_HEALTH_TOPIC).subscribe((envelope) => {
|
|
233
271
|
if (!envelope) return;
|
|
234
|
-
if (envelope.event === 'degraded')
|
|
235
|
-
else if (envelope.event === 'recovered')
|
|
272
|
+
if (envelope.event === 'degraded') { _healthServerDegraded = true; _recomputeHealth(); }
|
|
273
|
+
else if (envelope.event === 'recovered') { _healthServerDegraded = false; _recomputeHealth(); }
|
|
236
274
|
});
|
|
275
|
+
// Fold the connection's internal flow-control health in as a second,
|
|
276
|
+
// OR-ed input. A boolean is the only thing that crosses this accessor;
|
|
277
|
+
// no internal accounting value surfaces. Older adapter connections that
|
|
278
|
+
// predate the accessor simply do not contribute this input.
|
|
279
|
+
let offFlow = () => {};
|
|
280
|
+
try {
|
|
281
|
+
const conn = _connect();
|
|
282
|
+
if (conn && typeof conn._onLeaseDegraded === 'function') {
|
|
283
|
+
offFlow = conn._onLeaseDegraded((d) => { _healthFlowDegraded = !!d; _recomputeHealth(); });
|
|
284
|
+
}
|
|
285
|
+
} catch { /* connection not configured yet; flow health stays clear */ }
|
|
286
|
+
_healthUnsub = () => { offTopic(); offFlow(); };
|
|
237
287
|
}
|
|
238
288
|
|
|
239
289
|
/**
|
|
@@ -271,9 +321,18 @@ export function _resetHealth() {
|
|
|
271
321
|
_healthUnsub();
|
|
272
322
|
_healthUnsub = null;
|
|
273
323
|
}
|
|
324
|
+
_healthServerDegraded = false;
|
|
325
|
+
_healthFlowDegraded = false;
|
|
274
326
|
_healthStore.set('healthy');
|
|
275
327
|
}
|
|
276
328
|
|
|
329
|
+
// Flow control is owned end to end by the adapter connection's send gate: it
|
|
330
|
+
// advertises the capability, paces its own flow-controlled sends against the
|
|
331
|
+
// server's window, and reports a single degraded boolean. The realtime layer
|
|
332
|
+
// consumes that boolean through conn._onLeaseDegraded in
|
|
333
|
+
// _ensureHealthSubscription above and ORs it into realtime.health. There is no
|
|
334
|
+
// realtime-owned mirror of the gate - a second copy would only drift.
|
|
335
|
+
|
|
277
336
|
function _registerTopicErrorSetter(topic, setError) {
|
|
278
337
|
let set = _streamErrorByTopic.get(topic);
|
|
279
338
|
if (!set) { set = new Set(); _streamErrorByTopic.set(topic, set); }
|
|
@@ -494,6 +553,85 @@ function _warnCoalesceOnce(path) {
|
|
|
494
553
|
);
|
|
495
554
|
}
|
|
496
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Count one inbound frame for a topic and, if its measured rate crosses the
|
|
558
|
+
* high-frequency threshold, emit a one-shot dev hint. Counterpart to the
|
|
559
|
+
* server-side sampler: same threshold (200), same `coalesceBy` / `volatile`
|
|
560
|
+
* suggestions, same `svti.me/highfreq` link. The server reads rates the
|
|
561
|
+
* adapter pre-computes; the client has none, so it counts frames over a fixed
|
|
562
|
+
* window and derives the rate locally.
|
|
563
|
+
*
|
|
564
|
+
* Suppressed when the stream was declared with `coalesceBy` - that is the user
|
|
565
|
+
* already choosing the latest-value-wins mitigation, so the hint would be
|
|
566
|
+
* noise. (There is no client-side `volatile` declaration to read; `volatile`
|
|
567
|
+
* is a per-call RPC concern, not a stream option, so only `coalesceBy`
|
|
568
|
+
* suppresses here.) Opt out entirely with `configure({ publishRateHint: false })`.
|
|
569
|
+
*
|
|
570
|
+
* The whole function is dead code in production: the only caller is gated by
|
|
571
|
+
* the `import.meta.env`-folded `_IS_DEV` const, and this body re-checks it so a
|
|
572
|
+
* direct call from a test still no-ops under a production build.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} topic - the stream path (the identity available at dispatch)
|
|
575
|
+
* @param {any} options - the per-stream options object (read for `coalesceBy`)
|
|
576
|
+
*/
|
|
577
|
+
function _maybeHintPublishRate(topic, options) {
|
|
578
|
+
if (!_IS_DEV) return;
|
|
579
|
+
if (_clientConfig.publishRateHint === false) return;
|
|
580
|
+
if (_publishRateHintWarned.has(topic)) return;
|
|
581
|
+
// A declared-coalesced stream already picked latest-value-wins, so the hint
|
|
582
|
+
// would be noise: skip the counting work entirely, mirroring the server which
|
|
583
|
+
// marks such topics handled up front.
|
|
584
|
+
if (options && options.coalesceBy) return;
|
|
585
|
+
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
let win = _publishRateWindows.get(topic);
|
|
588
|
+
if (win === undefined) {
|
|
589
|
+
_publishRateWindows.set(topic, { start: now, count: 1 });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
win.count++;
|
|
593
|
+
const elapsed = now - win.start;
|
|
594
|
+
if (elapsed < _PUBLISH_RATE_HINT_WINDOW_MS) return;
|
|
595
|
+
|
|
596
|
+
// Window closed: derive events/sec and reset for the next window. A short
|
|
597
|
+
// final window (e.g. the stream unsubscribed mid-window) still scales to a
|
|
598
|
+
// per-second rate, so a genuine burst is not under-counted.
|
|
599
|
+
const rate = (win.count * 1000) / elapsed;
|
|
600
|
+
win.start = now;
|
|
601
|
+
win.count = 0;
|
|
602
|
+
|
|
603
|
+
if (rate < _PUBLISH_RATE_HINT_THRESHOLD) return;
|
|
604
|
+
|
|
605
|
+
if (_publishRateHintWarned.size >= _PUBLISH_RATE_HINT_DEDUP_MAX) {
|
|
606
|
+
const oldest = _publishRateHintWarned.values().next().value;
|
|
607
|
+
if (oldest !== undefined) _publishRateHintWarned.delete(oldest);
|
|
608
|
+
}
|
|
609
|
+
_publishRateHintWarned.add(topic);
|
|
610
|
+
// The window counter is never re-read once a topic has warned (the warned
|
|
611
|
+
// set short-circuits at the top), so drop it to keep the window map bounded
|
|
612
|
+
// by live unwarned topics, mirroring the server sampler's symmetry.
|
|
613
|
+
_publishRateWindows.delete(topic);
|
|
614
|
+
console.warn(
|
|
615
|
+
`[svelte-realtime] Topic '${topic}' is receiving ` +
|
|
616
|
+
`${Math.round(rate)} events/sec.\n` +
|
|
617
|
+
` For high-frequency streams, consider one of:\n` +
|
|
618
|
+
` live.stream(topic, loader, { coalesceBy: (data) => data.userId }) // latest-value-wins, queued per subscriber\n` +
|
|
619
|
+
` live.stream(topic, loader, { volatile: true }) // drop on backpressure, best-effort\n` +
|
|
620
|
+
` See: https://svti.me/highfreq`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Reset the dev-mode client publish-rate hint state. Tests only. Clears the
|
|
626
|
+
* one-shot warned set and the per-topic window counters so a previously seen
|
|
627
|
+
* topic can warn again.
|
|
628
|
+
* @internal
|
|
629
|
+
*/
|
|
630
|
+
export function _resetClientPublishRateWarning() {
|
|
631
|
+
_publishRateHintWarned.clear();
|
|
632
|
+
_publishRateWindows.clear();
|
|
633
|
+
}
|
|
634
|
+
|
|
497
635
|
/**
|
|
498
636
|
* Build a dedup key from path and args, avoiding JSON.stringify for common cases.
|
|
499
637
|
* @param {string} path
|
|
@@ -2424,6 +2562,7 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2424
2562
|
}
|
|
2425
2563
|
|
|
2426
2564
|
_devtoolsStreamEvent(path, envelope.event, envelope.data);
|
|
2565
|
+
if (_IS_DEV) _maybeHintPublishRate(path, options);
|
|
2427
2566
|
|
|
2428
2567
|
if (_useRAF) {
|
|
2429
2568
|
_activeBuf.push(envelope);
|
|
@@ -3530,7 +3669,7 @@ function _checkArgs(path, args) {
|
|
|
3530
3669
|
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
|
|
3531
3670
|
*/
|
|
3532
3671
|
|
|
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 } }} */
|
|
3672
|
+
/** @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
3673
|
let _clientConfig = {};
|
|
3535
3674
|
|
|
3536
3675
|
/** @type {boolean} */
|
|
@@ -3561,7 +3700,12 @@ let _replayingQueue = false;
|
|
|
3561
3700
|
* volatile traffic, lower it on mobile-constrained targets where the OS
|
|
3562
3701
|
* send buffer is tighter.
|
|
3563
3702
|
*
|
|
3564
|
-
*
|
|
3703
|
+
* `publishRateHint` (default enabled in dev) controls the one-shot console
|
|
3704
|
+
* hint logged when an inbound stream's frame rate crosses the high-frequency
|
|
3705
|
+
* threshold. Set `false` to silence it. Production builds strip the hint
|
|
3706
|
+
* regardless, so this only matters in development.
|
|
3707
|
+
*
|
|
3708
|
+
* @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
3709
|
*/
|
|
3566
3710
|
export function configure(config) {
|
|
3567
3711
|
_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.2",
|
|
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",
|
package/server.d.ts
CHANGED
|
@@ -1659,6 +1659,57 @@ export namespace live {
|
|
|
1659
1659
|
fn: T
|
|
1660
1660
|
): T;
|
|
1661
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
|
+
|
|
1662
1713
|
/**
|
|
1663
1714
|
* Create a real-time incremental aggregation over a source topic.
|
|
1664
1715
|
*
|
|
@@ -2495,6 +2546,15 @@ export function __registerEffect(path: string, fn: Function): void;
|
|
|
2495
2546
|
*/
|
|
2496
2547
|
export function __registerAggregate(path: string, fn: Function): void;
|
|
2497
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
|
+
|
|
2498
2558
|
/**
|
|
2499
2559
|
* Register room actions lazily. Called by the Vite-generated registry module.
|
|
2500
2560
|
* @internal
|
package/server.js
CHANGED
|
@@ -3720,6 +3720,139 @@ live.cron = function cron(schedule, topic, fn) {
|
|
|
3720
3720
|
return fn;
|
|
3721
3721
|
};
|
|
3722
3722
|
|
|
3723
|
+
/**
|
|
3724
|
+
* Declare a server-side feature flag exposed as a readable stream.
|
|
3725
|
+
*
|
|
3726
|
+
* A flag is a thin wrapper over `live.stream`: it declares a `merge: 'set'`
|
|
3727
|
+
* topic carrying the flag value, and any `.set(value)` pushes the new value
|
|
3728
|
+
* to every subscriber. On the client, `$live/<module>` exposes the export as
|
|
3729
|
+
* a readable store carrying the current value.
|
|
3730
|
+
*
|
|
3731
|
+
* Flags are cluster-consistent by default: a single-entry shared replay
|
|
3732
|
+
* buffer is enabled, so `.set()` writes the cluster-shared buffer and a
|
|
3733
|
+
* subscriber that connects fresh - to any replica, including one that never
|
|
3734
|
+
* set the flag locally - is served the cluster-latest value. Already-
|
|
3735
|
+
* subscribed clients stay in sync across the cluster as `.set()` relays the
|
|
3736
|
+
* update. Pass a custom `replay` object to size the buffer, or
|
|
3737
|
+
* `replay: false` to opt out (single-process apps lose nothing, since the
|
|
3738
|
+
* locally cached value is authoritative in one process).
|
|
3739
|
+
*
|
|
3740
|
+
* On every running replica an internal watcher keeps the cached value fresh
|
|
3741
|
+
* from boot. The watcher is installed when the registry module loads (the
|
|
3742
|
+
* same moment `live.effect` watchers become active), so it does not wait for
|
|
3743
|
+
* the flag module's first local import or subscribe: an inbound `set` relayed
|
|
3744
|
+
* from any replica updates the cached value within a tick, and the synchronous
|
|
3745
|
+
* `.get()` reflects the cluster-latest value on any running instance. For a
|
|
3746
|
+
* strict read on a replica that booted AFTER the last `set` and has not yet
|
|
3747
|
+
* received any inbound `set` (the watcher only catches post-boot sets), use the
|
|
3748
|
+
* asynchronous `getLatest()`, which reads the shared buffer directly.
|
|
3749
|
+
*
|
|
3750
|
+
* The `.set(value)` method publishes through the framework-owned platform
|
|
3751
|
+
* (the same path as the top-level `publish()` helper), so the new value
|
|
3752
|
+
* reaches every local subscriber and relays across the cluster when a bus
|
|
3753
|
+
* is wired. Call it from any server context after the platform has been
|
|
3754
|
+
* captured (RPC handler, cron tick, effect, an admin `+server.js` route).
|
|
3755
|
+
*
|
|
3756
|
+
* @param {string} topic - Topic carrying the flag value
|
|
3757
|
+
* @param {any} [initialValue] - Value served to subscribers before the first `.set`
|
|
3758
|
+
* @param {{ replay?: boolean | { size?: number } }} [options]
|
|
3759
|
+
* @returns {Function & { set(value: any): any, get(): any, getLatest(): Promise<any> }}
|
|
3760
|
+
*
|
|
3761
|
+
* @example
|
|
3762
|
+
* ```js
|
|
3763
|
+
* // src/live/flags.js
|
|
3764
|
+
* import { live } from 'svelte-realtime/server';
|
|
3765
|
+
* export const maintenance = live.flag('flag:maintenance', false);
|
|
3766
|
+
*
|
|
3767
|
+
* // Flip it from any handler:
|
|
3768
|
+
* export const toggleMaintenance = live(async (ctx, on) => {
|
|
3769
|
+
* maintenance.set(on);
|
|
3770
|
+
* });
|
|
3771
|
+
* ```
|
|
3772
|
+
*
|
|
3773
|
+
* ```svelte
|
|
3774
|
+
* <script>
|
|
3775
|
+
* import { maintenance } from '$live/flags';
|
|
3776
|
+
* </script>
|
|
3777
|
+
* {#if $maintenance}<Banner />{/if}
|
|
3778
|
+
* ```
|
|
3779
|
+
*/
|
|
3780
|
+
live.flag = function flag(topic, initialValue, options) {
|
|
3781
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
3782
|
+
throw new Error('[svelte-realtime] live.flag topic must be a non-empty string');
|
|
3783
|
+
}
|
|
3784
|
+
// The flag's value lives in a per-topic cell shared with the eager
|
|
3785
|
+
// registry-load watcher (installed by `__registerFlag`). Binding to the
|
|
3786
|
+
// cell instead of a private closure variable decouples the value from this
|
|
3787
|
+
// module's import: a `set` that arrives before the module is first imported
|
|
3788
|
+
// is captured into the cell by the eager watcher, so the first `.get()`
|
|
3789
|
+
// after import reads the cluster-latest value rather than a stale init.
|
|
3790
|
+
const cell = _flagCell(topic, initialValue);
|
|
3791
|
+
const initFn = async function flagInit() { return cell.value; };
|
|
3792
|
+
// Replay is ON by default with a single-entry buffer so the flag's
|
|
3793
|
+
// topic is replay-eligible at declaration: `.set() -> publish() ->
|
|
3794
|
+
// _maybeReplayPublish` writes the cluster-shared buffer, and a fresh
|
|
3795
|
+
// subscriber (or a just-booted replica) is served the cluster-latest
|
|
3796
|
+
// value through the seeding branch in `_executeStreamRpc`. Pass a
|
|
3797
|
+
// custom `replay` object to override the buffer size, or `replay: false`
|
|
3798
|
+
// to opt out (single-process apps lose nothing - the cached value is
|
|
3799
|
+
// authoritative in one process).
|
|
3800
|
+
const streamOpts = { merge: 'set' };
|
|
3801
|
+
if (options && options.replay === false) {
|
|
3802
|
+
// opt out: leave replay unset
|
|
3803
|
+
} else if (options && options.replay) {
|
|
3804
|
+
/** @type {any} */ (streamOpts).replay = options.replay;
|
|
3805
|
+
} else {
|
|
3806
|
+
/** @type {any} */ (streamOpts).replay = { size: 1 };
|
|
3807
|
+
}
|
|
3808
|
+
const stream = live.stream(topic, initFn, streamOpts);
|
|
3809
|
+
/** @type {any} */ (stream).__isFlag = true;
|
|
3810
|
+
/**
|
|
3811
|
+
* Read the flag's current value on the server (synchronous). On a running
|
|
3812
|
+
* replica this stays fresh from boot within a tick of any inbound `set` via
|
|
3813
|
+
* the per-topic watcher installed eagerly at registry load (see
|
|
3814
|
+
* `__registerFlag`). For a strict read on a replica that booted after the
|
|
3815
|
+
* last `set` and has not yet received any inbound `set`, use `getLatest()`.
|
|
3816
|
+
*/
|
|
3817
|
+
/** @type {any} */ (stream).get = function get() { return cell.value; };
|
|
3818
|
+
/**
|
|
3819
|
+
* Read the cluster-latest flag value (asynchronous). Reads the shared
|
|
3820
|
+
* replay buffer when one is wired and non-empty; otherwise falls back to
|
|
3821
|
+
* the locally cached value. Serves the strict read-after-cold-boot
|
|
3822
|
+
* case where a replica may not yet have observed the cluster-latest set.
|
|
3823
|
+
*/
|
|
3824
|
+
/** @type {any} */ (stream).getLatest = async function getLatest() {
|
|
3825
|
+
// Resolve the captured platform the same way `.set()` does (via the
|
|
3826
|
+
// top-level `publish()` helper), so `getLatest()` reads the shared
|
|
3827
|
+
// buffer whether the platform was captured by `_activateDerived` or
|
|
3828
|
+
// `setCronPlatform`.
|
|
3829
|
+
const platform = getPlatform();
|
|
3830
|
+
const replay = platform && /** @type {any} */ (platform).replay;
|
|
3831
|
+
if (replay && typeof replay.since === 'function') {
|
|
3832
|
+
try {
|
|
3833
|
+
const buffered = await replay.since(topic, 0);
|
|
3834
|
+
if (Array.isArray(buffered) && buffered.length > 0) {
|
|
3835
|
+
const last = buffered[buffered.length - 1];
|
|
3836
|
+
if (last && 'data' in last) return last.data;
|
|
3837
|
+
}
|
|
3838
|
+
} catch {}
|
|
3839
|
+
}
|
|
3840
|
+
return cell.value;
|
|
3841
|
+
};
|
|
3842
|
+
/** Publish a new flag value to every subscriber. */
|
|
3843
|
+
/** @type {any} */ (stream).set = function set(value) {
|
|
3844
|
+
cell.value = value;
|
|
3845
|
+
return publish(topic, 'set', value);
|
|
3846
|
+
};
|
|
3847
|
+
// Ensure the per-topic refresh watcher is installed. This is idempotent
|
|
3848
|
+
// with the eager `__registerFlag` install the registry module emits, and
|
|
3849
|
+
// covers the cases where a flag module is imported without a generated
|
|
3850
|
+
// registry (the dev-mode direct-load fallback, or a flag declared inline
|
|
3851
|
+
// in tests).
|
|
3852
|
+
_installFlagWatcher(topic);
|
|
3853
|
+
return /** @type {any} */ (stream);
|
|
3854
|
+
};
|
|
3855
|
+
|
|
3723
3856
|
/** @type {Map<string, { sources: string[], fn: Function, topic: string, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
|
|
3724
3857
|
const derivedRegistry = new Map();
|
|
3725
3858
|
|
|
@@ -3860,6 +3993,95 @@ export function __registerEffect(path, fn) {
|
|
|
3860
3993
|
_maybeLateActivate();
|
|
3861
3994
|
}
|
|
3862
3995
|
|
|
3996
|
+
/**
|
|
3997
|
+
* Per-topic value cells for `live.flag`. The cell holds the flag's current
|
|
3998
|
+
* value and is shared between the flag export's accessors (`get`/`set`/the
|
|
3999
|
+
* loader) and the eager refresh watcher installed at registry load. Keying on
|
|
4000
|
+
* the topic (rather than the export path) lets the watcher install before the
|
|
4001
|
+
* flag module is imported - the value the watcher captures from inbound sets is
|
|
4002
|
+
* exactly the value the flag's `.get()` reads once the module is imported.
|
|
4003
|
+
* @type {Map<string, { value: any }>}
|
|
4004
|
+
*/
|
|
4005
|
+
const _flagCells = new Map();
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* Per-topic refresh watcher entries, so the install is idempotent across the
|
|
4009
|
+
* eager registry call and the flag module's own import.
|
|
4010
|
+
* @type {Map<string, { sources: string[], fn: Function, debounce: number, timer: ReturnType<typeof setTimeout> | null }>}
|
|
4011
|
+
*/
|
|
4012
|
+
const _flagWatchers = new Map();
|
|
4013
|
+
|
|
4014
|
+
/**
|
|
4015
|
+
* Get (or create) the value cell for a flag topic. A cell created by the eager
|
|
4016
|
+
* watcher path may not have a meaningful seed yet; the first caller that knows
|
|
4017
|
+
* the declared `initialValue` (the watcher install or the flag body, whichever
|
|
4018
|
+
* runs first) seeds it. Inbound sets always overwrite the seed.
|
|
4019
|
+
* @param {string} topic
|
|
4020
|
+
* @param {any} [initialValue]
|
|
4021
|
+
* @returns {{ value: any }}
|
|
4022
|
+
*/
|
|
4023
|
+
function _flagCell(topic, initialValue) {
|
|
4024
|
+
let cell = _flagCells.get(topic);
|
|
4025
|
+
if (!cell) {
|
|
4026
|
+
cell = { value: initialValue };
|
|
4027
|
+
_flagCells.set(topic, cell);
|
|
4028
|
+
} else if (cell.value === undefined && initialValue !== undefined) {
|
|
4029
|
+
// Adopt the declared initial value when the cell was created without
|
|
4030
|
+
// one (e.g. the eager watcher had no static initialValue to pass).
|
|
4031
|
+
cell.value = initialValue;
|
|
4032
|
+
}
|
|
4033
|
+
return cell;
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/**
|
|
4037
|
+
* Install the per-topic flag refresh watcher into the effect index. Idempotent:
|
|
4038
|
+
* one watcher per topic, regardless of how many times this is called. The
|
|
4039
|
+
* watcher updates the topic's value cell on every inbound `set`, so a sync
|
|
4040
|
+
* `.get()` reflects the cluster-latest value from boot on every running replica.
|
|
4041
|
+
*
|
|
4042
|
+
* The bus inbound relay reaches `derivedPublishLocal -> fireWatchers(topic,
|
|
4043
|
+
* event, data)`, so a `set` originating on any replica updates the cell here
|
|
4044
|
+
* (the self-set echo on the origin is idempotent). `_maybeLateActivate()`
|
|
4045
|
+
* installs the publish wrap even when the flag is declared before
|
|
4046
|
+
* `_activateDerived`.
|
|
4047
|
+
* @param {string} topic
|
|
4048
|
+
*/
|
|
4049
|
+
function _installFlagWatcher(topic) {
|
|
4050
|
+
if (_flagWatchers.has(topic)) return;
|
|
4051
|
+
const cell = _flagCell(topic);
|
|
4052
|
+
const watcher = {
|
|
4053
|
+
sources: [topic],
|
|
4054
|
+
fn: function flagWatcher(event, data) { if (event === 'set') cell.value = data; },
|
|
4055
|
+
debounce: 0,
|
|
4056
|
+
timer: null
|
|
4057
|
+
};
|
|
4058
|
+
_flagWatchers.set(topic, watcher);
|
|
4059
|
+
let watcherSet = _effectBySource.get(topic);
|
|
4060
|
+
if (!watcherSet) { watcherSet = new Set(); _effectBySource.set(topic, watcherSet); }
|
|
4061
|
+
watcherSet.add(watcher);
|
|
4062
|
+
_watchedTopics.add(topic);
|
|
4063
|
+
_maybeLateActivate();
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
/**
|
|
4067
|
+
* Register a flag's refresh watcher eagerly. Called by the Vite-generated
|
|
4068
|
+
* registry module at registry-module load (the same lifecycle that activates
|
|
4069
|
+
* `live.effect` watchers), so the watcher is live from server boot on every
|
|
4070
|
+
* replica WITHOUT waiting for the flag module's first local import or subscribe.
|
|
4071
|
+
*
|
|
4072
|
+
* Carries the static topic and (when statically analyzable) the declared
|
|
4073
|
+
* `initialValue` so the cell is seeded before any inbound `set`. The flag's
|
|
4074
|
+
* `__register` stream entry is emitted alongside this and remains lazy; only the
|
|
4075
|
+
* watcher install is hoisted to boot.
|
|
4076
|
+
* @param {string} topic
|
|
4077
|
+
* @param {any} [initialValue]
|
|
4078
|
+
*/
|
|
4079
|
+
export function __registerFlag(topic, initialValue) {
|
|
4080
|
+
if (typeof topic !== 'string' || topic.length === 0) return;
|
|
4081
|
+
_flagCell(topic, initialValue);
|
|
4082
|
+
_installFlagWatcher(topic);
|
|
4083
|
+
}
|
|
4084
|
+
|
|
3863
4085
|
/** @type {Map<string, { source: string, reducers: any, topic: string, state: any, snapshot: Function | null, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
|
|
3864
4086
|
const aggregateRegistry = new Map();
|
|
3865
4087
|
/** @type {Map<string, any>} Topic-keyed lookup for aggregates */
|
|
@@ -6140,6 +6362,11 @@ export function _prepareHmr() {
|
|
|
6140
6362
|
_aggregateBySource.clear();
|
|
6141
6363
|
_aggregateByTopic.clear();
|
|
6142
6364
|
_watchedTopics.clear();
|
|
6365
|
+
// Drop the flag watcher index so `__registerFlag` reinstalls watchers on
|
|
6366
|
+
// the regenerated registry load. The value cells persist across HMR - the
|
|
6367
|
+
// flag's cluster-latest value should not reset when an unrelated module is
|
|
6368
|
+
// edited - and the reinstalled watcher rebinds to the surviving cell.
|
|
6369
|
+
_flagWatchers.clear();
|
|
6143
6370
|
_streamsWithUnsubscribe.clear();
|
|
6144
6371
|
_hasDynamicDerived = false;
|
|
6145
6372
|
_hasLazyReactive = false;
|
|
@@ -7002,6 +7229,27 @@ async function _executeStreamRpc(ws, platform, fn, ctx, args, msg, subscribedRef
|
|
|
7002
7229
|
} catch {}
|
|
7003
7230
|
}
|
|
7004
7231
|
|
|
7232
|
+
// Flag fresh-subscribe seeding (cluster-latest on cold connect). A
|
|
7233
|
+
// fresh subscribe omits `seq`, so the seq-gated block above is skipped
|
|
7234
|
+
// and the loader would otherwise return this replica's locally-cached
|
|
7235
|
+
// value. For a flag backed by shared replay, read the whole buffer
|
|
7236
|
+
// (size:1 => one `set` envelope) and serve it through the same
|
|
7237
|
+
// `replay: true` array response the seq-gated block uses, so a fresh
|
|
7238
|
+
// connect to a replica that never set the flag locally still gets the
|
|
7239
|
+
// cluster-latest value. Gated strictly on `__isFlag` so non-flag replay
|
|
7240
|
+
// streams (crud/latest, whose loaders intentionally hit the DB on a
|
|
7241
|
+
// fresh subscribe) keep loader-only fresh-subscribe behavior. Empty
|
|
7242
|
+
// buffer (no `.set()` anywhere yet) falls through to the loader.
|
|
7243
|
+
if (replayOpts && platform.replay && typeof clientSeq === 'undefined' && /** @type {any} */ (fn).__isFlag) {
|
|
7244
|
+
try {
|
|
7245
|
+
const missed = await platform.replay.since(topic, 0);
|
|
7246
|
+
if (Array.isArray(missed) && missed.length > 0) {
|
|
7247
|
+
const currentSeq = await platform.replay.seq(topic);
|
|
7248
|
+
return { id, ok: true, data: missed, topic, merge: streamOpts.merge, key: streamOpts.key, prepend: streamOpts.prepend, max: streamOpts.max, seq: currentSeq, replay: true };
|
|
7249
|
+
}
|
|
7250
|
+
} catch {}
|
|
7251
|
+
}
|
|
7252
|
+
|
|
7005
7253
|
// Seq-delta (user-provided bridge for older-than-buffer reconnects)
|
|
7006
7254
|
if (deltaOpts && typeof deltaOpts.fromSeq === 'function' && typeof clientSeq === 'number') {
|
|
7007
7255
|
try {
|
package/vite.js
CHANGED
|
@@ -21,6 +21,10 @@ const DYNAMIC_CHANNEL_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(\s*(?
|
|
|
21
21
|
const RATE_LIMIT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.rateLimit\s*\(/g;
|
|
22
22
|
const EFFECT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.effect\s*\(/g;
|
|
23
23
|
const AGGREGATE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.aggregate\s*\(/g;
|
|
24
|
+
// `live.flag(topic, initialValue)` declares a `merge: 'set'` stream carrying
|
|
25
|
+
// the flag value, so the client treats it exactly like a static stream: emit
|
|
26
|
+
// a `__stream(..., { merge: 'set' })` stub and register it as a plain stream.
|
|
27
|
+
const FLAG_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.flag\s*\(/g;
|
|
24
28
|
// `live.lock(...)` and `live.idempotent(...)` wrap an inner handler. From the
|
|
25
29
|
// client's perspective they're plain RPCs (the lock / idempotency runs
|
|
26
30
|
// server-side inside the wrapper), so the codegen treats them identically
|
|
@@ -1272,6 +1276,18 @@ function _generateClientStubs(filePath, modulePath, dir) {
|
|
|
1272
1276
|
}
|
|
1273
1277
|
}
|
|
1274
1278
|
|
|
1279
|
+
// Detect live.flag() exports (readable set-merge stream on the client)
|
|
1280
|
+
FLAG_EXPORT_RE.lastIndex = 0;
|
|
1281
|
+
while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
|
|
1282
|
+
const name = match[1];
|
|
1283
|
+
if (!/^\w+$/.test(name)) continue;
|
|
1284
|
+
if (!exportedNames.has(name)) {
|
|
1285
|
+
exportedNames.add(name);
|
|
1286
|
+
imports.add('__stream');
|
|
1287
|
+
lines.push(`export const ${name} = __stream(${safeModulePath(name)}, ${JSON.stringify({ merge: 'set' })});`);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1275
1291
|
// Dev warnings for non-live exports
|
|
1276
1292
|
const allExportRe = /export\s+(?:const|function|let|var|class)\s+(\w+)/g;
|
|
1277
1293
|
allExportRe.lastIndex = 0;
|
|
@@ -1684,6 +1700,65 @@ function _extractStreamOptions(source, name) {
|
|
|
1684
1700
|
return opts;
|
|
1685
1701
|
}
|
|
1686
1702
|
|
|
1703
|
+
/**
|
|
1704
|
+
* Extract the static declaration of a `live.flag(topic, initialValue)` export:
|
|
1705
|
+
* the required topic string literal, and the second argument's source text when
|
|
1706
|
+
* it is present. The topic is needed so the registry can install the flag's
|
|
1707
|
+
* refresh watcher eagerly (keyed by topic, before the flag module is imported).
|
|
1708
|
+
* The `initialArg` is the raw source of the second argument and is emitted only
|
|
1709
|
+
* when it is a statically safe literal (a string, number, boolean, or null) so
|
|
1710
|
+
* the value cell can be seeded at registry load; anything else is left for the
|
|
1711
|
+
* flag body to seed on first import. Returns null when the topic is not a plain
|
|
1712
|
+
* string literal (dynamic-topic flags are not supported by the codegen).
|
|
1713
|
+
* @param {string} source
|
|
1714
|
+
* @param {string} name
|
|
1715
|
+
* @returns {{ topic: string, initialArg: string | null } | null}
|
|
1716
|
+
*/
|
|
1717
|
+
function _extractFlagDecl(source, name) {
|
|
1718
|
+
const openPattern = new RegExp(
|
|
1719
|
+
`export\\s+const\\s+${name}\\s*=\\s*live\\.flag\\s*\\(`
|
|
1720
|
+
);
|
|
1721
|
+
const openMatch = openPattern.exec(source);
|
|
1722
|
+
if (!openMatch) return null;
|
|
1723
|
+
let i = openMatch.index + openMatch[0].length;
|
|
1724
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
1725
|
+
if (source[i] !== '\'' && source[i] !== '"' && source[i] !== '`') return null;
|
|
1726
|
+
const topicLit = _readStringLiteral(source, i);
|
|
1727
|
+
if (!topicLit) return null;
|
|
1728
|
+
const topic = topicLit.value;
|
|
1729
|
+
i = topicLit.end + 1;
|
|
1730
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
1731
|
+
if (source[i] !== ',') return { topic, initialArg: null };
|
|
1732
|
+
i++;
|
|
1733
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
1734
|
+
// Read the second argument up to the next top-level `,` or the closing `)`.
|
|
1735
|
+
let depth = 0;
|
|
1736
|
+
const argStart = i;
|
|
1737
|
+
while (i < source.length) {
|
|
1738
|
+
const skipped = _skipNonCode(source, i);
|
|
1739
|
+
if (skipped >= 0) { i = skipped + 1; continue; }
|
|
1740
|
+
const c = source[i];
|
|
1741
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
1742
|
+
else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; }
|
|
1743
|
+
else if (c === ',' && depth === 0) break;
|
|
1744
|
+
i++;
|
|
1745
|
+
}
|
|
1746
|
+
const rawArg = source.slice(argStart, i).trim();
|
|
1747
|
+
if (rawArg === '') return { topic, initialArg: null };
|
|
1748
|
+
// Only forward statically safe literals so the generated registry never
|
|
1749
|
+
// evaluates user expressions. String literals are re-quoted via the parsed
|
|
1750
|
+
// value; primitives pass through verbatim.
|
|
1751
|
+
if (rawArg[0] === '\'' || rawArg[0] === '"' || rawArg[0] === '`') {
|
|
1752
|
+
const lit = _readStringLiteral(rawArg, 0);
|
|
1753
|
+
if (lit && lit.end === rawArg.length - 1) return { topic, initialArg: JSON.stringify(lit.value) };
|
|
1754
|
+
return { topic, initialArg: null };
|
|
1755
|
+
}
|
|
1756
|
+
if (rawArg === 'true' || rawArg === 'false' || rawArg === 'null' || /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(rawArg)) {
|
|
1757
|
+
return { topic, initialArg: rawArg };
|
|
1758
|
+
}
|
|
1759
|
+
return { topic, initialArg: null };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1687
1762
|
/**
|
|
1688
1763
|
* Parse option properties from an object body string into an opts object.
|
|
1689
1764
|
* Shared by stream, channel, and room option extraction.
|
|
@@ -1851,7 +1926,7 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
|
|
|
1851
1926
|
|
|
1852
1927
|
const files = _findLiveFiles(liveDir);
|
|
1853
1928
|
const lines = [
|
|
1854
|
-
`import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions } from 'svelte-realtime/server';`,
|
|
1929
|
+
`import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions, __registerFlag } from 'svelte-realtime/server';`,
|
|
1855
1930
|
`const __L = fn => (fn.__lazy = true, fn);\n`
|
|
1856
1931
|
];
|
|
1857
1932
|
|
|
@@ -2061,6 +2136,27 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
|
|
|
2061
2136
|
}
|
|
2062
2137
|
}
|
|
2063
2138
|
|
|
2139
|
+
// Register live.flag() exports. The stream registration stays lazy (the
|
|
2140
|
+
// flag module is imported on first subscribe), but the refresh watcher
|
|
2141
|
+
// is installed eagerly via __registerFlag at registry-module load - the
|
|
2142
|
+
// same lifecycle that activates live.effect watchers - so a server-side
|
|
2143
|
+
// .get() reflects cluster-latest sets from boot without waiting for the
|
|
2144
|
+
// flag module's first local import.
|
|
2145
|
+
FLAG_EXPORT_RE.lastIndex = 0;
|
|
2146
|
+
while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
|
|
2147
|
+
const name = match[1];
|
|
2148
|
+
if (!/^\w+$/.test(name)) continue;
|
|
2149
|
+
if (!registered.has(name)) {
|
|
2150
|
+
registered.add(name);
|
|
2151
|
+
lines.push(`__register(${JSON.stringify(rel + '/' + name)}, ${_lazy(name)});`);
|
|
2152
|
+
const decl = _extractFlagDecl(source, name);
|
|
2153
|
+
if (decl) {
|
|
2154
|
+
const initArg = decl.initialArg === null ? '' : `, ${decl.initialArg}`;
|
|
2155
|
+
lines.push(`__registerFlag(${JSON.stringify(decl.topic)}${initArg});`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2064
2160
|
// Warn about exports with non-path-safe names (pass registered to avoid
|
|
2065
2161
|
// double-warning for names already handled above)
|
|
2066
2162
|
_warnUnsafeExports(source, `${dir}/${rel}`, registered);
|
|
@@ -2318,6 +2414,17 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
2318
2414
|
}
|
|
2319
2415
|
}
|
|
2320
2416
|
|
|
2417
|
+
// Detect live.flag() exports (readable set-merge stream)
|
|
2418
|
+
FLAG_EXPORT_RE.lastIndex = 0;
|
|
2419
|
+
while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
|
|
2420
|
+
const name = match[1];
|
|
2421
|
+
handledNames.add(name);
|
|
2422
|
+
if (!exports.some(e => e.includes(`export const ${name}:`))) {
|
|
2423
|
+
needsStreamStore = true;
|
|
2424
|
+
exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> };`);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2321
2428
|
// Detect live.binary() exports
|
|
2322
2429
|
BINARY_EXPORT_RE.lastIndex = 0;
|
|
2323
2430
|
while ((match = BINARY_EXPORT_RE.exec(source)) !== null) {
|