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 +2 -1
- package/package.json +119 -119
- package/src/server/sim-smooth.js +1 -1
- package/src/server/smooth.js +72 -9
- package/src/server/state.js +6 -0
- package/src/server/webhook-out.js +17 -2
- package/src/server/webhooks.js +47 -9
- package/src/server.d.ts +35 -9
- package/src/server.js +3 -2
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
package/src/server/sim-smooth.js
CHANGED
|
@@ -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
|
}
|
package/src/server/smooth.js
CHANGED
|
@@ -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
|
-
|
|
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 && (
|
|
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 <
|
|
1159
|
-
const u =
|
|
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 <
|
|
1229
|
-
const u =
|
|
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), `
|
|
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,
|
package/src/server/state.js
CHANGED
|
@@ -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
|
}
|
package/src/server/webhooks.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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). `
|
|
4267
|
-
*
|
|
4268
|
-
*
|
|
4269
|
-
|
|
4270
|
-
|
|
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
|
|
4348
|
-
*
|
|
4349
|
-
*
|
|
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?: {
|
|
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`
|
|
3540
|
-
//
|
|
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.
|