@hazem3500/palette 0.9.111 → 0.9.112

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.
@@ -19,16 +19,11 @@ export type WebhookEvent = {
19
19
  selector: string;
20
20
  author: string;
21
21
  body: string | null;
22
+ snapshotStatus?: "pending" | "skipped";
22
23
  };
23
24
 
24
- type WebhookEnv = {
25
- DB: {
26
- prepare(sql: string): {
27
- bind(...values: unknown[]): {
28
- first<T>(): Promise<T | null>;
29
- };
30
- };
31
- };
25
+ export type WebhookEnv = {
26
+ DB: D1Database;
32
27
  PALETTE_WEBHOOK_KEYS?: string;
33
28
  PALETTE_WEBHOOK_SEND?: (
34
29
  url: string,
@@ -163,9 +158,11 @@ export async function signWebhook(secret: string, timestamp: number, body: strin
163
158
  export async function dispatchWebhook(
164
159
  env: WebhookEnv,
165
160
  project: string,
166
- event: WebhookEvent
161
+ event: WebhookEvent,
162
+ destination?: { webhook_url: string; webhook_secret: string; webhook_events: string; webhook_enabled: number },
163
+ storedBody?: string
167
164
  ) {
168
- const row = await env.DB.prepare(
165
+ const row = destination ?? await env.DB.prepare(
169
166
  "SELECT webhook_url,webhook_secret,webhook_events,webhook_enabled FROM project_settings WHERE project=?"
170
167
  )
171
168
  .bind(project)
@@ -179,8 +176,8 @@ export async function dispatchWebhook(
179
176
  const events = JSON.parse(row.webhook_events ?? "[]") as unknown;
180
177
  if (!Array.isArray(events) || !events.includes(event.event)) return;
181
178
  if (!env.PALETTE_WEBHOOK_SEND) return;
182
- const body = JSON.stringify(event);
183
- if (encoder.encode(body).length > 16 * 1024) return;
179
+ const body = storedBody ?? JSON.stringify(event);
180
+ if (encoder.encode(body).length > 16 * 1024) throw Error("Webhook payload exceeds delivery limit");
184
181
  const timestamp = Math.floor(Date.now() / 1000);
185
182
  const secret = await decryptWebhookSecret(
186
183
  env.PALETTE_WEBHOOK_KEYS,
@@ -188,7 +185,7 @@ export async function dispatchWebhook(
188
185
  row.webhook_secret
189
186
  );
190
187
  const signature = await signWebhook(secret, timestamp, body);
191
- await env.PALETTE_WEBHOOK_SEND(row.webhook_url, body, {
188
+ await env.PALETTE_WEBHOOK_SEND(validateWebhookUrl(row.webhook_url), body, {
192
189
  "X-Palette-Delivery": event.id,
193
190
  "X-Palette-Event": event.event,
194
191
  "X-Palette-Signature": signature,
@@ -208,3 +205,91 @@ function fromBase64(value: string) {
208
205
  const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
209
206
  return Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0));
210
207
  }
208
+
209
+ export function enqueueWebhook(
210
+ db: D1Database,
211
+ project: string,
212
+ event: WebhookEvent,
213
+ commentId: string,
214
+ ) {
215
+ return db
216
+ .prepare(
217
+ `INSERT INTO webhook_outbox(id,project,body,attempts,next_attempt,claim_until,claim_token,state,url,secret) SELECT ?,?,?,0,?,0,'','pending',webhook_url,webhook_secret FROM project_settings WHERE EXISTS (SELECT 1 FROM comments WHERE id=?) AND project=? AND webhook_enabled=1 AND webhook_url IS NOT NULL AND webhook_secret IS NOT NULL AND webhook_events LIKE ?`,
218
+ )
219
+ .bind(
220
+ event.id,
221
+ project,
222
+ JSON.stringify(event),
223
+ Date.now(),
224
+ commentId,
225
+ project,
226
+ `%"${event.event}"%`,
227
+ );
228
+ }
229
+
230
+ /** A bounded drain; run periodically even when no requests arrive. */
231
+ export async function drainWebhooks(env: WebhookEnv) {
232
+ if (!env.PALETTE_WEBHOOK_SEND) return;
233
+ for (let count = 0; count < 20; count++) {
234
+ const now = Date.now();
235
+ const token = crypto.randomUUID();
236
+ const row = await env.DB.prepare(
237
+ `UPDATE webhook_outbox SET claim_token=?,claim_until=?,attempts=attempts+1 WHERE id=(SELECT id FROM webhook_outbox WHERE state='pending' AND next_attempt<=? AND claim_until<=? AND attempts<6 ORDER BY next_attempt,id LIMIT 1) AND state='pending' AND claim_until<=? AND attempts<6 RETURNING id,project,body,attempts,url,secret`,
238
+ )
239
+ .bind(token, now + 30000, now, now, now)
240
+ .first<{
241
+ id: string;
242
+ project: string;
243
+ body: string;
244
+ attempts: number;
245
+ url: string;
246
+ secret: string;
247
+ }>();
248
+ if (!row) break;
249
+ let state = "delivered";
250
+ let timer: ReturnType<typeof setTimeout> | undefined;
251
+ try {
252
+ await Promise.race([
253
+ dispatchWebhook(
254
+ env,
255
+ row.project,
256
+ JSON.parse(row.body) as WebhookEvent,
257
+ {
258
+ webhook_url: row.url,
259
+ webhook_secret: row.secret,
260
+ webhook_events: JSON.stringify([
261
+ (JSON.parse(row.body) as WebhookEvent).event,
262
+ ]),
263
+ webhook_enabled: 1,
264
+ },
265
+ row.body,
266
+ ),
267
+ new Promise<never>((_, reject) => {
268
+ timer = setTimeout(
269
+ () => reject(Error("Webhook send timed out")),
270
+ 5000,
271
+ );
272
+ }),
273
+ ]);
274
+ } catch {
275
+ state = row.attempts >= 6 ? "exhausted" : "pending";
276
+ } finally {
277
+ clearTimeout(timer);
278
+ }
279
+ await env.DB.prepare(
280
+ `UPDATE webhook_outbox SET state=?,next_attempt=?,claim_until=0,claim_token='' WHERE id=? AND claim_token=?`,
281
+ )
282
+ .bind(
283
+ state,
284
+ Date.now() + 10000 * 2 ** (row.attempts - 1),
285
+ row.id,
286
+ token,
287
+ )
288
+ .run();
289
+ }
290
+ await env.DB.prepare(
291
+ `UPDATE webhook_outbox SET state='exhausted' WHERE state='pending' AND attempts>=6 AND claim_until<=?`,
292
+ )
293
+ .bind(Date.now())
294
+ .run();
295
+ }