@xtandard/webhooks 0.1.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/LICENSE +21 -0
- package/README.md +315 -0
- package/bin/xtandard-webhooks.mjs +3 -0
- package/dist/basic-BIW3Rvuz.cjs +199 -0
- package/dist/basic-BIW3Rvuz.cjs.map +1 -0
- package/dist/basic-DKk0Xfuu.mjs +176 -0
- package/dist/basic-DKk0Xfuu.mjs.map +1 -0
- package/dist/chunk-D7D4PA-g.mjs +13 -0
- package/dist/cli.cjs +655 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +42 -0
- package/dist/cli.d.mts +42 -0
- package/dist/cli.mjs +653 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/contract-8h-Azxa5.d.cts +71 -0
- package/dist/contract-9XpcwcCn.mjs +22 -0
- package/dist/contract-9XpcwcCn.mjs.map +1 -0
- package/dist/contract-B2d5dNU3.cjs +33 -0
- package/dist/contract-B2d5dNU3.cjs.map +1 -0
- package/dist/contract-BEhDcd_5.mjs +28 -0
- package/dist/contract-BEhDcd_5.mjs.map +1 -0
- package/dist/contract-Bf1qguwt.cjs +57 -0
- package/dist/contract-Bf1qguwt.cjs.map +1 -0
- package/dist/contract-Bnb3fgRJ.d.cts +177 -0
- package/dist/contract-C2r2Xzwp.d.mts +46 -0
- package/dist/contract-CiPskNvS.d.cts +46 -0
- package/dist/contract-DhQ4JjGG.d.mts +71 -0
- package/dist/contract-T1kcZNdG.d.mts +177 -0
- package/dist/contract-lETlIuXo.d.cts +30 -0
- package/dist/contract-lETlIuXo.d.mts +30 -0
- package/dist/core-CMpnmI5Q.mjs +1605 -0
- package/dist/core-CMpnmI5Q.mjs.map +1 -0
- package/dist/core-DT4ppWh8.d.mts +502 -0
- package/dist/core-KJawHjFF.d.cts +502 -0
- package/dist/core-ZGhH6Vs2.cjs +1790 -0
- package/dist/core-ZGhH6Vs2.cjs.map +1 -0
- package/dist/core.cjs +8 -0
- package/dist/core.d.cts +2 -0
- package/dist/core.d.mts +2 -0
- package/dist/core.mjs +2 -0
- package/dist/create-fetch-handler-BIdk9P30.mjs +1724 -0
- package/dist/create-fetch-handler-BIdk9P30.mjs.map +1 -0
- package/dist/create-fetch-handler-CmooujQo.cjs +1771 -0
- package/dist/create-fetch-handler-CmooujQo.cjs.map +1 -0
- package/dist/create-fetch-handler-Dlkhustu.d.cts +162 -0
- package/dist/create-fetch-handler-jy3hy5nZ.d.mts +162 -0
- package/dist/dispatcher-B0xTEHt1.cjs +212 -0
- package/dist/dispatcher-B0xTEHt1.cjs.map +1 -0
- package/dist/dispatcher-Coubwrka.mjs +196 -0
- package/dist/dispatcher-Coubwrka.mjs.map +1 -0
- package/dist/entry-auth-basic.cjs +5 -0
- package/dist/entry-auth-basic.d.cts +83 -0
- package/dist/entry-auth-basic.d.mts +83 -0
- package/dist/entry-auth-basic.mjs +2 -0
- package/dist/entry-auth-delegated.cjs +28 -0
- package/dist/entry-auth-delegated.cjs.map +1 -0
- package/dist/entry-auth-delegated.d.cts +36 -0
- package/dist/entry-auth-delegated.d.mts +36 -0
- package/dist/entry-auth-delegated.mjs +27 -0
- package/dist/entry-auth-delegated.mjs.map +1 -0
- package/dist/entry-auth-none.cjs +4 -0
- package/dist/entry-auth-none.d.cts +25 -0
- package/dist/entry-auth-none.d.mts +25 -0
- package/dist/entry-auth-none.mjs +2 -0
- package/dist/entry-authorization-delegated.cjs +27 -0
- package/dist/entry-authorization-delegated.cjs.map +1 -0
- package/dist/entry-authorization-delegated.d.cts +31 -0
- package/dist/entry-authorization-delegated.d.mts +31 -0
- package/dist/entry-authorization-delegated.mjs +26 -0
- package/dist/entry-authorization-delegated.mjs.map +1 -0
- package/dist/entry-authorization-none.cjs +3 -0
- package/dist/entry-authorization-none.d.cts +18 -0
- package/dist/entry-authorization-none.d.mts +18 -0
- package/dist/entry-authorization-none.mjs +2 -0
- package/dist/entry-authorization-roles.cjs +6 -0
- package/dist/entry-authorization-roles.d.cts +65 -0
- package/dist/entry-authorization-roles.d.mts +65 -0
- package/dist/entry-authorization-roles.mjs +2 -0
- package/dist/entry-bun.cjs +24 -0
- package/dist/entry-bun.cjs.map +1 -0
- package/dist/entry-bun.d.cts +8 -0
- package/dist/entry-bun.d.mts +8 -0
- package/dist/entry-bun.mjs +23 -0
- package/dist/entry-bun.mjs.map +1 -0
- package/dist/entry-drizzle-mysql.cjs +20 -0
- package/dist/entry-drizzle-mysql.cjs.map +1 -0
- package/dist/entry-drizzle-mysql.d.cts +27 -0
- package/dist/entry-drizzle-mysql.d.mts +27 -0
- package/dist/entry-drizzle-mysql.mjs +19 -0
- package/dist/entry-drizzle-mysql.mjs.map +1 -0
- package/dist/entry-drizzle-pg.cjs +21 -0
- package/dist/entry-drizzle-pg.cjs.map +1 -0
- package/dist/entry-drizzle-pg.d.cts +26 -0
- package/dist/entry-drizzle-pg.d.mts +26 -0
- package/dist/entry-drizzle-pg.mjs +20 -0
- package/dist/entry-drizzle-pg.mjs.map +1 -0
- package/dist/entry-drizzle-sqlite.cjs +21 -0
- package/dist/entry-drizzle-sqlite.cjs.map +1 -0
- package/dist/entry-drizzle-sqlite.d.cts +23 -0
- package/dist/entry-drizzle-sqlite.d.mts +23 -0
- package/dist/entry-drizzle-sqlite.mjs +20 -0
- package/dist/entry-drizzle-sqlite.mjs.map +1 -0
- package/dist/entry-elysia.cjs +125 -0
- package/dist/entry-elysia.cjs.map +1 -0
- package/dist/entry-elysia.d.cts +1017 -0
- package/dist/entry-elysia.d.mts +1017 -0
- package/dist/entry-elysia.mjs +123 -0
- package/dist/entry-elysia.mjs.map +1 -0
- package/dist/entry-express.cjs +57 -0
- package/dist/entry-express.cjs.map +1 -0
- package/dist/entry-express.d.cts +15 -0
- package/dist/entry-express.d.mts +15 -0
- package/dist/entry-express.mjs +56 -0
- package/dist/entry-express.mjs.map +1 -0
- package/dist/entry-hono.cjs +35 -0
- package/dist/entry-hono.cjs.map +1 -0
- package/dist/entry-hono.d.cts +16 -0
- package/dist/entry-hono.d.mts +16 -0
- package/dist/entry-hono.mjs +34 -0
- package/dist/entry-hono.mjs.map +1 -0
- package/dist/entry-hooks-log.cjs +22 -0
- package/dist/entry-hooks-log.cjs.map +1 -0
- package/dist/entry-hooks-log.d.cts +23 -0
- package/dist/entry-hooks-log.d.mts +23 -0
- package/dist/entry-hooks-log.mjs +21 -0
- package/dist/entry-hooks-log.mjs.map +1 -0
- package/dist/entry-storage-cloudflare-kv.cjs +47 -0
- package/dist/entry-storage-cloudflare-kv.cjs.map +1 -0
- package/dist/entry-storage-cloudflare-kv.d.cts +42 -0
- package/dist/entry-storage-cloudflare-kv.d.mts +42 -0
- package/dist/entry-storage-cloudflare-kv.mjs +46 -0
- package/dist/entry-storage-cloudflare-kv.mjs.map +1 -0
- package/dist/entry-storage-drizzle.cjs +78 -0
- package/dist/entry-storage-drizzle.cjs.map +1 -0
- package/dist/entry-storage-drizzle.d.cts +30 -0
- package/dist/entry-storage-drizzle.d.mts +30 -0
- package/dist/entry-storage-drizzle.mjs +77 -0
- package/dist/entry-storage-drizzle.mjs.map +1 -0
- package/dist/entry-storage-file.cjs +4 -0
- package/dist/entry-storage-file.d.cts +30 -0
- package/dist/entry-storage-file.d.mts +30 -0
- package/dist/entry-storage-file.mjs +2 -0
- package/dist/entry-storage-libsql.cjs +3 -0
- package/dist/entry-storage-libsql.d.cts +48 -0
- package/dist/entry-storage-libsql.d.mts +48 -0
- package/dist/entry-storage-libsql.mjs +2 -0
- package/dist/entry-storage-memory.cjs +3 -0
- package/dist/entry-storage-memory.d.cts +2 -0
- package/dist/entry-storage-memory.d.mts +2 -0
- package/dist/entry-storage-memory.mjs +2 -0
- package/dist/entry-storage-mongodb.cjs +3 -0
- package/dist/entry-storage-mongodb.d.cts +55 -0
- package/dist/entry-storage-mongodb.d.mts +55 -0
- package/dist/entry-storage-mongodb.mjs +2 -0
- package/dist/entry-storage-postgres.cjs +3 -0
- package/dist/entry-storage-postgres.d.cts +62 -0
- package/dist/entry-storage-postgres.d.mts +62 -0
- package/dist/entry-storage-postgres.mjs +2 -0
- package/dist/entry-storage-redis.cjs +4 -0
- package/dist/entry-storage-redis.d.cts +77 -0
- package/dist/entry-storage-redis.d.mts +77 -0
- package/dist/entry-storage-redis.mjs +2 -0
- package/dist/entry-storage-sqlite.cjs +3 -0
- package/dist/entry-storage-sqlite.d.cts +36 -0
- package/dist/entry-storage-sqlite.d.mts +36 -0
- package/dist/entry-storage-sqlite.mjs +2 -0
- package/dist/entry-storage-unstorage.cjs +42 -0
- package/dist/entry-storage-unstorage.cjs.map +1 -0
- package/dist/entry-storage-unstorage.d.cts +29 -0
- package/dist/entry-storage-unstorage.d.mts +29 -0
- package/dist/entry-storage-unstorage.mjs +41 -0
- package/dist/entry-storage-unstorage.mjs.map +1 -0
- package/dist/file-COBYZA4Q.cjs +148 -0
- package/dist/file-COBYZA4Q.cjs.map +1 -0
- package/dist/file-fi02eFHk.mjs +131 -0
- package/dist/file-fi02eFHk.mjs.map +1 -0
- package/dist/index.cjs +123 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +368 -0
- package/dist/index.d.mts +366 -0
- package/dist/index.mjs +61 -0
- package/dist/index.mjs.map +1 -0
- package/dist/keys-Byyj4quQ.mjs +111 -0
- package/dist/keys-Byyj4quQ.mjs.map +1 -0
- package/dist/keys-FiKpaVHX.cjs +302 -0
- package/dist/keys-FiKpaVHX.cjs.map +1 -0
- package/dist/libsql-bpVi0bXN.mjs +113 -0
- package/dist/libsql-bpVi0bXN.mjs.map +1 -0
- package/dist/libsql-pPJEo1e4.cjs +124 -0
- package/dist/libsql-pPJEo1e4.cjs.map +1 -0
- package/dist/memory-8Ef-PL5a.cjs +137 -0
- package/dist/memory-8Ef-PL5a.cjs.map +1 -0
- package/dist/memory-BMsSSwqn.mjs +127 -0
- package/dist/memory-BMsSSwqn.mjs.map +1 -0
- package/dist/memory-FnMJWCmB.d.cts +28 -0
- package/dist/memory-qIvANEs_.d.mts +28 -0
- package/dist/mongodb-Cy8yo0uk.cjs +108 -0
- package/dist/mongodb-Cy8yo0uk.cjs.map +1 -0
- package/dist/mongodb-Ddaq9mml.mjs +97 -0
- package/dist/mongodb-Ddaq9mml.mjs.map +1 -0
- package/dist/none-BnZtaGNJ.mjs +23 -0
- package/dist/none-BnZtaGNJ.mjs.map +1 -0
- package/dist/none-CAsxCOWN.cjs +49 -0
- package/dist/none-CAsxCOWN.cjs.map +1 -0
- package/dist/none-CZVrfnmF.cjs +33 -0
- package/dist/none-CZVrfnmF.cjs.map +1 -0
- package/dist/none-GhVIoh_s.mjs +33 -0
- package/dist/none-GhVIoh_s.mjs.map +1 -0
- package/dist/postgres-C8WbchFa.cjs +134 -0
- package/dist/postgres-C8WbchFa.cjs.map +1 -0
- package/dist/postgres-c3pAhmhr.mjs +123 -0
- package/dist/postgres-c3pAhmhr.mjs.map +1 -0
- package/dist/react.css +1 -0
- package/dist/react.js +31465 -0
- package/dist/receiver.cjs +43 -0
- package/dist/receiver.cjs.map +1 -0
- package/dist/receiver.d.cts +36 -0
- package/dist/receiver.d.mts +36 -0
- package/dist/receiver.mjs +40 -0
- package/dist/receiver.mjs.map +1 -0
- package/dist/redis-CFJkuSgB.cjs +270 -0
- package/dist/redis-CFJkuSgB.cjs.map +1 -0
- package/dist/redis-CvLi0KF7.mjs +254 -0
- package/dist/redis-CvLi0KF7.mjs.map +1 -0
- package/dist/roles-D0G9XqBq.cjs +128 -0
- package/dist/roles-D0G9XqBq.cjs.map +1 -0
- package/dist/roles-vp361lTk.mjs +99 -0
- package/dist/roles-vp361lTk.mjs.map +1 -0
- package/dist/schema-mo__wv4P.d.cts +233 -0
- package/dist/schema-mo__wv4P.d.mts +233 -0
- package/dist/schema.cjs +13 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +2 -0
- package/dist/schema.d.mts +2 -0
- package/dist/schema.mjs +11 -0
- package/dist/schema.mjs.map +1 -0
- package/dist/signing.cjs +162 -0
- package/dist/signing.cjs.map +1 -0
- package/dist/signing.d.cts +73 -0
- package/dist/signing.d.mts +73 -0
- package/dist/signing.mjs +156 -0
- package/dist/signing.mjs.map +1 -0
- package/dist/sqlite-Cmqnrjes.mjs +67 -0
- package/dist/sqlite-Cmqnrjes.mjs.map +1 -0
- package/dist/sqlite-Dcufk0x3.cjs +78 -0
- package/dist/sqlite-Dcufk0x3.cjs.map +1 -0
- package/dist/table-Ce3Tzwqs.d.cts +11 -0
- package/dist/table-Ce3Tzwqs.d.mts +11 -0
- package/dist/testing.cjs +134 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +80 -0
- package/dist/testing.d.mts +80 -0
- package/dist/testing.mjs +131 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-react/react.d.ts +98 -0
- package/dist/types-react/schema.d.ts +229 -0
- package/dist/types-react/ui/App.d.ts +22 -0
- package/dist/types-react/ui/api.d.ts +97 -0
- package/dist/types-react/ui/components/JsonCodeEditor.d.ts +12 -0
- package/dist/types-react/ui/components/ThemeToggle.d.ts +2 -0
- package/dist/types-react/ui/components/Toast.d.ts +16 -0
- package/dist/types-react/ui/components/primitives.d.ts +50 -0
- package/dist/types-react/ui/components/ui-bits.d.ts +22 -0
- package/dist/types-react/ui/components/webhook-bits.d.ts +51 -0
- package/dist/types-react/ui/lib/format.d.ts +39 -0
- package/dist/types-react/ui/lib/nav-guard.d.ts +20 -0
- package/dist/types-react/ui/lib/utils.d.ts +3 -0
- package/dist/types-react/ui/theme.d.ts +12 -0
- package/dist/types-react/ui/types.d.ts +80 -0
- package/dist/types-react/ui/views/AuditView.d.ts +6 -0
- package/dist/types-react/ui/views/DeliveriesView.d.ts +12 -0
- package/dist/types-react/ui/views/EndpointsView.d.ts +11 -0
- package/dist/types-react/ui/views/EventTypesView.d.ts +11 -0
- package/dist/types-react/ui/views/MessagesView.d.ts +10 -0
- package/dist/types-react/ui/views/OverviewView.d.ts +12 -0
- package/dist/ui/assets/index-B0eoQX2U.css +1 -0
- package/dist/ui/assets/index-S5t_CLOe.js +209 -0
- package/dist/ui/index.html +14 -0
- package/package.json +487 -0
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { isTerminalDeliveryStatus } from "./schema.mjs";
|
|
3
|
+
import { generateSecret, signatureHeader } from "./signing.mjs";
|
|
4
|
+
import { C as messageKey, S as lastSegment, _ as eventTypeKey, a as attemptKey, b as idempotencyKey, c as byEndpointKey, d as byMessagePrefix, f as deliveriesPrefix, g as endpointsKey, h as endpointKey, i as applicationsKey, l as byEndpointPrefix, m as dueKey, n as applicationMetaKey, o as attemptsPrefix, p as deliveryKey, r as applicationPrefix, s as auditLogKey, t as RESERVED_APPLICATION_KEYS, u as byMessageKey, v as eventTypesKey, w as messagesPrefix, y as globalAuditLogKey } from "./keys-Byyj4quQ.mjs";
|
|
5
|
+
import { n as isCompareAndSwap, t as hasDeliveryQueue } from "./contract-BEhDcd_5.mjs";
|
|
6
|
+
import * as v from "valibot";
|
|
7
|
+
//#region src/deliver.ts
|
|
8
|
+
/** Default cap on how many response-body characters are kept per attempt. */
|
|
9
|
+
const DEFAULT_RESPONSE_BODY_LIMIT = 4096;
|
|
10
|
+
/** Default per-attempt timeout. */
|
|
11
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 2e4;
|
|
12
|
+
/**
|
|
13
|
+
* Build the exact signed request an attempt would POST — Standard Webhooks
|
|
14
|
+
* headers, all unexpired secrets signing — **without sending it**. Shared by
|
|
15
|
+
* {@link attemptDelivery} and the panel's request inspector so what you preview
|
|
16
|
+
* is byte-for-byte what a receiver gets.
|
|
17
|
+
*/
|
|
18
|
+
async function buildSignedRequest(input) {
|
|
19
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
20
|
+
const secrets = activeSecrets(input.endpoint, nowMs);
|
|
21
|
+
if (secrets.length === 0) throw new Error(`Endpoint ${input.endpoint.id} has no unexpired signing secret`);
|
|
22
|
+
const timestamp = Math.floor(nowMs / 1e3);
|
|
23
|
+
const signature = await signatureHeader(secrets, input.messageId, timestamp, input.body);
|
|
24
|
+
const headers = {
|
|
25
|
+
...input.endpoint.headers,
|
|
26
|
+
"content-type": "application/json",
|
|
27
|
+
"webhook-id": input.messageId,
|
|
28
|
+
"webhook-timestamp": String(timestamp),
|
|
29
|
+
"webhook-signature": signature
|
|
30
|
+
};
|
|
31
|
+
if (input.userAgent) headers["user-agent"] = input.userAgent;
|
|
32
|
+
return {
|
|
33
|
+
method: "POST",
|
|
34
|
+
url: input.endpoint.url,
|
|
35
|
+
headers,
|
|
36
|
+
body: input.body
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The endpoint's currently-signing secrets: the active one + unexpired rotation grace. */
|
|
40
|
+
function activeSecrets(endpoint, nowMs) {
|
|
41
|
+
return endpoint.secrets.filter((s) => !s.expiresAt || Date.parse(s.expiresAt) > nowMs).map((s) => s.secret);
|
|
42
|
+
}
|
|
43
|
+
const truncate = (value, limit) => value.length > limit ? value.slice(0, limit) : value;
|
|
44
|
+
/**
|
|
45
|
+
* Perform one signed POST to `endpoint.url`. Never throws for remote-party
|
|
46
|
+
* failures — network errors, timeouts, and non-2xx responses all come back as
|
|
47
|
+
* a failed {@link AttemptOutcome}. (It does throw on programmer error, e.g. an
|
|
48
|
+
* endpoint with no unexpired secret.)
|
|
49
|
+
*/
|
|
50
|
+
async function attemptDelivery(input) {
|
|
51
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
52
|
+
const at = new Date(nowMs).toISOString();
|
|
53
|
+
const timeoutMs = input.timeoutMs ?? 2e4;
|
|
54
|
+
const bodyLimit = input.responseBodyLimit ?? 4096;
|
|
55
|
+
const doFetch = input.fetch ?? fetch;
|
|
56
|
+
const { headers } = await buildSignedRequest({
|
|
57
|
+
endpoint: input.endpoint,
|
|
58
|
+
messageId: input.messageId,
|
|
59
|
+
body: input.body,
|
|
60
|
+
nowMs,
|
|
61
|
+
...input.userAgent !== void 0 ? { userAgent: input.userAgent } : {}
|
|
62
|
+
});
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
66
|
+
timer.unref?.();
|
|
67
|
+
try {
|
|
68
|
+
const response = await doFetch(input.endpoint.url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
body: input.body,
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
redirect: "manual"
|
|
74
|
+
});
|
|
75
|
+
let responseBody = "";
|
|
76
|
+
try {
|
|
77
|
+
responseBody = truncate(await response.text(), bodyLimit);
|
|
78
|
+
} catch {}
|
|
79
|
+
return {
|
|
80
|
+
ok: response.status >= 200 && response.status < 300,
|
|
81
|
+
httpStatus: response.status,
|
|
82
|
+
responseBody: responseBody || void 0,
|
|
83
|
+
durationMs: Date.now() - started,
|
|
84
|
+
at
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: truncate(controller.signal.aborted ? `Timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error), 512),
|
|
90
|
+
durationMs: Date.now() - started,
|
|
91
|
+
at
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/delivery-sink.ts
|
|
99
|
+
/**
|
|
100
|
+
* Invoke `listener` safely off the delivery path: synchronous throws are caught
|
|
101
|
+
* and a returned Promise's rejection is handled, so a broken sink can never
|
|
102
|
+
* fail (or slow, since it is not awaited) a delivery.
|
|
103
|
+
*/
|
|
104
|
+
function emitDelivery(listener, event, onError) {
|
|
105
|
+
try {
|
|
106
|
+
const result = listener(event);
|
|
107
|
+
if (result && typeof result.then === "function") result.catch((error) => onError?.(error, event));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
onError?.(error, event);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/duration.ts
|
|
114
|
+
const UNIT_MS = {
|
|
115
|
+
ms: 1,
|
|
116
|
+
s: 1e3,
|
|
117
|
+
m: 6e4,
|
|
118
|
+
h: 36e5,
|
|
119
|
+
d: 864e5
|
|
120
|
+
};
|
|
121
|
+
const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
|
|
122
|
+
/**
|
|
123
|
+
* Convert a {@link WebhookDuration} (`5000`, `"5s"`, `"30m"`, `"2h"`, `"5d"`)
|
|
124
|
+
* to milliseconds. Throws on malformed strings or negative numbers.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* import { durationToMs } from "@xtandard/webhooks";
|
|
129
|
+
*
|
|
130
|
+
* durationToMs("5m"); // 300000
|
|
131
|
+
* durationToMs(250); // 250
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
function durationToMs(duration) {
|
|
135
|
+
if (typeof duration === "number") {
|
|
136
|
+
if (!Number.isFinite(duration) || duration < 0) throw new Error(`Invalid duration: ${duration}`);
|
|
137
|
+
return duration;
|
|
138
|
+
}
|
|
139
|
+
const match = DURATION_RE.exec(duration.trim());
|
|
140
|
+
if (!match) throw new Error(`Invalid duration: "${duration}" (expected e.g. "5s", "30m", "2h")`);
|
|
141
|
+
return Number(match[1]) * UNIT_MS[match[2]];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Parse a comma-separated duration list (the `RETRY_SCHEDULE` env var format,
|
|
145
|
+
* e.g. `"0s,5s,5m,30m,2h,5h,10h"`) into a {@link WebhookDuration} array.
|
|
146
|
+
*/
|
|
147
|
+
function parseDurationList(input) {
|
|
148
|
+
return input.split(",").map((part) => part.trim()).filter((part) => part.length > 0).map((part) => {
|
|
149
|
+
const asNumber = Number(part);
|
|
150
|
+
const duration = Number.isFinite(asNumber) ? asNumber : part;
|
|
151
|
+
durationToMs(duration);
|
|
152
|
+
return duration;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/id.ts
|
|
157
|
+
var id_exports = /* @__PURE__ */ __exportAll({
|
|
158
|
+
idPattern: () => idPattern,
|
|
159
|
+
newId: () => newId
|
|
160
|
+
});
|
|
161
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
162
|
+
const ID_LENGTH = 22;
|
|
163
|
+
const RANDOM_BYTES = 16;
|
|
164
|
+
/** Encode 128 random bits as a fixed-length 22-char base62 string. */
|
|
165
|
+
function randomBase62() {
|
|
166
|
+
const bytes = new Uint8Array(RANDOM_BYTES);
|
|
167
|
+
crypto.getRandomValues(bytes);
|
|
168
|
+
let value = 0n;
|
|
169
|
+
for (const b of bytes) value = value << 8n | BigInt(b);
|
|
170
|
+
let out = "";
|
|
171
|
+
while (value > 0n) {
|
|
172
|
+
out = ALPHABET[Number(value % 62n)] + out;
|
|
173
|
+
value /= 62n;
|
|
174
|
+
}
|
|
175
|
+
return out.padStart(ID_LENGTH, "0");
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Generate a new entity id, e.g. `newId("msg")` → `"msg_0uK9…"` (22-char base62
|
|
179
|
+
* suffix).
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* import { newId } from "@xtandard/webhooks";
|
|
184
|
+
*
|
|
185
|
+
* const id = newId("ep"); // "ep_…"
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
function newId(prefix) {
|
|
189
|
+
return `${prefix}_${randomBase62()}`;
|
|
190
|
+
}
|
|
191
|
+
/** Regex an id of the given prefix must match. */
|
|
192
|
+
function idPattern(prefix) {
|
|
193
|
+
return new RegExp(`^${prefix}_[0-9A-Za-z]{${ID_LENGTH}}$`);
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/version.ts
|
|
197
|
+
/**
|
|
198
|
+
* Package version, used for the default dispatcher `user-agent`. Bumped by the
|
|
199
|
+
* release flow alongside `package.json`.
|
|
200
|
+
*
|
|
201
|
+
* @module
|
|
202
|
+
*/
|
|
203
|
+
/** The published package version. */
|
|
204
|
+
const VERSION = "0.1.0";
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/hooks/contract.ts
|
|
207
|
+
/**
|
|
208
|
+
* Thrown from a {@link WebhooksHooks.before} handler to **deny** a mutation with
|
|
209
|
+
* a clean HTTP status (default `403`). Any thrown error denies the mutation,
|
|
210
|
+
* but a plain `Error` maps to `500` at the API layer (treated as an unexpected
|
|
211
|
+
* bug); throw this to signal a deliberate policy rejection (`403`, or a custom
|
|
212
|
+
* `status` such as `409`/`422`).
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* before(event) {
|
|
217
|
+
* if (event.type === "message.publish" && overQuota(event.applicationKey)) {
|
|
218
|
+
* throw new HookDeniedError("Monthly webhook quota exceeded.", { status: 429 });
|
|
219
|
+
* }
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
var HookDeniedError = class extends Error {
|
|
224
|
+
/** HTTP status the API layer should respond with. Default `403`. */
|
|
225
|
+
status;
|
|
226
|
+
constructor(message, options) {
|
|
227
|
+
super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
228
|
+
this.name = "HookDeniedError";
|
|
229
|
+
this.status = options?.status ?? 403;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** Normalize the `hooks` option into a flat array (empty when unset). */
|
|
233
|
+
function normalizeHooks(input) {
|
|
234
|
+
if (!input) return [];
|
|
235
|
+
return Array.isArray(input) ? [...input] : [input];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Run every `before` hook sequentially, in order. The first hook to throw
|
|
239
|
+
* aborts: the error propagates to the caller (denying the mutation) and no
|
|
240
|
+
* later hook runs. A no-op when there are no `before` hooks.
|
|
241
|
+
*/
|
|
242
|
+
async function runBefore(hooks, event) {
|
|
243
|
+
for (const hook of hooks) if (hook.before) await hook.before(event);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Run every `after` hook, isolating failures. The mutation has already
|
|
247
|
+
* committed, so a throwing hook must not fail the operation — its error is
|
|
248
|
+
* routed to `onError` and swallowed. Remaining hooks still run.
|
|
249
|
+
*/
|
|
250
|
+
async function runAfter(hooks, event, onError) {
|
|
251
|
+
await Promise.all(hooks.map(async (hook) => {
|
|
252
|
+
if (!hook.after) return;
|
|
253
|
+
try {
|
|
254
|
+
await hook.after(event);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
onError(error, event);
|
|
257
|
+
}
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
/** Default `after`-hook error reporter: warn, but never throw. */
|
|
261
|
+
const defaultHookErrorReporter = (error, event) => {
|
|
262
|
+
console.warn(`[@xtandard/webhooks] after-hook for "${event.type}" threw:`, error);
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/validation.ts
|
|
266
|
+
/**
|
|
267
|
+
* Runtime validation for control-plane inputs, built on `valibot`.
|
|
268
|
+
*
|
|
269
|
+
* This is the **admin path** only — `publish()` performs its own minimal
|
|
270
|
+
* checks and the wire/receiver path never imports this module, so `valibot`
|
|
271
|
+
* stays off the hot paths. Validation combines structural parsing (valibot)
|
|
272
|
+
* with semantic checks (URL policy, reserved header names, reserved keys).
|
|
273
|
+
*
|
|
274
|
+
* @module
|
|
275
|
+
*/
|
|
276
|
+
/** Allowed characters for application keys and event type names. */
|
|
277
|
+
const KEY_REGEX = /^[a-zA-Z0-9._-]+$/;
|
|
278
|
+
/**
|
|
279
|
+
* Validate a caller-supplied string that becomes a **storage key segment**
|
|
280
|
+
* (e.g. an idempotency key). Rejects anything that could escape its namespace
|
|
281
|
+
* or traverse the filesystem on the file adapter: characters outside
|
|
282
|
+
* {@link KEY_REGEX}, and the dot-only segments `.` / `..`. Returns the issue
|
|
283
|
+
* list (empty when safe) so callers can fold it into a {@link ValidationError}.
|
|
284
|
+
*/
|
|
285
|
+
function validateKeySegment(value, path) {
|
|
286
|
+
if (!KEY_REGEX.test(value)) return [{
|
|
287
|
+
path,
|
|
288
|
+
message: `must match ${KEY_REGEX} (no slashes or path separators)`
|
|
289
|
+
}];
|
|
290
|
+
if (/^\.+$/.test(value)) return [{
|
|
291
|
+
path,
|
|
292
|
+
message: `"${value}" is not an allowed key`
|
|
293
|
+
}];
|
|
294
|
+
if (value.length > 256) return [{
|
|
295
|
+
path,
|
|
296
|
+
message: "must be at most 256 characters"
|
|
297
|
+
}];
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Header names owned by the Standard Webhooks wire contract. Endpoints may not
|
|
302
|
+
* override them via static headers.
|
|
303
|
+
*/
|
|
304
|
+
const RESERVED_HEADERS = [
|
|
305
|
+
"webhook-id",
|
|
306
|
+
"webhook-timestamp",
|
|
307
|
+
"webhook-signature"
|
|
308
|
+
];
|
|
309
|
+
const jsonValueSchema = v.lazy(() => v.union([
|
|
310
|
+
v.string(),
|
|
311
|
+
v.number(),
|
|
312
|
+
v.boolean(),
|
|
313
|
+
v.null(),
|
|
314
|
+
v.array(jsonValueSchema),
|
|
315
|
+
v.record(v.string(), jsonValueSchema)
|
|
316
|
+
]));
|
|
317
|
+
const keySchema = v.pipe(v.string(), v.minLength(1), v.maxLength(256), v.regex(KEY_REGEX));
|
|
318
|
+
const applicationSchema = v.object({
|
|
319
|
+
key: keySchema,
|
|
320
|
+
name: v.optional(v.string()),
|
|
321
|
+
metadata: v.optional(jsonValueSchema),
|
|
322
|
+
createdAt: v.optional(v.string()),
|
|
323
|
+
updatedAt: v.optional(v.string())
|
|
324
|
+
});
|
|
325
|
+
const eventTypeSchema = v.object({
|
|
326
|
+
name: keySchema,
|
|
327
|
+
description: v.optional(v.string()),
|
|
328
|
+
groupName: v.optional(v.string()),
|
|
329
|
+
schema: v.optional(jsonValueSchema),
|
|
330
|
+
deprecated: v.optional(v.boolean()),
|
|
331
|
+
createdAt: v.optional(v.string()),
|
|
332
|
+
updatedAt: v.optional(v.string())
|
|
333
|
+
});
|
|
334
|
+
const endpointSecretSchema = v.object({
|
|
335
|
+
secret: v.pipe(v.string(), v.minLength(1)),
|
|
336
|
+
createdAt: v.string(),
|
|
337
|
+
expiresAt: v.optional(v.string())
|
|
338
|
+
});
|
|
339
|
+
const endpointSchema = v.object({
|
|
340
|
+
id: v.optional(v.string()),
|
|
341
|
+
url: v.pipe(v.string(), v.minLength(1)),
|
|
342
|
+
description: v.optional(v.string()),
|
|
343
|
+
eventTypes: v.optional(v.array(keySchema)),
|
|
344
|
+
disabled: v.optional(v.boolean()),
|
|
345
|
+
disabledReason: v.optional(v.picklist(["manual", "auto"])),
|
|
346
|
+
headers: v.optional(v.record(v.string(), v.string())),
|
|
347
|
+
secrets: v.optional(v.array(endpointSecretSchema)),
|
|
348
|
+
metadata: v.optional(jsonValueSchema),
|
|
349
|
+
createdAt: v.optional(v.string()),
|
|
350
|
+
updatedAt: v.optional(v.string()),
|
|
351
|
+
firstFailingAt: v.optional(v.nullable(v.string()))
|
|
352
|
+
});
|
|
353
|
+
/** Raised by {@link assertValid} when an input fails validation. Maps to HTTP 422. */
|
|
354
|
+
var ValidationError = class extends Error {
|
|
355
|
+
errors;
|
|
356
|
+
constructor(errors) {
|
|
357
|
+
super(`Validation failed:\n${errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n")}`);
|
|
358
|
+
this.name = "ValidationError";
|
|
359
|
+
this.errors = errors;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
/** Throw a {@link ValidationError} when `result` is invalid. */
|
|
363
|
+
function assertValid(result) {
|
|
364
|
+
if (!result.valid) throw new ValidationError(result.errors);
|
|
365
|
+
}
|
|
366
|
+
function structuralIssues(issues, basePath) {
|
|
367
|
+
return issues.map((issue) => ({
|
|
368
|
+
path: `${basePath}.${(issue.path ?? []).map((p) => String(p.key)).join(".")}`,
|
|
369
|
+
message: issue.message
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Validate an {@link Application}: structure + reserved-key check.
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* import { validateApplication } from "@xtandard/webhooks";
|
|
378
|
+
*
|
|
379
|
+
* const result = validateApplication({ key: "acme" });
|
|
380
|
+
* // result.valid === true
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
function validateApplication(input, basePath = "application") {
|
|
384
|
+
const parsed = v.safeParse(applicationSchema, input);
|
|
385
|
+
if (!parsed.success) return {
|
|
386
|
+
valid: false,
|
|
387
|
+
errors: structuralIssues(parsed.issues, basePath)
|
|
388
|
+
};
|
|
389
|
+
const application = parsed.output;
|
|
390
|
+
const errors = [];
|
|
391
|
+
if (RESERVED_APPLICATION_KEYS.includes(application.key)) errors.push({
|
|
392
|
+
path: `${basePath}.key`,
|
|
393
|
+
message: `"${application.key}" is a reserved application key`
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
valid: errors.length === 0,
|
|
397
|
+
errors
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/** Validate an {@link EventType}: structural only (the name regex carries the semantics). */
|
|
401
|
+
function validateEventType(input, basePath = "eventType") {
|
|
402
|
+
const parsed = v.safeParse(eventTypeSchema, input);
|
|
403
|
+
if (!parsed.success) return {
|
|
404
|
+
valid: false,
|
|
405
|
+
errors: structuralIssues(parsed.issues, basePath)
|
|
406
|
+
};
|
|
407
|
+
return {
|
|
408
|
+
valid: true,
|
|
409
|
+
errors: []
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const LOCAL_HOSTS = new Set([
|
|
413
|
+
"localhost",
|
|
414
|
+
"127.0.0.1",
|
|
415
|
+
"[::1]",
|
|
416
|
+
"::1"
|
|
417
|
+
]);
|
|
418
|
+
/**
|
|
419
|
+
* Validate an endpoint destination URL: parseable, `https:` (or `http:` for
|
|
420
|
+
* localhost, or anywhere when `allowInsecureUrls`), no embedded credentials,
|
|
421
|
+
* and passing the host's optional `urlPolicy` gate.
|
|
422
|
+
*/
|
|
423
|
+
function validateEndpointUrl(url, options = {}, basePath = "endpoint.url") {
|
|
424
|
+
const errors = [];
|
|
425
|
+
let parsed;
|
|
426
|
+
try {
|
|
427
|
+
parsed = new URL(url);
|
|
428
|
+
} catch {
|
|
429
|
+
return {
|
|
430
|
+
valid: false,
|
|
431
|
+
errors: [{
|
|
432
|
+
path: basePath,
|
|
433
|
+
message: `invalid URL "${url}"`
|
|
434
|
+
}]
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (parsed.username || parsed.password) errors.push({
|
|
438
|
+
path: basePath,
|
|
439
|
+
message: "URL must not contain credentials"
|
|
440
|
+
});
|
|
441
|
+
if (parsed.protocol === "http:") {
|
|
442
|
+
if (!options.allowInsecureUrls && !LOCAL_HOSTS.has(parsed.hostname)) errors.push({
|
|
443
|
+
path: basePath,
|
|
444
|
+
message: "http URLs are only allowed for localhost (set allowInsecureUrls for dev)"
|
|
445
|
+
});
|
|
446
|
+
} else if (parsed.protocol !== "https:") errors.push({
|
|
447
|
+
path: basePath,
|
|
448
|
+
message: `unsupported protocol "${parsed.protocol}"`
|
|
449
|
+
});
|
|
450
|
+
if (errors.length === 0 && options.urlPolicy && !options.urlPolicy(url)) errors.push({
|
|
451
|
+
path: basePath,
|
|
452
|
+
message: "URL rejected by the configured urlPolicy"
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
valid: errors.length === 0,
|
|
456
|
+
errors
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Validate an {@link Endpoint} input: structure, URL policy, and static-header
|
|
461
|
+
* restrictions (the Standard Webhooks headers are reserved).
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```ts
|
|
465
|
+
* import { validateEndpoint } from "@xtandard/webhooks";
|
|
466
|
+
*
|
|
467
|
+
* const result = validateEndpoint({ url: "https://api.example.com/hooks" });
|
|
468
|
+
* // result.valid === true
|
|
469
|
+
* ```
|
|
470
|
+
*/
|
|
471
|
+
function validateEndpoint(input, options = {}, basePath = "endpoint") {
|
|
472
|
+
const parsed = v.safeParse(endpointSchema, input);
|
|
473
|
+
if (!parsed.success) return {
|
|
474
|
+
valid: false,
|
|
475
|
+
errors: structuralIssues(parsed.issues, basePath)
|
|
476
|
+
};
|
|
477
|
+
const endpoint = parsed.output;
|
|
478
|
+
const errors = [];
|
|
479
|
+
errors.push(...validateEndpointUrl(endpoint.url, options, `${basePath}.url`).errors);
|
|
480
|
+
for (const name of Object.keys(endpoint.headers ?? {})) if (RESERVED_HEADERS.includes(name.toLowerCase())) errors.push({
|
|
481
|
+
path: `${basePath}.headers.${name}`,
|
|
482
|
+
message: `"${name}" is reserved by the Standard Webhooks wire contract`
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
valid: errors.length === 0,
|
|
486
|
+
errors
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/core.ts
|
|
491
|
+
/**
|
|
492
|
+
* Admin + publish core — the operations layer the API, CLI, and dispatcher sit
|
|
493
|
+
* on top of.
|
|
494
|
+
*
|
|
495
|
+
* Owns the split between the **control plane** (CRUD on applications / event
|
|
496
|
+
* types / endpoints, browsing messages and deliveries, replay — rare,
|
|
497
|
+
* human-driven, hook-guarded, audited) and the **delivery plane** (`publish()`
|
|
498
|
+
* + the dispatcher's claim/record internals — the hot path). `publish()` never
|
|
499
|
+
* performs an HTTP call and never throws because an endpoint is down: it
|
|
500
|
+
* persists one message and fans out one pending delivery per matching enabled
|
|
501
|
+
* endpoint; the dispatcher owns all network I/O.
|
|
502
|
+
*
|
|
503
|
+
* Storage can be split: control data lives in `storage`, while deliveries +
|
|
504
|
+
* the due index live in `queueStorage` (defaults to `storage`) — e.g. control
|
|
505
|
+
* in Postgres, queue in Redis.
|
|
506
|
+
*
|
|
507
|
+
* @module
|
|
508
|
+
*/
|
|
509
|
+
/** Thrown by mutating operations when the core is in readonly mode. */
|
|
510
|
+
var ReadonlyError = class extends Error {
|
|
511
|
+
constructor(operation) {
|
|
512
|
+
super(`Cannot ${operation}: @xtandard/webhooks is in readonly mode.`);
|
|
513
|
+
this.name = "ReadonlyError";
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
/** Thrown when a referenced application/event type/endpoint/delivery does not exist. */
|
|
517
|
+
var NotFoundError = class extends Error {
|
|
518
|
+
constructor(message) {
|
|
519
|
+
super(message);
|
|
520
|
+
this.name = "NotFoundError";
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
/** Thrown when creating an entity whose key already exists. Maps to HTTP 409. */
|
|
524
|
+
var ConflictError = class extends Error {
|
|
525
|
+
constructor(message) {
|
|
526
|
+
super(message);
|
|
527
|
+
this.name = "ConflictError";
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
/** Thrown by {@link WebhooksCore.publish} when the payload exceeds the limit. Maps to 413. */
|
|
531
|
+
var PayloadTooLargeError = class extends Error {
|
|
532
|
+
constructor(size, limit) {
|
|
533
|
+
super(`Payload is ${size} bytes; the limit is ${limit} bytes.`);
|
|
534
|
+
this.name = "PayloadTooLargeError";
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
/**
|
|
538
|
+
* Thrown by {@link WebhooksCore.publish} when an idempotency key is reused with
|
|
539
|
+
* a **different** payload (same key + same payload returns the original
|
|
540
|
+
* message instead). Maps to HTTP 409.
|
|
541
|
+
*/
|
|
542
|
+
var IdempotencyConflictError = class extends Error {
|
|
543
|
+
constructor(key) {
|
|
544
|
+
super(`Idempotency key "${key}" was already used with a different payload.`);
|
|
545
|
+
this.name = "IdempotencyConflictError";
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const DEFAULT_PAYLOAD_LIMIT = 262144;
|
|
549
|
+
const DEFAULT_ROTATION_GRACE = "24h";
|
|
550
|
+
const MAX_PAGE = 200;
|
|
551
|
+
const DEFAULT_PAGE = 50;
|
|
552
|
+
/**
|
|
553
|
+
* Construct the core over the configured storage.
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* ```ts
|
|
557
|
+
* import { createWebhooksCore } from "@xtandard/webhooks";
|
|
558
|
+
* import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
|
|
559
|
+
*
|
|
560
|
+
* const core = createWebhooksCore({ storage: createMemoryStorage() });
|
|
561
|
+
*
|
|
562
|
+
* await core.createApplication({ key: "acme" });
|
|
563
|
+
* await core.upsertEventType({ name: "invoice.paid" });
|
|
564
|
+
* const endpoint = await core.createEndpoint("acme", {
|
|
565
|
+
* url: "https://api.acme-customer.com/webhooks",
|
|
566
|
+
* eventTypes: ["invoice.paid"],
|
|
567
|
+
* });
|
|
568
|
+
*
|
|
569
|
+
* // The hot path — call this from your app code:
|
|
570
|
+
* await core.publish("acme", {
|
|
571
|
+
* eventType: "invoice.paid",
|
|
572
|
+
* payload: { invoiceId: "inv_1", amount: 4200 },
|
|
573
|
+
* });
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
function createWebhooksCore(options) {
|
|
577
|
+
const storage = options.storage;
|
|
578
|
+
const queueStorage = options.queueStorage ?? options.storage;
|
|
579
|
+
const readonly = options.readonly ?? false;
|
|
580
|
+
const hooks = normalizeHooks(options.hooks);
|
|
581
|
+
const onHookError = options.onHookError ?? defaultHookErrorReporter;
|
|
582
|
+
const secretRotationGrace = options.secretRotationGrace ?? DEFAULT_ROTATION_GRACE;
|
|
583
|
+
const payloadLimitBytes = options.payloadLimitBytes ?? DEFAULT_PAYLOAD_LIMIT;
|
|
584
|
+
const requireKnownEventTypes = options.requireKnownEventTypes ?? true;
|
|
585
|
+
const now = options.now ?? Date.now;
|
|
586
|
+
const urlOptions = {
|
|
587
|
+
allowInsecureUrls: options.allowInsecureUrls ?? false,
|
|
588
|
+
...options.urlPolicy ? { urlPolicy: options.urlPolicy } : {}
|
|
589
|
+
};
|
|
590
|
+
const guard = (op) => {
|
|
591
|
+
if (readonly) throw new ReadonlyError(op);
|
|
592
|
+
};
|
|
593
|
+
const before = hooks.length ? (event) => runBefore(hooks, event) : null;
|
|
594
|
+
const after = hooks.length ? (event) => runAfter(hooks, event, onHookError) : null;
|
|
595
|
+
const nowIso = () => new Date(now()).toISOString();
|
|
596
|
+
async function indexAdd(store, key, value) {
|
|
597
|
+
const list = await store.getItem(key) ?? [];
|
|
598
|
+
if (!list.includes(value)) {
|
|
599
|
+
list.push(value);
|
|
600
|
+
await store.setItem(key, list);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function indexRemove(store, key, value) {
|
|
604
|
+
const list = await store.getItem(key) ?? [];
|
|
605
|
+
const next = list.filter((v) => v !== value);
|
|
606
|
+
if (next.length !== list.length) await store.setItem(key, next);
|
|
607
|
+
}
|
|
608
|
+
async function appendAudit(entry, scope = "app") {
|
|
609
|
+
const key = scope === "global" || !entry.applicationKey ? globalAuditLogKey() : auditLogKey(entry.applicationKey);
|
|
610
|
+
const log = await storage.getItem(key) ?? [];
|
|
611
|
+
log.push({
|
|
612
|
+
at: nowIso(),
|
|
613
|
+
...entry
|
|
614
|
+
});
|
|
615
|
+
await storage.setItem(key, log);
|
|
616
|
+
}
|
|
617
|
+
async function requireApplication(applicationKey) {
|
|
618
|
+
const app = await storage.getItem(applicationMetaKey(applicationKey));
|
|
619
|
+
if (!app) throw new NotFoundError(`Application "${applicationKey}" does not exist.`);
|
|
620
|
+
return app;
|
|
621
|
+
}
|
|
622
|
+
async function requireEndpoint(applicationKey, endpointId) {
|
|
623
|
+
await requireApplication(applicationKey);
|
|
624
|
+
const endpoint = await storage.getItem(endpointKey(applicationKey, endpointId));
|
|
625
|
+
if (!endpoint) throw new NotFoundError(`Endpoint "${endpointId}" does not exist in application "${applicationKey}".`);
|
|
626
|
+
return endpoint;
|
|
627
|
+
}
|
|
628
|
+
/** Write a delivery + its due-index entry (they always move together). */
|
|
629
|
+
async function writeDeliveryWithDue(delivery, dueAtMillis) {
|
|
630
|
+
await queueStorage.setItem(deliveryKey(delivery.applicationKey, delivery.id), delivery);
|
|
631
|
+
await queueStorage.setItem(dueKey(delivery.applicationKey, dueAtMillis, delivery.id), {
|
|
632
|
+
app: delivery.applicationKey,
|
|
633
|
+
deliveryId: delivery.id
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/** Remove the due entry a delivery currently occupies, wherever it is. */
|
|
637
|
+
async function removeDueEntry(delivery) {
|
|
638
|
+
const candidates = [];
|
|
639
|
+
if (delivery.nextAttemptAt) candidates.push(Date.parse(delivery.nextAttemptAt));
|
|
640
|
+
if (delivery.leaseUntil) candidates.push(Date.parse(delivery.leaseUntil));
|
|
641
|
+
for (const millis of candidates) if (Number.isFinite(millis)) await queueStorage.removeItem(dueKey(delivery.applicationKey, millis, delivery.id));
|
|
642
|
+
}
|
|
643
|
+
async function listAttempts(applicationKey, deliveryId) {
|
|
644
|
+
const keys = (await queueStorage.getKeys(attemptsPrefix(applicationKey, deliveryId))).sort();
|
|
645
|
+
return (await Promise.all(keys.map((k) => queueStorage.getItem(k)))).filter((a) => a !== null);
|
|
646
|
+
}
|
|
647
|
+
async function deleteDeliveryCascade(delivery) {
|
|
648
|
+
const app = delivery.applicationKey;
|
|
649
|
+
await removeDueEntry(delivery);
|
|
650
|
+
for (const k of await queueStorage.getKeys(attemptsPrefix(app, delivery.id))) await queueStorage.removeItem(k);
|
|
651
|
+
await queueStorage.removeItem(byMessageKey(app, delivery.messageId, delivery.id));
|
|
652
|
+
await queueStorage.removeItem(byEndpointKey(app, delivery.endpointId, delivery.id));
|
|
653
|
+
await queueStorage.removeItem(deliveryKey(app, delivery.id));
|
|
654
|
+
}
|
|
655
|
+
const pageSize = (limit) => Math.min(Math.max(limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
|
|
656
|
+
/** newest-first sort; id tiebreak keeps pagination stable. */
|
|
657
|
+
const byCreatedAtDesc = (a, b) => {
|
|
658
|
+
const diff = Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "");
|
|
659
|
+
return diff !== 0 ? diff : a.id < b.id ? 1 : -1;
|
|
660
|
+
};
|
|
661
|
+
function paginate(sorted, limit, beforeId) {
|
|
662
|
+
let start = 0;
|
|
663
|
+
if (beforeId) {
|
|
664
|
+
const idx = sorted.findIndex((item) => item.id === beforeId);
|
|
665
|
+
if (idx < 0) return [];
|
|
666
|
+
start = idx + 1;
|
|
667
|
+
}
|
|
668
|
+
return sorted.slice(start, start + pageSize(limit));
|
|
669
|
+
}
|
|
670
|
+
let pruneInFlight = null;
|
|
671
|
+
async function pruneMessagesForApp(applicationKey) {
|
|
672
|
+
const rule = options.retention?.messages;
|
|
673
|
+
if (!rule || rule.keepLast === void 0 && rule.maxAge === void 0) return;
|
|
674
|
+
const keys = await storage.getKeys(messagesPrefix(applicationKey));
|
|
675
|
+
const messages = (await Promise.all(keys.map((k) => storage.getItem(k)))).filter((m) => m !== null);
|
|
676
|
+
messages.sort(byCreatedAtDesc);
|
|
677
|
+
const maxAgeMs = rule.maxAge !== void 0 ? durationToMs(rule.maxAge) : null;
|
|
678
|
+
const cutoff = maxAgeMs !== null ? now() - maxAgeMs : null;
|
|
679
|
+
const pruned = [];
|
|
680
|
+
for (const [index, message] of messages.entries()) {
|
|
681
|
+
const keptByCount = rule.keepLast !== void 0 && index < rule.keepLast;
|
|
682
|
+
const keptByAge = cutoff !== null && Date.parse(message.createdAt) >= cutoff;
|
|
683
|
+
if (keptByCount || keptByAge) continue;
|
|
684
|
+
if (rule.keepLast === void 0 && cutoff === null) continue;
|
|
685
|
+
const deliveryIds = (await queueStorage.getKeys(byMessagePrefix(applicationKey, message.id))).map(lastSegment);
|
|
686
|
+
const deliveries = (await Promise.all(deliveryIds.map((id) => queueStorage.getItem(deliveryKey(applicationKey, id))))).filter((d) => d !== null);
|
|
687
|
+
if (deliveries.some((d) => !isTerminalDeliveryStatus(d.status))) continue;
|
|
688
|
+
for (const delivery of deliveries) await deleteDeliveryCascade(delivery);
|
|
689
|
+
if (message.idempotencyKey) await storage.removeItem(idempotencyKey(applicationKey, message.idempotencyKey));
|
|
690
|
+
await storage.removeItem(messageKey(applicationKey, message.id));
|
|
691
|
+
pruned.push(message);
|
|
692
|
+
}
|
|
693
|
+
if (pruned.length && after) await after({
|
|
694
|
+
type: "message.pruned",
|
|
695
|
+
applicationKey,
|
|
696
|
+
messages: pruned,
|
|
697
|
+
at: nowIso()
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
async function pruneAuditLog(key, applicationKey) {
|
|
701
|
+
const rule = options.retention?.audit;
|
|
702
|
+
if (!rule || rule.keepLast === void 0 && rule.maxAge === void 0) return;
|
|
703
|
+
const log = await storage.getItem(key) ?? [];
|
|
704
|
+
if (!log.length) return;
|
|
705
|
+
const maxAgeMs = rule.maxAge !== void 0 ? durationToMs(rule.maxAge) : null;
|
|
706
|
+
const cutoff = maxAgeMs !== null ? now() - maxAgeMs : null;
|
|
707
|
+
const kept = [];
|
|
708
|
+
const removed = [];
|
|
709
|
+
for (const [index, entry] of log.entries()) {
|
|
710
|
+
const fromEnd = log.length - index;
|
|
711
|
+
const keptByCount = rule.keepLast !== void 0 && fromEnd <= rule.keepLast;
|
|
712
|
+
const keptByAge = cutoff !== null && Date.parse(entry.at) >= cutoff;
|
|
713
|
+
if (keptByCount || keptByAge) kept.push(entry);
|
|
714
|
+
else removed.push(entry);
|
|
715
|
+
}
|
|
716
|
+
if (removed.length) {
|
|
717
|
+
await storage.setItem(key, kept);
|
|
718
|
+
if (after) await after({
|
|
719
|
+
type: "audit.pruned",
|
|
720
|
+
...applicationKey ? { applicationKey } : {},
|
|
721
|
+
entries: removed,
|
|
722
|
+
at: nowIso()
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function prunePass() {
|
|
727
|
+
const apps = await storage.getItem(applicationsKey()) ?? [];
|
|
728
|
+
for (const app of apps) {
|
|
729
|
+
await pruneMessagesForApp(app);
|
|
730
|
+
await pruneAuditLog(auditLogKey(app), app);
|
|
731
|
+
}
|
|
732
|
+
await pruneAuditLog(globalAuditLogKey());
|
|
733
|
+
}
|
|
734
|
+
/** Serialized: a call always gets a full pass over the state it observed. */
|
|
735
|
+
async function prune() {
|
|
736
|
+
while (pruneInFlight) await pruneInFlight;
|
|
737
|
+
pruneInFlight = prunePass().finally(() => {
|
|
738
|
+
pruneInFlight = null;
|
|
739
|
+
});
|
|
740
|
+
await pruneInFlight;
|
|
741
|
+
}
|
|
742
|
+
function schedulePrune() {
|
|
743
|
+
if (!options.retention) return;
|
|
744
|
+
setTimeout(() => {
|
|
745
|
+
prune().catch((error) => {
|
|
746
|
+
console.warn("[@xtandard/webhooks] retention prune failed:", error);
|
|
747
|
+
});
|
|
748
|
+
}, 0).unref?.();
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
options: Object.freeze({
|
|
752
|
+
storage,
|
|
753
|
+
queueStorage,
|
|
754
|
+
readonly,
|
|
755
|
+
hooks,
|
|
756
|
+
...options.retention ? { retention: options.retention } : {},
|
|
757
|
+
...options.dispatcher ? { dispatcher: options.dispatcher } : {},
|
|
758
|
+
secretRotationGrace,
|
|
759
|
+
allowInsecureUrls: urlOptions.allowInsecureUrls,
|
|
760
|
+
...options.urlPolicy ? { urlPolicy: options.urlPolicy } : {},
|
|
761
|
+
payloadLimitBytes,
|
|
762
|
+
requireKnownEventTypes,
|
|
763
|
+
...options.onDelivery ? { onDelivery: options.onDelivery } : {},
|
|
764
|
+
...options.onDeliveryError ? { onDeliveryError: options.onDeliveryError } : {},
|
|
765
|
+
now
|
|
766
|
+
}),
|
|
767
|
+
async listApplications() {
|
|
768
|
+
const keys = await storage.getItem(applicationsKey()) ?? [];
|
|
769
|
+
return (await Promise.all(keys.map((k) => storage.getItem(applicationMetaKey(k))))).filter((a) => a !== null);
|
|
770
|
+
},
|
|
771
|
+
async createApplication(input, opts) {
|
|
772
|
+
guard("create application");
|
|
773
|
+
assertValid(validateApplication(input));
|
|
774
|
+
if (await storage.getItem(applicationMetaKey(input.key))) throw new ConflictError(`Application "${input.key}" already exists.`);
|
|
775
|
+
const application = {
|
|
776
|
+
key: input.key,
|
|
777
|
+
...input.name !== void 0 ? { name: input.name } : {},
|
|
778
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {},
|
|
779
|
+
createdAt: nowIso(),
|
|
780
|
+
updatedAt: nowIso()
|
|
781
|
+
};
|
|
782
|
+
if (before) await before({
|
|
783
|
+
type: "application.create",
|
|
784
|
+
application,
|
|
785
|
+
actor: opts?.actor ?? null
|
|
786
|
+
});
|
|
787
|
+
await storage.setItem(applicationMetaKey(application.key), application);
|
|
788
|
+
await indexAdd(storage, applicationsKey(), application.key);
|
|
789
|
+
await appendAudit({
|
|
790
|
+
action: "application.create",
|
|
791
|
+
by: opts?.actor ?? null,
|
|
792
|
+
applicationKey: application.key
|
|
793
|
+
});
|
|
794
|
+
if (after) await after({
|
|
795
|
+
type: "application.created",
|
|
796
|
+
application,
|
|
797
|
+
at: nowIso()
|
|
798
|
+
});
|
|
799
|
+
return application;
|
|
800
|
+
},
|
|
801
|
+
async getApplication(applicationKey) {
|
|
802
|
+
return storage.getItem(applicationMetaKey(applicationKey));
|
|
803
|
+
},
|
|
804
|
+
async updateApplication(applicationKey, patch, opts) {
|
|
805
|
+
guard("update application");
|
|
806
|
+
const application = {
|
|
807
|
+
...await requireApplication(applicationKey),
|
|
808
|
+
...patch.name !== void 0 ? { name: patch.name } : {},
|
|
809
|
+
...patch.metadata !== void 0 ? { metadata: patch.metadata } : {},
|
|
810
|
+
updatedAt: nowIso()
|
|
811
|
+
};
|
|
812
|
+
assertValid(validateApplication(application));
|
|
813
|
+
if (before) await before({
|
|
814
|
+
type: "application.update",
|
|
815
|
+
application,
|
|
816
|
+
actor: opts?.actor ?? null
|
|
817
|
+
});
|
|
818
|
+
await storage.setItem(applicationMetaKey(applicationKey), application);
|
|
819
|
+
await appendAudit({
|
|
820
|
+
action: "application.update",
|
|
821
|
+
by: opts?.actor ?? null,
|
|
822
|
+
applicationKey
|
|
823
|
+
});
|
|
824
|
+
if (after) await after({
|
|
825
|
+
type: "application.updated",
|
|
826
|
+
application,
|
|
827
|
+
at: nowIso()
|
|
828
|
+
});
|
|
829
|
+
return application;
|
|
830
|
+
},
|
|
831
|
+
async deleteApplication(applicationKey, opts) {
|
|
832
|
+
guard("delete application");
|
|
833
|
+
const application = await requireApplication(applicationKey);
|
|
834
|
+
if (before) await before({
|
|
835
|
+
type: "application.delete",
|
|
836
|
+
applicationKey,
|
|
837
|
+
actor: opts?.actor ?? null
|
|
838
|
+
});
|
|
839
|
+
for (const key of await storage.getKeys(applicationPrefix(applicationKey))) await storage.removeItem(key);
|
|
840
|
+
if (queueStorage !== storage) for (const key of await queueStorage.getKeys(applicationPrefix(applicationKey))) await queueStorage.removeItem(key);
|
|
841
|
+
await indexRemove(storage, applicationsKey(), applicationKey);
|
|
842
|
+
await appendAudit({
|
|
843
|
+
action: "application.delete",
|
|
844
|
+
by: opts?.actor ?? null,
|
|
845
|
+
applicationKey
|
|
846
|
+
}, "global");
|
|
847
|
+
if (after) await after({
|
|
848
|
+
type: "application.deleted",
|
|
849
|
+
applicationKey,
|
|
850
|
+
application,
|
|
851
|
+
at: nowIso()
|
|
852
|
+
});
|
|
853
|
+
},
|
|
854
|
+
async listEventTypes() {
|
|
855
|
+
const names = await storage.getItem(eventTypesKey()) ?? [];
|
|
856
|
+
return (await Promise.all(names.map((n) => storage.getItem(eventTypeKey(n))))).filter((t) => t !== null).sort((a, b) => a.name.localeCompare(b.name));
|
|
857
|
+
},
|
|
858
|
+
async getEventType(name) {
|
|
859
|
+
return storage.getItem(eventTypeKey(name));
|
|
860
|
+
},
|
|
861
|
+
async upsertEventType(input, opts) {
|
|
862
|
+
guard("upsert event type");
|
|
863
|
+
assertValid(validateEventType(input));
|
|
864
|
+
const existing = await storage.getItem(eventTypeKey(input.name));
|
|
865
|
+
const eventType = {
|
|
866
|
+
...existing,
|
|
867
|
+
...input,
|
|
868
|
+
createdAt: existing?.createdAt ?? nowIso(),
|
|
869
|
+
updatedAt: nowIso()
|
|
870
|
+
};
|
|
871
|
+
if (before) await before({
|
|
872
|
+
type: "event-type.upsert",
|
|
873
|
+
eventType,
|
|
874
|
+
actor: opts?.actor ?? null
|
|
875
|
+
});
|
|
876
|
+
await storage.setItem(eventTypeKey(eventType.name), eventType);
|
|
877
|
+
await indexAdd(storage, eventTypesKey(), eventType.name);
|
|
878
|
+
await appendAudit({
|
|
879
|
+
action: existing ? "event-type.update" : "event-type.create",
|
|
880
|
+
by: opts?.actor ?? null,
|
|
881
|
+
subjectId: eventType.name
|
|
882
|
+
}, "global");
|
|
883
|
+
if (after) await after({
|
|
884
|
+
type: "event-type.upserted",
|
|
885
|
+
eventType,
|
|
886
|
+
at: nowIso()
|
|
887
|
+
});
|
|
888
|
+
return eventType;
|
|
889
|
+
},
|
|
890
|
+
async deleteEventType(name, opts) {
|
|
891
|
+
guard("delete event type");
|
|
892
|
+
const eventType = await storage.getItem(eventTypeKey(name));
|
|
893
|
+
if (!eventType) throw new NotFoundError(`Event type "${name}" does not exist.`);
|
|
894
|
+
if (before) await before({
|
|
895
|
+
type: "event-type.delete",
|
|
896
|
+
name,
|
|
897
|
+
actor: opts?.actor ?? null
|
|
898
|
+
});
|
|
899
|
+
await storage.removeItem(eventTypeKey(name));
|
|
900
|
+
await indexRemove(storage, eventTypesKey(), name);
|
|
901
|
+
await appendAudit({
|
|
902
|
+
action: "event-type.delete",
|
|
903
|
+
by: opts?.actor ?? null,
|
|
904
|
+
subjectId: name
|
|
905
|
+
}, "global");
|
|
906
|
+
if (after) await after({
|
|
907
|
+
type: "event-type.deleted",
|
|
908
|
+
name,
|
|
909
|
+
eventType,
|
|
910
|
+
at: nowIso()
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
async listEndpoints(applicationKey) {
|
|
914
|
+
await requireApplication(applicationKey);
|
|
915
|
+
const ids = await storage.getItem(endpointsKey(applicationKey)) ?? [];
|
|
916
|
+
return (await Promise.all(ids.map((id) => storage.getItem(endpointKey(applicationKey, id))))).filter((e) => e !== null);
|
|
917
|
+
},
|
|
918
|
+
async getEndpoint(applicationKey, endpointId) {
|
|
919
|
+
return storage.getItem(endpointKey(applicationKey, endpointId));
|
|
920
|
+
},
|
|
921
|
+
async createEndpoint(applicationKey, input, opts) {
|
|
922
|
+
guard("create endpoint");
|
|
923
|
+
await requireApplication(applicationKey);
|
|
924
|
+
assertValid(validateEndpoint(input, urlOptions));
|
|
925
|
+
const endpoint = {
|
|
926
|
+
id: newId("ep"),
|
|
927
|
+
url: input.url,
|
|
928
|
+
...input.description !== void 0 ? { description: input.description } : {},
|
|
929
|
+
...input.eventTypes !== void 0 ? { eventTypes: input.eventTypes } : {},
|
|
930
|
+
...input.headers !== void 0 ? { headers: input.headers } : {},
|
|
931
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {},
|
|
932
|
+
...input.disabled ? {
|
|
933
|
+
disabled: true,
|
|
934
|
+
disabledReason: "manual"
|
|
935
|
+
} : {},
|
|
936
|
+
secrets: [{
|
|
937
|
+
secret: generateSecret(),
|
|
938
|
+
createdAt: nowIso()
|
|
939
|
+
}],
|
|
940
|
+
createdAt: nowIso(),
|
|
941
|
+
updatedAt: nowIso(),
|
|
942
|
+
firstFailingAt: null
|
|
943
|
+
};
|
|
944
|
+
if (before) await before({
|
|
945
|
+
type: "endpoint.create",
|
|
946
|
+
applicationKey,
|
|
947
|
+
endpoint,
|
|
948
|
+
actor: opts?.actor ?? null
|
|
949
|
+
});
|
|
950
|
+
await storage.setItem(endpointKey(applicationKey, endpoint.id), endpoint);
|
|
951
|
+
await indexAdd(storage, endpointsKey(applicationKey), endpoint.id);
|
|
952
|
+
await appendAudit({
|
|
953
|
+
action: "endpoint.create",
|
|
954
|
+
by: opts?.actor ?? null,
|
|
955
|
+
applicationKey,
|
|
956
|
+
subjectId: endpoint.id
|
|
957
|
+
});
|
|
958
|
+
if (after) await after({
|
|
959
|
+
type: "endpoint.created",
|
|
960
|
+
applicationKey,
|
|
961
|
+
endpoint,
|
|
962
|
+
at: nowIso()
|
|
963
|
+
});
|
|
964
|
+
return endpoint;
|
|
965
|
+
},
|
|
966
|
+
async updateEndpoint(applicationKey, endpointId, patch, opts) {
|
|
967
|
+
guard("update endpoint");
|
|
968
|
+
const endpoint = {
|
|
969
|
+
...await requireEndpoint(applicationKey, endpointId),
|
|
970
|
+
...patch.url !== void 0 ? { url: patch.url } : {},
|
|
971
|
+
...patch.description !== void 0 ? { description: patch.description } : {},
|
|
972
|
+
...patch.eventTypes !== void 0 ? { eventTypes: patch.eventTypes } : {},
|
|
973
|
+
...patch.headers !== void 0 ? { headers: patch.headers } : {},
|
|
974
|
+
...patch.metadata !== void 0 ? { metadata: patch.metadata } : {},
|
|
975
|
+
updatedAt: nowIso()
|
|
976
|
+
};
|
|
977
|
+
assertValid(validateEndpoint(endpoint, urlOptions));
|
|
978
|
+
if (before) await before({
|
|
979
|
+
type: "endpoint.update",
|
|
980
|
+
applicationKey,
|
|
981
|
+
endpoint,
|
|
982
|
+
actor: opts?.actor ?? null
|
|
983
|
+
});
|
|
984
|
+
await storage.setItem(endpointKey(applicationKey, endpointId), endpoint);
|
|
985
|
+
await appendAudit({
|
|
986
|
+
action: "endpoint.update",
|
|
987
|
+
by: opts?.actor ?? null,
|
|
988
|
+
applicationKey,
|
|
989
|
+
subjectId: endpointId
|
|
990
|
+
});
|
|
991
|
+
if (after) await after({
|
|
992
|
+
type: "endpoint.updated",
|
|
993
|
+
applicationKey,
|
|
994
|
+
endpoint,
|
|
995
|
+
at: nowIso()
|
|
996
|
+
});
|
|
997
|
+
return endpoint;
|
|
998
|
+
},
|
|
999
|
+
async deleteEndpoint(applicationKey, endpointId, opts) {
|
|
1000
|
+
guard("delete endpoint");
|
|
1001
|
+
const endpoint = await requireEndpoint(applicationKey, endpointId);
|
|
1002
|
+
if (before) await before({
|
|
1003
|
+
type: "endpoint.delete",
|
|
1004
|
+
applicationKey,
|
|
1005
|
+
endpointId,
|
|
1006
|
+
actor: opts?.actor ?? null
|
|
1007
|
+
});
|
|
1008
|
+
await storage.removeItem(endpointKey(applicationKey, endpointId));
|
|
1009
|
+
await indexRemove(storage, endpointsKey(applicationKey), endpointId);
|
|
1010
|
+
await appendAudit({
|
|
1011
|
+
action: "endpoint.delete",
|
|
1012
|
+
by: opts?.actor ?? null,
|
|
1013
|
+
applicationKey,
|
|
1014
|
+
subjectId: endpointId
|
|
1015
|
+
});
|
|
1016
|
+
if (after) await after({
|
|
1017
|
+
type: "endpoint.deleted",
|
|
1018
|
+
applicationKey,
|
|
1019
|
+
endpoint,
|
|
1020
|
+
at: nowIso()
|
|
1021
|
+
});
|
|
1022
|
+
},
|
|
1023
|
+
async rotateSecret(applicationKey, endpointId, opts) {
|
|
1024
|
+
guard("rotate endpoint secret");
|
|
1025
|
+
const current = await requireEndpoint(applicationKey, endpointId);
|
|
1026
|
+
if (before) await before({
|
|
1027
|
+
type: "endpoint.rotate-secret",
|
|
1028
|
+
applicationKey,
|
|
1029
|
+
endpointId,
|
|
1030
|
+
actor: opts?.actor ?? null
|
|
1031
|
+
});
|
|
1032
|
+
const graceMs = durationToMs(secretRotationGrace);
|
|
1033
|
+
const nowMs = now();
|
|
1034
|
+
const [previous, ...rest] = current.secrets;
|
|
1035
|
+
const secrets = [
|
|
1036
|
+
{
|
|
1037
|
+
secret: generateSecret(),
|
|
1038
|
+
createdAt: nowIso()
|
|
1039
|
+
},
|
|
1040
|
+
...previous ? [{
|
|
1041
|
+
...previous,
|
|
1042
|
+
expiresAt: new Date(nowMs + graceMs).toISOString()
|
|
1043
|
+
}] : [],
|
|
1044
|
+
...rest.filter((s) => s.expiresAt && Date.parse(s.expiresAt) > nowMs)
|
|
1045
|
+
];
|
|
1046
|
+
const endpoint = {
|
|
1047
|
+
...current,
|
|
1048
|
+
secrets,
|
|
1049
|
+
updatedAt: nowIso()
|
|
1050
|
+
};
|
|
1051
|
+
await storage.setItem(endpointKey(applicationKey, endpointId), endpoint);
|
|
1052
|
+
await appendAudit({
|
|
1053
|
+
action: "endpoint.rotate-secret",
|
|
1054
|
+
by: opts?.actor ?? null,
|
|
1055
|
+
applicationKey,
|
|
1056
|
+
subjectId: endpointId
|
|
1057
|
+
});
|
|
1058
|
+
if (after) await after({
|
|
1059
|
+
type: "endpoint.secret-rotated",
|
|
1060
|
+
applicationKey,
|
|
1061
|
+
endpoint,
|
|
1062
|
+
at: nowIso()
|
|
1063
|
+
});
|
|
1064
|
+
return endpoint;
|
|
1065
|
+
},
|
|
1066
|
+
async getSecrets(applicationKey, endpointId) {
|
|
1067
|
+
return (await requireEndpoint(applicationKey, endpointId)).secrets;
|
|
1068
|
+
},
|
|
1069
|
+
async enableEndpoint(applicationKey, endpointId, opts) {
|
|
1070
|
+
guard("enable endpoint");
|
|
1071
|
+
const current = await requireEndpoint(applicationKey, endpointId);
|
|
1072
|
+
if (before) await before({
|
|
1073
|
+
type: "endpoint.enable",
|
|
1074
|
+
applicationKey,
|
|
1075
|
+
endpointId,
|
|
1076
|
+
actor: opts?.actor ?? null
|
|
1077
|
+
});
|
|
1078
|
+
const endpoint = {
|
|
1079
|
+
...current,
|
|
1080
|
+
updatedAt: nowIso(),
|
|
1081
|
+
firstFailingAt: null
|
|
1082
|
+
};
|
|
1083
|
+
delete endpoint.disabled;
|
|
1084
|
+
delete endpoint.disabledReason;
|
|
1085
|
+
await storage.setItem(endpointKey(applicationKey, endpointId), endpoint);
|
|
1086
|
+
await appendAudit({
|
|
1087
|
+
action: "endpoint.enable",
|
|
1088
|
+
by: opts?.actor ?? null,
|
|
1089
|
+
applicationKey,
|
|
1090
|
+
subjectId: endpointId
|
|
1091
|
+
});
|
|
1092
|
+
if (after) await after({
|
|
1093
|
+
type: "endpoint.enabled",
|
|
1094
|
+
applicationKey,
|
|
1095
|
+
endpoint,
|
|
1096
|
+
at: nowIso()
|
|
1097
|
+
});
|
|
1098
|
+
return endpoint;
|
|
1099
|
+
},
|
|
1100
|
+
async disableEndpoint(applicationKey, endpointId, opts) {
|
|
1101
|
+
guard("disable endpoint");
|
|
1102
|
+
const current = await requireEndpoint(applicationKey, endpointId);
|
|
1103
|
+
if (before) await before({
|
|
1104
|
+
type: "endpoint.disable",
|
|
1105
|
+
applicationKey,
|
|
1106
|
+
endpointId,
|
|
1107
|
+
actor: opts?.actor ?? null
|
|
1108
|
+
});
|
|
1109
|
+
const endpoint = {
|
|
1110
|
+
...current,
|
|
1111
|
+
disabled: true,
|
|
1112
|
+
disabledReason: "manual",
|
|
1113
|
+
updatedAt: nowIso()
|
|
1114
|
+
};
|
|
1115
|
+
await storage.setItem(endpointKey(applicationKey, endpointId), endpoint);
|
|
1116
|
+
await appendAudit({
|
|
1117
|
+
action: "endpoint.disable",
|
|
1118
|
+
by: opts?.actor ?? null,
|
|
1119
|
+
applicationKey,
|
|
1120
|
+
subjectId: endpointId
|
|
1121
|
+
});
|
|
1122
|
+
if (after) await after({
|
|
1123
|
+
type: "endpoint.disabled",
|
|
1124
|
+
applicationKey,
|
|
1125
|
+
endpoint,
|
|
1126
|
+
at: nowIso()
|
|
1127
|
+
});
|
|
1128
|
+
return endpoint;
|
|
1129
|
+
},
|
|
1130
|
+
async publish(applicationKey, input, opts) {
|
|
1131
|
+
guard("publish message");
|
|
1132
|
+
await requireApplication(applicationKey);
|
|
1133
|
+
if (requireKnownEventTypes) {
|
|
1134
|
+
if (!await storage.getItem(eventTypeKey(input.eventType))) throw new ValidationError([{
|
|
1135
|
+
path: "message.eventType",
|
|
1136
|
+
message: `unknown event type "${input.eventType}" (create it first, or set requireKnownEventTypes: false)`
|
|
1137
|
+
}]);
|
|
1138
|
+
}
|
|
1139
|
+
const serializedPayload = JSON.stringify(input.payload);
|
|
1140
|
+
if (serializedPayload === void 0) throw new ValidationError([{
|
|
1141
|
+
path: "message.payload",
|
|
1142
|
+
message: "payload must be JSON-serializable"
|
|
1143
|
+
}]);
|
|
1144
|
+
const size = new TextEncoder().encode(serializedPayload).length;
|
|
1145
|
+
if (size > payloadLimitBytes) throw new PayloadTooLargeError(size, payloadLimitBytes);
|
|
1146
|
+
if (input.idempotencyKey !== void 0) {
|
|
1147
|
+
const issues = validateKeySegment(input.idempotencyKey, "message.idempotencyKey");
|
|
1148
|
+
assertValid({
|
|
1149
|
+
valid: issues.length === 0,
|
|
1150
|
+
errors: issues
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
if (input.idempotencyKey) {
|
|
1154
|
+
const existingId = await storage.getItem(idempotencyKey(applicationKey, input.idempotencyKey));
|
|
1155
|
+
if (existingId) {
|
|
1156
|
+
const existing = await storage.getItem(messageKey(applicationKey, existingId));
|
|
1157
|
+
if (existing) {
|
|
1158
|
+
if (JSON.stringify(existing.payload) !== serializedPayload) throw new IdempotencyConflictError(input.idempotencyKey);
|
|
1159
|
+
const deliveryIds = (await queueStorage.getKeys(byMessagePrefix(applicationKey, existing.id))).map(lastSegment);
|
|
1160
|
+
return {
|
|
1161
|
+
message: existing,
|
|
1162
|
+
deliveries: (await Promise.all(deliveryIds.map((id) => queueStorage.getItem(deliveryKey(applicationKey, id))))).filter((d) => d !== null),
|
|
1163
|
+
deduplicated: true
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (before) await before({
|
|
1169
|
+
type: "message.publish",
|
|
1170
|
+
applicationKey,
|
|
1171
|
+
eventType: input.eventType,
|
|
1172
|
+
payload: input.payload,
|
|
1173
|
+
...input.idempotencyKey !== void 0 ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1174
|
+
actor: opts?.actor ?? null
|
|
1175
|
+
});
|
|
1176
|
+
const createdAt = nowIso();
|
|
1177
|
+
const timestamp = input.timestamp ?? createdAt;
|
|
1178
|
+
const message = {
|
|
1179
|
+
id: newId("msg"),
|
|
1180
|
+
eventType: input.eventType,
|
|
1181
|
+
payload: input.payload,
|
|
1182
|
+
timestamp,
|
|
1183
|
+
...input.idempotencyKey !== void 0 ? { idempotencyKey: input.idempotencyKey } : {},
|
|
1184
|
+
envelope: JSON.stringify({
|
|
1185
|
+
type: input.eventType,
|
|
1186
|
+
timestamp,
|
|
1187
|
+
data: input.payload
|
|
1188
|
+
}),
|
|
1189
|
+
createdAt
|
|
1190
|
+
};
|
|
1191
|
+
const ids = await storage.getItem(endpointsKey(applicationKey)) ?? [];
|
|
1192
|
+
const matching = (await Promise.all(ids.map((id) => storage.getItem(endpointKey(applicationKey, id))))).filter((e) => e !== null).filter((e) => !e.disabled && (!e.eventTypes || e.eventTypes.length === 0 || e.eventTypes.includes(input.eventType)));
|
|
1193
|
+
await storage.setItem(messageKey(applicationKey, message.id), message);
|
|
1194
|
+
if (input.idempotencyKey) await storage.setItem(idempotencyKey(applicationKey, input.idempotencyKey), message.id);
|
|
1195
|
+
const nowMs = now();
|
|
1196
|
+
const deliveries = [];
|
|
1197
|
+
for (const endpoint of matching) {
|
|
1198
|
+
const delivery = {
|
|
1199
|
+
id: newId("dlv"),
|
|
1200
|
+
applicationKey,
|
|
1201
|
+
messageId: message.id,
|
|
1202
|
+
endpointId: endpoint.id,
|
|
1203
|
+
status: "pending",
|
|
1204
|
+
attemptCount: 0,
|
|
1205
|
+
nextAttemptAt: createdAt,
|
|
1206
|
+
leaseUntil: null,
|
|
1207
|
+
createdAt,
|
|
1208
|
+
updatedAt: createdAt
|
|
1209
|
+
};
|
|
1210
|
+
await writeDeliveryWithDue(delivery, nowMs);
|
|
1211
|
+
await queueStorage.setItem(byMessageKey(applicationKey, message.id, delivery.id), 1);
|
|
1212
|
+
await queueStorage.setItem(byEndpointKey(applicationKey, endpoint.id, delivery.id), 1);
|
|
1213
|
+
deliveries.push(delivery);
|
|
1214
|
+
}
|
|
1215
|
+
if (after) await after({
|
|
1216
|
+
type: "message.published",
|
|
1217
|
+
applicationKey,
|
|
1218
|
+
message,
|
|
1219
|
+
deliveryIds: deliveries.map((d) => d.id),
|
|
1220
|
+
at: nowIso()
|
|
1221
|
+
});
|
|
1222
|
+
schedulePrune();
|
|
1223
|
+
return {
|
|
1224
|
+
message,
|
|
1225
|
+
deliveries,
|
|
1226
|
+
deduplicated: false
|
|
1227
|
+
};
|
|
1228
|
+
},
|
|
1229
|
+
async listMessages(applicationKey, opts = {}) {
|
|
1230
|
+
await requireApplication(applicationKey);
|
|
1231
|
+
const keys = await storage.getKeys(messagesPrefix(applicationKey));
|
|
1232
|
+
let messages = (await Promise.all(keys.map((k) => storage.getItem(k)))).filter((m) => m !== null);
|
|
1233
|
+
if (opts.eventType) messages = messages.filter((m) => m.eventType === opts.eventType);
|
|
1234
|
+
messages.sort(byCreatedAtDesc);
|
|
1235
|
+
return paginate(messages, opts.limit, opts.before);
|
|
1236
|
+
},
|
|
1237
|
+
async getMessage(applicationKey, messageId) {
|
|
1238
|
+
return storage.getItem(messageKey(applicationKey, messageId));
|
|
1239
|
+
},
|
|
1240
|
+
async listDeliveries(applicationKey, opts = {}) {
|
|
1241
|
+
await requireApplication(applicationKey);
|
|
1242
|
+
let ids;
|
|
1243
|
+
if (opts.messageId) ids = (await queueStorage.getKeys(byMessagePrefix(applicationKey, opts.messageId))).map(lastSegment);
|
|
1244
|
+
else if (opts.endpointId) ids = (await queueStorage.getKeys(byEndpointPrefix(applicationKey, opts.endpointId))).map(lastSegment);
|
|
1245
|
+
else ids = (await queueStorage.getKeys(deliveriesPrefix(applicationKey))).map(lastSegment);
|
|
1246
|
+
let deliveries = (await Promise.all(ids.map((id) => queueStorage.getItem(deliveryKey(applicationKey, id))))).filter((d) => d !== null);
|
|
1247
|
+
if (opts.status) deliveries = deliveries.filter((d) => d.status === opts.status);
|
|
1248
|
+
if (opts.endpointId) deliveries = deliveries.filter((d) => d.endpointId === opts.endpointId);
|
|
1249
|
+
if (opts.messageId) deliveries = deliveries.filter((d) => d.messageId === opts.messageId);
|
|
1250
|
+
deliveries.sort(byCreatedAtDesc);
|
|
1251
|
+
return paginate(deliveries, opts.limit, opts.before);
|
|
1252
|
+
},
|
|
1253
|
+
async getDelivery(applicationKey, deliveryId) {
|
|
1254
|
+
const delivery = await queueStorage.getItem(deliveryKey(applicationKey, deliveryId));
|
|
1255
|
+
if (!delivery) return null;
|
|
1256
|
+
return {
|
|
1257
|
+
delivery,
|
|
1258
|
+
attempts: await listAttempts(applicationKey, deliveryId)
|
|
1259
|
+
};
|
|
1260
|
+
},
|
|
1261
|
+
async previewDeliveryRequest(applicationKey, deliveryId) {
|
|
1262
|
+
const delivery = await queueStorage.getItem(deliveryKey(applicationKey, deliveryId));
|
|
1263
|
+
if (!delivery) return null;
|
|
1264
|
+
const [message, endpoint] = await Promise.all([storage.getItem(messageKey(applicationKey, delivery.messageId)), storage.getItem(endpointKey(applicationKey, delivery.endpointId))]);
|
|
1265
|
+
if (!message || !endpoint) return null;
|
|
1266
|
+
return buildSignedRequest({
|
|
1267
|
+
endpoint,
|
|
1268
|
+
messageId: message.id,
|
|
1269
|
+
body: message.envelope,
|
|
1270
|
+
nowMs: now(),
|
|
1271
|
+
userAgent: `xtandard-webhooks/${VERSION}`
|
|
1272
|
+
});
|
|
1273
|
+
},
|
|
1274
|
+
async retryDelivery(applicationKey, deliveryId, opts) {
|
|
1275
|
+
guard("retry delivery");
|
|
1276
|
+
await requireApplication(applicationKey);
|
|
1277
|
+
const current = await queueStorage.getItem(deliveryKey(applicationKey, deliveryId));
|
|
1278
|
+
if (!current) throw new NotFoundError(`Delivery "${deliveryId}" does not exist.`);
|
|
1279
|
+
if (current.status !== "failed") throw new ValidationError([{
|
|
1280
|
+
path: "delivery.status",
|
|
1281
|
+
message: `only failed (dead-letter) deliveries can be retried; this one is "${current.status}"`
|
|
1282
|
+
}]);
|
|
1283
|
+
if (before) await before({
|
|
1284
|
+
type: "delivery.retry",
|
|
1285
|
+
applicationKey,
|
|
1286
|
+
deliveryId,
|
|
1287
|
+
actor: opts?.actor ?? null
|
|
1288
|
+
});
|
|
1289
|
+
const nowMs = now();
|
|
1290
|
+
const delivery = {
|
|
1291
|
+
...current,
|
|
1292
|
+
status: "pending",
|
|
1293
|
+
nextAttemptAt: nowIso(),
|
|
1294
|
+
leaseUntil: null,
|
|
1295
|
+
pendingTrigger: "manual",
|
|
1296
|
+
updatedAt: nowIso()
|
|
1297
|
+
};
|
|
1298
|
+
await writeDeliveryWithDue(delivery, nowMs);
|
|
1299
|
+
await appendAudit({
|
|
1300
|
+
action: "delivery.retry",
|
|
1301
|
+
by: opts?.actor ?? null,
|
|
1302
|
+
applicationKey,
|
|
1303
|
+
subjectId: deliveryId
|
|
1304
|
+
});
|
|
1305
|
+
return delivery;
|
|
1306
|
+
},
|
|
1307
|
+
async recoverEndpoint(applicationKey, endpointId, input, opts) {
|
|
1308
|
+
guard("recover endpoint");
|
|
1309
|
+
await requireEndpoint(applicationKey, endpointId);
|
|
1310
|
+
const sinceMs = Date.parse(input.since);
|
|
1311
|
+
if (!Number.isFinite(sinceMs)) throw new ValidationError([{
|
|
1312
|
+
path: "since",
|
|
1313
|
+
message: `"${input.since}" is not a valid timestamp`
|
|
1314
|
+
}]);
|
|
1315
|
+
if (before) await before({
|
|
1316
|
+
type: "endpoint.recover",
|
|
1317
|
+
applicationKey,
|
|
1318
|
+
endpointId,
|
|
1319
|
+
since: input.since,
|
|
1320
|
+
actor: opts?.actor ?? null
|
|
1321
|
+
});
|
|
1322
|
+
const ids = (await queueStorage.getKeys(byEndpointPrefix(applicationKey, endpointId))).map(lastSegment);
|
|
1323
|
+
const nowMs = now();
|
|
1324
|
+
const recovered = [];
|
|
1325
|
+
for (const id of ids) {
|
|
1326
|
+
const delivery = await queueStorage.getItem(deliveryKey(applicationKey, id));
|
|
1327
|
+
if (!delivery || delivery.status !== "failed") continue;
|
|
1328
|
+
if (Date.parse(delivery.createdAt) < sinceMs) continue;
|
|
1329
|
+
await writeDeliveryWithDue({
|
|
1330
|
+
...delivery,
|
|
1331
|
+
status: "pending",
|
|
1332
|
+
nextAttemptAt: nowIso(),
|
|
1333
|
+
leaseUntil: null,
|
|
1334
|
+
pendingTrigger: "manual",
|
|
1335
|
+
updatedAt: nowIso()
|
|
1336
|
+
}, nowMs);
|
|
1337
|
+
recovered.push(id);
|
|
1338
|
+
}
|
|
1339
|
+
await appendAudit({
|
|
1340
|
+
action: "endpoint.recover",
|
|
1341
|
+
by: opts?.actor ?? null,
|
|
1342
|
+
applicationKey,
|
|
1343
|
+
subjectId: endpointId,
|
|
1344
|
+
message: `${recovered.length} deliveries re-queued since ${input.since}`
|
|
1345
|
+
});
|
|
1346
|
+
return { deliveryIds: recovered };
|
|
1347
|
+
},
|
|
1348
|
+
async sendExample(applicationKey, endpointId, input) {
|
|
1349
|
+
guard("send example delivery");
|
|
1350
|
+
const endpoint = await requireEndpoint(applicationKey, endpointId);
|
|
1351
|
+
const messageId = newId("msg");
|
|
1352
|
+
const timestamp = nowIso();
|
|
1353
|
+
const body = JSON.stringify({
|
|
1354
|
+
type: input.eventType,
|
|
1355
|
+
timestamp,
|
|
1356
|
+
data: input.payload ?? {
|
|
1357
|
+
example: true,
|
|
1358
|
+
eventType: input.eventType
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
const dispatcher = options.dispatcher;
|
|
1362
|
+
const outcome = await attemptDelivery({
|
|
1363
|
+
endpoint,
|
|
1364
|
+
messageId,
|
|
1365
|
+
body,
|
|
1366
|
+
...dispatcher?.timeoutMs !== void 0 ? { timeoutMs: dispatcher.timeoutMs } : {},
|
|
1367
|
+
...dispatcher?.responseBodyLimit !== void 0 ? { responseBodyLimit: dispatcher.responseBodyLimit } : {},
|
|
1368
|
+
...dispatcher?.fetch ? { fetch: dispatcher.fetch } : {},
|
|
1369
|
+
...dispatcher?.userAgent !== void 0 ? { userAgent: dispatcher.userAgent } : {},
|
|
1370
|
+
nowMs: now()
|
|
1371
|
+
});
|
|
1372
|
+
if (options.onDelivery) emitDelivery(options.onDelivery, {
|
|
1373
|
+
applicationKey,
|
|
1374
|
+
endpointId,
|
|
1375
|
+
messageId,
|
|
1376
|
+
deliveryId: messageId,
|
|
1377
|
+
eventType: input.eventType,
|
|
1378
|
+
attemptNumber: 1,
|
|
1379
|
+
ok: outcome.ok,
|
|
1380
|
+
terminal: true,
|
|
1381
|
+
...outcome.httpStatus !== void 0 ? { httpStatus: outcome.httpStatus } : {},
|
|
1382
|
+
durationMs: outcome.durationMs,
|
|
1383
|
+
trigger: "test",
|
|
1384
|
+
at: outcome.at
|
|
1385
|
+
}, options.onDeliveryError);
|
|
1386
|
+
return {
|
|
1387
|
+
outcome,
|
|
1388
|
+
body,
|
|
1389
|
+
messageId
|
|
1390
|
+
};
|
|
1391
|
+
},
|
|
1392
|
+
async listAudit(applicationKey) {
|
|
1393
|
+
if (applicationKey) return [...await storage.getItem(auditLogKey(applicationKey)) ?? []].reverse();
|
|
1394
|
+
const apps = await storage.getItem(applicationsKey()) ?? [];
|
|
1395
|
+
const merged = (await Promise.all([storage.getItem(globalAuditLogKey()), ...apps.map((app) => storage.getItem(auditLogKey(app)))])).flatMap((log) => log ?? []);
|
|
1396
|
+
merged.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
1397
|
+
return merged;
|
|
1398
|
+
},
|
|
1399
|
+
prune,
|
|
1400
|
+
async claimDueDeliveries(input) {
|
|
1401
|
+
const nowMs = now();
|
|
1402
|
+
const nowStr = new Date(nowMs).toISOString();
|
|
1403
|
+
if (hasDeliveryQueue(queueStorage)) return queueStorage.claimDue({
|
|
1404
|
+
now: nowStr,
|
|
1405
|
+
limit: input.limit,
|
|
1406
|
+
leaseMs: input.leaseMs
|
|
1407
|
+
});
|
|
1408
|
+
const apps = await storage.getItem(applicationsKey()) ?? [];
|
|
1409
|
+
const claimed = [];
|
|
1410
|
+
const cas = isCompareAndSwap(queueStorage) ? queueStorage : null;
|
|
1411
|
+
for (const app of apps) {
|
|
1412
|
+
if (claimed.length >= input.limit) break;
|
|
1413
|
+
const dueKeys = (await queueStorage.getKeys(`whk/${app}/due/`)).sort();
|
|
1414
|
+
for (const key of dueKeys) {
|
|
1415
|
+
if (claimed.length >= input.limit) break;
|
|
1416
|
+
const suffix = lastSegment(key);
|
|
1417
|
+
const sep = suffix.indexOf("~");
|
|
1418
|
+
if (sep === -1) continue;
|
|
1419
|
+
const dueAt = Number(suffix.slice(0, sep));
|
|
1420
|
+
if (!Number.isFinite(dueAt) || dueAt > nowMs) break;
|
|
1421
|
+
const entry = await queueStorage.getItem(key);
|
|
1422
|
+
if (!entry) continue;
|
|
1423
|
+
const dKey = deliveryKey(entry.app, entry.deliveryId);
|
|
1424
|
+
const delivery = await queueStorage.getItem(dKey);
|
|
1425
|
+
if (!delivery || isTerminalDeliveryStatus(delivery.status)) {
|
|
1426
|
+
await queueStorage.removeItem(key);
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
const leaseExpired = delivery.status === "delivering" && (!delivery.leaseUntil || Date.parse(delivery.leaseUntil) <= nowMs);
|
|
1430
|
+
if (delivery.status !== "pending" && !leaseExpired) continue;
|
|
1431
|
+
const next = {
|
|
1432
|
+
...delivery,
|
|
1433
|
+
status: "delivering",
|
|
1434
|
+
leaseUntil: new Date(nowMs + input.leaseMs).toISOString(),
|
|
1435
|
+
updatedAt: nowStr
|
|
1436
|
+
};
|
|
1437
|
+
if (cas) {
|
|
1438
|
+
if (!await cas.compareAndSwap({
|
|
1439
|
+
key: dKey,
|
|
1440
|
+
expected: delivery,
|
|
1441
|
+
next
|
|
1442
|
+
})) continue;
|
|
1443
|
+
} else await queueStorage.setItem(dKey, next);
|
|
1444
|
+
await queueStorage.removeItem(key);
|
|
1445
|
+
await queueStorage.setItem(dueKey(entry.app, nowMs + input.leaseMs, entry.deliveryId), entry);
|
|
1446
|
+
claimed.push(next);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return claimed;
|
|
1450
|
+
},
|
|
1451
|
+
async recordAttempt(input) {
|
|
1452
|
+
const { delivery, outcome } = input;
|
|
1453
|
+
const app = delivery.applicationKey;
|
|
1454
|
+
const attemptNumber = delivery.attemptCount + 1;
|
|
1455
|
+
const attempt = {
|
|
1456
|
+
id: newId("atp"),
|
|
1457
|
+
deliveryId: delivery.id,
|
|
1458
|
+
attemptNumber,
|
|
1459
|
+
at: outcome.at,
|
|
1460
|
+
durationMs: outcome.durationMs,
|
|
1461
|
+
ok: outcome.ok,
|
|
1462
|
+
...outcome.httpStatus !== void 0 ? { httpStatus: outcome.httpStatus } : {},
|
|
1463
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {},
|
|
1464
|
+
...outcome.responseBody !== void 0 ? { responseBody: outcome.responseBody } : {},
|
|
1465
|
+
trigger: input.trigger
|
|
1466
|
+
};
|
|
1467
|
+
await queueStorage.setItem(attemptKey(app, delivery.id, attemptNumber), attempt);
|
|
1468
|
+
await removeDueEntry(delivery);
|
|
1469
|
+
const base = { ...delivery };
|
|
1470
|
+
delete base.pendingTrigger;
|
|
1471
|
+
let next;
|
|
1472
|
+
if (outcome.ok) {
|
|
1473
|
+
next = {
|
|
1474
|
+
...base,
|
|
1475
|
+
status: "succeeded",
|
|
1476
|
+
attemptCount: attemptNumber,
|
|
1477
|
+
nextAttemptAt: null,
|
|
1478
|
+
leaseUntil: null,
|
|
1479
|
+
updatedAt: nowIso()
|
|
1480
|
+
};
|
|
1481
|
+
await queueStorage.setItem(deliveryKey(app, delivery.id), next);
|
|
1482
|
+
if (after) await after({
|
|
1483
|
+
type: "delivery.succeeded",
|
|
1484
|
+
applicationKey: app,
|
|
1485
|
+
delivery: next,
|
|
1486
|
+
attempt,
|
|
1487
|
+
at: nowIso()
|
|
1488
|
+
});
|
|
1489
|
+
} else if (input.nextAttemptAt) {
|
|
1490
|
+
next = {
|
|
1491
|
+
...base,
|
|
1492
|
+
status: "pending",
|
|
1493
|
+
attemptCount: attemptNumber,
|
|
1494
|
+
nextAttemptAt: input.nextAttemptAt,
|
|
1495
|
+
leaseUntil: null,
|
|
1496
|
+
updatedAt: nowIso()
|
|
1497
|
+
};
|
|
1498
|
+
await writeDeliveryWithDue(next, Date.parse(input.nextAttemptAt));
|
|
1499
|
+
} else {
|
|
1500
|
+
next = {
|
|
1501
|
+
...base,
|
|
1502
|
+
status: "failed",
|
|
1503
|
+
attemptCount: attemptNumber,
|
|
1504
|
+
nextAttemptAt: null,
|
|
1505
|
+
leaseUntil: null,
|
|
1506
|
+
updatedAt: nowIso()
|
|
1507
|
+
};
|
|
1508
|
+
await queueStorage.setItem(deliveryKey(app, delivery.id), next);
|
|
1509
|
+
if (after) await after({
|
|
1510
|
+
type: "delivery.exhausted",
|
|
1511
|
+
applicationKey: app,
|
|
1512
|
+
delivery: next,
|
|
1513
|
+
attempts: await listAttempts(app, delivery.id),
|
|
1514
|
+
at: nowIso()
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
if (options.onDelivery) emitDelivery(options.onDelivery, {
|
|
1518
|
+
applicationKey: app,
|
|
1519
|
+
endpointId: delivery.endpointId,
|
|
1520
|
+
messageId: delivery.messageId,
|
|
1521
|
+
deliveryId: delivery.id,
|
|
1522
|
+
eventType: input.eventType,
|
|
1523
|
+
attemptNumber,
|
|
1524
|
+
ok: outcome.ok,
|
|
1525
|
+
terminal: isTerminalDeliveryStatus(next.status),
|
|
1526
|
+
...outcome.httpStatus !== void 0 ? { httpStatus: outcome.httpStatus } : {},
|
|
1527
|
+
durationMs: outcome.durationMs,
|
|
1528
|
+
trigger: input.trigger,
|
|
1529
|
+
at: outcome.at
|
|
1530
|
+
}, options.onDeliveryError);
|
|
1531
|
+
return next;
|
|
1532
|
+
},
|
|
1533
|
+
async noteEndpointOutcome(applicationKey, endpointId, ok, policy) {
|
|
1534
|
+
const key = endpointKey(applicationKey, endpointId);
|
|
1535
|
+
const failingForDays = policy === false ? void 0 : policy?.failingForDays ?? 5;
|
|
1536
|
+
const plan = (current) => {
|
|
1537
|
+
if (ok) {
|
|
1538
|
+
if (!current.firstFailingAt) return null;
|
|
1539
|
+
return {
|
|
1540
|
+
next: {
|
|
1541
|
+
...current,
|
|
1542
|
+
firstFailingAt: null
|
|
1543
|
+
},
|
|
1544
|
+
disabled: false
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
const firstFailingAt = current.firstFailingAt ?? nowIso();
|
|
1548
|
+
let next = {
|
|
1549
|
+
...current,
|
|
1550
|
+
firstFailingAt
|
|
1551
|
+
};
|
|
1552
|
+
const shouldDisable = failingForDays !== void 0 && !current.disabled && now() - Date.parse(firstFailingAt) > failingForDays * 864e5;
|
|
1553
|
+
if (shouldDisable) next = {
|
|
1554
|
+
...next,
|
|
1555
|
+
disabled: true,
|
|
1556
|
+
disabledReason: "auto",
|
|
1557
|
+
updatedAt: nowIso()
|
|
1558
|
+
};
|
|
1559
|
+
return {
|
|
1560
|
+
next,
|
|
1561
|
+
disabled: shouldDisable
|
|
1562
|
+
};
|
|
1563
|
+
};
|
|
1564
|
+
const cas = isCompareAndSwap(storage) ? storage : null;
|
|
1565
|
+
let committed = null;
|
|
1566
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1567
|
+
const current = await storage.getItem(key);
|
|
1568
|
+
if (!current) return null;
|
|
1569
|
+
const planned = plan(current);
|
|
1570
|
+
if (!planned) return null;
|
|
1571
|
+
if (cas) {
|
|
1572
|
+
if (!await cas.compareAndSwap({
|
|
1573
|
+
key,
|
|
1574
|
+
expected: current,
|
|
1575
|
+
next: planned.next
|
|
1576
|
+
})) continue;
|
|
1577
|
+
} else await storage.setItem(key, planned.next);
|
|
1578
|
+
committed = planned;
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
if (!committed) return null;
|
|
1582
|
+
const updated = committed.next;
|
|
1583
|
+
if (committed.disabled) {
|
|
1584
|
+
await appendAudit({
|
|
1585
|
+
action: "endpoint.disable",
|
|
1586
|
+
applicationKey,
|
|
1587
|
+
subjectId: endpointId,
|
|
1588
|
+
message: `auto-disabled: failing since ${updated.firstFailingAt}`
|
|
1589
|
+
});
|
|
1590
|
+
if (after) await after({
|
|
1591
|
+
type: "endpoint.auto-disabled",
|
|
1592
|
+
applicationKey,
|
|
1593
|
+
endpoint: updated,
|
|
1594
|
+
at: nowIso()
|
|
1595
|
+
});
|
|
1596
|
+
return updated;
|
|
1597
|
+
}
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
//#endregion
|
|
1603
|
+
export { attemptDelivery as A, newId as C, DEFAULT_ATTEMPT_TIMEOUT_MS as D, emitDelivery as E, DEFAULT_RESPONSE_BODY_LIMIT as O, id_exports as S, parseDurationList as T, normalizeHooks as _, ReadonlyError as a, VERSION as b, RESERVED_HEADERS as c, validateApplication as d, validateEndpoint as f, defaultHookErrorReporter as g, HookDeniedError as h, PayloadTooLargeError as i, buildSignedRequest as j, activeSecrets as k, ValidationError as l, validateEventType as m, IdempotencyConflictError as n, createWebhooksCore as o, validateEndpointUrl as p, NotFoundError as r, KEY_REGEX as s, ConflictError as t, assertValid as u, runAfter as v, durationToMs as w, idPattern as x, runBefore as y };
|
|
1604
|
+
|
|
1605
|
+
//# sourceMappingURL=core-CMpnmI5Q.mjs.map
|