svelte-realtime 0.6.0-next.71 → 0.6.0-next.73

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 CHANGED
@@ -3613,6 +3613,7 @@ How it behaves:
3613
3613
 
3614
3614
  - **The local entity is predicted.** `view.command(cmd)` applies your function immediately, so `view.local` moves this frame, and transmits the command (frame-batched, fire-and-forget - loss is recovered by reconciliation, never retransmission). The server applies the same commands through the same function on its authoritative tick and acknowledges each owner with the resulting state. When the acknowledged state disagrees with the prediction, the simulation adopts the server's answer immediately and the *rendered* position eases over a short window (`smoothTimeMs`, default 100) - unless the divergence is below `errorThreshold` (default 1), where easing would smear precision for nothing and the correction snaps silently.
3615
3615
  - **Remote entities render slightly in the past.** Their positions interpolate between server updates on a server-synced clock, so a dropped or late frame is invisible. `interpolationMs: 'auto'` (the default) tracks twice the measured update interval and collapses toward its floor when updates arrive at display rate. State fields beyond `x`/`y` are latest-value.
3616
+ - **The wire cadence can drop below the tick rate** (`broadcastHz`): the simulation keeps ticking at `tickMs`, but entity updates go out every Nth tick, with the motion of skipped ticks coalesced into the next frame (each entity's *current* state, never a stale one) and the interpolation above covering the widened gap - a 60Hz simulation broadcasting at 20Hz cuts the fan-out bandwidth to a third, which is usually the scaling ceiling long before CPU. Acknowledgements, discrete events, and removals stay per-tick, so the owner's reconciliation and one-shot actions never lag, and the final rest state always flushes when motion stops. Off by default: every tick broadcasts.
3616
3617
  - **Echo suppression is on by default** (`noEcho`): broadcasts of an owner's own commanded updates skip that owner - the acknowledgement already carries the authoritative copy, so the echo would be wasted bytes that fight the prediction. `onMissing` motion produces no acknowledgement and broadcasts to the owner too.
3617
3618
  - **One entity per identity per topic**, keyed like presence rosters (`identify`-style user keys, or per-connection guest keys). A second tab takes ownership by constructing a view (its sync re-binds the entity); it never races commands against the first. Ownership is last-sync-wins: a dispossessed view's commands are silently ignored until its un-acked window overflows, and the overflow recovery resync re-takes ownership - so exactly one view per identity should be commanding at a time.
3618
3619
  - **Side effects in `apply` must guard on `ctx.firstTime`** - reconciliation replays commands, and the flag is true only on a command's first application. Randomness inside `apply` must come from `ctx.rng` (reseeded per command id, identical on prediction, replay, and the server); `Math.random()` there is the documented mistake that turns every random command into a misprediction. `ctx.key` names the entity whose command is being applied - the attribution handle for authoritative side effects (who fired the shot, whose action to log): the server sets it for every application, and the owner's own prediction reports the same key (null until the first sync reply announces the identity). Requires svelte-adapter-uws >= 0.6.0-next.50.
@@ -3623,7 +3624,7 @@ How it behaves:
3623
3624
  - **`view.now()`** is the estimated server wall-clock time, maintained from the server's frame stamps and the acknowledgement round trips. It is the right stamp for lag-compensated action arguments (see below).
3624
3625
  - **The wire is negotiated.** Binary frames engage per connection (`smooth.protocol:1`); everything degrades to JSON additively - old clients ignore the new events, and a server without the smooth plugin fails the first sync or command with an actionable version message, never a resolution crash.
3625
3626
 
3626
- Knobs (all optional, on the server declaration: `tickMs` 50, `noEcho` true, `queueCap` 1024, `guard`, `onMissing`, `snapshot` false, `snapshotDebounceMs` 1000; on the client factory: `interpolationMs` 'auto', `extrapolateMs` 250, `snapGapMs` 500, `smoothTimeMs` 100, `errorThreshold` 1, `computeError`, `cmdRate` 60, `windowCap` 256, `windowMaxAgeMs` 3000). The tradeoff to know: larger `interpolationMs` survives more dropped frames but renders remote entities further in the past.
3627
+ Knobs (all optional, on the server declaration: `tickMs` 50, `broadcastHz` off, `noEcho` true, `queueCap` 1024, `guard`, `onMissing`, `snapshot` false, `snapshotDebounceMs` 1000; on the client factory: `interpolationMs` 'auto', `extrapolateMs` 250, `snapGapMs` 500, `smoothTimeMs` 100, `errorThreshold` 1, `computeError`, `cmdRate` 60, `windowCap` 256, `windowMaxAgeMs` 3000). The tradeoff to know: larger `interpolationMs` survives more dropped frames but renders remote entities further in the past.
3627
3628
 
3628
3629
  Requires Svelte 5 (the view is a rune class) and svelte-adapter-uws 0.6.0-next.26 or newer (`view.onEvent` needs the adapter's client-side event delivery; the prediction/reconciliation surface alone needs only next.24).
3629
3630
 
package/package.json CHANGED
@@ -1,119 +1,119 @@
1
- {
2
- "name": "svelte-realtime",
3
- "version": "0.6.0-next.71",
4
- "publishConfig": {
5
- "tag": "next"
6
- },
7
- "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
8
- "author": "Kevin Radziszewski",
9
- "license": "MIT",
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/lanteanio/svelte-realtime.git"
13
- },
14
- "homepage": "https://github.com/lanteanio/svelte-realtime#readme",
15
- "bugs": {
16
- "url": "https://github.com/lanteanio/svelte-realtime/issues"
17
- },
18
- "type": "module",
19
- "bin": {
20
- "svelte-realtime": "./src/cli.js"
21
- },
22
- "exports": {
23
- ".": {
24
- "types": "./src/server.d.ts",
25
- "default": "./src/server.js"
26
- },
27
- "./server": {
28
- "types": "./src/server.d.ts",
29
- "default": "./src/server.js"
30
- },
31
- "./client": {
32
- "types": "./src/client.d.ts",
33
- "default": "./src/client.js"
34
- },
35
- "./multiplayer": {
36
- "types": "./src/client-multiplayer.d.ts",
37
- "default": "./src/client-multiplayer.svelte.js"
38
- },
39
- "./smooth": {
40
- "types": "./src/client-smooth.d.ts",
41
- "default": "./src/client-smooth.svelte.js"
42
- },
43
- "./doc": {
44
- "types": "./src/client-doc.d.ts",
45
- "default": "./src/client-doc.svelte.js"
46
- },
47
- "./rooms": {
48
- "types": "./src/client-rooms.d.ts",
49
- "default": "./src/client-rooms.svelte.js"
50
- },
51
- "./vite": {
52
- "types": "./src/vite.d.ts",
53
- "default": "./src/vite.js"
54
- },
55
- "./devtools": {
56
- "types": "./src/devtools.d.ts",
57
- "default": "./src/devtools.js"
58
- },
59
- "./testing": {
60
- "types": "./src/testing.d.ts",
61
- "default": "./src/testing.js"
62
- },
63
- "./testing/client": {
64
- "types": "./src/testing-client.d.ts",
65
- "default": "./src/testing-client.js"
66
- },
67
- "./hooks": {
68
- "types": "./src/hooks.d.ts",
69
- "default": "./src/hooks.js"
70
- },
71
- "./sim": {
72
- "types": "./src/sim.d.ts",
73
- "default": "./src/sim.js"
74
- }
75
- },
76
- "files": [
77
- "src",
78
- "LICENSE",
79
- "README.md",
80
- "MIGRATION.md"
81
- ],
82
- "scripts": {
83
- "check": "node scripts/check-types.js && node scripts/check-determinism.js && node scripts/check-slugs.js",
84
- "pretest": "npm run check",
85
- "test": "vitest run",
86
- "test:watch": "vitest",
87
- "test:e2e": "playwright test --config test/e2e/playwright.config.js",
88
- "test:chaos": "playwright test --config test/chaos/playwright.config.js",
89
- "test:fuzz": "node test/fuzz-wire.mjs"
90
- },
91
- "engines": {
92
- "node": ">=22.0.0"
93
- },
94
- "dependencies": {
95
- "@clack/prompts": "^0.10.1"
96
- },
97
- "peerDependencies": {
98
- "@sveltejs/kit": "^2.0.0",
99
- "svelte": "^4.0.0 || ^5.0.0",
100
- "svelte-adapter-uws": "^0.6.0-next.60"
101
- },
102
- "devDependencies": {
103
- "@playwright/test": "^1.59.1",
104
- "fast-check": "^4.7.0",
105
- "svelte-adapter-uws-extensions": "^0.6.0-next.20",
106
- "vitest": "^4.0.18"
107
- },
108
- "keywords": [
109
- "svelte",
110
- "sveltekit",
111
- "websocket",
112
- "rpc",
113
- "realtime",
114
- "pubsub",
115
- "uwebsockets",
116
- "streams",
117
- "live"
118
- ]
119
- }
1
+ {
2
+ "name": "svelte-realtime",
3
+ "version": "0.6.0-next.73",
4
+ "publishConfig": {
5
+ "tag": "next"
6
+ },
7
+ "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
8
+ "author": "Kevin Radziszewski",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/lanteanio/svelte-realtime.git"
13
+ },
14
+ "homepage": "https://github.com/lanteanio/svelte-realtime#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/lanteanio/svelte-realtime/issues"
17
+ },
18
+ "type": "module",
19
+ "bin": {
20
+ "svelte-realtime": "./src/cli.js"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./src/server.d.ts",
25
+ "default": "./src/server.js"
26
+ },
27
+ "./server": {
28
+ "types": "./src/server.d.ts",
29
+ "default": "./src/server.js"
30
+ },
31
+ "./client": {
32
+ "types": "./src/client.d.ts",
33
+ "default": "./src/client.js"
34
+ },
35
+ "./multiplayer": {
36
+ "types": "./src/client-multiplayer.d.ts",
37
+ "default": "./src/client-multiplayer.svelte.js"
38
+ },
39
+ "./smooth": {
40
+ "types": "./src/client-smooth.d.ts",
41
+ "default": "./src/client-smooth.svelte.js"
42
+ },
43
+ "./doc": {
44
+ "types": "./src/client-doc.d.ts",
45
+ "default": "./src/client-doc.svelte.js"
46
+ },
47
+ "./rooms": {
48
+ "types": "./src/client-rooms.d.ts",
49
+ "default": "./src/client-rooms.svelte.js"
50
+ },
51
+ "./vite": {
52
+ "types": "./src/vite.d.ts",
53
+ "default": "./src/vite.js"
54
+ },
55
+ "./devtools": {
56
+ "types": "./src/devtools.d.ts",
57
+ "default": "./src/devtools.js"
58
+ },
59
+ "./testing": {
60
+ "types": "./src/testing.d.ts",
61
+ "default": "./src/testing.js"
62
+ },
63
+ "./testing/client": {
64
+ "types": "./src/testing-client.d.ts",
65
+ "default": "./src/testing-client.js"
66
+ },
67
+ "./hooks": {
68
+ "types": "./src/hooks.d.ts",
69
+ "default": "./src/hooks.js"
70
+ },
71
+ "./sim": {
72
+ "types": "./src/sim.d.ts",
73
+ "default": "./src/sim.js"
74
+ }
75
+ },
76
+ "files": [
77
+ "src",
78
+ "LICENSE",
79
+ "README.md",
80
+ "MIGRATION.md"
81
+ ],
82
+ "scripts": {
83
+ "check": "node scripts/check-types.js && node scripts/check-determinism.js && node scripts/check-slugs.js",
84
+ "pretest": "npm run check",
85
+ "test": "vitest run",
86
+ "test:watch": "vitest",
87
+ "test:e2e": "playwright test --config test/e2e/playwright.config.js",
88
+ "test:chaos": "playwright test --config test/chaos/playwright.config.js",
89
+ "test:fuzz": "node test/fuzz-wire.mjs"
90
+ },
91
+ "engines": {
92
+ "node": ">=22.0.0"
93
+ },
94
+ "dependencies": {
95
+ "@clack/prompts": "^0.10.1"
96
+ },
97
+ "peerDependencies": {
98
+ "@sveltejs/kit": "^2.0.0",
99
+ "svelte": "^4.0.0 || ^5.0.0",
100
+ "svelte-adapter-uws": "^0.6.0-next.61"
101
+ },
102
+ "devDependencies": {
103
+ "@playwright/test": "^1.59.1",
104
+ "fast-check": "^4.7.0",
105
+ "svelte-adapter-uws-extensions": "^0.6.0-next.20",
106
+ "vitest": "^4.0.18"
107
+ },
108
+ "keywords": [
109
+ "svelte",
110
+ "sveltekit",
111
+ "websocket",
112
+ "rpc",
113
+ "realtime",
114
+ "pubsub",
115
+ "uwebsockets",
116
+ "streams",
117
+ "live"
118
+ ]
119
+ }
@@ -291,7 +291,7 @@ export async function runSmoothSim(config = {}) {
291
291
  const relSet = relevancy ? relevancy.get(identity) : undefined;
292
292
  let delivered = false;
293
293
  if (relSet) for (const key of relSet) if (key !== identity) { delivered = true; break; }
294
- if (delivered) rec.interest.noteSend(identity, wall, rec.tickMs);
294
+ if (delivered) rec.interest.noteSend(identity, wall, rec.tickMs * rec.broadcastEvery);
295
295
  }
