svelte-realtime 0.5.4 → 0.5.6
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 +89 -15
- package/client.d.ts +14 -0
- package/client.js +180 -94
- package/package.json +1 -1
- package/server.d.ts +120 -0
- package/server.js +391 -29
package/README.md
CHANGED
|
@@ -1271,6 +1271,27 @@ Call `configure()` once at app startup. The hooks fire on state transitions only
|
|
|
1271
1271
|
| `onConnect()` | Called when the WebSocket connection opens after a reconnect |
|
|
1272
1272
|
| `onDisconnect()` | Called when the WebSocket connection closes |
|
|
1273
1273
|
| `beforeReconnect()` | Called before each reconnection attempt (can be async) |
|
|
1274
|
+
| `timeout` | Default RPC timeout in ms (default `30000`). Per-call `.with({ timeout })` overrides. |
|
|
1275
|
+
| `resumeGraceMs` | Stream resume-grace window in ms (default `60000`). See [Pause and resume without re-rehydrating](#pause-and-resume-without-re-rehydrating) below. Set to `0` to disable. |
|
|
1276
|
+
|
|
1277
|
+
### Pause and resume without re-rehydrating
|
|
1278
|
+
|
|
1279
|
+
When the last subscriber of a stream unsubs, the stream releases its WebSocket subscription immediately (giving the server back its slot, dropping the in-flight counter) but keeps the in-memory data model -- `currentValue`, the last seen `seq` / `version`, the pagination `cursor`, and any history -- for `resumeGraceMs` (default 60 seconds). If a new `subscribe()` lands inside that window, the stream re-attaches its listeners and sends the retained cursor on the resume envelope, so the server can fill the gap from its bounded replay buffer (or `delta.fromSeq`, or a truncated-cache fall-through to a full rehydrate) instead of cold-starting.
|
|
1280
|
+
|
|
1281
|
+
This is the default for two reasons:
|
|
1282
|
+
|
|
1283
|
+
1. **Pause/resume UIs work for free.** A `{#if active} <SubscribedComponent /> {/if}` toggle, or an `$effect` whose subscribe-arm flips on user action, can pause and resume the subscription without re-loading from scratch. The events that arrived during the pause stream in via the replay buffer.
|
|
1284
|
+
2. **Browser back/forward feels instant.** Navigating away and back within the grace window restores the previous data immediately, and any events the user missed are gap-filled by the server.
|
|
1285
|
+
|
|
1286
|
+
If the grace expires without a new subscriber, the data model resets and the next subscribe is a true cold start. Apps that prefer aggressive memory reclamation can shorten or disable the grace:
|
|
1287
|
+
|
|
1288
|
+
```js
|
|
1289
|
+
configure({ resumeGraceMs: 0 }); // every unsub is a full reset (pre-grace behavior)
|
|
1290
|
+
configure({ resumeGraceMs: 5_000 }); // 5s grace covers brief toggles
|
|
1291
|
+
configure({ resumeGraceMs: 300_000 }); // 5min grace for navigation-heavy apps
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
The grace only affects local data retention. The server's replay buffer and `delta.fromSeq` window are independent and govern how far back the gap-fill can reach.
|
|
1274
1295
|
|
|
1275
1296
|
### Cross-origin and native app usage
|
|
1276
1297
|
|
|
@@ -2218,7 +2239,7 @@ On older adapters (`open(ws, platform)` is the only available hand-off point), c
|
|
|
2218
2239
|
|
|
2219
2240
|
Each worker process runs its own cron tick. In a single-process deployment that's exactly what you want. In a clustered deployment - whether `CLUSTER_MODE=reuseport` on Linux (N kernel workers per replica), acceptor mode on Windows / macOS (N internal workers per process), or N Docker replicas, or any combination - every worker fires every job in parallel by default. For "send the daily summary at 9am" jobs, that's almost certainly wrong.
|
|
2220
2241
|
|
|
2221
|
-
Wire a cluster-wide leader gate via `configureCron({ leader, bus })
|
|
2242
|
+
Wire a cluster-wide leader gate via `configureCron({ leader, bus })` (or via the higher-level `realtime({ bus, leader })` factory described in [Redis multi-instance](#redis-multi-instance) - same outcome, one fewer wiring step). The canonical leader implementation lives in `svelte-adapter-uws-extensions/redis/leader` (Redis SETNX lease) and the canonical bus is `svelte-adapter-uws-extensions/redis/pubsub`:
|
|
2222
2243
|
|
|
2223
2244
|
```js
|
|
2224
2245
|
// src/hooks.ws.js (clustered, with extensions)
|
|
@@ -2993,28 +3014,70 @@ If `replay: true` is declared but `platform.replay` is never set, dev-mode logs
|
|
|
2993
3014
|
|
|
2994
3015
|
## Redis multi-instance
|
|
2995
3016
|
|
|
2996
|
-
|
|
3017
|
+
One declaration of cluster intent reaches every framework publish surface. `realtime({ bus, leader })` (added in 0.5.6) wires `ctx.publish` for RPC, the cron tick, the reactive watcher path (`live.effect`, `live.derived`, `live.aggregate`, `live.webhook`), and the top-level `publish()` helper in one call. Handler code (`src/live/*.js`) is byte-identical between single-replica and cluster - you opt in by passing `bus` and `leader` at the top of `hooks.ws.js`, nothing else changes.
|
|
2997
3018
|
|
|
2998
3019
|
```js
|
|
2999
3020
|
// src/hooks.ws.js
|
|
3000
|
-
import {
|
|
3001
|
-
import { createRedis, createPubSubBus } from 'svelte-adapter-uws-extensions/redis';
|
|
3021
|
+
import { realtime } from 'svelte-realtime/server';
|
|
3022
|
+
import { createRedis, createPubSubBus, createLeader } from 'svelte-adapter-uws-extensions/redis';
|
|
3002
3023
|
|
|
3003
3024
|
const redis = createRedis();
|
|
3004
3025
|
const bus = createPubSubBus(redis);
|
|
3026
|
+
const leader = createLeader(redis);
|
|
3005
3027
|
|
|
3006
|
-
export
|
|
3007
|
-
bus
|
|
3028
|
+
export const { open, close, message, init } = realtime({
|
|
3029
|
+
bus,
|
|
3030
|
+
leader: leader.isLeader,
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
export function upgrade({ cookies }) {
|
|
3034
|
+
return validateSession(cookies.session_id) || false;
|
|
3008
3035
|
}
|
|
3036
|
+
```
|
|
3037
|
+
|
|
3038
|
+
Single-replica is the same file with no config:
|
|
3009
3039
|
|
|
3040
|
+
```js
|
|
3041
|
+
// src/hooks.ws.js (single-replica)
|
|
3042
|
+
import { realtime } from 'svelte-realtime/server';
|
|
3043
|
+
export const { open, close, message, init } = realtime();
|
|
3010
3044
|
export function upgrade({ cookies }) {
|
|
3011
3045
|
return validateSession(cookies.session_id) || false;
|
|
3012
3046
|
}
|
|
3047
|
+
```
|
|
3048
|
+
|
|
3049
|
+
### Layer 1: manual wiring (experts)
|
|
3013
3050
|
|
|
3014
|
-
|
|
3051
|
+
`realtime()` is sugar over the existing primitives. If you want fine control - per-route bus, custom hook composition, conditional cluster wiring - drop down to the building blocks. The pre-0.5.6 pattern still works unchanged, and as of 0.5.6 it routes through the same compose-at-publish-time pipeline, so reactive handlers (`live.effect`, `live.derived`, `live.aggregate`) pick up cluster relay automatically once a bus is wired:
|
|
3052
|
+
|
|
3053
|
+
```js
|
|
3054
|
+
// src/hooks.ws.js (manual primitives)
|
|
3055
|
+
import { setBus, setCronPlatform, _activateDerived, configureCron, pushHooks, message } from 'svelte-realtime/server';
|
|
3056
|
+
import { createRedis, createPubSubBus, createLeader } from 'svelte-adapter-uws-extensions/redis';
|
|
3057
|
+
|
|
3058
|
+
const redis = createRedis();
|
|
3059
|
+
const bus = createPubSubBus(redis);
|
|
3060
|
+
const leader = createLeader(redis);
|
|
3061
|
+
|
|
3062
|
+
setBus(bus); // process-wide bus; reactive seam + RPC auto-wrap + publish() helper all use it
|
|
3063
|
+
configureCron({ leader: leader.isLeader });
|
|
3064
|
+
|
|
3065
|
+
export { message };
|
|
3066
|
+
export const open = pushHooks.open;
|
|
3067
|
+
export const close = pushHooks.close;
|
|
3068
|
+
export function init({ platform }) {
|
|
3069
|
+
setCronPlatform(platform);
|
|
3070
|
+
_activateDerived(platform);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
export function upgrade({ cookies }) {
|
|
3074
|
+
return validateSession(cookies.session_id) || false;
|
|
3075
|
+
}
|
|
3015
3076
|
```
|
|
3016
3077
|
|
|
3017
|
-
|
|
3078
|
+
`setBus(bus)` and `configureCron({ bus })` write the same backing state; pick whichever reads better at the call site. `getBus()`, `getPlatform()`, and `publish(topic, event, data)` are exported for diagnostics and for publishing from outside a framework handler (e.g. a `+server.js` HTTP endpoint).
|
|
3079
|
+
|
|
3080
|
+
No changes needed in your live modules either way. `ctx.publish` delegates to whatever composed platform reached the handler, so cluster relay is transparent to user code.
|
|
3018
3081
|
|
|
3019
3082
|
If you already run Postgres and don't need Redis, you can use the [LISTEN/NOTIFY bridge](#postgres-notify) instead for cross-instance pub/sub.
|
|
3020
3083
|
|
|
@@ -3030,19 +3093,23 @@ When you add the Redis extensions from [svelte-adapter-uws-extensions](https://g
|
|
|
3030
3093
|
|
|
3031
3094
|
### Combined: Redis + rate limiting
|
|
3032
3095
|
|
|
3096
|
+
`realtime()` returns the standard hook set; for the cross-cutting `beforeExecute` rate-limit gate, swap `realtime()`'s `message` for a `createMessage` you composed yourself. The bus is still wired once via `setBus`, so the reactive seam and cron tick stay cluster-correct without a per-hook bus callback:
|
|
3097
|
+
|
|
3033
3098
|
```js
|
|
3034
|
-
import { createMessage, LiveError } from 'svelte-realtime/server';
|
|
3035
|
-
import { createRedis, createPubSubBus, createRateLimit } from 'svelte-adapter-uws-extensions/redis';
|
|
3099
|
+
import { realtime, createMessage, setBus, LiveError } from 'svelte-realtime/server';
|
|
3100
|
+
import { createRedis, createPubSubBus, createLeader, createRateLimit } from 'svelte-adapter-uws-extensions/redis';
|
|
3036
3101
|
|
|
3037
3102
|
const redis = createRedis();
|
|
3038
3103
|
const bus = createPubSubBus(redis);
|
|
3104
|
+
const leader = createLeader(redis);
|
|
3039
3105
|
const limiter = createRateLimit(redis, { points: 30, interval: 10000 });
|
|
3040
3106
|
|
|
3041
|
-
|
|
3107
|
+
setBus(bus);
|
|
3108
|
+
|
|
3109
|
+
export const { open, close, init } = realtime({ leader: leader.isLeader });
|
|
3042
3110
|
export function upgrade({ cookies }) { return validateSession(cookies.session_id) || false; }
|
|
3043
3111
|
|
|
3044
3112
|
export const message = createMessage({
|
|
3045
|
-
platform: (p) => bus.wrap(p),
|
|
3046
3113
|
async beforeExecute(ws, rpcPath) {
|
|
3047
3114
|
const { allowed, resetMs } = await limiter.consume(ws);
|
|
3048
3115
|
if (!allowed)
|
|
@@ -3051,6 +3118,8 @@ export const message = createMessage({
|
|
|
3051
3118
|
});
|
|
3052
3119
|
```
|
|
3053
3120
|
|
|
3121
|
+
Without a `platform` callback, `createMessage` auto-wraps with whatever `setBus(...)` wired - one source of truth, no double-wrap.
|
|
3122
|
+
|
|
3054
3123
|
---
|
|
3055
3124
|
|
|
3056
3125
|
## Postgres NOTIFY
|
|
@@ -3565,13 +3634,18 @@ Import from `svelte-realtime/server`.
|
|
|
3565
3634
|
| `guard(...fns)` | Per-module auth middleware |
|
|
3566
3635
|
| `LiveError(code, message?)` | Typed error (propagates to client) |
|
|
3567
3636
|
| `handleRpc(ws, data, platform, options?)` | Low-level RPC handler |
|
|
3568
|
-
| `message` | Ready-made message hook |
|
|
3569
|
-
| `createMessage(options?)` | Custom message hook factory |
|
|
3637
|
+
| `message` | Ready-made message hook (auto bus-wraps when `setBus` is wired) |
|
|
3638
|
+
| `createMessage(options?)` | Custom message hook factory (auto bus-wraps unless `options.platform` is provided) |
|
|
3639
|
+
| `realtime(config?)` | One-call setup returning `{ open, close, message, init, upgrade? }` - wires bus + leader + platform from a single declaration of cluster intent |
|
|
3640
|
+
| `setBus(bus)` | Configure the process-wide bus consulted by every framework publish surface (alias for `configureCron({ bus })`) |
|
|
3641
|
+
| `getBus()` | Read the process-wide bus, or `null` |
|
|
3642
|
+
| `getPlatform()` | Read the framework-owned composed platform after `init` has captured it |
|
|
3643
|
+
| `publish(topic, event, data, options?)` | Top-level publish helper (routes through the composed platform; relays via bus when wired) |
|
|
3570
3644
|
| `pipe(stream, ...transforms)` | Composable stream transforms |
|
|
3571
3645
|
| `close` | Ready-made close hook (fires onUnsubscribe for remaining topics) |
|
|
3572
3646
|
| `unsubscribe` | Ready-made unsubscribe hook (fires onUnsubscribe in real time) |
|
|
3573
3647
|
| `setCronPlatform(platform)` | Capture platform for cron jobs (call from `init({ platform })`) |
|
|
3574
|
-
| `configureCron({ leader })` | Cluster-mode leader gate for cron (default: every worker fires) |
|
|
3648
|
+
| `configureCron({ leader, bus })` | Cluster-mode leader gate for cron (default: every worker fires); `bus` also wires the process-wide bus |
|
|
3575
3649
|
| `onError(handler)` | Global error handler for cron, effects, and derived |
|
|
3576
3650
|
| `onCronError(handler)` | Deprecated alias for `onError` |
|
|
3577
3651
|
| `enableSignals(ws)` | Enable point-to-point signal delivery |
|
package/client.d.ts
CHANGED
|
@@ -534,6 +534,20 @@ export function configure(config: {
|
|
|
534
534
|
onConnect?(): void;
|
|
535
535
|
/** Called when the WebSocket connection closes. */
|
|
536
536
|
onDisconnect?(): void;
|
|
537
|
+
/** Default RPC timeout in ms; per-call `.with({ timeout })` overrides. @default 30000 */
|
|
538
|
+
timeout?: number;
|
|
539
|
+
/**
|
|
540
|
+
* Stream resume-grace window in ms. When the last subscriber of a stream
|
|
541
|
+
* unsubs, the WS subscription is released immediately but the data model
|
|
542
|
+
* (currentValue, seq, version, cursor) is kept for this long. A new
|
|
543
|
+
* subscribe within the window resumes from the retained cursor so the
|
|
544
|
+
* server can fill the gap from its replay buffer (or fromSeq, or
|
|
545
|
+
* truncated -> full rehydrate) instead of cold-rehydrating. Covers
|
|
546
|
+
* pause/resume UIs and browser back/forward navigation. Set to 0 to
|
|
547
|
+
* disable the grace window.
|
|
548
|
+
* @default 60000
|
|
549
|
+
*/
|
|
550
|
+
resumeGraceMs?: number;
|
|
537
551
|
/** Offline mutation queue configuration. */
|
|
538
552
|
offline?: {
|
|
539
553
|
/** Enable queuing RPCs when disconnected. */
|
package/client.js
CHANGED
|
@@ -314,6 +314,25 @@ function _getTimeout() {
|
|
|
314
314
|
return _clientConfig.timeout || _DEFAULT_TIMEOUT;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
const _DEFAULT_RESUME_GRACE_MS = 60000;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Stream resume-grace window in ms. When the last subscriber unsubs, the
|
|
321
|
+
* stream releases its WS subscription immediately but keeps the in-memory
|
|
322
|
+
* data model (currentValue, _lastSeq, _lastVersion, _cursor) for this
|
|
323
|
+
* long. A new subscribe() within the window resumes from the retained
|
|
324
|
+
* cursor so the server can fill the gap from its replay buffer instead
|
|
325
|
+
* of cold-rehydrating. Set to 0 to disable the grace window (every
|
|
326
|
+
* cleanup is a full reset).
|
|
327
|
+
*
|
|
328
|
+
* @returns {number}
|
|
329
|
+
*/
|
|
330
|
+
function _getResumeGraceMs() {
|
|
331
|
+
const v = _clientConfig.resumeGraceMs;
|
|
332
|
+
if (typeof v === 'number' && v >= 0) return v;
|
|
333
|
+
return _DEFAULT_RESUME_GRACE_MS;
|
|
334
|
+
}
|
|
335
|
+
|
|
317
336
|
/** @type {boolean} Whether the connection is permanently dead (terminal close code, exhausted retries, or explicit close) */
|
|
318
337
|
let _terminated = false;
|
|
319
338
|
|
|
@@ -2509,9 +2528,16 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2509
2528
|
}
|
|
2510
2529
|
|
|
2511
2530
|
/**
|
|
2512
|
-
*
|
|
2531
|
+
* Tear down WS-level subscription handles, transient flags, and any
|
|
2532
|
+
* in-flight subscribe request. Leaves the in-memory data model
|
|
2533
|
+
* (currentValue, _index, _history, _lastSeq, _lastVersion, _cursor,
|
|
2534
|
+
* _hasMore, _schemaVersion, topic) intact so that a subscribe() call
|
|
2535
|
+
* landing during the resume-grace window can reattach listeners,
|
|
2536
|
+
* call fetchAndSubscribe() with the retained seq/version/cursor, and
|
|
2537
|
+
* let the server fill the gap from its replay buffer (or fromSeq, or
|
|
2538
|
+
* a truncated -> full rehydrate fallback) instead of cold-starting.
|
|
2513
2539
|
*/
|
|
2514
|
-
function
|
|
2540
|
+
function _releaseSubscription() {
|
|
2515
2541
|
if (pendingId) {
|
|
2516
2542
|
const entry = pending.get(pendingId);
|
|
2517
2543
|
if (entry) {
|
|
@@ -2547,10 +2573,19 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2547
2573
|
_bufA.length = 0;
|
|
2548
2574
|
_bufB.length = 0;
|
|
2549
2575
|
_activeBuf = _bufA;
|
|
2576
|
+
fetching = false;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* Reset the in-memory data model and error state. Runs when the
|
|
2581
|
+
* resume-grace window expires with no new subscriber, or immediately
|
|
2582
|
+
* on cleanup when resumeGraceMs is 0. After this runs, the next
|
|
2583
|
+
* subscribe() is a true cold start.
|
|
2584
|
+
*/
|
|
2585
|
+
function _resetSession() {
|
|
2550
2586
|
if (topic) _unregisterTopicErrorSetter(topic, _setError);
|
|
2551
2587
|
topic = null;
|
|
2552
2588
|
initialLoaded = false;
|
|
2553
|
-
fetching = false;
|
|
2554
2589
|
buffer = [];
|
|
2555
2590
|
currentValue = undefined;
|
|
2556
2591
|
store.set(undefined);
|
|
@@ -2562,17 +2597,6 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2562
2597
|
_history = [];
|
|
2563
2598
|
_historyIndex = -1;
|
|
2564
2599
|
_reconnectAttempts = 0;
|
|
2565
|
-
// Reset session-resume cursors. Cleanup means the stream is being
|
|
2566
|
-
// abandoned (last subscriber gone, deferred-cleanup microtask fired);
|
|
2567
|
-
// the next subscribe must start fresh, not falsely resume from
|
|
2568
|
-
// whatever seq / version / cursor the prior session left behind.
|
|
2569
|
-
// Without these resets, an unmount/remount cycle (e.g. browser back
|
|
2570
|
-
// then forward) sends a stale `seq` to the server, the server
|
|
2571
|
-
// responds with a since-seq delta (often empty), and the client's
|
|
2572
|
-
// reset `currentValue = undefined` never gets repopulated -- the
|
|
2573
|
-
// store stays undefined and any `{#if $store === undefined}` spinner
|
|
2574
|
-
// hangs forever. In-session WS reconnects do NOT go through cleanup,
|
|
2575
|
-
// so the replay-buffer gap-fill optimization is preserved for those.
|
|
2576
2600
|
_lastSeq = null;
|
|
2577
2601
|
_lastVersion = undefined;
|
|
2578
2602
|
_schemaVersion = initialSchemaVersion;
|
|
@@ -2582,8 +2606,95 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2582
2606
|
_devtoolsStream(path, null, 0, merge);
|
|
2583
2607
|
}
|
|
2584
2608
|
|
|
2585
|
-
/**
|
|
2609
|
+
/**
|
|
2610
|
+
* Full cleanup: release WS handles AND reset session state. Equivalent
|
|
2611
|
+
* to the pre-grace cleanup; used when resumeGraceMs is 0 (opt-out) or
|
|
2612
|
+
* when grace expires.
|
|
2613
|
+
*/
|
|
2614
|
+
function cleanup() {
|
|
2615
|
+
_releaseSubscription();
|
|
2616
|
+
_resetSession();
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/** @type {boolean} Whether a microtask-deferred cleanup is pending (handles rapid sync unsub+resub) */
|
|
2586
2620
|
let _pendingCleanup = false;
|
|
2621
|
+
/** @type {ReturnType<typeof setTimeout> | null} Resume-grace expiry timer; non-null while state is being retained for a possible resume */
|
|
2622
|
+
let _resumeGraceTimer = null;
|
|
2623
|
+
/** @type {boolean} Whether the stream is in the resume-grace window (released WS, retained data) */
|
|
2624
|
+
let _inGracePeriod = false;
|
|
2625
|
+
|
|
2626
|
+
/**
|
|
2627
|
+
* Wire up the per-subscribe lifecycle listeners: quiescence tracking
|
|
2628
|
+
* (registers the stream with the global in-flight counter) and the
|
|
2629
|
+
* reconnect-on-open watcher. Shared between first-subscribe and
|
|
2630
|
+
* resume-from-grace so both paths get the same listener setup.
|
|
2631
|
+
*/
|
|
2632
|
+
function _attachLifecycleListeners() {
|
|
2633
|
+
_quiescenceUnsub = _statusStore.subscribe((s) => {
|
|
2634
|
+
const inFlight = s === 'loading' || s === 'reconnecting';
|
|
2635
|
+
if (inFlight && !_countedInFlight) {
|
|
2636
|
+
_countedInFlight = true;
|
|
2637
|
+
_addInFlight();
|
|
2638
|
+
} else if (!inFlight && _countedInFlight) {
|
|
2639
|
+
_countedInFlight = false;
|
|
2640
|
+
_removeInFlight();
|
|
2641
|
+
}
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
// `status.subscribe` fires synchronously with the current value, which
|
|
2645
|
+
// for a stream subscribing during page hydration is usually 'connecting'
|
|
2646
|
+
// (since `_connect()` is lazy on the first subscriber). Filter on 'open'
|
|
2647
|
+
// first and track whether we've ever seen one, so the FIRST 'open' is
|
|
2648
|
+
// the lifetime baseline rather than treating it as a reconnect bounce.
|
|
2649
|
+
let hasOpenedOnce = false;
|
|
2650
|
+
statusUnsub = status.subscribe((s) => {
|
|
2651
|
+
if (s !== 'open') return;
|
|
2652
|
+
if (!hasOpenedOnce) {
|
|
2653
|
+
hasOpenedOnce = true;
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
if (subCount > 0) {
|
|
2657
|
+
_status = 'reconnecting';
|
|
2658
|
+
_statusStore.set('reconnecting');
|
|
2659
|
+
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
|
2660
|
+
let delay;
|
|
2661
|
+
// Reconnect jitter: spread a fleet's reconnect attempts across the
|
|
2662
|
+
// window so a server restart does not get a thundering-herd retry
|
|
2663
|
+
// spike. Math.random is the right primitive here - jitter does not
|
|
2664
|
+
// need crypto-quality entropy. Not security-relevant.
|
|
2665
|
+
if (_reconnectAttempts < 2) {
|
|
2666
|
+
delay = 20 + Math.floor(Math.random() * 80);
|
|
2667
|
+
} else {
|
|
2668
|
+
const base = Math.min(1000 * Math.pow(2.2, _reconnectAttempts - 2), 300000);
|
|
2669
|
+
delay = Math.floor(base * (0.75 + Math.random() * 0.5));
|
|
2670
|
+
}
|
|
2671
|
+
_reconnectAttempts++;
|
|
2672
|
+
_reconnectTimer = setTimeout(() => {
|
|
2673
|
+
_reconnectTimer = null;
|
|
2674
|
+
if (topicUnsub) {
|
|
2675
|
+
topicUnsub();
|
|
2676
|
+
topicUnsub = null;
|
|
2677
|
+
}
|
|
2678
|
+
initialLoaded = false;
|
|
2679
|
+
fetching = false;
|
|
2680
|
+
buffer = [];
|
|
2681
|
+
fetchAndSubscribe();
|
|
2682
|
+
}, delay);
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
// Surface terminal close as an error on the stream (adapter 0.4.0)
|
|
2687
|
+
try {
|
|
2688
|
+
const conn = _connect();
|
|
2689
|
+
if (conn && typeof conn.ready === 'function') {
|
|
2690
|
+
conn.ready().catch((/** @type {any} */ err) => {
|
|
2691
|
+
if (subCount > 0) {
|
|
2692
|
+
_setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
} catch {}
|
|
2697
|
+
}
|
|
2587
2698
|
|
|
2588
2699
|
return {
|
|
2589
2700
|
// Stamped metadata so test-affordances like `subscribeAt`
|
|
@@ -2598,83 +2709,35 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2598
2709
|
subscribe(fn) {
|
|
2599
2710
|
if (subCount++ === 0) {
|
|
2600
2711
|
if (_pendingCleanup) {
|
|
2601
|
-
// Rapid resub - cancel the pending cleanup,
|
|
2712
|
+
// Rapid sync resub (same microtask) - cancel the pending cleanup,
|
|
2713
|
+
// WS subscription is still attached.
|
|
2602
2714
|
_pendingCleanup = false;
|
|
2603
|
-
} else {
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
if (inFlight && !_countedInFlight) {
|
|
2614
|
-
_countedInFlight = true;
|
|
2615
|
-
_addInFlight();
|
|
2616
|
-
} else if (!inFlight && _countedInFlight) {
|
|
2617
|
-
_countedInFlight = false;
|
|
2618
|
-
_removeInFlight();
|
|
2619
|
-
}
|
|
2620
|
-
});
|
|
2621
|
-
|
|
2622
|
-
// Listen for reconnects to refetch (debounced to avoid thundering herd).
|
|
2623
|
-
// `status.subscribe` fires synchronously with the current value, which
|
|
2624
|
-
// for a stream subscribing during page hydration is usually 'connecting'
|
|
2625
|
-
// (since `_connect()` is lazy on the first subscriber). Filter on 'open'
|
|
2626
|
-
// first and track whether we've ever seen one, so the FIRST 'open' is
|
|
2627
|
-
// the lifetime baseline rather than treating it as a reconnect bounce.
|
|
2628
|
-
let hasOpenedOnce = false;
|
|
2629
|
-
statusUnsub = status.subscribe((s) => {
|
|
2630
|
-
if (s !== 'open') return;
|
|
2631
|
-
if (!hasOpenedOnce) {
|
|
2632
|
-
hasOpenedOnce = true;
|
|
2633
|
-
return;
|
|
2715
|
+
} else if (_inGracePeriod) {
|
|
2716
|
+
// Resume during the grace window: WS handles were released but
|
|
2717
|
+
// session state (currentValue, _lastSeq, _lastVersion, _cursor)
|
|
2718
|
+
// is intact. Re-attach lifecycle listeners and call
|
|
2719
|
+
// fetchAndSubscribe(); the retained cursors ride along on the
|
|
2720
|
+
// subscribe envelope so the server can fill the gap from its
|
|
2721
|
+
// replay buffer (or fromSeq, or truncated -> full rehydrate).
|
|
2722
|
+
if (_resumeGraceTimer) {
|
|
2723
|
+
clearTimeout(_resumeGraceTimer);
|
|
2724
|
+
_resumeGraceTimer = null;
|
|
2634
2725
|
}
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
_reconnectAttempts++;
|
|
2651
|
-
_reconnectTimer = setTimeout(() => {
|
|
2652
|
-
_reconnectTimer = null;
|
|
2653
|
-
if (topicUnsub) {
|
|
2654
|
-
topicUnsub();
|
|
2655
|
-
topicUnsub = null;
|
|
2656
|
-
}
|
|
2657
|
-
initialLoaded = false;
|
|
2658
|
-
fetching = false;
|
|
2659
|
-
buffer = [];
|
|
2660
|
-
fetchAndSubscribe();
|
|
2661
|
-
}, delay);
|
|
2662
|
-
}
|
|
2663
|
-
});
|
|
2664
|
-
|
|
2665
|
-
// Surface terminal close as an error on the stream (adapter 0.4.0)
|
|
2666
|
-
try {
|
|
2667
|
-
const conn = _connect();
|
|
2668
|
-
if (conn && typeof conn.ready === 'function') {
|
|
2669
|
-
conn.ready().catch((/** @type {any} */ err) => {
|
|
2670
|
-
if (subCount > 0) {
|
|
2671
|
-
_setError(new RpcError(err?.code || 'CONNECTION_CLOSED', err?.message || 'Connection permanently closed'));
|
|
2672
|
-
}
|
|
2673
|
-
});
|
|
2674
|
-
}
|
|
2675
|
-
} catch {}
|
|
2676
|
-
|
|
2677
|
-
} // end else (not _pendingCleanup)
|
|
2726
|
+
_inGracePeriod = false;
|
|
2727
|
+
// Flip status back to 'loading' so the newly-attached quiescence
|
|
2728
|
+
// subscriber sees us as in-flight while the resume envelope is
|
|
2729
|
+
// outstanding (it fires synchronously with the current value).
|
|
2730
|
+
_status = 'loading';
|
|
2731
|
+
_statusStore.set('loading');
|
|
2732
|
+
_attachLifecycleListeners();
|
|
2733
|
+
fetchAndSubscribe();
|
|
2734
|
+
_devtoolsStream(path, topic, subCount, merge);
|
|
2735
|
+
} else {
|
|
2736
|
+
// First subscriber - start the stream
|
|
2737
|
+
fetchAndSubscribe();
|
|
2738
|
+
_devtoolsStream(path, topic, subCount, merge);
|
|
2739
|
+
_attachLifecycleListeners();
|
|
2740
|
+
}
|
|
2678
2741
|
}
|
|
2679
2742
|
|
|
2680
2743
|
const unsub = store.subscribe(fn);
|
|
@@ -2686,7 +2749,24 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
|
|
|
2686
2749
|
queueMicrotask(() => {
|
|
2687
2750
|
if (_pendingCleanup && subCount === 0) {
|
|
2688
2751
|
_pendingCleanup = false;
|
|
2689
|
-
|
|
2752
|
+
const graceMs = _getResumeGraceMs();
|
|
2753
|
+
if (graceMs > 0) {
|
|
2754
|
+
// Release WS handles immediately (give server back the
|
|
2755
|
+
// subscription, stop counting toward quiescence) but
|
|
2756
|
+
// retain session state for graceMs to support pause/resume
|
|
2757
|
+
// and back/forward navigation patterns. If a new
|
|
2758
|
+
// subscribe() lands before the timer fires, it resumes
|
|
2759
|
+
// from the retained seq via fetchAndSubscribe.
|
|
2760
|
+
_releaseSubscription();
|
|
2761
|
+
_inGracePeriod = true;
|
|
2762
|
+
_resumeGraceTimer = setTimeout(() => {
|
|
2763
|
+
_resumeGraceTimer = null;
|
|
2764
|
+
_inGracePeriod = false;
|
|
2765
|
+
_resetSession();
|
|
2766
|
+
}, graceMs);
|
|
2767
|
+
} else {
|
|
2768
|
+
cleanup();
|
|
2769
|
+
}
|
|
2690
2770
|
}
|
|
2691
2771
|
});
|
|
2692
2772
|
}
|
|
@@ -3336,7 +3416,7 @@ function _checkArgs(path, args) {
|
|
|
3336
3416
|
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
|
|
3337
3417
|
*/
|
|
3338
3418
|
|
|
3339
|
-
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: 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 } }} */
|
|
3419
|
+
/** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
|
|
3340
3420
|
let _clientConfig = {};
|
|
3341
3421
|
|
|
3342
3422
|
/** @type {boolean} */
|
|
@@ -3352,9 +3432,15 @@ let _isOffline = false;
|
|
|
3352
3432
|
let _replayingQueue = false;
|
|
3353
3433
|
|
|
3354
3434
|
/**
|
|
3355
|
-
* Configure client-side connection hooks
|
|
3435
|
+
* Configure client-side connection hooks, RPC timeout, stream resume
|
|
3436
|
+
* grace window, and offline queue.
|
|
3437
|
+
*
|
|
3438
|
+
* `resumeGraceMs` (default 60000) controls how long a stream retains its
|
|
3439
|
+
* data model after the last subscriber unsubs. A new subscribe within the
|
|
3440
|
+
* window resumes from the retained seq/version/cursor so the server can
|
|
3441
|
+
* gap-fill instead of cold-rehydrating. Set to 0 to disable.
|
|
3356
3442
|
*
|
|
3357
|
-
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, 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
|
|
3443
|
+
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
|
|
3358
3444
|
*/
|
|
3359
3445
|
export function configure(config) {
|
|
3360
3446
|
_clientConfig = config;
|
package/package.json
CHANGED
package/server.d.ts
CHANGED
|
@@ -2445,3 +2445,123 @@ export function close(
|
|
|
2445
2445
|
ws: WebSocket<any>,
|
|
2446
2446
|
ctx: { platform: Platform; subscriptions?: Set<string> | string[] }
|
|
2447
2447
|
): void;
|
|
2448
|
+
|
|
2449
|
+
// ---------------------------------------------------------------------------
|
|
2450
|
+
// Cluster wiring + realtime() convenience factory
|
|
2451
|
+
// ---------------------------------------------------------------------------
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* Cluster bus contract. Any object exposing `wrap(platform)` matches.
|
|
2455
|
+
* The canonical implementation is `redisBus()` from
|
|
2456
|
+
* `svelte-adapter-uws-extensions/redis/pubsub`; sharded-pubsub and
|
|
2457
|
+
* test buses conform to the same shape.
|
|
2458
|
+
*/
|
|
2459
|
+
export interface ClusterBus {
|
|
2460
|
+
wrap(platform: Platform): Platform;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* Configure the process-wide cluster bus. One declaration of cluster
|
|
2465
|
+
* intent reaches every framework publish surface in lockstep: RPC
|
|
2466
|
+
* `ctx.publish` (auto-wrapped by `message` / `createMessage`), cron
|
|
2467
|
+
* tick publishes, the reactive watcher publish wrap (`live.effect`,
|
|
2468
|
+
* `live.derived`, `live.aggregate`, `live.webhook`), and the top-level
|
|
2469
|
+
* `publish()` helper.
|
|
2470
|
+
*
|
|
2471
|
+
* Pass `null` to clear and revert to single-replica behaviour.
|
|
2472
|
+
*
|
|
2473
|
+
* `configureCron({ bus })` is equivalent for the bus field - they
|
|
2474
|
+
* write the same backing state. Most apps reach for
|
|
2475
|
+
* `realtime({ bus, leader })` instead and never call this directly.
|
|
2476
|
+
*/
|
|
2477
|
+
export function setBus(bus: ClusterBus | null): void;
|
|
2478
|
+
|
|
2479
|
+
/** Read the process-wide bus, or `null` when none is configured. */
|
|
2480
|
+
export function getBus(): ClusterBus | null;
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Read the framework-owned composed platform - the same reference
|
|
2484
|
+
* handed to every reactive handler. Returns `null` before the adapter's
|
|
2485
|
+
* `init({ platform })` hook has captured it on this worker.
|
|
2486
|
+
*/
|
|
2487
|
+
export function getPlatform(): Platform | null;
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* Publish from outside a framework handler (e.g. a `+server.js` HTTP
|
|
2491
|
+
* handler). Routes through the composed platform so the publish reaches
|
|
2492
|
+
* every local subscriber, fires every reactive watcher, and relays to
|
|
2493
|
+
* other cluster instances when a bus is wired. Throws when called
|
|
2494
|
+
* before the platform has been captured.
|
|
2495
|
+
*/
|
|
2496
|
+
export function publish(topic: string, event: string, data?: unknown, options?: unknown): void;
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Configuration accepted by `realtime()`.
|
|
2500
|
+
*/
|
|
2501
|
+
export interface RealtimeConfig {
|
|
2502
|
+
/**
|
|
2503
|
+
* Cluster bus. Pass `redisBus()` or any object exposing
|
|
2504
|
+
* `wrap(platform)` to enable cluster fan-out. Omit (or pass `null`)
|
|
2505
|
+
* for single-replica.
|
|
2506
|
+
*/
|
|
2507
|
+
bus?: ClusterBus | null;
|
|
2508
|
+
/**
|
|
2509
|
+
* Cluster leader gate for cron's "exactly once across the cluster"
|
|
2510
|
+
* semantics. Pass `redisLeader().isLeader` (or any function returning
|
|
2511
|
+
* the current leader status). Omit for single-replica or for the
|
|
2512
|
+
* "every worker fires" default.
|
|
2513
|
+
*/
|
|
2514
|
+
leader?: (() => boolean) | null;
|
|
2515
|
+
/**
|
|
2516
|
+
* Optional `upgrade` hook handed straight back out as part of the
|
|
2517
|
+
* returned hook set. Lets you write a single one-import-one-
|
|
2518
|
+
* destructure `hooks.ws.js`. Omit and export your own `upgrade`
|
|
2519
|
+
* separately if you prefer.
|
|
2520
|
+
*/
|
|
2521
|
+
upgrade?: (...args: any[]) => any;
|
|
2522
|
+
/**
|
|
2523
|
+
* Optional error handler for cron / effect / derived failures.
|
|
2524
|
+
* Equivalent to calling `onError(handler)`.
|
|
2525
|
+
*/
|
|
2526
|
+
onError?: (path: string, error: unknown) => void;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* Shape returned by `realtime()`. Spread into `hooks.ws.js` exports.
|
|
2531
|
+
* `upgrade` is present only when the caller passed one in the config.
|
|
2532
|
+
*/
|
|
2533
|
+
export interface RealtimeHooks {
|
|
2534
|
+
open(ws: any, ctx: { platform: Platform }): void;
|
|
2535
|
+
close(ws: any, ctx: { platform: Platform; subscriptions?: Set<string> | string[] }): void;
|
|
2536
|
+
message(ws: any, ctx: { data: ArrayBuffer; platform: Platform }): void;
|
|
2537
|
+
init(ctx: { platform: Platform }): void;
|
|
2538
|
+
upgrade?: (...args: any[]) => any;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/**
|
|
2542
|
+
* One-call setup that wires every framework seam from a single
|
|
2543
|
+
* declaration of cluster intent. Returns the standard adapter hook
|
|
2544
|
+
* set so `hooks.ws.js` is a one-import-one-destructure file.
|
|
2545
|
+
*
|
|
2546
|
+
* Single-replica:
|
|
2547
|
+
* ```js
|
|
2548
|
+
* import { realtime } from 'svelte-realtime/server';
|
|
2549
|
+
* export const { open, close, message, init } = realtime();
|
|
2550
|
+
* export function upgrade({ cookies }) { ... }
|
|
2551
|
+
* ```
|
|
2552
|
+
*
|
|
2553
|
+
* Cluster:
|
|
2554
|
+
* ```js
|
|
2555
|
+
* import { realtime } from 'svelte-realtime/server';
|
|
2556
|
+
* import { redisBus, redisLeader } from 'svelte-adapter-uws-extensions/redis';
|
|
2557
|
+
* export const { open, close, message, init } = realtime({
|
|
2558
|
+
* bus: redisBus(),
|
|
2559
|
+
* leader: redisLeader().isLeader,
|
|
2560
|
+
* });
|
|
2561
|
+
* export function upgrade({ cookies }) { ... }
|
|
2562
|
+
* ```
|
|
2563
|
+
*
|
|
2564
|
+
* Handler-level code is byte-identical between the two modes; the only
|
|
2565
|
+
* difference is whether `bus` and `leader` are passed at the top.
|
|
2566
|
+
*/
|
|
2567
|
+
export function realtime(config?: RealtimeConfig): RealtimeHooks;
|
package/server.js
CHANGED
|
@@ -3426,23 +3426,80 @@ let _cronPlatform = null;
|
|
|
3426
3426
|
let _cronLeader = null;
|
|
3427
3427
|
|
|
3428
3428
|
/**
|
|
3429
|
-
*
|
|
3430
|
-
*
|
|
3431
|
-
*
|
|
3432
|
-
*
|
|
3433
|
-
*
|
|
3434
|
-
*
|
|
3435
|
-
*
|
|
3436
|
-
*
|
|
3437
|
-
*
|
|
3438
|
-
*
|
|
3439
|
-
*
|
|
3440
|
-
*
|
|
3429
|
+
* Process-wide cluster bus. Single source of truth consulted by every
|
|
3430
|
+
* publish surface in the framework (RPC `ctx.publish`, cron tick,
|
|
3431
|
+
* reactive watchers' publish wrap, top-level `publish()` helper). When
|
|
3432
|
+
* set, outbound publishes relay to other cluster instances via
|
|
3433
|
+
* `bus.wrap(platform).publish`; inbound relays from other instances
|
|
3434
|
+
* arrive through the bus's own subscriber and are broadcast on this
|
|
3435
|
+
* instance via the wrapped surrogate's `publish`.
|
|
3436
|
+
*
|
|
3437
|
+
* Written by `setBus(bus)`, `configureCron({ bus })`, and the
|
|
3438
|
+
* `realtime({ bus })` factory. Read via `getBus()` and consulted at
|
|
3439
|
+
* publish-time by every framework seam so a single declaration of
|
|
3440
|
+
* deployment intent covers all of them. Without a bus, every seam
|
|
3441
|
+
* publishes locally (the single-replica default).
|
|
3442
|
+
*
|
|
3443
|
+
* Composition discipline: `bus.wrap(...)` is applied at publish time
|
|
3444
|
+
* over a snapshot of the raw adapter publish (the "surrogate"). The
|
|
3445
|
+
* reactive seam mutates `platform.publish` to a `derivedPublish` that
|
|
3446
|
+
* routes through the wrapped surrogate when a bus is configured, so
|
|
3447
|
+
* the same publish call fires reactive watchers AND relays to the
|
|
3448
|
+
* cluster in one step. Inbound relays from other instances arrive via
|
|
3449
|
+
* the wrapped surrogate's publish (which runs the local broadcast + the
|
|
3450
|
+
* watcher fan-out without re-relaying), so derived / effect /
|
|
3451
|
+
* aggregate handlers on receiving instances see the cross-cluster
|
|
3452
|
+
* stream the same way they see local publishes.
|
|
3441
3453
|
*
|
|
3442
3454
|
* @type {{ wrap: (platform: any) => any } | null}
|
|
3443
3455
|
*/
|
|
3456
|
+
let _bus = null;
|
|
3457
|
+
/**
|
|
3458
|
+
* Legacy alias retained so the existing `_cronBus` references in the
|
|
3459
|
+
* cron tick read the canonical bus without surgery. Always equal to
|
|
3460
|
+
* `_bus` (the setter writes both in lockstep). Treat as read-only.
|
|
3461
|
+
*/
|
|
3444
3462
|
let _cronBus = null;
|
|
3445
3463
|
|
|
3464
|
+
/**
|
|
3465
|
+
* Sentinel attached to platforms that have been wrapped with the
|
|
3466
|
+
* process-wide bus. Lets the framework detect already-wrapped inputs
|
|
3467
|
+
* and skip re-wrapping, so a user who passes a manually `bus.wrap`-ed
|
|
3468
|
+
* platform via `createMessage({ platform })` is not double-wrapped by
|
|
3469
|
+
* the auto-wrap path.
|
|
3470
|
+
*/
|
|
3471
|
+
const _BUS_WRAPPED = Symbol.for('svelte-realtime.busWrapped');
|
|
3472
|
+
|
|
3473
|
+
/**
|
|
3474
|
+
* Write the process-wide bus. Validated like `configureCron({ bus })`
|
|
3475
|
+
* - must expose `.wrap(platform)` or be `null`. Mirrored into the
|
|
3476
|
+
* legacy `_cronBus` alias so the existing cron tick keeps reading the
|
|
3477
|
+
* canonical value without surgery. Bumps `_busEpoch` so memoized
|
|
3478
|
+
* `bus.wrap(...)` caches (per-platform, computed lazily by the
|
|
3479
|
+
* reactive wrap and the RPC message hooks) invalidate on swap.
|
|
3480
|
+
* @param {{ wrap: (platform: any) => any } | null} bus
|
|
3481
|
+
*/
|
|
3482
|
+
function _setBus(bus) {
|
|
3483
|
+
if (bus !== null && (typeof bus !== 'object' || typeof bus.wrap !== 'function')) {
|
|
3484
|
+
throw new Error('[svelte-realtime] setBus: bus must expose a .wrap(platform) method or be null');
|
|
3485
|
+
}
|
|
3486
|
+
_bus = bus;
|
|
3487
|
+
_cronBus = bus;
|
|
3488
|
+
_busEpoch++;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
/** Read the process-wide bus (or null when no cluster intent is wired). */
|
|
3492
|
+
function _getBus() {
|
|
3493
|
+
return _bus;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
/**
|
|
3497
|
+
* Monotonic counter bumped on every bus swap. Used by per-platform
|
|
3498
|
+
* `bus.wrap(...)` caches to detect "the bus changed under me, re-wrap"
|
|
3499
|
+
* without holding a strong reference to the old bus.
|
|
3500
|
+
*/
|
|
3501
|
+
let _busEpoch = 0;
|
|
3502
|
+
|
|
3446
3503
|
/**
|
|
3447
3504
|
* One-shot flag for the "configureCron leader without bus" warning.
|
|
3448
3505
|
* Setting `leader` declares cluster intent; not also wiring a bus
|
|
@@ -5192,6 +5249,49 @@ function _wrapPlatformPublish(platform) {
|
|
|
5192
5249
|
? /** @type {any} */ (platform).publishBatched.bind(platform)
|
|
5193
5250
|
: null;
|
|
5194
5251
|
|
|
5252
|
+
// Memoized bus-wrapped surrogate. Recomputed when the process-wide
|
|
5253
|
+
// bus changes (detected via `_busEpoch`). The surrogate's `publish`
|
|
5254
|
+
// is `derivedPublishLocal` (local broadcast + watcher fan-out, no
|
|
5255
|
+
// re-relay), so inbound bus deliveries fire watchers on the
|
|
5256
|
+
// receiving instance without bouncing the message back out onto the
|
|
5257
|
+
// bus. The wrapped surrogate's `publish` (set up by the extension's
|
|
5258
|
+
// `bus.wrap`) does relay + delegate-to-surrogate; outbound publishes
|
|
5259
|
+
// from user code go through `derivedPublish` below, which routes via
|
|
5260
|
+
// this cache when a bus is configured.
|
|
5261
|
+
let _cachedBusEpoch = -1;
|
|
5262
|
+
/** @type {((topic: string, event: string, data: any, opts?: any) => any) | null} */
|
|
5263
|
+
let _busPublish = null;
|
|
5264
|
+
/** @type {((batch: any) => any) | null} */
|
|
5265
|
+
let _busPublishBatched = null;
|
|
5266
|
+
function _refreshBusCache() {
|
|
5267
|
+
if (_cachedBusEpoch === _busEpoch) return;
|
|
5268
|
+
_cachedBusEpoch = _busEpoch;
|
|
5269
|
+
const bus = _getBus();
|
|
5270
|
+
if (!bus) {
|
|
5271
|
+
_busPublish = null;
|
|
5272
|
+
_busPublishBatched = null;
|
|
5273
|
+
return;
|
|
5274
|
+
}
|
|
5275
|
+
// Surrogate holds derivedPublishLocal as its publish so inbound
|
|
5276
|
+
// cluster relays still fire reactive watchers on this instance
|
|
5277
|
+
// but do not bounce back out. Spread carries the rest of the
|
|
5278
|
+
// platform surface (subscribe, send, redis, replay, ...) so the
|
|
5279
|
+
// extensions's bus.wrap sees a complete platform shape.
|
|
5280
|
+
/** @type {any} */
|
|
5281
|
+
const surrogate = Object.assign(Object.create(Object.getPrototypeOf(platform)), platform);
|
|
5282
|
+
surrogate.publish = derivedPublishLocal;
|
|
5283
|
+
if (originalPublishBatched) surrogate.publishBatched = derivedPublishBatchedLocal;
|
|
5284
|
+
const wrapped = bus.wrap(surrogate);
|
|
5285
|
+
// Tag so a downstream auto-wrap pass (e.g. message hook) can
|
|
5286
|
+
// detect "already wrapped by us" and skip re-wrapping. The tag
|
|
5287
|
+
// records the bus identity so a later swap re-wraps cleanly.
|
|
5288
|
+
/** @type {any} */ (wrapped)[_BUS_WRAPPED] = bus;
|
|
5289
|
+
_busPublish = typeof wrapped.publish === 'function' ? wrapped.publish.bind(wrapped) : null;
|
|
5290
|
+
_busPublishBatched = typeof /** @type {any} */ (wrapped).publishBatched === 'function'
|
|
5291
|
+
? /** @type {any} */ (wrapped).publishBatched.bind(wrapped)
|
|
5292
|
+
: null;
|
|
5293
|
+
}
|
|
5294
|
+
|
|
5195
5295
|
let _publishDepth = 0;
|
|
5196
5296
|
|
|
5197
5297
|
function fireWatchers(topic, event, data) {
|
|
@@ -5297,10 +5397,37 @@ function _wrapPlatformPublish(platform) {
|
|
|
5297
5397
|
_publishDepth--;
|
|
5298
5398
|
}
|
|
5299
5399
|
|
|
5300
|
-
|
|
5400
|
+
// Inner publish used by the bus-wrap surrogate. Does the local
|
|
5401
|
+
// broadcast + watcher fan-out but NEVER relays - relay is the
|
|
5402
|
+
// outer `derivedPublish`'s job (via the wrapped surrogate). This
|
|
5403
|
+
// is also what runs when an inbound message arrives from another
|
|
5404
|
+
// instance, so cluster-relayed events fire derived / effect /
|
|
5405
|
+
// aggregate watchers on the receiving instance.
|
|
5406
|
+
function derivedPublishLocal(topic, event, data, opts) {
|
|
5301
5407
|
const result = originalPublish(topic, event, data, opts);
|
|
5302
5408
|
fireWatchers(topic, event, data);
|
|
5303
5409
|
return result;
|
|
5410
|
+
}
|
|
5411
|
+
|
|
5412
|
+
function derivedPublishBatchedLocal(batch) {
|
|
5413
|
+
const result = originalPublishBatched ? originalPublishBatched(batch) : undefined;
|
|
5414
|
+
if (Array.isArray(batch) && _watchedTopics.size > 0) {
|
|
5415
|
+
for (const item of batch) {
|
|
5416
|
+
if (!item || typeof item.topic !== 'string') continue;
|
|
5417
|
+
fireWatchers(item.topic, item.event, item.data);
|
|
5418
|
+
}
|
|
5419
|
+
}
|
|
5420
|
+
return result;
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
// Outbound user-facing publish. When a bus is configured, routes
|
|
5424
|
+
// through the wrapped surrogate so the publish both broadcasts
|
|
5425
|
+
// locally (with watchers) and relays to the cluster in one step.
|
|
5426
|
+
// Without a bus, identical to the legacy local-only path.
|
|
5427
|
+
platform.publish = function derivedPublish(topic, event, data, opts) {
|
|
5428
|
+
_refreshBusCache();
|
|
5429
|
+
if (_busPublish) return _busPublish(topic, event, data, opts);
|
|
5430
|
+
return derivedPublishLocal(topic, event, data, opts);
|
|
5304
5431
|
};
|
|
5305
5432
|
|
|
5306
5433
|
if (originalPublishBatched) {
|
|
@@ -5311,14 +5438,9 @@ function _wrapPlatformPublish(platform) {
|
|
|
5311
5438
|
// publishes from the batched path - they only fire from the unbatched
|
|
5312
5439
|
// platform.publish path that some test mocks happen to use.
|
|
5313
5440
|
/** @type {any} */ (platform).publishBatched = function derivedPublishBatched(batch) {
|
|
5314
|
-
|
|
5315
|
-
if (
|
|
5316
|
-
|
|
5317
|
-
if (!item || typeof item.topic !== 'string') continue;
|
|
5318
|
-
fireWatchers(item.topic, item.event, item.data);
|
|
5319
|
-
}
|
|
5320
|
-
}
|
|
5321
|
-
return result;
|
|
5441
|
+
_refreshBusCache();
|
|
5442
|
+
if (_busPublishBatched) return _busPublishBatched(batch);
|
|
5443
|
+
return derivedPublishBatchedLocal(batch);
|
|
5322
5444
|
};
|
|
5323
5445
|
}
|
|
5324
5446
|
}
|
|
@@ -5589,7 +5711,7 @@ export function setCronPlatform(platform) {
|
|
|
5589
5711
|
export function configureCron(config) {
|
|
5590
5712
|
if (config === null) {
|
|
5591
5713
|
_cronLeader = null;
|
|
5592
|
-
|
|
5714
|
+
_setBus(null);
|
|
5593
5715
|
return;
|
|
5594
5716
|
}
|
|
5595
5717
|
if (typeof config !== 'object') {
|
|
@@ -5608,13 +5730,15 @@ export function configureCron(config) {
|
|
|
5608
5730
|
}
|
|
5609
5731
|
}
|
|
5610
5732
|
if (config.bus !== undefined) {
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5733
|
+
// Routes through `_setBus` so the canonical `_bus` (consulted by
|
|
5734
|
+
// the reactive wrap, the RPC auto-wrap, and the top-level
|
|
5735
|
+
// `publish()` helper) stays in lockstep with the legacy
|
|
5736
|
+
// `_cronBus` alias - one declaration of cluster intent covers
|
|
5737
|
+
// every framework seam, not just cron.
|
|
5738
|
+
if (config.bus !== null && (typeof config.bus !== 'object' || typeof config.bus.wrap !== 'function')) {
|
|
5614
5739
|
throw new Error('[svelte-realtime] configureCron: bus must expose a .wrap(platform) method or be null');
|
|
5615
|
-
} else {
|
|
5616
|
-
_cronBus = config.bus;
|
|
5617
5740
|
}
|
|
5741
|
+
_setBus(config.bus);
|
|
5618
5742
|
}
|
|
5619
5743
|
// Diagnostic: cluster intent (leader) without cluster fan-out (bus)
|
|
5620
5744
|
// is almost always a misconfig. Leader-only cron ticks publish on the
|
|
@@ -8056,16 +8180,59 @@ export function close(ws, { platform, subscriptions }) {
|
|
|
8056
8180
|
}
|
|
8057
8181
|
}
|
|
8058
8182
|
|
|
8183
|
+
/**
|
|
8184
|
+
* Per-platform cache of the bus-wrapped surrogate used by the RPC hook
|
|
8185
|
+
* (`message` / `createMessage`). Keyed on the raw adapter platform, with
|
|
8186
|
+
* the bus identity stored alongside so a `setBus(differentBus)` swap is
|
|
8187
|
+
* detected and re-wrapped without holding a strong reference to the old
|
|
8188
|
+
* bus. WeakMap so the entry clears when the platform is GC-eligible.
|
|
8189
|
+
* @type {WeakMap<object, { bus: any, wrapped: any, epoch: number }>}
|
|
8190
|
+
*/
|
|
8191
|
+
const _rpcBusWrapCache = new WeakMap();
|
|
8192
|
+
|
|
8193
|
+
/**
|
|
8194
|
+
* Resolve the platform handed to `handleRpc` from the WS message path.
|
|
8195
|
+
* When a process-wide bus is configured (via `setBus`,
|
|
8196
|
+
* `configureCron({ bus })`, or `realtime({ bus })`), the raw adapter
|
|
8197
|
+
* platform is wrapped on first use and memoized for subsequent
|
|
8198
|
+
* messages on the same platform. When the user manually pre-wraps via
|
|
8199
|
+
* `createMessage({ platform })`, this is bypassed (their callback
|
|
8200
|
+
* runs first) so we never double-wrap.
|
|
8201
|
+
*
|
|
8202
|
+
* @param {import('svelte-adapter-uws').Platform} platform
|
|
8203
|
+
* @returns {import('svelte-adapter-uws').Platform}
|
|
8204
|
+
*/
|
|
8205
|
+
function _autoBusWrap(platform) {
|
|
8206
|
+
const bus = _getBus();
|
|
8207
|
+
if (!bus) return platform;
|
|
8208
|
+
// Idempotence: if the input has already been wrapped by this
|
|
8209
|
+
// framework against the current bus, return it untouched.
|
|
8210
|
+
if (/** @type {any} */ (platform)[_BUS_WRAPPED] === bus) return platform;
|
|
8211
|
+
const entry = _rpcBusWrapCache.get(/** @type {any} */ (platform));
|
|
8212
|
+
if (entry && entry.bus === bus && entry.epoch === _busEpoch) return entry.wrapped;
|
|
8213
|
+
const wrapped = bus.wrap(platform);
|
|
8214
|
+
/** @type {any} */ (wrapped)[_BUS_WRAPPED] = bus;
|
|
8215
|
+
_rpcBusWrapCache.set(/** @type {any} */ (platform), { bus, wrapped, epoch: _busEpoch });
|
|
8216
|
+
return wrapped;
|
|
8217
|
+
}
|
|
8218
|
+
|
|
8059
8219
|
/**
|
|
8060
8220
|
* Ready-made message hook. Re-export from hooks.ws.js for zero-config RPC routing.
|
|
8061
8221
|
*
|
|
8222
|
+
* When a process-wide bus is configured (via `setBus`,
|
|
8223
|
+
* `configureCron({ bus })`, or `realtime({ bus })`), this hook auto-
|
|
8224
|
+
* wraps the adapter platform so RPC `ctx.publish` relays to other
|
|
8225
|
+
* cluster instances without any per-hook wiring. Without a bus,
|
|
8226
|
+
* publishes stay local - the single-replica default with zero
|
|
8227
|
+
* overhead.
|
|
8228
|
+
*
|
|
8062
8229
|
* Signature matches the adapter's message hook exactly.
|
|
8063
8230
|
*
|
|
8064
8231
|
* @param {any} ws
|
|
8065
8232
|
* @param {{ data: ArrayBuffer, platform: import('svelte-adapter-uws').Platform }} ctx
|
|
8066
8233
|
*/
|
|
8067
8234
|
export function message(ws, { data, platform }) {
|
|
8068
|
-
handleRpc(ws, data, platform);
|
|
8235
|
+
handleRpc(ws, data, _autoBusWrap(platform));
|
|
8069
8236
|
}
|
|
8070
8237
|
|
|
8071
8238
|
/**
|
|
@@ -8086,10 +8253,205 @@ export function createMessage(options) {
|
|
|
8086
8253
|
const hasRpcOpts = beforeExecute || onError;
|
|
8087
8254
|
|
|
8088
8255
|
return function customMessage(ws, { data, platform }) {
|
|
8089
|
-
|
|
8256
|
+
// User-supplied `platform` callback signals "I am wiring the
|
|
8257
|
+
// transform myself"; we run it as-is and skip the auto bus
|
|
8258
|
+
// wrap so we never double-wrap. Without the callback, we
|
|
8259
|
+
// route through `_autoBusWrap` so the process-wide bus
|
|
8260
|
+
// reaches RPC handlers with zero per-hook config.
|
|
8261
|
+
const p = transformPlatform ? transformPlatform(platform) : _autoBusWrap(platform);
|
|
8090
8262
|
const handled = handleRpc(ws, data, p, hasRpcOpts ? rpcOpts : undefined);
|
|
8091
8263
|
if (!handled && onUnhandled) {
|
|
8092
8264
|
onUnhandled(ws, data, p);
|
|
8093
8265
|
}
|
|
8094
8266
|
};
|
|
8095
8267
|
}
|
|
8268
|
+
|
|
8269
|
+
// ---------------------------------------------------------------------------
|
|
8270
|
+
// Cluster wiring: process-wide bus + composed-platform accessors
|
|
8271
|
+
// ---------------------------------------------------------------------------
|
|
8272
|
+
|
|
8273
|
+
/**
|
|
8274
|
+
* Configure the process-wide cluster bus. Consumed by every framework
|
|
8275
|
+
* publish surface in lockstep: RPC `ctx.publish` (via the `message` /
|
|
8276
|
+
* `createMessage` auto-wrap), cron tick publishes, reactive watchers'
|
|
8277
|
+
* publish wrap (`live.effect`, `live.derived`, `live.aggregate`,
|
|
8278
|
+
* `live.webhook`), and the top-level `publish()` helper. One
|
|
8279
|
+
* declaration of cluster intent covers all of them.
|
|
8280
|
+
*
|
|
8281
|
+
* Pass a bus exposing `.wrap(platform)` (e.g. `redisBus()` from
|
|
8282
|
+
* `svelte-adapter-uws-extensions/redis/pubsub`) to enable cluster
|
|
8283
|
+
* fan-out. Pass `null` to clear and revert to single-replica behaviour.
|
|
8284
|
+
*
|
|
8285
|
+
* `configureCron({ bus })` is equivalent to `setBus(bus)` for the bus
|
|
8286
|
+
* field - they write the same backing state. Pick whichever reads more
|
|
8287
|
+
* naturally at the call site; the typical app uses
|
|
8288
|
+
* `realtime({ bus, leader })` instead and never calls either directly.
|
|
8289
|
+
*
|
|
8290
|
+
* @param {{ wrap: (platform: any) => any } | null} bus
|
|
8291
|
+
*
|
|
8292
|
+
* @example
|
|
8293
|
+
* ```js
|
|
8294
|
+
* // hooks.ws.js (Layer 1 / expert wiring)
|
|
8295
|
+
* import { setBus, setCronPlatform, _activateDerived, configureCron, message } from 'svelte-realtime/server';
|
|
8296
|
+
* import { redisBus, redisLeader } from 'svelte-adapter-uws-extensions/redis';
|
|
8297
|
+
*
|
|
8298
|
+
* const bus = redisBus();
|
|
8299
|
+
* setBus(bus);
|
|
8300
|
+
* configureCron({ leader: redisLeader() });
|
|
8301
|
+
*
|
|
8302
|
+
* export { message };
|
|
8303
|
+
* export function init({ platform }) {
|
|
8304
|
+
* setCronPlatform(platform);
|
|
8305
|
+
* _activateDerived(platform);
|
|
8306
|
+
* }
|
|
8307
|
+
* ```
|
|
8308
|
+
*/
|
|
8309
|
+
export function setBus(bus) {
|
|
8310
|
+
_setBus(bus);
|
|
8311
|
+
}
|
|
8312
|
+
|
|
8313
|
+
/**
|
|
8314
|
+
* Read the process-wide bus, or `null` when none is configured. Useful
|
|
8315
|
+
* for diagnostics, conditional cluster-only wiring, and tests.
|
|
8316
|
+
*
|
|
8317
|
+
* @returns {{ wrap: (platform: any) => any } | null}
|
|
8318
|
+
*/
|
|
8319
|
+
export function getBus() {
|
|
8320
|
+
return _getBus();
|
|
8321
|
+
}
|
|
8322
|
+
|
|
8323
|
+
/**
|
|
8324
|
+
* Read the framework-owned composed platform - the same reference handed
|
|
8325
|
+
* to every `live.effect` / `live.derived` / `live.aggregate` handler and
|
|
8326
|
+
* threaded through `ctx.platform` in RPC / cron / webhook contexts.
|
|
8327
|
+
*
|
|
8328
|
+
* Returns `null` before `setCronPlatform(platform)` /
|
|
8329
|
+
* `_activateDerived(platform)` / `realtime().init({ platform })` has
|
|
8330
|
+
* captured the adapter platform on this worker.
|
|
8331
|
+
*
|
|
8332
|
+
* Use for publish from outside a framework handler (e.g. a `+server.js`
|
|
8333
|
+
* HTTP handler) when you want the same cluster semantics. Most callers
|
|
8334
|
+
* should reach for the top-level `publish()` helper instead.
|
|
8335
|
+
*
|
|
8336
|
+
* @returns {import('svelte-adapter-uws').Platform | null}
|
|
8337
|
+
*/
|
|
8338
|
+
export function getPlatform() {
|
|
8339
|
+
return _derivedPlatform || _cronPlatform || null;
|
|
8340
|
+
}
|
|
8341
|
+
|
|
8342
|
+
/**
|
|
8343
|
+
* Publish from outside a framework handler. Routes through the
|
|
8344
|
+
* framework-owned composed platform, so the same publish reaches every
|
|
8345
|
+
* local subscriber, fires every reactive watcher (`live.effect`,
|
|
8346
|
+
* `live.derived`, `live.aggregate`), and relays to other cluster
|
|
8347
|
+
* instances when a bus is wired - identical semantics to a publish
|
|
8348
|
+
* inside an RPC, cron, or effect handler.
|
|
8349
|
+
*
|
|
8350
|
+
* Throws when the platform has not yet been captured (called before
|
|
8351
|
+
* the adapter's `init({ platform })` hook fires, or in a process where
|
|
8352
|
+
* no svelte-realtime wiring ran). For the rare case where you genuinely
|
|
8353
|
+
* want a no-op when the platform is absent (e.g. a shared utility
|
|
8354
|
+
* that may run in non-realtime contexts), guard with `getPlatform()`.
|
|
8355
|
+
*
|
|
8356
|
+
* @param {string} topic
|
|
8357
|
+
* @param {string} event
|
|
8358
|
+
* @param {unknown} data
|
|
8359
|
+
* @param {unknown} [options]
|
|
8360
|
+
*
|
|
8361
|
+
* @example
|
|
8362
|
+
* ```js
|
|
8363
|
+
* // src/routes/webhooks/+server.js
|
|
8364
|
+
* import { publish } from 'svelte-realtime/server';
|
|
8365
|
+
*
|
|
8366
|
+
* export async function POST({ request }) {
|
|
8367
|
+
* const payload = await request.json();
|
|
8368
|
+
* publish('audit', 'webhook', payload);
|
|
8369
|
+
* return new Response();
|
|
8370
|
+
* }
|
|
8371
|
+
* ```
|
|
8372
|
+
*/
|
|
8373
|
+
export function publish(topic, event, data, options) {
|
|
8374
|
+
const platform = getPlatform();
|
|
8375
|
+
if (!platform) {
|
|
8376
|
+
throw new Error('[svelte-realtime] publish: platform has not been captured yet. Wire `realtime({ ... }).init` (or `setCronPlatform` + `_activateDerived`) from your hooks.ws.js init({ platform }) hook before calling publish() at module scope.');
|
|
8377
|
+
}
|
|
8378
|
+
return platform.publish(topic, event, data, options);
|
|
8379
|
+
}
|
|
8380
|
+
|
|
8381
|
+
// ---------------------------------------------------------------------------
|
|
8382
|
+
// realtime() - Layer 2 convenience factory
|
|
8383
|
+
// ---------------------------------------------------------------------------
|
|
8384
|
+
|
|
8385
|
+
/**
|
|
8386
|
+
* One-call setup that wires every framework seam from a single
|
|
8387
|
+
* declaration of cluster intent. Returns the standard adapter hook
|
|
8388
|
+
* set (`open`, `close`, `message`, `init`) plus optional `upgrade`,
|
|
8389
|
+
* so `hooks.ws.js` is a one-import-one-destructure file:
|
|
8390
|
+
*
|
|
8391
|
+
* ```js
|
|
8392
|
+
* // src/hooks.ws.js (single-replica)
|
|
8393
|
+
* import { realtime } from 'svelte-realtime/server';
|
|
8394
|
+
* export const { upgrade, open, close, message, init } = realtime({
|
|
8395
|
+
* upgrade: ({ cookies }) => validate(cookies),
|
|
8396
|
+
* });
|
|
8397
|
+
* ```
|
|
8398
|
+
*
|
|
8399
|
+
* ```js
|
|
8400
|
+
* // src/hooks.ws.js (cluster)
|
|
8401
|
+
* import { realtime } from 'svelte-realtime/server';
|
|
8402
|
+
* import { redisBus, redisLeader } from 'svelte-adapter-uws-extensions/redis';
|
|
8403
|
+
*
|
|
8404
|
+
* export const { upgrade, open, close, message, init } = realtime({
|
|
8405
|
+
* bus: redisBus(),
|
|
8406
|
+
* leader: redisLeader().isLeader,
|
|
8407
|
+
* upgrade: ({ cookies }) => validate(cookies),
|
|
8408
|
+
* });
|
|
8409
|
+
* ```
|
|
8410
|
+
*
|
|
8411
|
+
* Handler-level code (`live.rpc`, `live.effect`, `live.derived`,
|
|
8412
|
+
* `live.aggregate`, `live.cron`, `live.webhook`, ...) is byte-identical
|
|
8413
|
+
* between the two modes - the only difference between single-replica
|
|
8414
|
+
* and cluster is whether `bus` and `leader` are passed at the top.
|
|
8415
|
+
*
|
|
8416
|
+
* `realtime()` is sugar over the existing primitives. Internally it
|
|
8417
|
+
* calls `setBus(bus)`, `configureCron({ leader })`,
|
|
8418
|
+
* `setCronPlatform(platform)`, and `_activateDerived(platform)` in the
|
|
8419
|
+
* right order when the adapter's `init` hook fires. Mixing `realtime()`
|
|
8420
|
+
* with direct calls to those primitives is supported - the primitives
|
|
8421
|
+
* remain first-class and write the same backing state.
|
|
8422
|
+
*
|
|
8423
|
+
* @param {{
|
|
8424
|
+
* bus?: { wrap: (platform: any) => any } | null,
|
|
8425
|
+
* leader?: (() => boolean) | null,
|
|
8426
|
+
* upgrade?: (...args: any[]) => any,
|
|
8427
|
+
* onError?: (path: string, error: unknown) => void,
|
|
8428
|
+
* }} [config]
|
|
8429
|
+
*/
|
|
8430
|
+
export function realtime(config) {
|
|
8431
|
+
const cfg = config || {};
|
|
8432
|
+
const { bus, leader, upgrade: upgradeFn, onError } = cfg;
|
|
8433
|
+
|
|
8434
|
+
if (bus !== undefined) _setBus(bus);
|
|
8435
|
+
if (leader !== undefined) configureCron({ leader });
|
|
8436
|
+
if (typeof onError === 'function') {
|
|
8437
|
+
// Routes through the existing module-level setter so the
|
|
8438
|
+
// behaviour matches a direct `onError(handler)` call - one
|
|
8439
|
+
// source of truth for the cron / effect / derived error path.
|
|
8440
|
+
_serverErrorHandler = onError;
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8443
|
+
const hooks = {
|
|
8444
|
+
open: pushHooks.open,
|
|
8445
|
+
close: pushHooks.close,
|
|
8446
|
+
message,
|
|
8447
|
+
init(ctx) {
|
|
8448
|
+
if (!ctx || !ctx.platform) {
|
|
8449
|
+
throw new Error('[svelte-realtime] realtime().init: missing platform on hook context (expected adapter init({ platform }) signature)');
|
|
8450
|
+
}
|
|
8451
|
+
setCronPlatform(ctx.platform);
|
|
8452
|
+
_activateDerived(ctx.platform);
|
|
8453
|
+
},
|
|
8454
|
+
};
|
|
8455
|
+
if (typeof upgradeFn === 'function') /** @type {any} */ (hooks).upgrade = upgradeFn;
|
|
8456
|
+
return hooks;
|
|
8457
|
+
}
|