jazz-webhook 0.18.28
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE.txt +19 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/successMap.d.ts +13 -0
- package/dist/successMap.d.ts.map +1 -0
- package/dist/successMap.js +32 -0
- package/dist/successMap.js.map +1 -0
- package/dist/test/http-server.d.ts +73 -0
- package/dist/test/http-server.d.ts.map +1 -0
- package/dist/test/http-server.js +177 -0
- package/dist/test/http-server.js.map +1 -0
- package/dist/test/setup.d.ts +2 -0
- package/dist/test/setup.d.ts.map +1 -0
- package/dist/test/setup.js +11 -0
- package/dist/test/setup.js.map +1 -0
- package/dist/test/successMap.test.d.ts +2 -0
- package/dist/test/successMap.test.d.ts.map +1 -0
- package/dist/test/successMap.test.js +172 -0
- package/dist/test/successMap.test.js.map +1 -0
- package/dist/test/webhook.test.d.ts +2 -0
- package/dist/test/webhook.test.d.ts.map +1 -0
- package/dist/test/webhook.test.js +356 -0
- package/dist/test/webhook.test.js.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/webhook.d.ts +67 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +281 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +34 -0
- package/src/index.ts +2 -0
- package/src/successMap.ts +55 -0
- package/src/test/http-server.ts +233 -0
- package/src/test/setup.ts +12 -0
- package/src/test/successMap.test.ts +215 -0
- package/src/test/webhook.test.ts +586 -0
- package/src/types.ts +106 -0
- package/src/webhook.ts +387 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +14 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { co, z, Account, Group } from "jazz-tools";
|
|
2
|
+
import { CojsonInternalTypes, CoID, RawCoValue } from "cojson";
|
|
3
|
+
import {
|
|
4
|
+
getTransactionsToTry,
|
|
5
|
+
getTxIdKey,
|
|
6
|
+
markSuccessful,
|
|
7
|
+
SuccessMap,
|
|
8
|
+
TxIdKey,
|
|
9
|
+
} from "./successMap.js";
|
|
10
|
+
|
|
11
|
+
export const WebhookRegistration = co.map({
|
|
12
|
+
webhookUrl: z.string(),
|
|
13
|
+
coValueId: z.string(),
|
|
14
|
+
active: z.boolean(),
|
|
15
|
+
successMap: SuccessMap,
|
|
16
|
+
});
|
|
17
|
+
export type WebhookRegistration = co.loaded<typeof WebhookRegistration>;
|
|
18
|
+
|
|
19
|
+
export const RegistryState = co.record(z.string(), WebhookRegistration);
|
|
20
|
+
export type RegistryState = co.loaded<typeof RegistryState>;
|
|
21
|
+
|
|
22
|
+
export interface JazzWebhookOptions {
|
|
23
|
+
/** Maximum number of retry attempts for failed webhooks (default: 5) */
|
|
24
|
+
maxRetries?: number;
|
|
25
|
+
/** Base delay in milliseconds for exponential backoff (default: 1000ms) */
|
|
26
|
+
baseDelayMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_JazzWebhookOptions: Required<JazzWebhookOptions> = {
|
|
30
|
+
maxRetries: 3,
|
|
31
|
+
baseDelayMs: 20_000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class WebhookRegistry {
|
|
35
|
+
public state: RegistryState;
|
|
36
|
+
private activeSubscriptions = new Map<string, () => void>();
|
|
37
|
+
private rootUnsubscribe: (() => void) | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
registry: RegistryState,
|
|
41
|
+
private options: JazzWebhookOptions = {},
|
|
42
|
+
) {
|
|
43
|
+
this.state = registry;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static createRegistry(owner: Group | Account): RegistryState {
|
|
47
|
+
return RegistryState.create({}, owner);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static async loadAndStart(
|
|
51
|
+
registryId: string,
|
|
52
|
+
options: JazzWebhookOptions = {},
|
|
53
|
+
) {
|
|
54
|
+
const registry = await RegistryState.load(registryId);
|
|
55
|
+
|
|
56
|
+
if (!registry) {
|
|
57
|
+
throw new Error(`Webhook registry with ID ${registryId} not found`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const webhook = new WebhookRegistry(registry, options);
|
|
61
|
+
webhook.start();
|
|
62
|
+
return webhook;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers a new webhook for a CoValue.
|
|
67
|
+
*
|
|
68
|
+
* @param webhookUrl - The HTTP URL to call when the CoValue changes
|
|
69
|
+
* @param coValueId - The ID of the CoValue to monitor (must start with "co_z")
|
|
70
|
+
* @returns The ID of the registered webhook
|
|
71
|
+
*/
|
|
72
|
+
async register(webhookUrl: string, coValueId: string): Promise<string> {
|
|
73
|
+
const registrationId = await registerWebhook({
|
|
74
|
+
registryId: this.state.$jazz.id,
|
|
75
|
+
webhookUrl,
|
|
76
|
+
coValueId,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// wait for registration to become visible in the registry
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
this.state.$jazz.subscribe((state, unsubscribe) => {
|
|
82
|
+
if (state.$jazz.refs[registrationId]) {
|
|
83
|
+
resolve(registrationId);
|
|
84
|
+
unsubscribe();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Unregisters a webhook and stops monitoring the CoValue.
|
|
92
|
+
*
|
|
93
|
+
* @param webhookId - The ID of the webhook to unregister
|
|
94
|
+
*/
|
|
95
|
+
unregister(webhookId: string): void {
|
|
96
|
+
const webhook = this.state[webhookId];
|
|
97
|
+
if (!webhook) {
|
|
98
|
+
throw new Error(`Webhook with ID ${webhookId} not found`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.state.$jazz.delete(webhookId);
|
|
102
|
+
webhook.$jazz.set("active", false);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Starts monitoring a CoValue and emitting webhooks when it changes.
|
|
107
|
+
*
|
|
108
|
+
* @param webhookId - The ID of the webhook to start monitoring
|
|
109
|
+
*/
|
|
110
|
+
private subscribe(webhook: WebhookRegistration) {
|
|
111
|
+
const emitter = new WebhookEmitter(webhook, this.options);
|
|
112
|
+
|
|
113
|
+
this.activeSubscriptions.set(webhook.$jazz.id, () => {
|
|
114
|
+
emitter.stop();
|
|
115
|
+
this.activeSubscriptions.delete(webhook.$jazz.id);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Starts monitoring all active webhooks in the registry.
|
|
121
|
+
*
|
|
122
|
+
* This method iterates through all webhooks in the registry and starts
|
|
123
|
+
* subscriptions for any active webhooks that don't already have active
|
|
124
|
+
* subscriptions. This is useful for resuming webhook monitoring after
|
|
125
|
+
* a shutdown or when loading an existing registry.
|
|
126
|
+
*/
|
|
127
|
+
async start(): Promise<void> {
|
|
128
|
+
if (this.rootUnsubscribe) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// TODO: this would be much more efficient with subscription diffs
|
|
133
|
+
const createAndDeleteSubscriptions = (
|
|
134
|
+
registry: co.loaded<typeof RegistryState, { $each: true }>,
|
|
135
|
+
) => {
|
|
136
|
+
for (const webhook of Object.values(registry)) {
|
|
137
|
+
const exists = this.activeSubscriptions.has(webhook.$jazz.id);
|
|
138
|
+
if (webhook.active && !exists) {
|
|
139
|
+
this.subscribe(webhook);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [id, unsubscribe] of this.activeSubscriptions.entries()) {
|
|
143
|
+
if (!registry[id]) {
|
|
144
|
+
unsubscribe();
|
|
145
|
+
this.activeSubscriptions.delete(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.rootUnsubscribe = this.state.$jazz.subscribe(
|
|
151
|
+
{
|
|
152
|
+
resolve: {
|
|
153
|
+
$each: true,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
createAndDeleteSubscriptions,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
shutdown(): void {
|
|
161
|
+
for (const unsubscribe of this.activeSubscriptions.values()) {
|
|
162
|
+
unsubscribe();
|
|
163
|
+
}
|
|
164
|
+
this.rootUnsubscribe?.();
|
|
165
|
+
this.rootUnsubscribe = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class WebhookError extends Error {
|
|
170
|
+
constructor(
|
|
171
|
+
message: string,
|
|
172
|
+
public readonly retryAfterMs?: number,
|
|
173
|
+
) {
|
|
174
|
+
super(message);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Manages webhook emission with queuing, retry logic, and exponential backoff.
|
|
180
|
+
*
|
|
181
|
+
* The WebhookEmitter handles sending HTTP POST requests to webhook callbacks when
|
|
182
|
+
* a CoValue changes. It implements several key behaviors:
|
|
183
|
+
*
|
|
184
|
+
*/
|
|
185
|
+
class WebhookEmitter {
|
|
186
|
+
successMap: Promise<SuccessMap> | undefined;
|
|
187
|
+
loadedSuccessMap: SuccessMap | undefined;
|
|
188
|
+
pending = new Map<
|
|
189
|
+
TxIdKey,
|
|
190
|
+
{ nRetries: number; timeout?: ReturnType<typeof setTimeout> }
|
|
191
|
+
>();
|
|
192
|
+
unsubscribe: () => void;
|
|
193
|
+
|
|
194
|
+
constructor(
|
|
195
|
+
private webhook: WebhookRegistration,
|
|
196
|
+
private options: JazzWebhookOptions = {},
|
|
197
|
+
) {
|
|
198
|
+
const unsubscribeWebhook = this.webhook.$jazz.subscribe((webhook) => {
|
|
199
|
+
this.webhook = webhook;
|
|
200
|
+
});
|
|
201
|
+
const unsubscribeValue = this.webhook.$jazz.localNode.subscribe(
|
|
202
|
+
this.webhook.coValueId as CoID<RawCoValue>,
|
|
203
|
+
async (value) => {
|
|
204
|
+
if (value === "unavailable") {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!this.webhook.active) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const todo = getTransactionsToTry(
|
|
211
|
+
await this.loadSuccessMap(),
|
|
212
|
+
value.core.knownState(),
|
|
213
|
+
);
|
|
214
|
+
for (const txID of todo) {
|
|
215
|
+
if (!this.pending.has(getTxIdKey(txID))) {
|
|
216
|
+
this.makeAttempt(txID);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
this.unsubscribe = () => {
|
|
223
|
+
unsubscribeWebhook();
|
|
224
|
+
unsubscribeValue();
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async loadSuccessMap() {
|
|
229
|
+
if (this.loadedSuccessMap) {
|
|
230
|
+
return this.loadedSuccessMap;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!this.successMap) {
|
|
234
|
+
this.successMap = this.webhook.$jazz
|
|
235
|
+
.ensureLoaded({ resolve: { successMap: true } })
|
|
236
|
+
.then((loaded) => loaded.successMap);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.loadedSuccessMap = await this.successMap;
|
|
240
|
+
return this.loadedSuccessMap;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
getRetryDelay(nRetries: number) {
|
|
244
|
+
const baseDelayMs =
|
|
245
|
+
this.options.baseDelayMs || DEFAULT_JazzWebhookOptions.baseDelayMs;
|
|
246
|
+
|
|
247
|
+
return baseDelayMs * 2 ** nRetries;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
getMaxRetries() {
|
|
251
|
+
return this.options.maxRetries || DEFAULT_JazzWebhookOptions.maxRetries;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
stop() {
|
|
255
|
+
this.pending.forEach((entry) => {
|
|
256
|
+
clearTimeout(entry.timeout);
|
|
257
|
+
});
|
|
258
|
+
this.pending.clear();
|
|
259
|
+
this.unsubscribe();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
makeAttempt(txID: CojsonInternalTypes.TransactionID) {
|
|
263
|
+
const txIdKey = getTxIdKey(txID);
|
|
264
|
+
let entry = this.pending.get(txIdKey);
|
|
265
|
+
|
|
266
|
+
if (entry && entry.nRetries >= this.getMaxRetries()) {
|
|
267
|
+
// TODO: should we track failed transactions?
|
|
268
|
+
this.pending.delete(txIdKey);
|
|
269
|
+
clearTimeout(entry.timeout);
|
|
270
|
+
console.error(
|
|
271
|
+
`Max retries reached for txID: ${txIdKey} on webhook: ${this.webhook.$jazz.id}`,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (entry) {
|
|
277
|
+
entry.nRetries++;
|
|
278
|
+
clearTimeout(entry.timeout);
|
|
279
|
+
} else {
|
|
280
|
+
entry = { nRetries: 0 };
|
|
281
|
+
this.pending.set(txIdKey, entry);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const scheduleRetry = (delayMs?: number) => {
|
|
285
|
+
const delay = delayMs ?? this.getRetryDelay(entry?.nRetries || 0);
|
|
286
|
+
|
|
287
|
+
entry.timeout = setTimeout(() => {
|
|
288
|
+
this.makeAttempt(txID);
|
|
289
|
+
}, delay);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
fetch(this.webhook.webhookUrl, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
"Content-Type": "application/json",
|
|
296
|
+
},
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
coValueId: this.webhook.coValueId,
|
|
299
|
+
txID: txID,
|
|
300
|
+
}),
|
|
301
|
+
})
|
|
302
|
+
.then(async (response) => {
|
|
303
|
+
if (response.ok) {
|
|
304
|
+
const successMap = await this.loadSuccessMap();
|
|
305
|
+
markSuccessful(successMap, txID);
|
|
306
|
+
this.pending.delete(txIdKey);
|
|
307
|
+
clearTimeout(entry.timeout);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let retryAfterMs: number | undefined;
|
|
312
|
+
if (response.headers.has("Retry-After")) {
|
|
313
|
+
try {
|
|
314
|
+
retryAfterMs =
|
|
315
|
+
parseFloat(response.headers.get("Retry-After")!) * 1000;
|
|
316
|
+
} catch {
|
|
317
|
+
console.warn(
|
|
318
|
+
`Invalid Retry-After header: ${response.headers.get("Retry-After")}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
throw new WebhookError(
|
|
323
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
324
|
+
retryAfterMs,
|
|
325
|
+
);
|
|
326
|
+
})
|
|
327
|
+
.catch((error) => {
|
|
328
|
+
scheduleRetry(
|
|
329
|
+
error instanceof WebhookError ? error.retryAfterMs : undefined,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const registerWebhook = async (options: {
|
|
336
|
+
webhookUrl: string;
|
|
337
|
+
coValueId: string;
|
|
338
|
+
registryId?: string;
|
|
339
|
+
}): Promise<string> => {
|
|
340
|
+
const { webhookUrl, coValueId, registryId } = {
|
|
341
|
+
registryId: process.env.JAZZ_WEBHOOK_REGISTRY_ID,
|
|
342
|
+
...options,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (!registryId) {
|
|
346
|
+
throw new Error("Invalid webhook secret");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
new URL(webhookUrl);
|
|
351
|
+
} catch {
|
|
352
|
+
throw new Error(`Invalid webhook URL: ${webhookUrl}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!registryId.startsWith("co_z")) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Invalid Registry ID format: ${coValueId}. Expected format: co_z...`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!coValueId.startsWith("co_z")) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Invalid CoValue ID format: ${coValueId}. Expected format: co_z...`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const registry = await RegistryState.load(registryId);
|
|
368
|
+
|
|
369
|
+
if (!registry) {
|
|
370
|
+
throw new Error(`Couldn't load registry with ID ${registryId}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const registration = WebhookRegistration.create(
|
|
374
|
+
{
|
|
375
|
+
webhookUrl,
|
|
376
|
+
coValueId,
|
|
377
|
+
active: true,
|
|
378
|
+
successMap: SuccessMap.create({}, registry.$jazz.owner),
|
|
379
|
+
},
|
|
380
|
+
registry.$jazz.owner,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
registry.$jazz.set(registration.$jazz.id, registration);
|
|
384
|
+
|
|
385
|
+
await Account.getMe().$jazz.waitForAllCoValuesSync();
|
|
386
|
+
return registration.$jazz.id;
|
|
387
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"target": "ES2020",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["./src/**/*"]
|
|
17
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineProject } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineProject({
|
|
4
|
+
test: {
|
|
5
|
+
name: "jazz-webhook",
|
|
6
|
+
include: ["src/**/*.test.{js,ts}"],
|
|
7
|
+
setupFiles: ["src/test/setup.ts"],
|
|
8
|
+
testTimeout: 30000, // Increased timeout for webhook tests with real HTTP server
|
|
9
|
+
typecheck: {
|
|
10
|
+
enabled: true,
|
|
11
|
+
checker: "tsc",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|