@automate.ax/integration-contracts 0.107.0 → 0.109.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.
@@ -34,7 +34,28 @@ const AIRTABLE_VIEW_CHANGE_SCHEMA = z.looseObject({
34
34
  .optional(),
35
35
  destroyedRecordIds: z.string().array().optional(),
36
36
  })
37
+ const AIRTABLE_FIELD_DEFINITION_SCHEMA = z.object({
38
+ name: z.string(),
39
+ type: z.string(),
40
+ })
41
+ const AIRTABLE_FIELD_CHANGE_SCHEMA = z.looseObject({
42
+ current: AIRTABLE_FIELD_DEFINITION_SCHEMA.partial(),
43
+ previous: AIRTABLE_FIELD_DEFINITION_SCHEMA.partial().optional(),
44
+ })
45
+ const AIRTABLE_TABLE_METADATA_SCHEMA = z.object({
46
+ description: z.string().nullable(),
47
+ name: z.string(),
48
+ })
37
49
  const AIRTABLE_TABLE_CHANGE_SCHEMA = z.looseObject({
50
+ changedFieldsById: z
51
+ .record(z.string(), AIRTABLE_FIELD_CHANGE_SCHEMA)
52
+ .optional(),
53
+ changedMetadata: z
54
+ .looseObject({
55
+ current: AIRTABLE_TABLE_METADATA_SCHEMA.partial(),
56
+ previous: AIRTABLE_TABLE_METADATA_SCHEMA.partial(),
57
+ })
58
+ .optional(),
38
59
  changedRecordsById: z
39
60
  .record(z.string(), AIRTABLE_CHANGED_RECORD_SCHEMA)
40
61
  .optional(),
@@ -44,8 +65,17 @@ const AIRTABLE_TABLE_CHANGE_SCHEMA = z.looseObject({
44
65
  createdRecordsById: z
45
66
  .record(z.string(), AIRTABLE_CREATED_RECORD_SCHEMA)
46
67
  .optional(),
68
+ createdFieldsById: z
69
+ .record(z.string(), AIRTABLE_FIELD_DEFINITION_SCHEMA)
70
+ .optional(),
71
+ destroyedFieldIds: z.string().array().optional(),
47
72
  destroyedRecordIds: z.string().array().optional(),
48
73
  })
74
+ const AIRTABLE_CREATED_TABLE_SCHEMA = z.looseObject({
75
+ fieldsById: z.record(z.string(), AIRTABLE_FIELD_DEFINITION_SCHEMA).optional(),
76
+ metadata: AIRTABLE_TABLE_METADATA_SCHEMA.optional(),
77
+ recordsById: z.record(z.string(), AIRTABLE_CREATED_RECORD_SCHEMA).optional(),
78
+ })
49
79
 
50
80
  /** One provider-native Airtable webhook transaction payload. */
51
81
  export const AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA = z
@@ -55,13 +85,84 @@ export const AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA = z
55
85
  changedTablesById: z
56
86
  .record(z.string(), AIRTABLE_TABLE_CHANGE_SCHEMA)
57
87
  .optional(),
88
+ createdTablesById: z
89
+ .record(z.string(), AIRTABLE_CREATED_TABLE_SCHEMA)
90
+ .optional(),
58
91
  code: z.string().optional(),
59
92
  error: z.literal(true).optional(),
93
+ destroyedTableIds: z.string().array().optional(),
60
94
  payloadFormat: z.string(),
61
95
  timestamp: z.iso.datetime({ offset: true }),
62
96
  })
63
97
  .catchall(z.json())
64
98
 
