svelte-realtime 0.5.5 → 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.
Files changed (4) hide show
  1. package/README.md +68 -15
  2. package/package.json +1 -1
  3. package/server.d.ts +120 -0
  4. package/server.js +391 -29
package/README.md CHANGED
@@ -2239,7 +2239,7 @@ On older adapters (`open(ws, platform)` is the only available hand-off point), c
2239
2239
 
2240
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.
2241
2241
 
2242
- Wire a cluster-wide leader gate via `configureCron({ leader, bus })`. 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`:
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`:
2243
2243
 
2244
2244
  ```js
2245
2245
  // src/hooks.ws.js (clustered, with extensions)
@@ -3014,28 +3014,70 @@ If `replay: true` is declared but `platform.replay` is never set, dev-mode logs
3014
3014
 
3015
3015
  ## Redis multi-instance
3016
3016
 
3017
- Use `createMessage` with the Redis pub/sub bus for multi-instance deployments. `ctx.publish` automatically goes through Redis when the platform is wrapped.
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.
3018
3018
 
3019
3019
  ```js
3020
3020
  // src/hooks.ws.js
3021
- import { createMessage } from 'svelte-realtime/server';
3022
- 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';
3023
3023
 
3024
3024
  const redis = createRedis();
3025
3025
  const bus = createPubSubBus(redis);
3026
+ const leader = createLeader(redis);
3026
3027
 
3027
- export function open(ws, { platform }) {
3028
- bus.activate(platform);
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;
3029
3035
  }
3036
+ ```
3030
3037
 
3038
+ Single-replica is the same file with no config:
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();
3031
3044
  export function upgrade({ cookies }) {
3032
3045
  return validateSession(cookies.session_id) || false;
3033
3046
  }
3047
+ ```
3048
+
3049
+ ### Layer 1: manual wiring (experts)
3050
+
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
+ }
3034
3072
 
3035
- export const message = createMessage({ platform: (p) => bus.wrap(p) });
3073
+ export function upgrade({ cookies }) {
3074
+ return validateSession(cookies.session_id) || false;
3075
+ }
3036
3076
  ```
3037
3077
 
3038
- No changes needed in your live modules. `ctx.publish` delegates to whatever platform was passed in, so Redis wrapping is transparent.
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.
3039
3081
 
3040
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.
3041
3083
 
@@ -3051,19 +3093,23 @@ When you add the Redis extensions from [svelte-adapter-uws-extensions](https://g
3051
3093
 
3052
3094
  ### Combined: Redis + rate limiting
3053
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
+
3054
3098
  ```js
3055
- import { createMessage, LiveError } from 'svelte-realtime/server';
3056
- 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';
3057
3101
 
3058
3102
  const redis = createRedis();
3059
3103
  const bus = createPubSubBus(redis);
3104
+ const leader = createLeader(redis);
3060
3105
  const limiter = createRateLimit(redis, { points: 30, interval: 10000 });
3061
3106
 
3062
- export function open(ws, { platform }) { bus.activate(platform); }
3107
+ setBus(bus);
3108
+
3109
+ export const { open, close, init } = realtime({ leader: leader.isLeader });
3063
3110
  export function upgrade({ cookies }) { return validateSession(cookies.session_id) || false; }
3064
3111
 
3065
3112
  export const message = createMessage({
3066
- platform: (p) => bus.wrap(p),
3067
3113
  async beforeExecute(ws, rpcPath) {
3068
3114
  const { allowed, resetMs } = await limiter.consume(ws);
3069
3115
  if (!allowed)
@@ -3072,6 +3118,8 @@ export const message = createMessage({
3072
3118
  });
3073
3119
  ```
3074
3120
 
3121
+ Without a `platform` callback, `createMessage` auto-wraps with whatever `setBus(...)` wired - one source of truth, no double-wrap.
3122
+
3075
3123
  ---
3076
3124
 
3077
3125
  ## Postgres NOTIFY
@@ -3586,13 +3634,18 @@ Import from `svelte-realtime/server`.
3586
3634
  | `guard(...fns)` | Per-module auth middleware |
3587
3635
  | `LiveError(code, message?)` | Typed error (propagates to client) |
3588
3636
  | `handleRpc(ws, data, platform, options?)` | Low-level RPC handler |
3589
- | `message` | Ready-made message hook |
3590
- | `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) |
3591
3644
  | `pipe(stream, ...transforms)` | Composable stream transforms |
3592
3645
  | `close` | Ready-made close hook (fires onUnsubscribe for remaining topics) |
3593
3646
  | `unsubscribe` | Ready-made unsubscribe hook (fires onUnsubscribe in real time) |
3594
3647
  | `setCronPlatform(platform)` | Capture platform for cron jobs (call from `init({ platform })`) |
3595
- | `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 |
3596
3649
  | `onError(handler)` | Global error handler for cron, effects, and derived |
3597
3650
  | `onCronError(handler)` | Deprecated alias for `onError` |
3598
3651
  | `enableSignals(ws)` | Enable point-to-point signal delivery |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
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
- * Optional cluster bus for cron fan-out. When set, every cron fire
3430
- * publishes through `_cronBus.wrap(_cronPlatform)` instead of through
3431
- * the raw platform, so leader-only ticks reach subscribers on
3432
- * non-leader instances via the bus's relay channel.
3433
- *
3434
- * Why this is cron-specific (not derived/effect/aggregate): derived /
3435
- * aggregate watchers re-publish through the realtime-wrapped
3436
- * `platform.publish` captured at activation time. Every instance sees
3437
- * the source-topic firehose via its own bus subscriber and computes
3438
- * its own derived locally; bus-relaying derived publishes would cause
3439
- * double delivery. Cron is different - only the leader fires it, so
3440
- * the leader's publish must relay or remote subscribers see nothing.
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
- platform.publish = function derivedPublish(topic, event, data, opts) {
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
- const result = originalPublishBatched(batch);
5315
- if (Array.isArray(batch) && _watchedTopics.size > 0) {
5316
- for (const item of batch) {
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
- _cronBus = null;
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
- if (config.bus === null) {
5612
- _cronBus = null;
5613
- } else if (typeof config.bus !== 'object' || typeof config.bus.wrap !== 'function') {
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
- const p = transformPlatform ? transformPlatform(platform) : platform;
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
+ }