@richardmcquiston01/error-log-adapter 0.5.0 → 0.6.0

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/dist/index.d.cts CHANGED
@@ -89,17 +89,50 @@ interface WebhookErrorOptions {
89
89
  * still have received the entry.
90
90
  */
91
91
  timeoutMs?: number;
92
+ /**
93
+ * How many times to try again after a failure that may go away on its own: a
94
+ * network error, a timeout, HTTP 408, 425, 429 or any 5xx. Other 4xx answers and
95
+ * redirects are never retried. Defaults to 0 (send once). The wait doubles
96
+ * after each attempt, starting at `retryDelayMs`, with jitter, and a
97
+ * `Retry-After` header is honored. `timeoutMs` applies to each attempt, and
98
+ * `log()` resolves only after the last one. Every attempt sends the same body,
99
+ * and with `retries` above 0 the body always carries an `id`, so the receiver
100
+ * can drop a repeat if an earlier attempt did arrive.
101
+ */
102
+ retries?: number;
103
+ /** Wait before the first retry, in milliseconds. Defaults to 500. */
104
+ retryDelayMs?: number;
105
+ /** Longest wait between attempts, in milliseconds. Defaults to 30000. */
106
+ maxRetryDelayMs?: number;
107
+ /**
108
+ * Shared secret for signing each request, so the receiver can verify the
109
+ * entry came from you and was not altered. Adds `X-Signature-Timestamp`
110
+ * (Unix seconds) and `X-Signature-256` (`sha256=` and the hex HMAC-SHA256 of
111
+ * `<timestamp>.<body>`). The receiver should recompute it and reject old
112
+ * timestamps. Needs Web Crypto (Node 20+, browsers, Deno, Bun); without it
113
+ * `log()` fails rather than sending an unsigned entry.
114
+ */
115
+ signingSecret?: string;
92
116
  }
93
117
  declare class WebhookErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
94
118
  private readonly url;
95
119
  private readonly options;
96
120
  private readonly prefix;
97
121
  private readonly timeoutMs;
122
+ private readonly retries;
123
+ private readonly retryDelayMs;
124
+ private readonly maxRetryDelayMs;
98
125
  /** Scheme and host only. Webhook URLs often carry a secret in the path or query. */
99
126
  private readonly target;
100
- /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
127
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
101
128
  constructor(url: string, options?: WebhookErrorOptions);
102
129
  log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
130
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
131
+ private retryWaitMs;
132
+ private send;
133
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
134
+ private signatureHeaders;
135
+ private rejected;
103
136
  private redirectMessage;
104
137
  /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
105
138
  private redact;
@@ -929,4 +962,64 @@ declare class SupabaseErrorAdapter<TCode extends string = string> implements Err
929
962
  private fail;
930
963
  }
931
964
 
