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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +16 -0
  3. package/LICENSE.txt +19 -0
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/successMap.d.ts +13 -0
  9. package/dist/successMap.d.ts.map +1 -0
  10. package/dist/successMap.js +32 -0
  11. package/dist/successMap.js.map +1 -0
  12. package/dist/test/http-server.d.ts +73 -0
  13. package/dist/test/http-server.d.ts.map +1 -0
  14. package/dist/test/http-server.js +177 -0
  15. package/dist/test/http-server.js.map +1 -0
  16. package/dist/test/setup.d.ts +2 -0
  17. package/dist/test/setup.d.ts.map +1 -0
  18. package/dist/test/setup.js +11 -0
  19. package/dist/test/setup.js.map +1 -0
  20. package/dist/test/successMap.test.d.ts +2 -0
  21. package/dist/test/successMap.test.d.ts.map +1 -0
  22. package/dist/test/successMap.test.js +172 -0
  23. package/dist/test/successMap.test.js.map +1 -0
  24. package/dist/test/webhook.test.d.ts +2 -0
  25. package/dist/test/webhook.test.d.ts.map +1 -0
  26. package/dist/test/webhook.test.js +356 -0
  27. package/dist/test/webhook.test.js.map +1 -0
  28. package/dist/types.d.ts +87 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +15 -0
  31. package/dist/types.js.map +1 -0
  32. package/dist/webhook.d.ts +67 -0
  33. package/dist/webhook.d.ts.map +1 -0
  34. package/dist/webhook.js +281 -0
  35. package/dist/webhook.js.map +1 -0
  36. package/package.json +34 -0
  37. package/src/index.ts +2 -0
  38. package/src/successMap.ts +55 -0
  39. package/src/test/http-server.ts +233 -0
  40. package/src/test/setup.ts +12 -0
  41. package/src/test/successMap.test.ts +215 -0
  42. package/src/test/webhook.test.ts +586 -0
  43. package/src/types.ts +106 -0
  44. package/src/webhook.ts +387 -0
  45. package/tsconfig.json +17 -0
  46. 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
+ }
@@ -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
+ });