296
296
  rec.lagComp.record(catalog, rec.monoClock.mono(wall));
297
297
  }
@@ -340,6 +340,18 @@ export function _smoothRecord(name, cfg, platform, rt) {
340
340
  codec: rt.createSmoothWireCodec(),
341
341
  platform,
342
342
  tickMs: cfg.tickMs,
343
+ // Broadcast cadence gate (broadcastHz): entity updates go on the wire
344
+ // every Nth tick instead of every tick, the client interpolation covering
345
+ // the widened gap. 1 (the default) is the byte-identical every-tick path.
346
+ // `pendingWire` accumulates the movers of skipped ticks (key -> the LAST
347
+ // tick's commanded flag, which decides the owner-echo exclusion at flush);
348
+ // the send tick re-reads each mover's CURRENT authoritative state, so
349
+ // skipped motion is coalesced, never lost.
350
+ broadcastEvery: cfg.broadcastHz
351
+ ? Math.max(1, Math.round(1000 / cfg.tickMs / cfg.broadcastHz))
352
+ : 1,
353
+ tickNo: 0,
354
+ pendingWire: new Map(),
343
355
  noEcho: cfg.noEcho,
344
356
  timer: null,
345
357
  // Cluster bookkeeping (unused on the single-instance path). `cfg` is
@@ -1019,7 +1031,10 @@ function _smoothDeliverCulled(rec, relevancy, catalogByKey, moved, t) {
1019
1031
  if (key !== identity) delivered = true;
1020
1032
  }
1021
1033
  }