932
- export { type CollectionLike, CompositeErrorAdapter, type CompositeErrorOptions, ConsoleErrorAdapter, type ConsoleErrorOptions, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, type FileSystemLike, type FirebaseErrorOptions, type FirebaseErrorRecord, type FirestoreCollectionLike, FirestoreErrorAdapter, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbRequestLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, type LockManagerLike, MongoDbErrorAdapter, type MongoDbErrorOptions, MySqlErrorAdapter, PostgresErrorAdapter, type ReadWriteFileSystemLike, RealtimeDatabaseErrorAdapter, type RealtimeDatabaseRefLike, type RedisClientLike, RedisErrorAdapter, type RedisErrorOptions, type S3BatchOptions, type S3ClientLike, S3ErrorAdapter, type S3ErrorOptions, type S3PutObjectInput, type SerializedErrorLogEntry, type SqlClientLike, type SqlDialect, type SqlErrorOptions, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, type SupabaseClientLike, SupabaseErrorAdapter, type SupabaseErrorLike, type SupabaseErrorOptions, type SupabaseErrorRow, type SupabaseInsertResult, type SupabaseSetupOptions, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, buildErrorLogTableDdl, buildMySqlWidenColumnsSql, buildSupabaseSetupSql, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
965
+ /** What listeners receive: the serialized entry plus its UUID. */
966
+ interface EventEmitterErrorRecord extends SerializedErrorLogEntry {
967
+ id: string;
968
+ }
969
+ /**
970
+ * The only thing this adapter needs from an emitter: `emit(eventName, payload)`.
971
+ * Node's `EventEmitter` and mitt-style emitters fit as they are. A browser
972
+ * `EventTarget` needs a one-line wrapper:
973
+ *
974
+ * ```ts
975
+ * const target = new EventTarget();
976
+ * const emitter: EmitterLike = {
977
+ * emit: (name, payload) =>
978
+ * target.dispatchEvent(new CustomEvent(name, { detail: payload })),
979
+ * };
980
+ * ```
981
+ */
982
+ interface EmitterLike {
983
+ emit(eventName: string, payload: EventEmitterErrorRecord): unknown;
984
+ }
985
+ interface EventEmitterErrorOptions {
986
+ /**
987
+ * Name of the event to emit. Defaults to `error-log`. Avoid `error` on a Node
988
+ * `EventEmitter`: it throws when no listener is attached, and `log` would then
989
+ * return `ok: false`.
990
+ */
991
+ eventName?: string;
992
+ /**
993
+ * Treat an emit that reached nobody as a failure. Node's `EventEmitter.emit`
994
+ * returns `false` when there are no listeners; with this set to `true`, `log`
995
+ * then returns `ok: false` instead of letting the entry vanish. Emitters whose
996
+ * `emit` returns anything else (mitt, an `EventTarget` wrapper) cannot tell,
997
+ * so they always count as delivered. Defaults to `false`.
998
+ */
999
+ requireListener?: boolean;
1000
+ /** Text printed before failure messages. Defaults to `[error-log-adapter]`. */
1001
+ prefix?: string;
1002
+ }
1003
+ /**
1004
+ * Emits each entry as an event on an emitter you own, for in-process hooks such
1005
+ * as a toast, a metrics counter, or a test assertion. Listeners run
1006
+ * synchronously inside `emit`, so a listener that throws makes `log` return
1007
+ * `ok: false` with the listener's message; the application is not affected.
1008
+ *
1009
+ * ```ts
1010
+ * const bus = new EventEmitter();
1011
+ * bus.on('error-log', (record) => showToast(record.message));
1012
+ * const adapter = new EventEmitterErrorAdapter(bus);
1013
+ * ```
1014
+ */
1015
+ declare class EventEmitterErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
1016
+ private readonly emitter;
1017
+ private readonly eventName;
1018
+ private readonly requireListener;
1019
+ private readonly prefix;
1020
+ /** @throws {RangeError} if `options.eventName` is empty. */
1021
+ constructor(emitter: EmitterLike, options?: EventEmitterErrorOptions);
1022
+ log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
1023
+ }
1024
+
1025
+ export { type CollectionLike, CompositeErrorAdapter, type CompositeErrorOptions, ConsoleErrorAdapter, type ConsoleErrorOptions, type EmitterLike, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, EventEmitterErrorAdapter, type EventEmitterErrorOptions, type EventEmitterErrorRecord, type FileSystemLike, type FirebaseErrorOptions, type FirebaseErrorRecord, type FirestoreCollectionLike, FirestoreErrorAdapter, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbRequestLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, type LockManagerLike, MongoDbErrorAdapter, type MongoDbErrorOptions, MySqlErrorAdapter, PostgresErrorAdapter, type ReadWriteFileSystemLike, RealtimeDatabaseErrorAdapter, type RealtimeDatabaseRefLike, type RedisClientLike, RedisErrorAdapter, type RedisErrorOptions, type S3BatchOptions, type S3ClientLike, S3ErrorAdapter, type S3ErrorOptions, type S3PutObjectInput, type SerializedErrorLogEntry, type SqlClientLike, type SqlDialect, type SqlErrorOptions, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, type SupabaseClientLike, SupabaseErrorAdapter, type SupabaseErrorLike, type SupabaseErrorOptions, type SupabaseErrorRow, type SupabaseInsertResult, type SupabaseSetupOptions, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, buildErrorLogTableDdl, buildMySqlWidenColumnsSql, buildSupabaseSetupSql, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
package/dist/index.d.ts CHANGED
@@ -89,17 +89,50 @@ interface WebhookErrorOptions {
89
89
  * still have received the entry.
90
90
  */
91
91
  timeoutMs?: number;
92
+ /**
93
+ * How many times to try again after a failure that may go away on its own: a
94
+ * network error, a timeout, HTTP 408, 425, 429 or any 5xx. Other 4xx answers and
95
+ * redirects are never retried. Defaults to 0 (send once). The wait doubles
96
+ * after each attempt, starting at `retryDelayMs`, with jitter, and a
97
+ * `Retry-After` header is honored. `timeoutMs` applies to each attempt, and
98
+ * `log()` resolves only after the last one. Every attempt sends the same body,
99
+ * and with `retries` above 0 the body always carries an `id`, so the receiver
100
+ * can drop a repeat if an earlier attempt did arrive.
101
+ */
102
+ retries?: number;
103
+ /** Wait before the first retry, in milliseconds. Defaults to 500. */
104
+ retryDelayMs?: number;
105
+ /** Longest wait between attempts, in milliseconds. Defaults to 30000. */
106
+ maxRetryDelayMs?: number;
107
+ /**
108
+ * Shared secret for signing each request, so the receiver can verify the
109
+ * entry came from you and was not altered. Adds `X-Signature-Timestamp`
110
+ * (Unix seconds) and `X-Signature-256` (`sha256=` and the hex HMAC-SHA256 of
111
+ * `<timestamp>.<body>`). The receiver should recompute it and reject old
112
+ * timestamps. Needs Web Crypto (Node 20+, browsers, Deno, Bun); without it
113
+ * `log()` fails rather than sending an unsigned entry.
114
+ */
115
+ signingSecret?: string;
92
116
  }
93
117
  declare class WebhookErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
94
118
  private readonly url;
95
119
  private readonly options;
96
120
  private readonly prefix;
97
121
  private readonly timeoutMs;
122
+ private readonly retries;
123
+ private readonly retryDelayMs;
124
+ private readonly maxRetryDelayMs;
98
125
  /** Scheme and host only. Webhook URLs often carry a secret in the path or query. */
99
126
  private readonly target;
100
- /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
127
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
101
128
  constructor(url: string, options?: WebhookErrorOptions);
102
129
  log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
130
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
131
+ private retryWaitMs;
132
+ private send;
133
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
134
+ private signatureHeaders;
135
+ private rejected;
103
136
  private redirectMessage;
104
137
  /** Replaces the full URL in a message with its origin, in case the runtime put it there. */
105
138
  private redact;
@@ -929,4 +962,64 @@ declare class SupabaseErrorAdapter<TCode extends string = string> implements Err
929
962
  private fail;
930
963
  }
