@daloyjs/core 0.36.0 → 0.37.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 +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +76 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound webhook delivery — the sending counterpart to the inbound
|
|
3
|
+
* {@link verifyWebhookSignature} / {@link signWebhookPayload} helpers.
|
|
4
|
+
*
|
|
5
|
+
* Where the inbound helpers answer *“is this webhook I received
|
|
6
|
+
* authentic?”*, this module answers *“how do I reliably and
|
|
7
|
+
* securely deliver a webhook to someone else?”* — the three things a
|
|
8
|
+
* production webhook sender needs:
|
|
9
|
+
*
|
|
10
|
+
* - **Signed delivery with timestamped signatures.** Every request carries
|
|
11
|
+
* an HMAC signature computed over `"<timestamp>.<body>"` (the Stripe /
|
|
12
|
+
* Standard Webhooks convention), plus an idempotency id and a timestamp
|
|
13
|
+
* header, so the receiver can authenticate the payload and reject
|
|
14
|
+
* replays with {@link verifyWebhookSignature}.
|
|
15
|
+
* - **Retry with backoff.** Transient failures (network errors, timeouts,
|
|
16
|
+
* `408` / `429` / `5xx`) are retried with exponential backoff and full
|
|
17
|
+
* jitter, honouring a `Retry-After` header. The signature is computed
|
|
18
|
+
* **once** so every retry carries the same id and signature — the
|
|
19
|
+
* receiver can dedupe on the id.
|
|
20
|
+
* - **Dead-letter semantics.** When every attempt is exhausted (or the
|
|
21
|
+
* upstream returns a permanent `4xx`), the failed delivery is handed to
|
|
22
|
+
* a {@link WebhookDeadLetterSink} for later inspection or replay instead
|
|
23
|
+
* of being silently dropped.
|
|
24
|
+
*
|
|
25
|
+
* Delivery is **SSRF-hardened by default**: the transport defaults to
|
|
26
|
+
* {@link fetchGuard}, so a webhook URL that resolves to cloud-metadata or
|
|
27
|
+
* an internal address is refused before any bytes are sent. Pass your own
|
|
28
|
+
* `fetch` (e.g. `fetchGuard({ allowPrivate: true })` or a
|
|
29
|
+
* {@link resilientFetch}) to change that posture deliberately.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createWebhookSender, MemoryWebhookDeadLetterSink } from "@daloyjs/core";
|
|
33
|
+
*
|
|
34
|
+
* const deadLetters = new MemoryWebhookDeadLetterSink();
|
|
35
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET!, deadLetter: deadLetters });
|
|
36
|
+
*
|
|
37
|
+
* const result = await send({
|
|
38
|
+
* url: "https://example.com/hooks",
|
|
39
|
+
* eventType: "invoice.paid",
|
|
40
|
+
* payload: { id: "in_123", amount: 4200 },
|
|
41
|
+
* });
|
|
42
|
+
* if (!result.ok) {
|
|
43
|
+
* // result.deadLettered === true; inspect deadLetters.list()
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
* @since 0.37.0
|
|
49
|
+
*/
|
|
50
|
+
import { type WebhookHmacAlgorithm } from "./security.js";
|
|
51
|
+
/**
|
|
52
|
+
* A single webhook event to deliver. The `payload` is signed and sent as
|
|
53
|
+
* the request body; everything else shapes the request and the signature
|
|
54
|
+
* headers.
|
|
55
|
+
*
|
|
56
|
+
* @since 0.37.0
|
|
57
|
+
*/
|
|
58
|
+
export interface WebhookEvent {
|
|
59
|
+
/** Absolute `http(s)` URL of the receiver. */
|
|
60
|
+
url: string;
|
|
61
|
+
/**
|
|
62
|
+
* The event body. An object or array is JSON-serialised; a `string` is
|
|
63
|
+
* sent verbatim; a `Uint8Array` is sent as raw bytes. The signature is
|
|
64
|
+
* always computed over the exact bytes sent.
|
|
65
|
+
*/
|
|
66
|
+
payload: unknown;
|
|
67
|
+
/**
|
|
68
|
+
* Optional event type (e.g. `"invoice.paid"`), emitted as a header and
|
|
69
|
+
* recorded on the dead letter. Purely informational.
|
|
70
|
+
*/
|
|
71
|
+
eventType?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Stable idempotency id, emitted in the id header so the receiver can
|
|
74
|
+
* dedupe retries. A random UUID is generated when omitted.
|
|
75
|
+
*/
|
|
76
|
+
id?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Extra request headers merged in **after** the signature headers, so
|
|
79
|
+
* they cannot overwrite the id / timestamp / signature headers.
|
|
80
|
+
*/
|
|
81
|
+
headers?: Record<string, string>;
|
|
82
|
+
/**
|
|
83
|
+
* Override the `Content-Type` header. Defaults to `application/json`
|
|
84
|
+
* for objects / strings and `application/octet-stream` for bytes.
|
|
85
|
+
*/
|
|
86
|
+
contentType?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A failed delivery handed to a {@link WebhookDeadLetterSink} after every
|
|
90
|
+
* attempt is exhausted. Carries enough context to inspect, alert on, or
|
|
91
|
+
* replay the delivery later.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.37.0
|
|
94
|
+
*/
|
|
95
|
+
export interface WebhookDeadLetter {
|
|
96
|
+
/** The idempotency id used for the delivery. */
|
|
97
|
+
id: string;
|
|
98
|
+
/** The receiver URL. */
|
|
99
|
+
url: string;
|
|
100
|
+
/** The event type, when one was supplied. */
|
|
101
|
+
eventType?: string;
|
|
102
|
+
/** The exact body bytes that were signed and sent. */
|
|
103
|
+
payload: Uint8Array;
|
|
104
|
+
/** The `Content-Type` that was sent. */
|
|
105
|
+
contentType: string;
|
|
106
|
+
/** Total number of attempts made before giving up. */
|
|
107
|
+
attempts: number;
|
|
108
|
+
/** The last HTTP status seen, when the final failure was a response. */
|
|
109
|
+
lastStatus?: number;
|
|
110
|
+
/** The last error message, when the final failure was a thrown error. */
|
|
111
|
+
lastError?: string;
|
|
112
|
+
/** The Unix-seconds timestamp bound into the signature. */
|
|
113
|
+
timestamp: number;
|
|
114
|
+
/** Wall-clock time (ms since epoch) the delivery was dead-lettered. */
|
|
115
|
+
failedAt: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A sink that receives {@link WebhookDeadLetter}s for permanently-failed
|
|
119
|
+
* deliveries. Implement this to persist to a queue, database, or alerting
|
|
120
|
+
* pipeline. `add` may be async; the sender awaits it.
|
|
121
|
+
*
|
|
122
|
+
* @since 0.37.0
|
|
123
|
+
*/
|
|
124
|
+
export interface WebhookDeadLetterSink {
|
|
125
|
+
add(letter: WebhookDeadLetter): void | Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* An in-memory, bounded {@link WebhookDeadLetterSink} suitable for tests
|
|
129
|
+
* and single-process apps. Holds the most recent `capacity` dead letters
|
|
130
|
+
* (default `1000`) in a ring buffer; older entries are evicted.
|
|
131
|
+
*
|
|
132
|
+
* @since 0.37.0
|
|
133
|
+
*/
|
|
134
|
+
export declare class MemoryWebhookDeadLetterSink implements WebhookDeadLetterSink {
|
|
135
|
+
#private;
|
|
136
|
+
constructor(capacity?: number);
|
|
137
|
+
/** Append a dead letter, evicting the oldest if at capacity. */
|
|
138
|
+
add(letter: WebhookDeadLetter): void;
|
|
139
|
+
/** A snapshot of the currently-held dead letters, oldest first. */
|
|
140
|
+
list(): readonly WebhookDeadLetter[];
|
|
141
|
+
/** Remove and return every held dead letter (e.g. for a replay sweep). */
|
|
142
|
+
drain(): WebhookDeadLetter[];
|
|
143
|
+
/** The number of dead letters currently held. */
|
|
144
|
+
get size(): number;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Per-attempt telemetry passed to {@link WebhookSenderOptions.onAttempt}.
|
|
148
|
+
*
|
|
149
|
+
* @since 0.37.0
|
|
150
|
+
*/
|
|
151
|
+
export interface WebhookAttempt {
|
|
152
|
+
/** The idempotency id of the delivery. */
|
|
153
|
+
id: string;
|
|
154
|
+
/** 1-based attempt number. */
|
|
155
|
+
attempt: number;
|
|
156
|
+
/** The HTTP status, when the attempt produced a response. */
|
|
157
|
+
status?: number;
|
|
158
|
+
/** The error, when the attempt threw (network error / timeout). */
|
|
159
|
+
error?: unknown;
|
|
160
|
+
/** Whether the sender will retry after this attempt. */
|
|
161
|
+
willRetry: boolean;
|
|
162
|
+
/** The backoff delay (ms) before the next attempt, when retrying. */
|
|
163
|
+
delayMs?: number;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The outcome of a {@link createWebhookSender} delivery. Never throws for
|
|
167
|
+
* an ordinary delivery failure — inspect `ok` / `deadLettered` instead.
|
|
168
|
+
*
|
|
169
|
+
* @since 0.37.0
|
|
170
|
+
*/
|
|
171
|
+
export interface WebhookDeliveryResult {
|
|
172
|
+
/** `true` when the receiver returned a 2xx response. */
|
|
173
|
+
ok: boolean;
|
|
174
|
+
/** The idempotency id used for the delivery. */
|
|
175
|
+
id: string;
|
|
176
|
+
/** The event type, when one was supplied. */
|
|
177
|
+
eventType?: string;
|
|
178
|
+
/** Total number of attempts made. */
|
|
179
|
+
attempts: number;
|
|
180
|
+
/** The final HTTP status, when the last attempt produced a response. */
|
|
181
|
+
status?: number;
|
|
182
|
+
/** The final response object, when the last attempt produced one. */
|
|
183
|
+
response?: Response;
|
|
184
|
+
/** The final error, when the last attempt threw. */
|
|
185
|
+
error?: unknown;
|
|
186
|
+
/** Whether the failed delivery was handed to the dead-letter sink. */
|
|
187
|
+
deadLettered: boolean;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Configuration for {@link createWebhookSender}. Only `secret` is
|
|
191
|
+
* required; every other field has a production-safe default.
|
|
192
|
+
*
|
|
193
|
+
* @since 0.37.0
|
|
194
|
+
*/
|
|
195
|
+
export interface WebhookSenderOptions {
|
|
196
|
+
/** HMAC secret used to sign every delivery. */
|
|
197
|
+
secret: string | Uint8Array;
|
|
198
|
+
/** HMAC digest. Default `"sha256"`. */
|
|
199
|
+
algorithm?: WebhookHmacAlgorithm;
|
|
200
|
+
/**
|
|
201
|
+
* Transport. Defaults to {@link fetchGuard} so webhook URLs that resolve
|
|
202
|
+
* to internal / cloud-metadata addresses are refused (SSRF defence).
|
|
203
|
+
* Pass your own to relax or extend that posture.
|
|
204
|
+
*/
|
|
205
|
+
fetch?: typeof fetch;
|
|
206
|
+
/** Maximum total attempts (first try + retries). Default `5`. */
|
|
207
|
+
maxAttempts?: number;
|
|
208
|
+
/** Base backoff for the first retry, in ms. Default `500`. */
|
|
209
|
+
retryDelayMs?: number;
|
|
210
|
+
/** Upper bound on any single backoff delay, in ms. Default `30_000`. */
|
|
211
|
+
maxRetryDelayMs?: number;
|
|
212
|
+
/** Exponential backoff multiplier. Default `2`. */
|
|
213
|
+
backoffFactor?: number;
|
|
214
|
+
/** Apply full jitter to backoff. Default `true`. */
|
|
215
|
+
jitter?: boolean;
|
|
216
|
+
/** Per-attempt timeout, in ms. `0` disables. Default `10_000`. */
|
|
217
|
+
timeoutMs?: number;
|
|
218
|
+
/**
|
|
219
|
+
* Response statuses that trigger a retry. Default
|
|
220
|
+
* `[408, 429, 500, 502, 503, 504]`. Any other non-2xx status is a
|
|
221
|
+
* permanent failure (dead-lettered immediately).
|
|
222
|
+
*/
|
|
223
|
+
retryableStatuses?: readonly number[];
|
|
224
|
+
/** Honour a `Retry-After` header on a retryable response. Default `true`. */
|
|
225
|
+
respectRetryAfter?: boolean;
|
|
226
|
+
/** Header carrying the idempotency id. Default `"webhook-id"`. */
|
|
227
|
+
idHeader?: string;
|
|
228
|
+
/** Header carrying the Unix-seconds timestamp. Default `"webhook-timestamp"`. */
|
|
229
|
+
timestampHeader?: string;
|
|
230
|
+
/** Header carrying the signature. Default `"webhook-signature"`. */
|
|
231
|
+
signatureHeader?: string;
|
|
232
|
+
/** Header carrying the event type. Default `"webhook-event-type"`. */
|
|
233
|
+
eventTypeHeader?: string;
|
|
234
|
+
/** `User-Agent` sent with every delivery. Default `"DaloyJS-Webhook/1.0"`. */
|
|
235
|
+
userAgent?: string;
|
|
236
|
+
/** Sink for permanently-failed deliveries. */
|
|
237
|
+
deadLetter?: WebhookDeadLetterSink;
|
|
238
|
+
/** Clock (ms since epoch). Default {@link Date.now}. Override in tests. */
|
|
239
|
+
now?: () => number;
|
|
240
|
+
/**
|
|
241
|
+
* Abortable sleep, primarily for deterministic tests. Defaults to a
|
|
242
|
+
* `setTimeout`-based sleep.
|
|
243
|
+
*/
|
|
244
|
+
sleep?: (ms: number) => Promise<void>;
|
|
245
|
+
/** Per-attempt observer (e.g. to emit a metric). */
|
|
246
|
+
onAttempt?: (attempt: WebhookAttempt) => void;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build a webhook sender bound to a signing secret and delivery policy.
|
|
250
|
+
* The returned `send(event)` function signs, delivers, retries, and
|
|
251
|
+
* dead-letters a single {@link WebhookEvent}, resolving to a
|
|
252
|
+
* {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
|
|
253
|
+
* failure).
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET! });
|
|
258
|
+
* const result = await send({ url, eventType: "user.created", payload: { id } });
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* @since 0.37.0
|
|
262
|
+
*/
|
|
263
|
+
export declare function createWebhookSender(options: WebhookSenderOptions): (event: WebhookEvent) => Promise<WebhookDeliveryResult>;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound webhook delivery — the sending counterpart to the inbound
|
|
3
|
+
* {@link verifyWebhookSignature} / {@link signWebhookPayload} helpers.
|
|
4
|
+
*
|
|
5
|
+
* Where the inbound helpers answer *“is this webhook I received
|
|
6
|
+
* authentic?”*, this module answers *“how do I reliably and
|
|
7
|
+
* securely deliver a webhook to someone else?”* — the three things a
|
|
8
|
+
* production webhook sender needs:
|
|
9
|
+
*
|
|
10
|
+
* - **Signed delivery with timestamped signatures.** Every request carries
|
|
11
|
+
* an HMAC signature computed over `"<timestamp>.<body>"` (the Stripe /
|
|
12
|
+
* Standard Webhooks convention), plus an idempotency id and a timestamp
|
|
13
|
+
* header, so the receiver can authenticate the payload and reject
|
|
14
|
+
* replays with {@link verifyWebhookSignature}.
|
|
15
|
+
* - **Retry with backoff.** Transient failures (network errors, timeouts,
|
|
16
|
+
* `408` / `429` / `5xx`) are retried with exponential backoff and full
|
|
17
|
+
* jitter, honouring a `Retry-After` header. The signature is computed
|
|
18
|
+
* **once** so every retry carries the same id and signature — the
|
|
19
|
+
* receiver can dedupe on the id.
|
|
20
|
+
* - **Dead-letter semantics.** When every attempt is exhausted (or the
|
|
21
|
+
* upstream returns a permanent `4xx`), the failed delivery is handed to
|
|
22
|
+
* a {@link WebhookDeadLetterSink} for later inspection or replay instead
|
|
23
|
+
* of being silently dropped.
|
|
24
|
+
*
|
|
25
|
+
* Delivery is **SSRF-hardened by default**: the transport defaults to
|
|
26
|
+
* {@link fetchGuard}, so a webhook URL that resolves to cloud-metadata or
|
|
27
|
+
* an internal address is refused before any bytes are sent. Pass your own
|
|
28
|
+
* `fetch` (e.g. `fetchGuard({ allowPrivate: true })` or a
|
|
29
|
+
* {@link resilientFetch}) to change that posture deliberately.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createWebhookSender, MemoryWebhookDeadLetterSink } from "@daloyjs/core";
|
|
33
|
+
*
|
|
34
|
+
* const deadLetters = new MemoryWebhookDeadLetterSink();
|
|
35
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET!, deadLetter: deadLetters });
|
|
36
|
+
*
|
|
37
|
+
* const result = await send({
|
|
38
|
+
* url: "https://example.com/hooks",
|
|
39
|
+
* eventType: "invoice.paid",
|
|
40
|
+
* payload: { id: "in_123", amount: 4200 },
|
|
41
|
+
* });
|
|
42
|
+
* if (!result.ok) {
|
|
43
|
+
* // result.deadLettered === true; inspect deadLetters.list()
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
* @since 0.37.0
|
|
49
|
+
*/
|
|
50
|
+
import { signWebhookPayload } from "./security.js";
|
|
51
|
+
import { fetchGuard } from "./fetch-guard.js";
|
|
52
|
+
/**
|
|
53
|
+
* An in-memory, bounded {@link WebhookDeadLetterSink} suitable for tests
|
|
54
|
+
* and single-process apps. Holds the most recent `capacity` dead letters
|
|
55
|
+
* (default `1000`) in a ring buffer; older entries are evicted.
|
|
56
|
+
*
|
|
57
|
+
* @since 0.37.0
|
|
58
|
+
*/
|
|
59
|
+
export class MemoryWebhookDeadLetterSink {
|
|
60
|
+
#capacity;
|
|
61
|
+
#items = [];
|
|
62
|
+
constructor(capacity = 1000) {
|
|
63
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
64
|
+
throw new RangeError("MemoryWebhookDeadLetterSink: capacity must be a positive integer");
|
|
65
|
+
}
|
|
66
|
+
this.#capacity = capacity;
|
|
67
|
+
}
|
|
68
|
+
/** Append a dead letter, evicting the oldest if at capacity. */
|
|
69
|
+
add(letter) {
|
|
70
|
+
this.#items.push(letter);
|
|
71
|
+
if (this.#items.length > this.#capacity)
|
|
72
|
+
this.#items.shift();
|
|
73
|
+
}
|
|
74
|
+
/** A snapshot of the currently-held dead letters, oldest first. */
|
|
75
|
+
list() {
|
|
76
|
+
return [...this.#items];
|
|
77
|
+
}
|
|
78
|
+
/** Remove and return every held dead letter (e.g. for a replay sweep). */
|
|
79
|
+
drain() {
|
|
80
|
+
const out = this.#items;
|
|
81
|
+
this.#items = [];
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The number of dead letters currently held. */
|
|
85
|
+
get size() {
|
|
86
|
+
return this.#items.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const DEFAULT_RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
|
|
90
|
+
function defaultSleep(ms) {
|
|
91
|
+
if (ms <= 0)
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const timer = setTimeout(resolve, ms);
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function parseRetryAfter(value, now) {
|
|
99
|
+
if (!value)
|
|
100
|
+
return undefined;
|
|
101
|
+
const trimmed = value.trim();
|
|
102
|
+
if (/^\d+$/.test(trimmed))
|
|
103
|
+
return Number(trimmed) * 1000;
|
|
104
|
+
const dateMs = Date.parse(trimmed);
|
|
105
|
+
if (!Number.isNaN(dateMs))
|
|
106
|
+
return Math.max(0, dateMs - now);
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
/** Serialise a payload to body bytes + a default content type. */
|
|
110
|
+
function encodePayload(payload) {
|
|
111
|
+
if (payload instanceof Uint8Array) {
|
|
112
|
+
return { bytes: payload, contentType: "application/octet-stream" };
|
|
113
|
+
}
|
|
114
|
+
if (typeof payload === "string") {
|
|
115
|
+
return { bytes: new TextEncoder().encode(payload), contentType: "application/json" };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
bytes: new TextEncoder().encode(JSON.stringify(payload ?? null)),
|
|
119
|
+
contentType: "application/json",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function randomId() {
|
|
123
|
+
const c = globalThis.crypto;
|
|
124
|
+
if (c?.randomUUID)
|
|
125
|
+
return c.randomUUID();
|
|
126
|
+
// Web-Crypto is mandatory on every runtime Daloy supports; this is an
|
|
127
|
+
// unreachable last-resort guard so a missing global never throws.
|
|
128
|
+
throw new Error("WebCrypto unavailable: cannot generate a webhook id");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build a webhook sender bound to a signing secret and delivery policy.
|
|
132
|
+
* The returned `send(event)` function signs, delivers, retries, and
|
|
133
|
+
* dead-letters a single {@link WebhookEvent}, resolving to a
|
|
134
|
+
* {@link WebhookDeliveryResult} (it does not throw on ordinary delivery
|
|
135
|
+
* failure).
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* const send = createWebhookSender({ secret: process.env.WEBHOOK_SECRET! });
|
|
140
|
+
* const result = await send({ url, eventType: "user.created", payload: { id } });
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* @since 0.37.0
|
|
144
|
+
*/
|
|
145
|
+
export function createWebhookSender(options) {
|
|
146
|
+
if (options.secret === undefined || options.secret === null || options.secret === "") {
|
|
147
|
+
throw new Error("createWebhookSender(): a non-empty signing secret is required");
|
|
148
|
+
}
|
|
149
|
+
const algorithm = options.algorithm ?? "sha256";
|
|
150
|
+
const transport = options.fetch ?? fetchGuard();
|
|
151
|
+
const maxAttempts = options.maxAttempts ?? 5;
|
|
152
|
+
const retryDelayMs = options.retryDelayMs ?? 500;
|
|
153
|
+
const maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000;
|
|
154
|
+
const backoffFactor = options.backoffFactor ?? 2;
|
|
155
|
+
const jitter = options.jitter ?? true;
|
|
156
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
157
|
+
const respectRetryAfter = options.respectRetryAfter ?? true;
|
|
158
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
159
|
+
throw new RangeError("createWebhookSender(): maxAttempts must be a positive integer");
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
162
|
+
throw new RangeError("createWebhookSender(): timeoutMs must be a non-negative number");
|
|
163
|
+
}
|
|
164
|
+
const retryStatuses = new Set(options.retryableStatuses ?? DEFAULT_RETRYABLE_STATUSES);
|
|
165
|
+
const idHeader = options.idHeader ?? "webhook-id";
|
|
166
|
+
const timestampHeader = options.timestampHeader ?? "webhook-timestamp";
|
|
167
|
+
const signatureHeader = options.signatureHeader ?? "webhook-signature";
|
|
168
|
+
const eventTypeHeader = options.eventTypeHeader ?? "webhook-event-type";
|
|
169
|
+
const userAgent = options.userAgent ?? "DaloyJS-Webhook/1.0";
|
|
170
|
+
const now = options.now ?? Date.now;
|
|
171
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
172
|
+
function backoffFor(attempt, response) {
|
|
173
|
+
if (respectRetryAfter && response) {
|
|
174
|
+
const fromHeader = parseRetryAfter(response.headers.get("retry-after"), now());
|
|
175
|
+
if (fromHeader !== undefined)
|
|
176
|
+
return Math.min(maxRetryDelayMs, fromHeader);
|
|
177
|
+
}
|
|
178
|
+
const exp = retryDelayMs * backoffFactor ** (attempt - 1);
|
|
179
|
+
const capped = Math.min(maxRetryDelayMs, exp);
|
|
180
|
+
// Backoff jitter spreads load; it is not a security primitive.
|
|
181
|
+
return jitter ? Math.random() * capped : capped; // daloy-allow-weak-random: backoff jitter is not a security primitive
|
|
182
|
+
}
|
|
183
|
+
async function attemptOnce(url, headers, body) {
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
let timer;
|
|
186
|
+
if (timeoutMs > 0) {
|
|
187
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
188
|
+
timer.unref?.();
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const response = await transport(url, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers,
|
|
194
|
+
body: body,
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
});
|
|
197
|
+
return { response };
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return { error };
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
if (timer)
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return async function send(event) {
|
|
208
|
+
const id = event.id ?? randomId();
|
|
209
|
+
const { bytes, contentType: defaultContentType } = encodePayload(event.payload);
|
|
210
|
+
const contentType = event.contentType ?? defaultContentType;
|
|
211
|
+
const timestamp = Math.floor(now() / 1000);
|
|
212
|
+
// Sign ONCE — every retry carries the same id + signature so the
|
|
213
|
+
// receiver can dedupe and the timestamp stays stable.
|
|
214
|
+
const signature = await signWebhookPayload({
|
|
215
|
+
payload: bytes,
|
|
216
|
+
secret: options.secret,
|
|
217
|
+
algorithm,
|
|
218
|
+
timestamp,
|
|
219
|
+
});
|
|
220
|
+
const baseHeaders = {
|
|
221
|
+
"content-type": contentType,
|
|
222
|
+
"user-agent": userAgent,
|
|
223
|
+
[idHeader]: id,
|
|
224
|
+
[timestampHeader]: String(timestamp),
|
|
225
|
+
[signatureHeader]: `${algorithm}=${signature}`,
|
|
226
|
+
};
|
|
227
|
+
if (event.eventType !== undefined)
|
|
228
|
+
baseHeaders[eventTypeHeader] = event.eventType;
|
|
229
|
+
// Caller headers are merged last but cannot clobber signature headers.
|
|
230
|
+
const reserved = new Set([
|
|
231
|
+
"content-type",
|
|
232
|
+
idHeader.toLowerCase(),
|
|
233
|
+
timestampHeader.toLowerCase(),
|
|
234
|
+
signatureHeader.toLowerCase(),
|
|
235
|
+
eventTypeHeader.toLowerCase(),
|
|
236
|
+
]);
|
|
237
|
+
for (const [k, v] of Object.entries(event.headers ?? {})) {
|
|
238
|
+
if (!reserved.has(k.toLowerCase()))
|
|
239
|
+
baseHeaders[k] = v;
|
|
240
|
+
}
|
|
241
|
+
let lastStatus;
|
|
242
|
+
let lastResponse;
|
|
243
|
+
let lastError;
|
|
244
|
+
let madeAttempts = 0;
|
|
245
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
246
|
+
madeAttempts = attempt;
|
|
247
|
+
const { response, error } = await attemptOnce(event.url, baseHeaders, bytes);
|
|
248
|
+
if (response) {
|
|
249
|
+
lastResponse = response;
|
|
250
|
+
lastStatus = response.status;
|
|
251
|
+
lastError = undefined;
|
|
252
|
+
if (response.ok) {
|
|
253
|
+
options.onAttempt?.({ id, attempt, status: response.status, willRetry: false });
|
|
254
|
+
return { ok: true, id, eventType: event.eventType, attempts: attempt, status: response.status, response, deadLettered: false };
|
|
255
|
+
}
|
|
256
|
+
const retryable = retryStatuses.has(response.status) && attempt < maxAttempts;
|
|
257
|
+
const delayMs = retryable ? backoffFor(attempt, response) : undefined;
|
|
258
|
+
options.onAttempt?.({ id, attempt, status: response.status, willRetry: retryable, delayMs });
|
|
259
|
+
if (!retryable)
|
|
260
|
+
break;
|
|
261
|
+
await sleep(delayMs);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Thrown error. An SSRF refusal is a permanent decision about the
|
|
265
|
+
// target and is never retried; a per-attempt timeout (our own abort)
|
|
266
|
+
// and ordinary network errors are transient and retried.
|
|
267
|
+
lastError = error;
|
|
268
|
+
lastResponse = undefined;
|
|
269
|
+
lastStatus = undefined;
|
|
270
|
+
const isSsrf = error instanceof Error && error.name === "SsrfBlockedError";
|
|
271
|
+
const retryable = !isSsrf && attempt < maxAttempts;
|
|
272
|
+
const delayMs = retryable ? backoffFor(attempt) : undefined;
|
|
273
|
+
options.onAttempt?.({ id, attempt, error, willRetry: retryable, delayMs });
|
|
274
|
+
if (!retryable)
|
|
275
|
+
break;
|
|
276
|
+
await sleep(delayMs);
|
|
277
|
+
}
|
|
278
|
+
// Exhausted / permanent failure → dead-letter.
|
|
279
|
+
let deadLettered = false;
|
|
280
|
+
if (options.deadLetter) {
|
|
281
|
+
await options.deadLetter.add({
|
|
282
|
+
id,
|
|
283
|
+
url: event.url,
|
|
284
|
+
...(event.eventType !== undefined ? { eventType: event.eventType } : {}),
|
|
285
|
+
payload: bytes,
|
|
286
|
+
contentType,
|
|
287
|
+
attempts: madeAttempts,
|
|
288
|
+
...(lastStatus !== undefined ? { lastStatus } : {}),
|
|
289
|
+
...(lastError !== undefined ? { lastError: lastError instanceof Error ? lastError.message : String(lastError) } : {}),
|
|
290
|
+
timestamp,
|
|
291
|
+
failedAt: now(),
|
|
292
|
+
});
|
|
293
|
+
deadLettered = true;
|
|
294
|
+
}
|
|
295
|
+
const result = {
|
|
296
|
+
ok: false,
|
|
297
|
+
id,
|
|
298
|
+
attempts: madeAttempts,
|
|
299
|
+
deadLettered,
|
|
300
|
+
};
|
|
301
|
+
if (event.eventType !== undefined)
|
|
302
|
+
result.eventType = event.eventType;
|
|
303
|
+
if (lastStatus !== undefined)
|
|
304
|
+
result.status = lastStatus;
|
|
305
|
+
if (lastResponse !== undefined)
|
|
306
|
+
result.response = lastResponse;
|
|
307
|
+
if (lastError !== undefined)
|
|
308
|
+
result.error = lastError;
|
|
309
|
+
return result;
|
|
310
|
+
};
|
|
311
|
+
}
|
package/dist/websocket.d.ts
CHANGED
|
@@ -84,6 +84,40 @@ export interface WebSocketContext<P extends string = string, S = AppState> {
|
|
|
84
84
|
/** Subprotocols offered by the client (parsed from `Sec-WebSocket-Protocol`). */
|
|
85
85
|
protocols: string[];
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Optional contract/documentation metadata for a WebSocket route, mirroring
|
|
89
|
+
* the HTTP route `meta` block. Consumed by the built-in AsyncAPI generator
|
|
90
|
+
* ({@link generateAsyncAPI}) to describe the channel, its operations, and the
|
|
91
|
+
* payloads exchanged over the socket. Purely descriptive — it never changes
|
|
92
|
+
* runtime behavior or the RFC 6455 handshake.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.37.0
|
|
95
|
+
*/
|
|
96
|
+
export interface WebSocketMeta {
|
|
97
|
+
/** Short channel summary surfaced as the AsyncAPI channel/operation summary. */
|
|
98
|
+
summary?: string;
|
|
99
|
+
/** Longer CommonMark description for the channel. */
|
|
100
|
+
description?: string;
|
|
101
|
+
/** Tags applied to the generated AsyncAPI operations. */
|
|
102
|
+
tags?: string[];
|
|
103
|
+
/**
|
|
104
|
+
* Schema describing messages the **server sends to clients** (outbound).
|
|
105
|
+
* Surfaced as the payload of the AsyncAPI `send` operation. Falls back to no
|
|
106
|
+
* outbound message when omitted.
|
|
107
|
+
*/
|
|
108
|
+
send?: StandardSchemaV1;
|
|
109
|
+
/**
|
|
110
|
+
* Schema describing messages the **server receives from clients** (inbound).
|
|
111
|
+
* Surfaced as the payload of the AsyncAPI `receive` operation. Defaults to
|
|
112
|
+
* {@link WebSocketHandler.request}'s `body` schema when omitted.
|
|
113
|
+
*/
|
|
114
|
+
receive?: StandardSchemaV1;
|
|
115
|
+
/**
|
|
116
|
+
* Stable identifier base used to derive the AsyncAPI `operationId`s and
|
|
117
|
+
* channel name for this route. Defaults to a slug derived from the path.
|
|
118
|
+
*/
|
|
119
|
+
operationId?: string;
|
|
120
|
+
}
|
|
87
121
|
/**
|
|
88
122
|
* User-supplied WebSocket lifecycle callbacks.
|
|
89
123
|
*
|
|
@@ -98,6 +132,13 @@ export interface WebSocketHandler<P extends string = string, S = AppState, TData
|
|
|
98
132
|
request?: {
|
|
99
133
|
body?: StandardSchemaV1;
|
|
100
134
|
};
|
|
135
|
+
/**
|
|
136
|
+
* Optional contract/documentation metadata consumed by the built-in
|
|
137
|
+
* AsyncAPI generator ({@link generateAsyncAPI}). Purely descriptive.
|
|
138
|
+
*
|
|
139
|
+
* @since 0.37.0
|
|
140
|
+
*/
|
|
141
|
+
meta?: WebSocketMeta;
|
|
101
142
|
/** Close the connection when queued outbound bytes exceed backpressureLimit. Default: true. */
|
|
102
143
|
closeOnBackpressureLimit?: boolean;
|
|
103
144
|
/** Maximum queued outbound bytes before backpressure handling triggers. Default: 1 MiB. */
|
|
@@ -224,6 +265,17 @@ export declare class WebSocketRegistry {
|
|
|
224
265
|
private entries;
|
|
225
266
|
add(path: PathString, handler: WebSocketHandler<any, any, any>, createState?: WebSocketStateFactory, options?: NormalizedWebSocketOptions): void;
|
|
226
267
|
find(pathname: string): RouteMatch<WebSocketRouteEntry> | undefined;
|
|
268
|
+
/**
|
|
269
|
+
* List every registered WebSocket route entry in registration order.
|
|
270
|
+
*
|
|
271
|
+
* Returns a shallow copy so callers (the AsyncAPI generator, introspection
|
|
272
|
+
* tooling) cannot mutate the registry's internal array. The entry objects
|
|
273
|
+
* themselves are shared by reference and must be treated as read-only.
|
|
274
|
+
*
|
|
275
|
+
* @returns A new array of the registered {@link WebSocketRouteEntry} values.
|
|
276
|
+
* @since 0.37.0
|
|
277
|
+
*/
|
|
278
|
+
list(): WebSocketRouteEntry[];
|
|
227
279
|
get size(): number;
|
|
228
280
|
runtimeOptions(): NormalizedWebSocketOptions;
|
|
229
281
|
}
|
package/dist/websocket.js
CHANGED
|
@@ -227,6 +227,19 @@ export class WebSocketRegistry {
|
|
|
227
227
|
find(pathname) {
|
|
228
228
|
return this.router.find("GET", pathname);
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* List every registered WebSocket route entry in registration order.
|
|
232
|
+
*
|
|
233
|
+
* Returns a shallow copy so callers (the AsyncAPI generator, introspection
|
|
234
|
+
* tooling) cannot mutate the registry's internal array. The entry objects
|
|
235
|
+
* themselves are shared by reference and must be treated as read-only.
|
|
236
|
+
*
|
|
237
|
+
* @returns A new array of the registered {@link WebSocketRouteEntry} values.
|
|
238
|
+
* @since 0.37.0
|
|
239
|
+
*/
|
|
240
|
+
list() {
|
|
241
|
+
return [...this.entries];
|
|
242
|
+
}
|
|
230
243
|
get size() {
|
|
231
244
|
return this._size;
|
|
232
245
|
}
|