1022
- if (delivered && rec.lagComp !== null) rec.interest.noteSend(identity, t, rec.tickMs);
1034
+ // The cadence seed is the WIRE interval (tickMs widened by the broadcast
1035
+ // gate), not the sim interval - the estimator's cold-start guess must
1036
+ // match what a subscriber actually receives.
1037
+ if (delivered && rec.lagComp !== null) rec.interest.noteSend(identity, t, rec.tickMs * rec.broadcastEvery);
1023
1038
  }
1024
1039
  }
1025
1040
 
@@ -1116,6 +1131,41 @@ function _smoothTick(rec) {
1116
1131
  // in the catalog taken below, so they broadcast, ring-record, and cull
1117
1132
  // atomically with the drain.
1118
1133
  const worldRun = rec.world !== null ? rec.world.run(updates, acks, t) : null;
1134
+ // Broadcast cadence gate (broadcastHz): the simulation keeps ticking at
1135
+ // tickMs - commands drain, acknowledgements return, events fire, the
1136
+ // lag-compensation ring records - but continuous entity updates go on the
1137
+ // wire only every broadcastEvery-th tick. The drain reports PER-TICK deltas,
1138
+ // so a skipped tick's movers are accumulated into `pendingWire` rather than
1139
+ // dropped, and the send tick re-reads each mover's CURRENT authoritative
1140
+ // state - motion across skipped ticks is coalesced into one frame, never
1141
+ // lost. The stored flag is the LAST tick's `commanded`: a trailing commanded
1142
+ // change was acknowledged with the final state (the owner already holds it,
1143
+ // so the echo exclusion may apply), a trailing onMissing/injected change was
1144
+ // not (the owner must receive the broadcast). An idle drain with pending
1145
+ // motion flushes immediately: the single-instance tick stops re-arming at
1146
+ // idle, and the final rest state must reach the viewers before it does. A
1147
+ // mover removed between accumulation and flush is skipped - its departure
1148
+ // was already broadcast by the remove path. Everything below the gate
1149
+ // consumes `wireUpdates`; on the default path it IS the drain's updates
1150
+ // array, so the every-tick behavior is byte-identical.
1151
+ let wireUpdates = updates;
1152
+ if (rec.broadcastEvery > 1) {
1153
+ for (let i = 0; i < updates.length; i++) {
1154
+ rec.pendingWire.set(updates[i].key, updates[i].commanded);
1155
+ }
1156
+ rec.tickNo++;
1157
+ if (rec.pendingWire.size > 0 && (rec.tickNo % rec.broadcastEvery === 0 || idle)) {
1158
+ wireUpdates = [];
1159
+ for (const [key, commanded] of rec.pendingWire) {
1160
+ const e = rec.authority.get(key);
1161
+ if (e === undefined) continue;
1162
+ wireUpdates.push({ key, state: e.state, ws: e.ws, commanded });
1163
+ }
1164
+ rec.pendingWire.clear();
1165
+ } else {
1166
+ wireUpdates = [];
1167
+ }
1168
+ }
1119
1169
  // Area-of-interest: when this topic opted into interest, build the relevancy
1120
1170
  // for THIS instance's local subscribers once per tick from the drained catalog
1121
1171
  // (which on a cluster owner spans every entity cluster-wide, local and remote
@@ -1142,7 +1192,7 @@ function _smoothTick(rec) {
1142
1192
  // either wants it (one allocation when both are on), but compute relevancy only
1143
1193
  // on the interest cadence (so the interest LOD counter is unchanged when the
1144
1194
  // catalog was taken solely for lag compensation).
1145
- const wantInterest = rec.interest !== null && (updates.length > 0 || rec.interestDirty);
1195
+ const wantInterest = rec.interest !== null && (wireUpdates.length > 0 || rec.interestDirty);
1146
1196
  const catalog = (rec.lagComp !== null || wantInterest) ? rec.authority.catalog() : null;
1147
1197
  const relevancy = wantInterest
1148
1198
  ? rec.interest.compute(catalog, rec.registry.keys(), rec.interestTick++)
@@ -1155,8 +1205,8 @@ function _smoothTick(rec) {
1155
1205
  if (!idle || (worldRun !== null && worldRun.dirty)) rec.lagCompLastActive = t;
1156
1206
  }
1157
1207
  rec.interestDirty = false;
1158
- for (let i = 0; i < updates.length; i++) {
1159
- const u = updates[i];
1208
+ for (let i = 0; i < wireUpdates.length; i++) {
1209
+ const u = wireUpdates[i];
1160
1210
  // Cells mode: route each changed entity to its cell topic (native shared
1161
1211
  // fan-out), not the per-subscriber walk or the base broadcast. Egress is
1162
1212
  // O(cells), not O(subscribers). The mover's own cell subscription follows it
@@ -1225,8 +1275,8 @@ function _smoothTick(rec) {
1225
1275
  // remote frame advances the lag-comp send-cadence estimator (the client discards
1226
1276
  // its own entity frame before measuring its interpolation delay).
1227
1277
  const moved = new Map();
1228
- for (let i = 0; i < updates.length; i++) {
1229
- const u = updates[i];
1278
+ for (let i = 0; i < wireUpdates.length; i++) {
1279
+ const u = wireUpdates[i];
1230
1280
  moved.set(u.key, { state: u.state, exclude: rec.noEcho && u.commanded ? u.key : undefined });
1231
1281
  }
1232
1282
  _smoothDeliverCulled(rec, relevancy, catalogByKey, moved, t);
@@ -2262,8 +2312,10 @@ export function _smoothEdgeMeasure(rec, ctx, payload, shooterKey, now) {
2262
2312
  }
2263
2313
  // Favor-the-shooter reach width = measured uplink (max-of-recent) + the
2264
2314
  // client's interpolation delay; both server-measured, clamped to the cap.
2315
+ // The interpolation seed is the WIRE interval (tickMs widened by the
2316
+ // broadcast gate) - the client renders behind the cadence it receives.
2265
2317
  const maxUp = st ? st.tracker.maxUplink() : null;
2266
- const serverInterp = rec.aoi.interpDelayMs(shooterKey, rec.tickMs, now);
2318
+ const serverInterp = rec.aoi.interpDelayMs(shooterKey, rec.tickMs * rec.broadcastEvery, now);
2267
2319
  reach = maxUp === null ? ht.maxRewindMs : Math.min(ht.maxRewindMs, maxUp + serverInterp);
2268
2320
  // The rewind age is a pure duration the owner applies to its own present.
2269
2321
  rewindAge = Math.max(0, now - rtStamp);
@@ -2481,7 +2533,14 @@ export async function _smoothResolveShot(rec, name, shooterKey, shooterEntity, c
2481
2533
  * shared step), `initial` (starting state, or `(key) => state`), `guard?`
2482
2534
  * (auth check, same shape as room guards), `onMissing?` (per-tick
2483
2535
  * continuation for command-less entities; omitted = hold position),
2484
- * `tickMs?` (authoritative tick interval, default 50), `noEcho?` (suppress
2536
+ * `tickMs?` (authoritative tick interval, default 50), `broadcastHz?` (wire
2537
+ * broadcast rate: entity updates are sent every Nth tick - N derived from
2538
+ * tickMs - instead of every tick, with the motion of skipped ticks coalesced
2539
+ * into the next frame (an entity's CURRENT state, never a stale one) and the
2540
+ * client interpolation covering the widened gap. Acknowledgements, discrete
2541
+ * events, and removals stay per-tick, so the owner's reconciliation and
2542
+ * one-shot actions never lag. The final rest state always flushes when motion
2543
+ * stops. Default: broadcast every tick, byte-identical), `noEcho?` (suppress
2485
2544
  * echoing an owner's own commanded updates in broadcasts, default true - the
2486
2545
  * acknowledgement carries the owner's copy; onMissing motion has no
2487
2546
  * acknowledgement and always broadcasts to the owner too), `queueCap?`
@@ -2522,7 +2581,7 @@ export async function _smoothResolveShot(rec, name, shooterKey, shooterEntity, c
2522
2581
  * module, like `apply`; requires svelte-adapter-uws >= 0.6.0-next.49 for the
2523
2582
  * client-side unpack). Default off, byte-identical wire).
2524
2583
  *
2525
- * @param {{ topic: string | Function, apply: Function, initial: any, guard?: Function, onMissing?: Function, tickMs?: number, noEcho?: boolean, queueCap?: number, snapshot?: boolean, snapshotDebounceMs?: number, topicArgs?: number, interest?: { radius: number, position: (state: any) => ({ x: number, y: number } | null), lod?: Array<{ within: number, rate: number }>, cell?: number, budget?: number }, wire?: { state?: { pack: (state: any) => any, unpack: (packed: any) => any }, command?: { pack: (cmd: any) => any, unpack: (packed: any) => any } } }} config
2584
+ * @param {{ topic: string | Function, apply: Function, initial: any, guard?: Function, onMissing?: Function, tickMs?: number, broadcastHz?: number, noEcho?: boolean, queueCap?: number, snapshot?: boolean, snapshotDebounceMs?: number, topicArgs?: number, interest?: { radius: number, position: (state: any) => ({ x: number, y: number } | null), lod?: Array<{ within: number, rate: number }>, cell?: number, budget?: number }, wire?: { state?: { pack: (state: any) => any, unpack: (packed: any) => any }, command?: { pack: (cmd: any) => any, unpack: (packed: any) => any } } }} config
2526
2585
  */
2527
2586
  export const _smoothRegister = function smooth(config) {
2528
2587
  if (!config || typeof config !== 'object') {
@@ -2545,6 +2604,9 @@ export const _smoothRegister = function smooth(config) {
2545
2604
  if (!(typeof tickMs === 'number' && Number.isFinite(tickMs) && tickMs > 0)) {
2546
2605
  throw new Error('[svelte-realtime] live.smooth() tickMs must be a positive number');
2547
2606
  }
2607
+ if (config.broadcastHz !== undefined && !(typeof config.broadcastHz === 'number' && Number.isFinite(config.broadcastHz) && config.broadcastHz > 0)) {
2608
+ throw new Error('[svelte-realtime] live.smooth() broadcastHz must be a positive number');
2609
+ }
2548
2610
  if (config.queueCap !== undefined && !(typeof config.queueCap === 'number' && Number.isInteger(config.queueCap) && config.queueCap >= 1)) {
2549
2611
  throw new Error('[svelte-realtime] live.smooth() queueCap must be an integer of at least 1');
2550
2612
  }
@@ -2567,6 +2629,7 @@ export const _smoothRegister = function smooth(config) {
2567
2629
  onMissing: config.onMissing,
2568
2630
  queueCap: config.queueCap,
2569
2631
  tickMs,
2632
+ broadcastHz: config.broadcastHz,
2570
2633
  noEcho: config.noEcho !== false,
2571
2634
  snapshot: config.snapshot === true,
2572
2635
  snapshotDebounceMs,
@@ -342,6 +342,12 @@ export const state = {
342
342
  /** @type {any} Dead-letter store for undeliverable outbound webhooks (null = capture off). */
343
343
  webhookDeadLetter: null,
344
344
 
345
+ /** @type {any} Retry budget for outbound webhooks (null = unrationed). */
346
+ webhookBudget: null,
347
+
348
+ /** @type {any} Endpoint-ejection breaker for outbound webhooks (null = no ejection). */
349
+ webhookBreaker: null,
350
+
345
351
  /** @type {import('svelte-adapter-uws').Platform | null} Captured platform for dynamic derived recomputation */
346
352
  derivedPlatform: null,
347
353
 
@@ -11,6 +11,21 @@ import { _IS_DEV } from './env.js';
11
11
  /** Re-exported for the registration-time SSRF validation message in webhooks.js. */
12
12
  export { redactUrl as _redactUrl };
13
13
 
14
+ /**
15
+ * Assemble the optional delivery-control hooks for one webhook entry: the
16
+ * configured retry budget + endpoint-ejection breaker (wired via
17
+ * `configureWebhooks` / `realtime({ webhooks })`), keyed by the webhook's
18
+ * registration id so one endpoint's failures cannot trip or starve another.
19
+ * Returns undefined when neither is configured, so an unconfigured delivery is
20
+ * byte-identical to the bare call.
21
+ */
22
+ function _webhookHooks(entry) {
23
+ const budget = state.webhookBudget;
24
+ const breaker = state.webhookBreaker;
25
+ if (!budget && !breaker) return undefined;
26
+ return { budget: budget || undefined, breaker: breaker || undefined, key: entry.id };
27
+ }
28
+
14
29
  function _reportWebhookOutFailure(config, err, event, data, attempts) {
15
30
  if (config.onFailure) {
16
31
  try {
@@ -26,7 +41,7 @@ function _reportWebhookOutFailure(config, err, event, data, attempts) {
26
41
 
27
42
  export async function _fireWebhookOut(entry, topic, event, data, platform) {
28
43
  void platform; // accepted for call-site parity; delivery reads entry.config
29
- const r = await deliverWebhook(entry.config, topic, event, data);
44
+ const r = await deliverWebhook(entry.config, topic, event, data, _webhookHooks(entry));
30
45
  if (r.ok) return;
31
46
  _reportWebhookOutFailure(entry.config, r.err, event, data, r.attempts);
32
47
  const store = state.webhookDeadLetter;
@@ -47,7 +62,7 @@ export async function _fireWebhookOut(entry, topic, event, data, platform) {
47
62
  }
48
63
 
49
64
  export async function _replayWebhookOut(entry, topic, event, data) {
50
- const r = await deliverWebhook(entry.config, topic, event, data);
65
+ const r = await deliverWebhook(entry.config, topic, event, data, _webhookHooks(entry));
51
66
  if (r.ok) return { ok: true };
52
67
  return { ok: false, error: String((r.err && r.err.message) || r.err || 'unknown') };
53
68
  }
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import { checkUrl } from 'svelte-adapter-uws/safe-url';
3
+ import { createRetryBudget, createWebhookBreaker } from 'svelte-adapter-uws/plugins/webhooks';
3
4
  import { _redactUrl, _replayWebhookOut } from './webhook-out.js';
4
5
  import { state, _webhookOutById } from './state.js';
5
6
  import { createDeadLetterStore } from './dead-letter.js';
@@ -114,17 +115,29 @@ export const _webhooksOutboundRegister = function outbound(sources, config) {
114
115
  };
115
116
 
116
117
  /**
117
- * Configure the outbound-webhook plane. `deadLetter` enables the dead-letter
118
- * store that retains UNDELIVERABLE outbound-webhook events (retry-exhausted,
119
- * SSRF-blocked, etc.) for admin inspection + replay, instead of reporting then
120
- * dropping them. Off by default - a DLQ retains attacker-influenced event data,
121
- * so it is opt-in.
118
+ * Configure the outbound-webhook plane.
122
119
  *
123
- * Pass `true` for the default in-memory store, a store instance (e.g. a
124
- * Redis/Postgres store for a cluster) for shared durability, or `false`/`null`
125
- * to disable. Also wired from `realtime({ webhooks: { deadLetter } })`.
120
+ * `deadLetter` enables the dead-letter store that retains UNDELIVERABLE
121
+ * outbound-webhook events (retry-exhausted, SSRF-blocked, etc.) for admin
122
+ * inspection + replay, instead of reporting then dropping them. Off by default -
123
+ * a DLQ retains attacker-influenced event data, so it is opt-in.
126
124
  *
127
- * @param {{ deadLetter?: boolean | object | null }} [config]
125
+ * `budget` rations retry AMPLIFICATION so a storm of failing deliveries to one
126
+ * endpoint cannot launch unbounded retry work (the first attempt of each
127
+ * delivery always proceeds). `breaker` ejects a persistently-failing endpoint:
128
+ * an open circuit fast-fails to the dead-letter store without touching the
129
+ * network, and heals after a probe succeeds. Both are keyed by the webhook's
130
+ * registration id, so one endpoint cannot trip or starve another. Off by
131
+ * default.
132
+ *
133
+ * Each option takes `true` for the built-in single-instance default (an
134
+ * in-memory store / an in-process token bucket / an in-process breaker), an
135
+ * instance for a cluster (e.g. a Redis dead-letter store, or a shared
136
+ * budget/breaker coordinator) for cross-instance durability, or `false`/`null`
137
+ * to disable. Also wired from `realtime({ webhooks: { deadLetter, budget,
138
+ * breaker } })`.
139
+ *
140
+ * @param {{ deadLetter?: boolean | object | null, budget?: boolean | object | null, breaker?: boolean | object | null }} [config]
128
141
  */
129
142
  export function configureWebhooks(config = {}) {
130
143
  if (config.deadLetter !== undefined) {
@@ -136,6 +149,31 @@ export function configureWebhooks(config = {}) {
136
149
  state.webhookDeadLetter = null;
137
150
  }
138
151
  }
152
+ if (config.budget !== undefined) {
153
+ if (config.budget === true) {
154
+ if (!state.webhookBudget) state.webhookBudget = createRetryBudget();
155
+ } else if (config.budget && typeof config.budget === 'object') {
156
+ if (typeof config.budget.take !== 'function') {
157
+ throw new Error('[svelte-realtime] configureWebhooks({ budget }): a budget must have a take(key) method (or pass true for the built-in)');
158
+ }
159
+ state.webhookBudget = config.budget;
160
+ } else {
161
+ state.webhookBudget = null;
162
+ }
163
+ }
164
+ if (config.breaker !== undefined) {
165
+ if (config.breaker === true) {
166
+ if (!state.webhookBreaker) state.webhookBreaker = createWebhookBreaker();
167
+ } else if (config.breaker && typeof config.breaker === 'object') {
168
+ const b = config.breaker;
169
+ if (typeof b.guard !== 'function' || typeof b.success !== 'function' || typeof b.failure !== 'function') {
170
+ throw new Error('[svelte-realtime] configureWebhooks({ breaker }): a breaker must have guard(key), success(key), and failure(err, key) methods (or pass true for the built-in)');
171
+ }
172
+ state.webhookBreaker = b;
173
+ } else {
174
+ state.webhookBreaker = null;
175
+ }
176
+ }
139
177
  }
140
178
 
141
179
  /**
package/src/server.d.ts CHANGED
@@ -3061,6 +3061,18 @@ export interface SmoothConfig {
3061
3061
  onMissing?: (state: any, lastCommand: any) => any;
3062
3062
  /** Authoritative tick interval in milliseconds. @default 50 */
3063
3063
  tickMs?: number;
3064
+ /**
3065
+ * Wire broadcast rate in Hz: entity updates are sent every Nth tick (N
3066
+ * derived from `tickMs`) instead of every tick, with the motion of skipped
3067
+ * ticks coalesced into the next frame (each entity's CURRENT state, never a
3068
+ * stale one) and the client interpolation covering the widened gap - the
3069
+ * simulation keeps ticking at `tickMs`, only the on-wire cadence drops.
3070
+ * Acknowledgements, discrete events, and removals stay per-tick, so the
3071
+ * owner's reconciliation and one-shot actions never lag, and the final rest
3072
+ * state always flushes when motion stops. Off by default: every tick
3073
+ * broadcasts, byte-identical.
3074
+ */
3075
+ broadcastHz?: number;
3064
3076
  /**
3065
3077
  * Suppress echoing an owner's own commanded updates in broadcasts - the
3066
3078
  * acknowledgement carries the owner's copy. `onMissing` motion has no
@@ -4263,11 +4275,18 @@ export interface RealtimeConfig {
4263
4275
  * that retains UNDELIVERABLE outbound-webhook events (retry-exhausted,
4264
4276
  * SSRF-blocked, etc.) for admin inspection and replay, instead of reporting
4265
4277
  * then dropping them. Off by default (a DLQ retains attacker-influenced event
4266
- * data). `true` uses the default in-memory store; a store instance gives a
4267
- * cluster shared durable store; `false`/`null` disables. Equivalent to
4268
- * `configureWebhooks({ deadLetter })`.
4269
- */
4270
- webhooks?: { deadLetter?: boolean | DeadLetterStore | null } | null;
4278
+ * data). `webhooks.budget` rations retry amplification and `webhooks.breaker`
4279
+ * ejects a persistently-failing endpoint (fast-fail to the DLQ, healing after
4280
+ * a probe succeeds), both keyed by the webhook's registration id. Each takes
4281
+ * `true` for the built-in single-instance default, an instance for a cluster
4282
+ * (a shared store / budget / breaker), or `false`/`null` to disable.
4283
+ * Equivalent to `configureWebhooks({ deadLetter, budget, breaker })`.
4284
+ */
4285
+ webhooks?: {
4286
+ deadLetter?: boolean | DeadLetterStore | null;
4287
+ budget?: boolean | import('svelte-adapter-uws/plugins/webhooks').RetryBudget | null;
4288
+ breaker?: boolean | import('svelte-adapter-uws/plugins/webhooks').WebhookBreaker | null;
4289
+ } | null;
4271
4290
  /**
4272
4291
  * Opt-in protocol/contract version, an integer the app bumps only on a BREAKING
4273
4292
  * wire/contract change. Declare it identically here and in client `configure({
@@ -4344,12 +4363,19 @@ export interface DeadLetterStore {
4344
4363
  export function createDeadLetterStore(options?: { max?: number; ttlMs?: number }): DeadLetterStore;
4345
4364
 
4346
4365
  /**
4347
- * Configure the outbound-webhook plane. `deadLetter: true` enables the default
4348
- * in-memory dead-letter store; a store instance wires a custom/cluster store;
4349
- * `false`/`null` disables capture (the default). Also wired from
4366
+ * Configure the outbound-webhook plane. `deadLetter` captures undeliverable
4367
+ * events; `budget` rations retry amplification; `breaker` ejects a
4368
+ * persistently-failing endpoint (fast-fail to the DLQ, healing after a probe
4369
+ * succeeds), keyed by the webhook's registration id. Each takes `true` for the
4370
+ * built-in single-instance default, an instance for a cluster (a shared store /
4371
+ * budget / breaker), or `false`/`null` to disable (the default). Also wired from
4350
4372
  * `realtime({ webhooks })`.
4351
4373
  */
4352
- export function configureWebhooks(config?: { deadLetter?: boolean | DeadLetterStore | null }): void;
4374
+ export function configureWebhooks(config?: {
4375
+ deadLetter?: boolean | DeadLetterStore | null;
4376
+ budget?: boolean | import('svelte-adapter-uws/plugins/webhooks').RetryBudget | null;
4377
+ breaker?: boolean | import('svelte-adapter-uws/plugins/webhooks').WebhookBreaker | null;
4378
+ }): void;
4353
4379
 
4354
4380
  /** The configured dead-letter store, or `null` when capture is off. */
4355
4381
  export function getDeadLetter(): DeadLetterStore | null;
package/src/server.js CHANGED
@@ -3536,8 +3536,9 @@ export function realtime(config) {
3536
3536
 
3537
3537
  if (bus !== undefined) _setBus(bus);
3538
3538
  if (leader !== undefined) configureCron({ leader });
3539
- // Outbound-webhook plane: `webhooks.deadLetter` enables the dead-letter store
3540
- // (retain + admin-replay undeliverable webhook events). Off unless configured.
3539
+ // Outbound-webhook plane: `webhooks.deadLetter` retains + admin-replays
3540
+ // undeliverable events; `webhooks.budget`/`webhooks.breaker` ration retries
3541
+ // and eject a failing endpoint. Off unless configured.
3541
3542
  if (webhooks !== undefined) configureWebhooks(webhooks);
3542
3543
  // Multi-tenancy opt-in: a resolver mapping the server-trusted authenticated
3543
3544
  // user (ws.getUserData()) to a tenant id auto-scopes every topic and key.