931
964
 
932
- export { type CollectionLike, CompositeErrorAdapter, type CompositeErrorOptions, ConsoleErrorAdapter, type ConsoleErrorOptions, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, type FileSystemLike, type FirebaseErrorOptions, type FirebaseErrorRecord, type FirestoreCollectionLike, FirestoreErrorAdapter, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbRequestLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, type LockManagerLike, MongoDbErrorAdapter, type MongoDbErrorOptions, MySqlErrorAdapter, PostgresErrorAdapter, type ReadWriteFileSystemLike, RealtimeDatabaseErrorAdapter, type RealtimeDatabaseRefLike, type RedisClientLike, RedisErrorAdapter, type RedisErrorOptions, type S3BatchOptions, type S3ClientLike, S3ErrorAdapter, type S3ErrorOptions, type S3PutObjectInput, type SerializedErrorLogEntry, type SqlClientLike, type SqlDialect, type SqlErrorOptions, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, type SupabaseClientLike, SupabaseErrorAdapter, type SupabaseErrorLike, type SupabaseErrorOptions, type SupabaseErrorRow, type SupabaseInsertResult, type SupabaseSetupOptions, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, buildErrorLogTableDdl, buildMySqlWidenColumnsSql, buildSupabaseSetupSql, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
965
+ /** What listeners receive: the serialized entry plus its UUID. */
966
+ interface EventEmitterErrorRecord extends SerializedErrorLogEntry {
967
+ id: string;
968
+ }
969
+ /**
970
+ * The only thing this adapter needs from an emitter: `emit(eventName, payload)`.
971
+ * Node's `EventEmitter` and mitt-style emitters fit as they are. A browser
972
+ * `EventTarget` needs a one-line wrapper:
973
+ *
974
+ * ```ts
975
+ * const target = new EventTarget();
976
+ * const emitter: EmitterLike = {
977
+ * emit: (name, payload) =>
978
+ * target.dispatchEvent(new CustomEvent(name, { detail: payload })),
979
+ * };
980
+ * ```
981
+ */
982
+ interface EmitterLike {
983
+ emit(eventName: string, payload: EventEmitterErrorRecord): unknown;
984
+ }
985
+ interface EventEmitterErrorOptions {
986
+ /**
987
+ * Name of the event to emit. Defaults to `error-log`. Avoid `error` on a Node
988
+ * `EventEmitter`: it throws when no listener is attached, and `log` would then
989
+ * return `ok: false`.
990
+ */
991
+ eventName?: string;
992
+ /**
993
+ * Treat an emit that reached nobody as a failure. Node's `EventEmitter.emit`
994
+ * returns `false` when there are no listeners; with this set to `true`, `log`
995
+ * then returns `ok: false` instead of letting the entry vanish. Emitters whose
996
+ * `emit` returns anything else (mitt, an `EventTarget` wrapper) cannot tell,
997
+ * so they always count as delivered. Defaults to `false`.
998
+ */
999
+ requireListener?: boolean;
1000
+ /** Text printed before failure messages. Defaults to `[error-log-adapter]`. */
1001
+ prefix?: string;
1002
+ }
1003
+ /**
1004
+ * Emits each entry as an event on an emitter you own, for in-process hooks such
1005
+ * as a toast, a metrics counter, or a test assertion. Listeners run
1006
+ * synchronously inside `emit`, so a listener that throws makes `log` return
1007
+ * `ok: false` with the listener's message; the application is not affected.
1008
+ *
1009
+ * ```ts
1010
+ * const bus = new EventEmitter();
1011
+ * bus.on('error-log', (record) => showToast(record.message));
1012
+ * const adapter = new EventEmitterErrorAdapter(bus);
1013
+ * ```
1014
+ */
1015
+ declare class EventEmitterErrorAdapter<TCode extends string = string> implements ErrorLogAdapter<TCode> {
1016
+ private readonly emitter;
1017
+ private readonly eventName;
1018
+ private readonly requireListener;
1019
+ private readonly prefix;
1020
+ /** @throws {RangeError} if `options.eventName` is empty. */
1021
+ constructor(emitter: EmitterLike, options?: EventEmitterErrorOptions);
1022
+ log(entry: ErrorLogEntry<TCode>): Promise<ErrorLogResult>;
1023
+ }
1024
+
1025
+ export { type CollectionLike, CompositeErrorAdapter, type CompositeErrorOptions, ConsoleErrorAdapter, type ConsoleErrorOptions, type EmitterLike, type ErrorLogAdapter, type ErrorLogEntry, type ErrorLogResult, EventEmitterErrorAdapter, type EventEmitterErrorOptions, type EventEmitterErrorRecord, type FileSystemLike, type FirebaseErrorOptions, type FirebaseErrorRecord, type FirestoreCollectionLike, FirestoreErrorAdapter, FlatFileErrorAdapter, type FlatFileErrorOptions, type IdbDatabaseLike, type IdbObjectStoreLike, type IdbRequestLike, type IdbTransactionLike, type IdbUpgradeDatabaseLike, IndexedDbErrorAdapter, type IndexedDbErrorOptions, type IndexedDbErrorRecord, JsonFileErrorAdapter, type JsonFileErrorOptions, type JsonFileErrorRecord, type LockManagerLike, MongoDbErrorAdapter, type MongoDbErrorOptions, MySqlErrorAdapter, PostgresErrorAdapter, type ReadWriteFileSystemLike, RealtimeDatabaseErrorAdapter, type RealtimeDatabaseRefLike, type RedisClientLike, RedisErrorAdapter, type RedisErrorOptions, type S3BatchOptions, type S3ClientLike, S3ErrorAdapter, type S3ErrorOptions, type S3PutObjectInput, type SerializedErrorLogEntry, type SqlClientLike, type SqlDialect, type SqlErrorOptions, type SqliteClientLike, SqliteErrorAdapter, type SqliteErrorOptions, type StorageLike, type SupabaseClientLike, SupabaseErrorAdapter, type SupabaseErrorLike, type SupabaseErrorOptions, type SupabaseErrorRow, type SupabaseInsertResult, type SupabaseSetupOptions, WebStorageErrorAdapter, type WebStorageErrorOptions, type WebStorageErrorRecord, WebhookErrorAdapter, type WebhookErrorOptions, buildCreateErrorLogTableSql, buildErrorLogTableDdl, buildMySqlWidenColumnsSql, buildSupabaseSetupSql, createErrorLogStore, formatErrorLogLine, serializeCause, serializeEntry };
package/dist/index.mjs CHANGED
@@ -229,6 +229,78 @@ function withTimeout(start, timeoutMs, message) {
229
229
  }
230
230
 
231
231
  // src/adapters/webhook-error-adapter.ts
232
+ var DEFAULT_RETRY_DELAY_MS = 500;
233
+ var DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
234
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
235
+ var SigningUnavailableError = class extends Error {
236
+ };
237
+ async function hmacSha256Hex(secret, message) {
238
+ const subtle = globalThis.crypto?.subtle;
239
+ if (subtle === void 0) {
240
+ throw new SigningUnavailableError(
241
+ "signingSecret needs Web Crypto (crypto.subtle), which this runtime does not provide, so the entry was not sent unsigned"
242
+ );
243
+ }
244
+ const encoder = new TextEncoder();
245
+ const key = await subtle.importKey(
246
+ "raw",
247
+ encoder.encode(secret),
248
+ { name: "HMAC", hash: "SHA-256" },
249
+ false,
250
+ ["sign"]
251
+ );
252
+ const signature = await subtle.sign(
253
+ "HMAC",
254
+ key,
255
+ encoder.encode(message)
256
+ );
257
+ return Array.from(
258
+ new Uint8Array(signature),
259
+ (byte) => byte.toString(16).padStart(2, "0")
260
+ ).join("");
261
+ }
262
+ function parseRetryAfterMs(value) {
263
+ if (value === null || value === void 0 || value.trim() === "") {
264
+ return void 0;
265
+ }
266
+ const seconds = Number(value);
267
+ if (Number.isFinite(seconds) && seconds >= 0) {
268
+ return seconds * 1e3;
269
+ }
270
+ const date = Date.parse(value);
271
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
272
+ }
273
+ function validateRetryOptions(options) {
274
+ const retries = options.retries ?? 0;
275
+ if (!Number.isInteger(retries) || retries < 0) {
276
+ throw new RangeError(
277
+ `WebhookErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
278
+ );
279
+ }
280
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
281
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs <= 0) {
282
+ throw new RangeError(
283
+ `WebhookErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
284
+ );
285
+ }
286
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
287
+ if (!Number.isFinite(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
288
+ throw new RangeError(
289
+ `WebhookErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
290
+ );
291
+ }
292
+ if (options.signingSecret === "") {
293
+ throw new RangeError(
294
+ "WebhookErrorAdapter: signingSecret must not be empty; leave it out to send unsigned."
295
+ );
296
+ }
297
+ return { retries, retryDelayMs, maxRetryDelayMs };
298
+ }
299
+ function sleep(ms) {
300
+ return new Promise((resolve) => {
301
+ setTimeout(resolve, ms);
302
+ });
303
+ }
232
304
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
233
305
  "localhost",
234
306
  "127.0.0.1",
@@ -254,15 +326,57 @@ function parseWebhookUrl(url, allowInsecure) {
254
326
  );
255
327
  }
256
328
  var WebhookErrorAdapter = class {
257
- /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
329
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
258
330
  constructor(url, options = {}) {
259
331
  this.url = url;
260
332
  this.options = options;
261
333
  this.prefix = options.prefix ?? DEFAULT_PREFIX;
262
334
  this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
335
+ ({
336
+ retries: this.retries,
337
+ retryDelayMs: this.retryDelayMs,
338
+ maxRetryDelayMs: this.maxRetryDelayMs
339
+ } = validateRetryOptions(options));
263
340
  this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
264
341
  }
265
342
  async log(entry) {
343
+ let body;
344
+ try {
345
+ body = JSON.stringify(
346
+ hasCallerId(entry) || this.retries > 0 ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
347
+ );
348
+ } catch (error) {
349
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
350
+ return this.fail(
351
+ `could not prepare the entry: ${this.redact(describeError(error))}`
352
+ );
353
+ }
354
+ let attempt = await this.send(body);
355
+ let attempts = 1;
356
+ while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
357
+ await sleep(this.retryWaitMs(attempts, attempt.retryAfterMs));
358
+ attempt = await this.send(body);
359
+ attempts += 1;
360
+ }
361
+ if (attempt.ok) {
362
+ return { ok: true };
363
+ }
364
+ return this.fail(
365
+ attempts > 1 ? `${attempt.reason} (after ${attempts} attempts)` : attempt.reason
366
+ );
367
+ }
368
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
369
+ retryWaitMs(retryNumber, retryAfterMs) {
370
+ if (retryAfterMs !== void 0) {
371
+ return Math.min(retryAfterMs, this.maxRetryDelayMs);
372
+ }
373
+ const ceiling = Math.min(
374
+ this.maxRetryDelayMs,
375
+ this.retryDelayMs * 2 ** (retryNumber - 1)
376
+ );
377
+ return ceiling / 2 + Math.random() * ceiling / 2;
378
+ }
379
+ async send(body) {
266
380
  const controller = new AbortController();
267
381
  let timedOut = false;
268
382
  const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
@@ -274,43 +388,70 @@ var WebhookErrorAdapter = class {
274
388
  method: "POST",
275
389
  headers: {
276
390
  "Content-Type": "application/json",
277
- ...this.options.headers
391
+ ...this.options.headers,
392
+ ...await this.signatureHeaders(body)
278
393
  },
279
- // A caller-supplied id goes along so the receiver can drop a repeat.
280
- body: JSON.stringify(
281
- hasCallerId(entry) ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
282
- ),
394
+ body,
283
395
  // A redirect would turn the POST into a GET without a body (302, 303)
284
396
  // or re-send the entry and headers to another host (307, 308).
285
397
  redirect: "manual",
286
398
  signal: controller.signal
287
399
  });
288
400
  if (response.type === "opaqueredirect") {
289
- return this.fail(this.redirectMessage("a redirect"));
401
+ return this.rejected(this.redirectMessage("a redirect"));
290
402
  }
291
403
  if (!response.ok) {
292
404
  if (response.status >= 300 && response.status < 400) {
293
- return this.fail(this.redirectMessage(`HTTP ${response.status}`));
405
+ return this.rejected(this.redirectMessage(`HTTP ${response.status}`));
294
406
  }
295
- return this.fail(
296
- `webhook ${this.target} responded with HTTP ${response.status}`
297
- );
407
+ return {
408
+ ok: false,
409
+ reason: `webhook ${this.target} responded with HTTP ${response.status}`,
410
+ retryable: response.status >= 500 || RETRYABLE_STATUSES.has(response.status),
411
+ retryAfterMs: parseRetryAfterMs(response.headers?.get("retry-after"))
412
+ };
298
413
  }
299
414
  return { ok: true };
300
415
  } catch (error) {
301
416
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
417
+ if (error instanceof SigningUnavailableError) {
418
+ return this.rejected(error.message);
419
+ }
302
420
  if (timedOut) {
303
- return this.fail(
304
- timeoutMessage(`webhook ${this.target}`, this.timeoutMs)
305
- );
421
+ return {
422
+ ok: false,
423
+ reason: timeoutMessage(`webhook ${this.target}`, this.timeoutMs),
424
+ retryable: true
425
+ };
306
426
  }
307
- return this.fail(
308
- `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`
309
- );
427
+ return {
428
+ ok: false,
429
+ reason: `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`,
430
+ retryable: true
431
+ };
310
432
  } finally {
311
433
  clearTimeout(timer);
312
434
  }
313
435
  }
436
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
437
+ async signatureHeaders(body) {
438
+ const secret = this.options.signingSecret;
439
+ if (secret === void 0) {
440
+ return {};
441
+ }
442
+ const timestamp = String(Math.floor(Date.now() / 1e3));
443
+ const signature = await hmacSha256Hex(
444
+ secret,
445
+ `${timestamp}.${body}`
446
+ );
447
+ return {
448
+ "X-Signature-Timestamp": timestamp,
449
+ "X-Signature-256": `sha256=${signature}`
450
+ };
451
+ }
452
+ rejected(reason) {
453
+ return { ok: false, reason, retryable: false };
454
+ }
314
455
  redirectMessage(what) {
315
456
  return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
316
457
  }
@@ -1590,9 +1731,49 @@ var SupabaseErrorAdapter = class {
1590
1731
  });
1591
1732
  }
1592
1733
  };
1734
+
1735
+ // src/adapters/event-emitter-error-adapter.ts
1736
+ var DEFAULT_EVENT_NAME = "error-log";
1737
+ var EventEmitterErrorAdapter = class {
1738
+ /** @throws {RangeError} if `options.eventName` is empty. */
1739
+ constructor(emitter, options = {}) {
1740
+ this.emitter = emitter;
1741
+ this.eventName = options.eventName ?? DEFAULT_EVENT_NAME;
1742
+ if (this.eventName === "") {
1743
+ throw new RangeError(
1744
+ "EventEmitterErrorAdapter: eventName must not be empty."
1745
+ );
1746
+ }
1747
+ this.requireListener = options.requireListener ?? false;
1748
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1749
+ }
1750
+ async log(entry) {
1751
+ try {
1752
+ const record = {
1753
+ id: resolveEntryId(entry),
1754
+ ...serializeEntry(entry)
1755
+ };
1756
+ const delivered = this.emitter.emit(this.eventName, record);
1757
+ if (this.requireListener && delivered === false) {
1758
+ throw new Error(
1759
+ `no listener is attached to "${this.eventName}", so nothing received the entry`
1760
+ );
1761
+ }
1762
+ return { ok: true };
1763
+ } catch (error) {
1764
+ return reportFailure({
1765
+ adapterName: "EventEmitterErrorAdapter",
1766
+ prefix: this.prefix,
1767
+ action: `to emit "${this.eventName}"`,
1768
+ error
1769
+ });
1770
+ }
1771
+ }
1772
+ };
1593
1773
  export {
1594
1774
  CompositeErrorAdapter,
1595
1775
  ConsoleErrorAdapter,
1776
+ EventEmitterErrorAdapter,
1596
1777
  FirestoreErrorAdapter,
1597
1778
  FlatFileErrorAdapter,
1598
1779
  IndexedDbErrorAdapter,