99
+ export const AIRTABLE_BASE_EVENT_SCHEMA = z.object({
100
+ /** Airtable base that emitted the transaction. */
101
+ baseId: z.string(),
102
+
103
+ /** Complete provider webhook transaction. */
104
+ event: AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA,
105
+
106
+ /** Time at which Airtable committed the transaction. */
107
+ occurredAt: z.iso.datetime({ offset: true }),
108
+
109
+ /** Airtable system that caused the transaction. */
110
+ source: z.string(),
111
+
112
+ /** Provider-specific actor or source details. */
113
+ sourceMetadata: z.record(z.string(), z.json()).optional(),
114
+
115
+ /** Base-scoped transaction number assigned by Airtable. */
116
+ transactionNumber: z.number().int().nonnegative(),
117
+ })
118
+
119
+ const AIRTABLE_TABLE_EVENT_SCHEMA = AIRTABLE_BASE_EVENT_SCHEMA.extend({
120
+ /** Stable Airtable table ID. */
121
+ tableId: z.string(),
122
+ })
123
+
124
+ export const AIRTABLE_TABLE_CREATED_EVENT_SCHEMA =
125
+ AIRTABLE_TABLE_EVENT_SCHEMA.extend({
126
+ /** Field definitions keyed by stable field ID. */
127
+ fields: z.record(z.string(), AIRTABLE_FIELD_DEFINITION_SCHEMA),
128
+
129
+ /** Created table metadata when included by Airtable. */
130
+ metadata: AIRTABLE_TABLE_METADATA_SCHEMA.optional(),
131
+ })
132
+
133
+ export const AIRTABLE_TABLE_UPDATED_EVENT_SCHEMA =
134
+ AIRTABLE_TABLE_EVENT_SCHEMA.extend({
135
+ /** Current table metadata fields changed by the transaction. */
136
+ current: AIRTABLE_TABLE_METADATA_SCHEMA.partial(),
137
+
138
+ /** Previous table metadata fields changed by the transaction. */
139
+ previous: AIRTABLE_TABLE_METADATA_SCHEMA.partial(),
140
+ })
141
+
142
+ export const AIRTABLE_TABLE_DELETED_EVENT_SCHEMA = AIRTABLE_TABLE_EVENT_SCHEMA
143
+
144
+ const AIRTABLE_FIELD_EVENT_SCHEMA = AIRTABLE_TABLE_EVENT_SCHEMA.extend({
145
+ /** Stable Airtable field ID. */
146
+ fieldId: z.string(),
147
+ })
148
+
149
+ export const AIRTABLE_FIELD_CREATED_EVENT_SCHEMA =
150
+ AIRTABLE_FIELD_EVENT_SCHEMA.extend({
151
+ /** Created field definition. */
152
+ field: AIRTABLE_FIELD_DEFINITION_SCHEMA,
153
+ })
154
+
155
+ export const AIRTABLE_FIELD_UPDATED_EVENT_SCHEMA =
156
+ AIRTABLE_FIELD_EVENT_SCHEMA.extend({
157
+ /** Current field definition values changed by the transaction. */
158
+ current: AIRTABLE_FIELD_DEFINITION_SCHEMA.partial(),
159
+
160
+ /** Previous field definition values changed by the transaction. */
161
+ previous: AIRTABLE_FIELD_DEFINITION_SCHEMA.partial().optional(),
162
+ })
163
+
164
+ export const AIRTABLE_FIELD_DELETED_EVENT_SCHEMA = AIRTABLE_FIELD_EVENT_SCHEMA
165
+
65
166
  const AIRTABLE_RECORD_EVENT_SCHEMA = z.object({
66
167
  /** Airtable base containing the affected record. */
67
168
  baseId: z.string(),
@@ -123,7 +224,14 @@ export const AIRTABLE_RECORD_MOVED_OUT_OF_VIEW_EVENT_SCHEMA =
123
224
  viewId: z.string(),
124
225
  })
125
226
 
