@simmit/sdk 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/dist/index.js ADDED
@@ -0,0 +1,718 @@
1
+ // src/error.ts
2
+ var SimmitError = class extends Error {
3
+ };
4
+ var APIError = class _APIError extends SimmitError {
5
+ /** HTTP status of the response that caused the error. */
6
+ status;
7
+ /** HTTP headers of the response that caused the error. */
8
+ headers;
9
+ /** Machine-readable `code` from the error envelope. */
10
+ code;
11
+ /** Typed `meta` from the error envelope. */
12
+ meta;
13
+ /** Raw parsed JSON error body — escape hatch for unmapped fields. */
14
+ error;
15
+ constructor(status, body, message, headers) {
16
+ super(_APIError.makeMessage(status, body, message));
17
+ this.status = status;
18
+ this.headers = headers;
19
+ this.error = body;
20
+ const envelope = body;
21
+ this.code = typeof envelope?.code === "string" ? envelope.code : void 0;
22
+ this.meta = body ? envelope?.meta ?? null : void 0;
23
+ }
24
+ static makeMessage(status, body, message) {
25
+ const bodyMessage = body?.error;
26
+ const msg = typeof bodyMessage === "string" ? bodyMessage : body ? JSON.stringify(body) : message;
27
+ if (status && msg) return `${status} ${msg}`;
28
+ if (status) return `${status} status code (no body)`;
29
+ if (msg) return msg;
30
+ return "(no status code or body)";
31
+ }
32
+ /**
33
+ * Maps a response to the most specific error class: status selects the base
34
+ * class; an enumerated `code` with structured `meta` selects the subclass;
35
+ * anything unrecognized falls back to the status class so new server codes
36
+ * degrade gracefully without breaking `instanceof` handling.
37
+ */
38
+ static generate(status, body, message, headers) {
39
+ if (!status || !headers) {
40
+ return new APIConnectionError({
41
+ message,
42
+ cause: body instanceof Error ? body : void 0
43
+ });
44
+ }
45
+ const code = body?.code;
46
+ if (status === 400) return new BadRequestError(400, body, message, headers);
47
+ if (status === 401) {
48
+ return new AuthenticationError(401, body, message, headers);
49
+ }
50
+ if (status === 402) {
51
+ if (code === "insufficient_credits") {
52
+ return new InsufficientCreditsError(402, body, message, headers);
53
+ }
54
+ if (code === "insufficient_credits_liability") {
55
+ return new InsufficientCreditsLiabilityError(
56
+ 402,
57
+ body,
58
+ message,
59
+ headers
60
+ );
61
+ }
62
+ return new BillingError(402, body, message, headers);
63
+ }
64
+ if (status === 404) return new NotFoundError(404, body, message, headers);
65
+ if (status === 409) {
66
+ if (code === "idempotency_key_reuse") {
67
+ return new IdempotencyKeyReuseError(409, body, message, headers);
68
+ }
69
+ if (code === "result_not_ready") {
70
+ return new ResultNotReadyError(409, body, message, headers);
71
+ }
72
+ if (code === "job_not_cancellable") {
73
+ return new JobNotCancellableError(409, body, message, headers);
74
+ }
75
+ return new ConflictError(409, body, message, headers);
76
+ }
77
+ if (status === 413) {
78
+ return new RequestTooLargeError(413, body, message, headers);
79
+ }
80
+ if (status === 422) {
81
+ if (code === "input_sanitized_rejected") {
82
+ return new InvalidProfileError(422, body, message, headers);
83
+ }
84
+ if (code === "result_unavailable") {
85
+ return new ResultUnavailableError(422, body, message, headers);
86
+ }
87
+ return new UnprocessableEntityError(422, body, message, headers);
88
+ }
89
+ if (status === 429) {
90
+ if (code === "max_active_jobs_exceeded") {
91
+ return new MaxActiveJobsError(429, body, message, headers);
92
+ }
93
+ return new RateLimitError(429, body, message, headers);
94
+ }
95
+ if (status === 503 && isServiceUnavailableBody(body)) {
96
+ return new ServiceUnavailableError(503, body, message, headers);
97
+ }
98
+ if (status >= 500) {
99
+ return new InternalServerError(status, body, message, headers);
100
+ }
101
+ return new _APIError(status, body, message, headers);
102
+ }
103
+ };
104
+ var BadRequestError = class extends APIError {
105
+ };
106
+ var AuthenticationError = class extends APIError {
107
+ };
108
+ var BillingError = class extends APIError {
109
+ };
110
+ var InsufficientCreditsError = class extends BillingError {
111
+ };
112
+ var InsufficientCreditsLiabilityError = class extends BillingError {
113
+ };
114
+ var NotFoundError = class extends APIError {
115
+ };
116
+ var ConflictError = class extends APIError {
117
+ };
118
+ var IdempotencyKeyReuseError = class extends ConflictError {
119
+ };
120
+ var ResultNotReadyError = class extends ConflictError {
121
+ };
122
+ var JobNotCancellableError = class extends ConflictError {
123
+ };
124
+ var RequestTooLargeError = class extends APIError {
125
+ };
126
+ var UnprocessableEntityError = class extends APIError {
127
+ };
128
+ var InvalidProfileError = class extends UnprocessableEntityError {
129
+ };
130
+ var ResultUnavailableError = class extends UnprocessableEntityError {
131
+ };
132
+ var RateLimitError = class extends APIError {
133
+ };
134
+ var MaxActiveJobsError = class extends RateLimitError {
135
+ };
136
+ var InternalServerError = class extends APIError {
137
+ };
138
+ var SERVICE_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
139
+ "queue_unavailable",
140
+ "queue_health_unknown",
141
+ "secret_store_unavailable",
142
+ "api_maintenance"
143
+ ]);
144
+ function isServiceUnavailableBody(body) {
145
+ const code = body?.code;
146
+ return typeof code === "string" && SERVICE_UNAVAILABLE_CODES.has(code);
147
+ }
148
+ var ServiceUnavailableError = class extends InternalServerError {
149
+ /** The discriminated 503 envelope: `if (e.body.code === 'api_maintenance') e.body.meta.retryAfterSeconds`. */
150
+ get body() {
151
+ return this.error;
152
+ }
153
+ };
154
+ var APIConnectionError = class extends APIError {
155
+ constructor({
156
+ message,
157
+ cause
158
+ } = {}) {
159
+ super(void 0, void 0, message ?? "Connection error.", void 0);
160
+ if (cause) this.cause = cause;
161
+ }
162
+ };
163
+ var APIConnectionTimeoutError = class extends APIConnectionError {
164
+ constructor({ message } = {}) {
165
+ super({ message: message ?? "Request timed out." });
166
+ }
167
+ };
168
+ var APIUserAbortError = class extends APIError {
169
+ constructor({ message } = {}) {
170
+ super(void 0, void 0, message ?? "Request was aborted.", void 0);
171
+ }
172
+ };
173
+ var JobUnsuccessfulError = class extends SimmitError {
174
+ job;
175
+ constructor(job, message) {
176
+ super(
177
+ message ?? `Job ${job.id} ${job.status}` + (job.statusReason ? `: ${job.statusReason}` : "") + (job.errorCode ? ` (${job.errorCode})` : "")
178
+ );
179
+ this.job = job;
180
+ }
181
+ };
182
+ var JobFailedError = class extends JobUnsuccessfulError {
183
+ };
184
+ var JobCancelledError = class extends JobUnsuccessfulError {
185
+ };
186
+ var JobTimedOutError = class extends JobUnsuccessfulError {
187
+ };
188
+ var JobWaitTimeoutError = class extends SimmitError {
189
+ jobId;
190
+ lastStatus;
191
+ constructor(args) {
192
+ super(
193
+ args.message ?? `Timed out waiting for job ${args.jobId} (last status: ${args.lastStatus}). The job is still running server-side and continues to bill.`
194
+ );
195
+ this.jobId = args.jobId;
196
+ this.lastStatus = args.lastStatus;
197
+ }
198
+ };
199
+ var WebhookVerificationError = class extends SimmitError {
200
+ };
201
+
202
+ // src/api-promise.ts
203
+ var APIPromise = class extends Promise {
204
+ // Chained promises (.then/.catch) must be plain Promises: this class's
205
+ // constructor signature is incompatible with the executor the runtime
206
+ // would otherwise pass via the species constructor.
207
+ static get [Symbol.species]() {
208
+ return Promise;
209
+ }
210
+ #parsed;
211
+ constructor(parsed) {
212
+ super((resolve) => resolve(void 0));
213
+ this.#parsed = parsed;
214
+ }
215
+ then(onfulfilled, onrejected) {
216
+ return this.#parsed.then((result) => result.data).then(onfulfilled, onrejected);
217
+ }
218
+ withResponse() {
219
+ return this.#parsed;
220
+ }
221
+ asResponse() {
222
+ return this.#parsed.then((result) => result.response);
223
+ }
224
+ };
225
+
226
+ // src/internal/abort.ts
227
+ function throwIfUserAborted(signal) {
228
+ if (signal?.aborted) throw new APIUserAbortError();
229
+ }
230
+ function sleep(ms, signal) {
231
+ return new Promise((resolve, reject) => {
232
+ throwIfUserAborted(signal);
233
+ const timeoutId = setTimeout(() => {
234
+ signal?.removeEventListener("abort", onAbort);
235
+ resolve();
236
+ }, ms);
237
+ const onAbort = () => {
238
+ clearTimeout(timeoutId);
239
+ reject(new APIUserAbortError());
240
+ };
241
+ signal?.addEventListener("abort", onAbort, { once: true });
242
+ });
243
+ }
244
+
245
+ // src/internal/request.ts
246
+ var INITIAL_BACKOFF_MS = 500;
247
+ var MAX_BACKOFF_MS = 8e3;
248
+ var MAX_RETRY_AFTER_MS = 6e4;
249
+ function makeRequest(config, spec, options = {}) {
250
+ return new APIPromise(run(config, spec, options));
251
+ }
252
+ async function run(config, spec, options) {
253
+ const maxRetries = options.maxRetries ?? config.maxRetries;
254
+ const timeout = options.timeout ?? config.timeout;
255
+ const headers = buildHeaders(config, spec, options);
256
+ const url = `${config.baseURL.replace(/\/+$/, "")}${spec.path}`;
257
+ const body = spec.body === void 0 ? void 0 : JSON.stringify(spec.body);
258
+ for (let attempt = 0; ; attempt++) {
259
+ throwIfUserAborted(options.signal);
260
+ let result;
261
+ try {
262
+ result = await fetchAttempt(config, spec, options, {
263
+ url,
264
+ headers,
265
+ body,
266
+ timeout
267
+ });
268
+ } catch (err) {
269
+ if (err instanceof APIUserAbortError) throw err;
270
+ if (attempt < maxRetries) {
271
+ await backoff(attempt, void 0, options.signal);
272
+ continue;
273
+ }
274
+ throw err;
275
+ }
276
+ const { response, json } = result;
277
+ if (response.ok) {
278
+ return { data: json, response };
279
+ }
280
+ if (shouldRetryStatus(response.status) && attempt < maxRetries) {
281
+ await backoff(
282
+ attempt,
283
+ response.headers.get("retry-after"),
284
+ options.signal
285
+ );
286
+ continue;
287
+ }
288
+ throw APIError.generate(
289
+ response.status,
290
+ typeof json === "object" && json !== null ? json : void 0,
291
+ response.statusText,
292
+ response.headers
293
+ );
294
+ }
295
+ }
296
+ async function fetchAttempt(config, spec, options, attempt) {
297
+ const controller = new AbortController();
298
+ const timeoutId = setTimeout(() => controller.abort(), attempt.timeout);
299
+ const onUserAbort = () => controller.abort();
300
+ options.signal?.addEventListener("abort", onUserAbort, { once: true });
301
+ try {
302
+ const response = await config.fetch(attempt.url, {
303
+ ...config.fetchOptions,
304
+ method: spec.method,
305
+ headers: attempt.headers,
306
+ ...attempt.body !== void 0 ? { body: attempt.body } : {},
307
+ signal: controller.signal
308
+ });
309
+ let json;
310
+ if (response.ok) {
311
+ json = await response.json();
312
+ } else {
313
+ try {
314
+ json = await response.json();
315
+ } catch (err) {
316
+ if (controller.signal.aborted) throw err;
317
+ json = void 0;
318
+ }
319
+ }
320
+ return { response, json };
321
+ } catch (err) {
322
+ if (options.signal?.aborted) throw new APIUserAbortError();
323
+ if (controller.signal.aborted) {
324
+ throw new APIConnectionTimeoutError();
325
+ }
326
+ throw new APIConnectionError({
327
+ cause: err instanceof Error ? err : void 0
328
+ });
329
+ } finally {
330
+ clearTimeout(timeoutId);
331
+ options.signal?.removeEventListener("abort", onUserAbort);
332
+ }
333
+ }
334
+ function buildHeaders(config, spec, options) {
335
+ const idempotent = spec.idempotent && spec.method === "POST";
336
+ const merged = {
337
+ authorization: `Bearer ${config.secretKey}`,
338
+ ...spec.body !== void 0 ? { "content-type": "application/json" } : {},
339
+ ...idempotent && !options.idempotencyKey ? {
340
+ // Generated once per call and reused across retry attempts — that
341
+ // is what makes POST retries safe by default. The auto
342
+ // key is an SDK built-in default (lowest tier), so defaultHeaders
343
+ // may override it.
344
+ "idempotency-key": `simmit-node-retry-${crypto.randomUUID()}`
345
+ } : {},
346
+ ...lowercaseKeys(config.defaultHeaders),
347
+ ...idempotent && options.idempotencyKey ? {
348
+ // An explicit key is a per-request option: it must beat constructor
349
+ // defaultHeaders. Raw options.headers still wins last.
350
+ "idempotency-key": options.idempotencyKey
351
+ } : {},
352
+ ...lowercaseKeys(options.headers)
353
+ };
354
+ const headers = {};
355
+ for (const [key, value] of Object.entries(merged)) {
356
+ if (typeof value === "string") headers[key] = value;
357
+ }
358
+ return headers;
359
+ }
360
+ function lowercaseKeys(record) {
361
+ if (!record) return {};
362
+ return Object.fromEntries(
363
+ Object.entries(record).map(([key, value]) => [key.toLowerCase(), value])
364
+ );
365
+ }
366
+ function shouldRetryStatus(status) {
367
+ return status === 408 || status === 429 || status >= 500;
368
+ }
369
+ async function backoff(attempt, retryAfterHeader, signal) {
370
+ const retryAfterMs = parseRetryAfter(retryAfterHeader);
371
+ const delay = retryAfterMs !== void 0 ? retryAfterMs : Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS) * (1 - 0.25 * Math.random());
372
+ await sleep(delay, signal);
373
+ }
374
+ function parseRetryAfter(header) {
375
+ if (!header) return void 0;
376
+ let ms;
377
+ if (/^\d+$/.test(header.trim())) {
378
+ ms = Number(header.trim()) * 1e3;
379
+ } else {
380
+ ms = new Date(header).getTime() - Date.now();
381
+ }
382
+ return Number.isFinite(ms) && ms > 0 && ms <= MAX_RETRY_AFTER_MS ? ms : void 0;
383
+ }
384
+
385
+ // src/resources/artifacts.ts
386
+ var Artifacts = class {
387
+ #client;
388
+ constructor(client) {
389
+ this.#client = client;
390
+ }
391
+ /**
392
+ * Fetch a stable public download URL for an artifact, valid for the
393
+ * artifact's full retention window — the same URL `jobs.getResult` returns,
394
+ * fetched on demand (e.g. browser flows that control the final fetch). The
395
+ * artifact is gone (410) once its retention window passes.
396
+ */
397
+ getUrl(artifactId, options) {
398
+ return this.#client._request(
399
+ {
400
+ method: "GET",
401
+ path: `/v1/simc/artifacts/${encodeURIComponent(artifactId)}/url`
402
+ },
403
+ options
404
+ );
405
+ }
406
+ };
407
+
408
+ // src/resources/credits.ts
409
+ var Credits = class {
410
+ #client;
411
+ constructor(client) {
412
+ this.#client = client;
413
+ }
414
+ /** Fetch the account's current credit balance and per-grant breakdown. */
415
+ get(options) {
416
+ return this.#client._request(
417
+ { method: "GET", path: "/v1/simc/credits" },
418
+ options
419
+ );
420
+ }
421
+ };
422
+
423
+ // src/internal/poll.ts
424
+ var MIN_POLL_INTERVAL_MS = 100;
425
+ var DEFAULT_POLL_INTERVAL_MS = 1e3;
426
+ var MAX_POLL_INTERVAL_MS = 1e4;
427
+ var POLL_BACKOFF_FACTOR = 1.5;
428
+ var DEADLINE_GRACE_MS = 6e4;
429
+ var FALLBACK_WAIT_TIMEOUT_MS = 45 * 60 * 1e3;
430
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
431
+ "completed",
432
+ "failed",
433
+ "cancelled",
434
+ "timed_out"
435
+ ]);
436
+ function isTerminal(status) {
437
+ return TERMINAL_STATUSES.has(status);
438
+ }
439
+ function deriveWaitTimeoutMs(created) {
440
+ const { runtimeSeconds, queueSeconds } = created.runtime.ceiling;
441
+ if (runtimeSeconds != null && queueSeconds != null) {
442
+ return (runtimeSeconds + queueSeconds) * 1e3 + DEADLINE_GRACE_MS;
443
+ }
444
+ return FALLBACK_WAIT_TIMEOUT_MS;
445
+ }
446
+ function nextPollInterval(interval) {
447
+ return Math.min(interval * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL_MS);
448
+ }
449
+
450
+ // src/resources/jobs.ts
451
+ var Jobs = class {
452
+ #client;
453
+ constructor(client) {
454
+ this.#client = client;
455
+ }
456
+ /**
457
+ * Submit a new SimC sim. Returns immediately with the job handle; the sim
458
+ * runs asynchronously. `idempotent: true` makes the request layer attach an
459
+ * auto-generated idempotency key so the POST is safe to retry; pass
460
+ * `options.idempotencyKey` to supply your own.
461
+ */
462
+ create(params, options) {
463
+ return this.#client._request(
464
+ { method: "POST", path: "/v1/simc/jobs", body: params, idempotent: true },
465
+ options
466
+ );
467
+ }
468
+ /** Fetch the full record for a job. */
469
+ get(jobId, options) {
470
+ return this.#client._request(
471
+ { method: "GET", path: `/v1/simc/jobs/${encodeURIComponent(jobId)}` },
472
+ options
473
+ );
474
+ }
475
+ /**
476
+ * Fetch the live status of a job in any state — `status`, `errorCode`,
477
+ * `progress`, and `queue` estimate. Unlike `getResult`, it never throws for a
478
+ * non-terminal job, so it is the supported way to drive a custom poll loop.
479
+ */
480
+ getStatus(jobId, options) {
481
+ return this.#client._request(
482
+ {
483
+ method: "GET",
484
+ path: `/v1/simc/jobs/${encodeURIComponent(jobId)}/status`
485
+ },
486
+ options
487
+ );
488
+ }
489
+ /**
490
+ * Fetch the result summary of a terminal job. Throws `ResultNotReadyError`
491
+ * (409) while the job is still running — poll `/status` or use
492
+ * `createAndWait` rather than `/result` for a job in flight.
493
+ */
494
+ getResult(jobId, options) {
495
+ return this.#client._request(
496
+ {
497
+ method: "GET",
498
+ path: `/v1/simc/jobs/${encodeURIComponent(jobId)}/result`
499
+ },
500
+ options
501
+ );
502
+ }
503
+ /**
504
+ * Submit a job and resolve once it reaches a terminal state. Polls
505
+ * `GET /v1/simc/jobs/{id}/status` (first after `pollIntervalMs`, then ×1.5 to
506
+ * a 10s cap), then fetches the full record. Resolves with the `CompletedJob`
507
+ * on success; throws `JobFailedError` / `JobCancelledError` /
508
+ * `JobTimedOutError` for the other terminal states, or `JobWaitTimeoutError`
509
+ * if the deadline passes first — the job keeps running and is **not**
510
+ * cancelled (call `cancel(jobId)` to stop the spend). `signal` aborts the wait
511
+ * with `APIUserAbortError`, also without cancelling.
512
+ */
513
+ async createAndWait(params, options = {}) {
514
+ const {
515
+ pollIntervalMs,
516
+ waitTimeoutMs,
517
+ onCreated,
518
+ onPoll,
519
+ ...requestOptions
520
+ } = options;
521
+ const created = await this.create(params, requestOptions);
522
+ onCreated?.(created);
523
+ const deadline = Date.now() + (waitTimeoutMs ?? deriveWaitTimeoutMs(created));
524
+ let interval = Math.max(
525
+ pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
526
+ MIN_POLL_INTERVAL_MS
527
+ );
528
+ let lastStatus = "pending";
529
+ for (; ; ) {
530
+ const remaining = deadline - Date.now();
531
+ if (remaining <= 0) {
532
+ throw new JobWaitTimeoutError({ jobId: created.id, lastStatus });
533
+ }
534
+ await sleep(Math.min(interval, remaining), requestOptions.signal);
535
+ const status = await this.getStatus(created.id, requestOptions);
536
+ onPoll?.(status);
537
+ lastStatus = status.status;
538
+ if (isTerminal(status.status)) {
539
+ const job = await this.get(created.id, requestOptions);
540
+ switch (job.status) {
541
+ case "completed":
542
+ return job;
543
+ case "failed":
544
+ throw new JobFailedError(job);
545
+ case "cancelled":
546
+ throw new JobCancelledError(job);
547
+ case "timed_out":
548
+ throw new JobTimedOutError(job);
549
+ }
550
+ lastStatus = job.status;
551
+ }
552
+ interval = nextPollInterval(interval);
553
+ }
554
+ }
555
+ /**
556
+ * Request cancellation. Returns `status: 'cancelled'` when the job ended
557
+ * before it ran, or `status: 'cancel_requested'` when an in-flight job was
558
+ * signaled to stop. Repeat calls are naturally idempotent, so no key is sent.
559
+ */
560
+ cancel(jobId, options) {
561
+ return this.#client._request(
562
+ {
563
+ method: "POST",
564
+ path: `/v1/simc/jobs/${encodeURIComponent(jobId)}/cancel`
565
+ },
566
+ options
567
+ );
568
+ }
569
+ };
570
+
571
+ // src/client.ts
572
+ var Simmit = class {
573
+ jobs;
574
+ credits;
575
+ artifacts;
576
+ baseURL;
577
+ #config;
578
+ constructor(options = {}) {
579
+ const secretKey = options.secretKey ?? readEnv("SIMMIT_SECRET_KEY");
580
+ if (!secretKey) {
581
+ throw new SimmitError(
582
+ "Missing secret key. Pass secretKey or set SIMMIT_SECRET_KEY."
583
+ );
584
+ }
585
+ this.baseURL = options.baseURL ?? readEnv("SIMMIT_BASE_URL") ?? "https://api.simmit.com";
586
+ this.#config = {
587
+ secretKey,
588
+ baseURL: this.baseURL,
589
+ timeout: options.timeout ?? 6e4,
590
+ maxRetries: options.maxRetries ?? 2,
591
+ defaultHeaders: options.defaultHeaders,
592
+ // Resolved lazily so a fetch patched onto globalThis after the client
593
+ // is constructed (msw, APM instrumentation) is still honored.
594
+ fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
595
+ fetchOptions: options.fetchOptions
596
+ };
597
+ this.jobs = new Jobs(this);
598
+ this.credits = new Credits(this);
599
+ this.artifacts = new Artifacts(this);
600
+ }
601
+ /** @internal Resource classes route through here; not public surface. */
602
+ _request(spec, options) {
603
+ return makeRequest(this.#config, spec, options);
604
+ }
605
+ };
606
+ function readEnv(name) {
607
+ if (typeof process === "undefined") return void 0;
608
+ const value = process.env?.[name]?.trim();
609
+ return value || void 0;
610
+ }
611
+
612
+ // src/webhook.ts
613
+ var DEFAULT_TOLERANCE_SECONDS = 300;
614
+ async function unwrapWebhook(rawBody, signatureHeader, secret, options) {
615
+ if (!secret) {
616
+ throw new WebhookVerificationError("Webhook signing secret is empty.");
617
+ }
618
+ const tolerance = options?.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
619
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
620
+ throw new WebhookVerificationError(
621
+ "toleranceSeconds must be a non-negative number."
622
+ );
623
+ }
624
+ const { timestampRaw, timestamp, signature } = parseSignatureHeader(signatureHeader);
625
+ const expected = await hmacSha256Hex(secret, `${timestampRaw}.${rawBody}`);
626
+ if (!timingSafeEqual(expected, signature)) {
627
+ throw new WebhookVerificationError("Webhook signature does not match.");
628
+ }
629
+ if (Math.abs(Math.floor(Date.now() / 1e3) - timestamp) > tolerance) {
630
+ throw new WebhookVerificationError(
631
+ "Webhook timestamp is outside the tolerance window."
632
+ );
633
+ }
634
+ try {
635
+ return JSON.parse(rawBody);
636
+ } catch {
637
+ throw new WebhookVerificationError("Webhook body is not valid JSON.");
638
+ }
639
+ }
640
+ function parseSignatureHeader(header) {
641
+ let timestampRaw;
642
+ let signature;
643
+ for (const part of header.split(",")) {
644
+ const eq = part.indexOf("=");
645
+ if (eq === -1) continue;
646
+ const key = part.slice(0, eq).trim();
647
+ const value = part.slice(eq + 1).trim();
648
+ if (key === "t") timestampRaw = value;
649
+ else if (key === "v1") signature = value;
650
+ }
651
+ if (!timestampRaw || !signature || !/^\d+$/.test(timestampRaw)) {
652
+ throw new WebhookVerificationError(
653
+ 'Malformed signature header; expected "t=<unix>,v1=<hex>".'
654
+ );
655
+ }
656
+ return { timestampRaw, timestamp: Number(timestampRaw), signature };
657
+ }
658
+ async function hmacSha256Hex(secret, payload) {
659
+ const encoder = new TextEncoder();
660
+ const key = await crypto.subtle.importKey(
661
+ "raw",
662
+ encoder.encode(secret),
663
+ { name: "HMAC", hash: "SHA-256" },
664
+ false,
665
+ ["sign"]
666
+ );
667
+ const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
668
+ return toHex(new Uint8Array(mac));
669
+ }
670
+ function toHex(bytes) {
671
+ let hex = "";
672
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
673
+ return hex;
674
+ }
675
+ function timingSafeEqual(a, b) {
676
+ if (a.length !== b.length) return false;
677
+ let mismatch = 0;
678
+ for (let i = 0; i < a.length; i++) {
679
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
680
+ }
681
+ return mismatch === 0;
682
+ }
683
+ export {
684
+ APIConnectionError,
685
+ APIConnectionTimeoutError,
686
+ APIError,
687
+ APIPromise,
688
+ APIUserAbortError,
689
+ AuthenticationError,
690
+ BadRequestError,
691
+ BillingError,
692
+ ConflictError,
693
+ IdempotencyKeyReuseError,
694
+ InsufficientCreditsError,
695
+ InsufficientCreditsLiabilityError,
696
+ InternalServerError,
697
+ InvalidProfileError,
698
+ JobCancelledError,
699
+ JobFailedError,
700
+ JobNotCancellableError,
701
+ JobTimedOutError,
702
+ JobUnsuccessfulError,
703
+ JobWaitTimeoutError,
704
+ MaxActiveJobsError,
705
+ NotFoundError,
706
+ RateLimitError,
707
+ RequestTooLargeError,
708
+ ResultNotReadyError,
709
+ ResultUnavailableError,
710
+ ServiceUnavailableError,
711
+ Simmit,
712
+ SimmitError,
713
+ UnprocessableEntityError,
714
+ WebhookVerificationError,
715
+ Simmit as default,
716
+ unwrapWebhook
717
+ };
718
+ //# sourceMappingURL=index.js.map