@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/README.md +55 -19
- package/dist/index.cjs +199 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -2
- package/dist/index.d.ts +95 -2
- package/dist/index.mjs +198 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
405
|
+
return this.rejected(this.redirectMessage(`HTTP ${response.status}`));
|
|
294
406
|
}
|
|
295
|
-
return
|
|
296
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
reason: timeoutMessage(`webhook ${this.target}`, this.timeoutMs),
|
|
424
|
+
retryable: true
|
|
425
|
+
};
|
|
306
426
|
}
|
|
307
|
-
return
|
|
308
|
-
|
|
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,
|