126
- export const AIRTABLE_RECORD_EVENT_TYPES = [
227
+ export const AIRTABLE_EVENT_TYPES = [
228
+ "airtable.base.event",
229
+ "airtable.table.created",
230
+ "airtable.table.updated",
231
+ "airtable.table.deleted",
232
+ "airtable.field.created",
233
+ "airtable.field.updated",
234
+ "airtable.field.deleted",
127
235
  "airtable.record.created",
128
236
  "airtable.record.updated",
129
237
  "airtable.record.deleted",
@@ -131,77 +239,206 @@ export const AIRTABLE_RECORD_EVENT_TYPES = [
131
239
  "airtable.record.movedOutOfView",
132
240
  ] as const
133
241
 
134
- export type AirtableRecordEventType =
135
- (typeof AIRTABLE_RECORD_EVENT_TYPES)[number]
242
+ export type AirtableEventType = (typeof AIRTABLE_EVENT_TYPES)[number]
136
243
 
137
244
  /**
138
- * Expands one Airtable webhook transaction into semantic record events.
245
+ * Expands one Airtable webhook transaction into broad and semantic events.
139
246
  *
140
247
  * @param value - Untrusted provider payload returned by Airtable.
141
248
  * @param baseId - Stable ID of the base that owns the webhook.
142
249
  */
143
- export function normalizeAirtableRecordEvents(value: unknown, baseId: string) {
250
+ export function normalizeAirtableEvents(value: unknown, baseId: string) {
144
251
  const event = AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA.parse(value)
145
252
 
146
- return Object.entries(event.changedTablesById ?? {}).flatMap(
147
- ([tableId, table]) => [
148
- ...Object.entries(table.createdRecordsById ?? {}).map(
149
- ([recordId, record]) => ({
150
- eventType: "airtable.record.created" as const,
151
- payload: AIRTABLE_RECORD_CREATED_EVENT_SCHEMA.parse({
152
- ...recordEventBase(event, baseId, tableId, recordId),
153
- createdAt: record.createdTime,
154
- fields: record.cellValuesByFieldId,
253
+ return [
254
+ {
255
+ eventType: "airtable.base.event" as const,
256
+ payload: AIRTABLE_BASE_EVENT_SCHEMA.parse(baseEventBase(event, baseId)),
257
+ },
258
+ ...Object.entries(event.createdTablesById ?? {}).flatMap(
259
+ ([tableId, table]) => [
260
+ {
261
+ eventType: "airtable.table.created" as const,
262
+ payload: AIRTABLE_TABLE_CREATED_EVENT_SCHEMA.parse({
263
+ ...tableEventBase(event, baseId, tableId),
264
+ fields: table.fieldsById ?? {},
265
+ metadata: table.metadata,
155
266
  }),
156
- }),
157
- ),
158
- ...Object.entries(table.changedRecordsById ?? {}).map(
159
- ([recordId, record]) => ({
160
- eventType: "airtable.record.updated" as const,
161
- payload: AIRTABLE_RECORD_UPDATED_EVENT_SCHEMA.parse({
162
- ...recordEventBase(event, baseId, tableId, recordId),
163
- changedFieldIds: Object.keys(record.current.cellValuesByFieldId),
164
- fields: {
165
- ...record.unchanged?.cellValuesByFieldId,
166
- ...record.current.cellValuesByFieldId,
167
- },
168
- previousFields: {
169
- ...record.unchanged?.cellValuesByFieldId,
170
- ...record.previous?.cellValuesByFieldId,
171
- },
267
+ },
268
+ ...Object.entries(table.fieldsById ?? {}).map(([fieldId, field]) => ({
269
+ eventType: "airtable.field.created" as const,
270
+ payload: AIRTABLE_FIELD_CREATED_EVENT_SCHEMA.parse({
271
+ ...fieldEventBase(event, baseId, tableId, fieldId),
272
+ field,
172
273
  }),
173
- }),
274
+ })),
275
+ ...Object.entries(table.recordsById ?? {}).map(
276
+ ([recordId, record]) => ({
277
+ eventType: "airtable.record.created" as const,
278
+ payload: AIRTABLE_RECORD_CREATED_EVENT_SCHEMA.parse({
279
+ ...recordEventBase(event, baseId, tableId, recordId),
280
+ createdAt: record.createdTime,
281
+ fields: record.cellValuesByFieldId,
282
+ }),
283
+ }),
284
+ ),
285
+ ],
286
+ ),
287
+ ...(event.destroyedTableIds ?? []).map((tableId) => ({
288
+ eventType: "airtable.table.deleted" as const,
289
+ payload: AIRTABLE_TABLE_DELETED_EVENT_SCHEMA.parse(
290
+ tableEventBase(event, baseId, tableId),
174
291
  ),
175
- ...(table.destroyedRecordIds ?? []).map((recordId) => ({
176
- eventType: "airtable.record.deleted" as const,
177
- payload: AIRTABLE_RECORD_DELETED_EVENT_SCHEMA.parse(
178
- recordEventBase(event, baseId, tableId, recordId),
292
+ })),
293
+ ...Object.entries(event.changedTablesById ?? {}).flatMap(
294
+ ([tableId, table]) => [
295
+ ...(table.changedMetadata
296
+ ? [
297
+ {
298
+ eventType: "airtable.table.updated" as const,
299
+ payload: AIRTABLE_TABLE_UPDATED_EVENT_SCHEMA.parse({
300
+ ...tableEventBase(event, baseId, tableId),
301
+ current: table.changedMetadata.current,
302
+ previous: table.changedMetadata.previous,
303
+ }),
304
+ },
305
+ ]
306
+ : []),
307
+ ...Object.entries(table.createdFieldsById ?? {}).map(
308
+ ([fieldId, field]) => ({
309
+ eventType: "airtable.field.created" as const,
310
+ payload: AIRTABLE_FIELD_CREATED_EVENT_SCHEMA.parse({
311
+ ...fieldEventBase(event, baseId, tableId, fieldId),
312
+ field,
313
+ }),
314
+ }),
179
315
  ),
180
- })),
181
- ...Object.entries(table.changedViewsById ?? {}).flatMap(
182
- ([viewId, view]) => [
183
- ...Object.entries(view.createdRecordsById ?? {}).map(
184
- ([recordId, record]) => ({
185
- eventType: "airtable.record.movedIntoView" as const,
186
- payload: AIRTABLE_RECORD_MOVED_INTO_VIEW_EVENT_SCHEMA.parse({
187
- ...recordEventBase(event, baseId, tableId, recordId),
188
- createdAt: record.createdTime,
189
- fields: record.cellValuesByFieldId,
190
- viewId,
191
- }),
316
+ ...Object.entries(table.changedFieldsById ?? {}).map(
317
+ ([fieldId, field]) => ({
318
+ eventType: "airtable.field.updated" as const,
319
+ payload: AIRTABLE_FIELD_UPDATED_EVENT_SCHEMA.parse({
320
+ ...fieldEventBase(event, baseId, tableId, fieldId),
321
+ current: field.current,
322
+ previous: field.previous,
192
323
  }),
324
+ }),
325
+ ),
326
+ ...(table.destroyedFieldIds ?? []).map((fieldId) => ({
327
+ eventType: "airtable.field.deleted" as const,
328
+ payload: AIRTABLE_FIELD_DELETED_EVENT_SCHEMA.parse(
329
+ fieldEventBase(event, baseId, tableId, fieldId),
193
330
  ),
194
- ...(view.destroyedRecordIds ?? []).map((recordId) => ({
195
- eventType: "airtable.record.movedOutOfView" as const,
196
- payload: AIRTABLE_RECORD_MOVED_OUT_OF_VIEW_EVENT_SCHEMA.parse({
331
+ })),
332
+ ...Object.entries(table.createdRecordsById ?? {}).map(
333
+ ([recordId, record]) => ({
334
+ eventType: "airtable.record.created" as const,
335
+ payload: AIRTABLE_RECORD_CREATED_EVENT_SCHEMA.parse({
197
336
  ...recordEventBase(event, baseId, tableId, recordId),
198
- viewId,
337
+ createdAt: record.createdTime,
338
+ fields: record.cellValuesByFieldId,
199
339
  }),
200
- })),
201
- ],
202
- ),
203
- ],
204
- )
340
+ }),
341
+ ),
342
+ ...Object.entries(table.changedRecordsById ?? {}).map(
343
+ ([recordId, record]) => ({
344
+ eventType: "airtable.record.updated" as const,
345
+ payload: AIRTABLE_RECORD_UPDATED_EVENT_SCHEMA.parse({
346
+ ...recordEventBase(event, baseId, tableId, recordId),
347
+ changedFieldIds: Object.keys(record.current.cellValuesByFieldId),
348
+ fields: {
349
+ ...record.unchanged?.cellValuesByFieldId,
350
+ ...record.current.cellValuesByFieldId,
351
+ },
352
+ previousFields: {
353
+ ...record.unchanged?.cellValuesByFieldId,
354
+ ...record.previous?.cellValuesByFieldId,
355
+ },
356
+ }),
357
+ }),
358
+ ),
359
+ ...(table.destroyedRecordIds ?? []).map((recordId) => ({
360
+ eventType: "airtable.record.deleted" as const,
361
+ payload: AIRTABLE_RECORD_DELETED_EVENT_SCHEMA.parse(
362
+ recordEventBase(event, baseId, tableId, recordId),
363
+ ),
364
+ })),
365
+ ...Object.entries(table.changedViewsById ?? {}).flatMap(
366
+ ([viewId, view]) => [
367
+ ...Object.entries(view.createdRecordsById ?? {}).map(
368
+ ([recordId, record]) => ({
369
+ eventType: "airtable.record.movedIntoView" as const,
370
+ payload: AIRTABLE_RECORD_MOVED_INTO_VIEW_EVENT_SCHEMA.parse({
371
+ ...recordEventBase(event, baseId, tableId, recordId),
372
+ createdAt: record.createdTime,
373
+ fields: record.cellValuesByFieldId,
374
+ viewId,
375
+ }),
376
+ }),
377
+ ),
378
+ ...(view.destroyedRecordIds ?? []).map((recordId) => ({
379
+ eventType: "airtable.record.movedOutOfView" as const,
380
+ payload: AIRTABLE_RECORD_MOVED_OUT_OF_VIEW_EVENT_SCHEMA.parse({
381
+ ...recordEventBase(event, baseId, tableId, recordId),
382
+ viewId,
383
+ }),
384
+ })),
385
+ ],
386
+ ),
387
+ ],
388
+ ),
389
+ ]
390
+ }
391
+
392
+ /**
393
+ * Builds fields shared by every event in one Airtable transaction.
394
+ *
395
+ * @param event - Parsed provider transaction.
396
+ * @param baseId - Stable Airtable base ID.
397
+ */
398
+ function baseEventBase(
399
+ event: z.output<typeof AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA>,
400
+ baseId: string,
401
+ ) {
402
+ return {
403
+ baseId,
404
+ event,
405
+ occurredAt: event.timestamp,
406
+ source: event.actionMetadata.source,
407
+ sourceMetadata: event.actionMetadata.sourceMetadata,
408
+ transactionNumber: event.baseTransactionNumber,
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Adds a stable table ID to the shared Airtable event fields.
414
+ *
415
+ * @param event - Parsed provider transaction.
416
+ * @param baseId - Stable Airtable base ID.
417
+ * @param tableId - Stable Airtable table ID.
418
+ */
419
+ function tableEventBase(
420
+ event: z.output<typeof AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA>,
421
+ baseId: string,
422
+ tableId: string,
423
+ ) {
424
+ return { ...baseEventBase(event, baseId), tableId }
425
+ }
426
+
427
+ /**
428
+ * Adds stable table and field IDs to the shared Airtable event fields.
429
+ *
430
+ * @param event - Parsed provider transaction.
431
+ * @param baseId - Stable Airtable base ID.
432
+ * @param tableId - Stable Airtable table ID.
433
+ * @param fieldId - Stable Airtable field ID.
434
+ */
435
+ function fieldEventBase(
436
+ event: z.output<typeof AIRTABLE_WEBHOOK_PAYLOAD_SCHEMA>,
437
+ baseId: string,
438
+ tableId: string,
439
+ fieldId: string,
440
+ ) {
441
+ return { ...tableEventBase(event, baseId, tableId), fieldId }
205
442
  }
206
443
 
207
444
  /**
@@ -219,13 +456,7 @@ function recordEventBase(
219
456
  recordId: string,
220
457
  ) {
221
458
  return {
222
- baseId,
223
- event,
224
- occurredAt: event.timestamp,
459
+ ...tableEventBase(event, baseId, tableId),
225
460
  recordId,
226
- source: event.actionMetadata.source,
227
- sourceMetadata: event.actionMetadata.sourceMetadata,
228
- tableId,
229
- transactionNumber: event.baseTransactionNumber,
230
461
  }
231
462
  }
package/src/brevo/api.ts CHANGED
@@ -10,6 +10,64 @@ const BREVO_ERROR_SCHEMA = z.looseObject({
10
10
  message: z.string().optional(),
11
11
  })
12
12
 
13
+ /** Provider rate-limit values returned with a Brevo response. */
14
+ export interface BrevoRateLimitMetadata {
15
+ /** Maximum requests allowed in the current window. */
16
+ limit?: number
17
+
18
+ /** Requests remaining in the current window. */
19
+ remaining?: number
20
+
21
+ /** Seconds until the current window resets. */
22
+ reset?: number
23
+ }
24
+
25
+ /** Structured failure returned by the Brevo API. */
26
+ export class BrevoApiError extends Error {
27
+ /** Provider error classification. */
28
+ readonly code?: string
29
+
30
+ /** API path that failed. */
31
+ readonly path: string
32
+
33
+ /** Provider rate-limit metadata. */
34
+ readonly rateLimit: BrevoRateLimitMetadata
35
+
36
+ /** Delay in seconds before the request should be retried. */
37
+ readonly retryAfter?: number
38
+
39
+ /** HTTP status returned by Brevo. */
40
+ readonly status: number
41
+
42
+ /**
43
+ * Creates a structured Brevo provider error.
44
+ *
45
+ * @param options - Provider and transport failure details.
46
+ * @param options.code - Provider error classification.
47
+ * @param options.message - Human-readable provider failure.
48
+ * @param options.path - API path that failed.
49
+ * @param options.rateLimit - Provider rate-limit metadata.
50
+ * @param options.retryAfter - Provider retry delay in seconds.
51
+ * @param options.status - HTTP status.
52
+ */
53
+ constructor(options: {
54
+ code?: string
55
+ message: string
56
+ path: string
57
+ rateLimit: BrevoRateLimitMetadata
58
+ retryAfter?: number
59
+ status: number
60
+ }) {
61
+ super(`Brevo ${options.path} failed: ${options.message}`)
62
+ this.name = "BrevoApiError"
63
+ this.code = options.code
64
+ this.path = options.path
65
+ this.rateLimit = options.rateLimit
66
+ this.retryAfter = options.retryAfter
67
+ this.status = options.status
68
+ }
69
+ }
70
+
13
71
  /** Options accepted by the authenticated Brevo request helper. */
14
72
  export interface BrevoRequestOptions extends RequestInit {
15
73
  /** Query parameters appended to the request URL. */
@@ -63,14 +121,25 @@ export function getBrevoApi(secret: Record<string, unknown>) {
63
121
  const response = await fetch(url, { ...requestInit, headers })
64
122
  if (response.ok) return response
65
123
 
66
- const error = BREVO_ERROR_SCHEMA.safeParse(
67
- await response.json().catch(() => ({})),
68
- )
69
- throw new Error(
70
- error.success && error.data.message
71
- ? `Brevo API error (${response.status}): ${error.data.message}`
72
- : `Brevo API request failed with status ${response.status}.`,
73
- )
124
+ const responseText = await response.text()
125
+ const error = BREVO_ERROR_SCHEMA.safeParse(parseJson(responseText))
126
+ throw new BrevoApiError({
127
+ code: error.success ? error.data.code : undefined,
128
+ message:
129
+ (error.success ? error.data.message : undefined) ??
130
+ (responseText.trim() || `HTTP ${response.status}`),
131
+ path: `${url.pathname}${url.search}`,
132
+ rateLimit: {
133
+ limit: parseNumberHeader(response.headers, "x-sib-ratelimit-limit"),
134
+ remaining: parseNumberHeader(
135
+ response.headers,
136
+ "x-sib-ratelimit-remaining",
137
+ ),
138
+ reset: parseNumberHeader(response.headers, "x-sib-ratelimit-reset"),
139
+ },
140
+ retryAfter: parseNumberHeader(response.headers, "retry-after"),
141
+ status: response.status,
142
+ })
74
143
  },
75
144
  }
76
145
  }
@@ -87,3 +156,29 @@ export async function parseBrevoResponse<T extends z.ZodType>(
87
156
  ): Promise<z.output<T>> {
88
157
  return schema.parse(await response.json())
89
158
  }
159
+
160
+ /**
161
+ * Parses provider JSON without hiding non-JSON error bodies.
162
+ *
163
+ * @param value - Provider response text.
164
+ */
165
+ function parseJson(value: string): unknown {
166
+ try {
167
+ return JSON.parse(value)
168
+ } catch {
169
+ return undefined
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Parses an optional numeric HTTP header.
175
+ *
176
+ * @param headers - Provider response headers.
177
+ * @param name - Case-insensitive header name.
178
+ */
179
+ function parseNumberHeader(headers: Headers, name: string) {
180
+ const value = headers.get(name)
181
+ if (value === null) return undefined
182
+ const parsed = Number(value)
183
+ return Number.isFinite(parsed) ? parsed : undefined
184
+ }