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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.71",
3
+ "version": "0.6.0-next.72",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -97,7 +97,7 @@
97
97
  "peerDependencies": {
98
98
  "@sveltejs/kit": "^2.0.0",
99
99
  "svelte": "^4.0.0 || ^5.0.0",
100
- "svelte-adapter-uws": "^0.6.0-next.60"
100
+ "svelte-adapter-uws": "^0.6.0-next.61"
101
101
  },
102
102
  "devDependencies": {
103
103
  "@playwright/test": "^1.59.1",
@@ -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
@@ -4263,11 +4263,18 @@ export interface RealtimeConfig {
4263
4263
  * that retains UNDELIVERABLE outbound-webhook events (retry-exhausted,
4264
4264
  * SSRF-blocked, etc.) for admin inspection and replay, instead of reporting
4265
4265
  * 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;
4266
+ * data). `webhooks.budget` rations retry amplification and `webhooks.breaker`
4267
+ * ejects a persistently-failing endpoint (fast-fail to the DLQ, healing after
4268
+ * a probe succeeds), both keyed by the webhook's registration id. Each takes
4269
+ * `true` for the built-in single-instance default, an instance for a cluster
4270
+ * (a shared store / budget / breaker), or `false`/`null` to disable.
4271
+ * Equivalent to `configureWebhooks({ deadLetter, budget, breaker })`.
4272
+ */
4273
+ webhooks?: {
4274
+ deadLetter?: boolean | DeadLetterStore | null;
4275
+ budget?: boolean | import('svelte-adapter-uws/plugins/webhooks').RetryBudget | null;
4276
+ breaker?: boolean | import('svelte-adapter-uws/plugins/webhooks').WebhookBreaker | null;
4277
+ } | null;
4271
4278
  /**
4272
4279
  * Opt-in protocol/contract version, an integer the app bumps only on a BREAKING
4273
4280
  * wire/contract change. Declare it identically here and in client `configure({
@@ -4344,12 +4351,19 @@ export interface DeadLetterStore {
4344
4351
  export function createDeadLetterStore(options?: { max?: number; ttlMs?: number }): DeadLetterStore;
4345
4352
 
4346
4353
  /**
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
4354
+ * Configure the outbound-webhook plane. `deadLetter` captures undeliverable
4355
+ * events; `budget` rations retry amplification; `breaker` ejects a
4356
+ * persistently-failing endpoint (fast-fail to the DLQ, healing after a probe
4357
+ * succeeds), keyed by the webhook's registration id. Each takes `true` for the
4358
+ * built-in single-instance default, an instance for a cluster (a shared store /
4359
+ * budget / breaker), or `false`/`null` to disable (the default). Also wired from
4350
4360
  * `realtime({ webhooks })`.
4351
4361
  */
4352
- export function configureWebhooks(config?: { deadLetter?: boolean | DeadLetterStore | null }): void;
4362
+ export function configureWebhooks(config?: {
4363
+ deadLetter?: boolean | DeadLetterStore | null;
4364
+ budget?: boolean | import('svelte-adapter-uws/plugins/webhooks').RetryBudget | null;
4365
+ breaker?: boolean | import('svelte-adapter-uws/plugins/webhooks').WebhookBreaker | null;
4366
+ }): void;
4353
4367
 
4354
4368
  /** The configured dead-letter store, or `null` when capture is off. */
4355
4369
  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.