@spooled/sdk 1.2.1 → 1.3.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.cjs CHANGED
@@ -1,7 +1,5 @@
1
1
  'use strict';
2
2
 
3
- var crypto = require('crypto');
4
- var buffer = require('buffer');
5
3
  var grpc2 = require('@grpc/grpc-js');
6
4
  var protoLoader = require('@grpc/proto-loader');
7
5
  var fs = require('fs');
@@ -50,10 +48,10 @@ var DEFAULT_CONFIG = {
50
48
  successThreshold: 3,
51
49
  timeout: 3e4
52
50
  },
53
- userAgent: "@spooled/sdk-nodejs/1.2.1",
51
+ userAgent: "@spooled/sdk-nodejs/1.3.0",
54
52
  autoRefreshToken: true
55
53
  };
56
- var SDK_VERSION = "1.2.1";
54
+ var SDK_VERSION = "1.3.0";
57
55
  var API_VERSION = "v1";
58
56
  var API_BASE_PATH = `/api/${API_VERSION}`;
59
57
  function normalizeCredential(value) {
@@ -110,9 +108,9 @@ function resolveConfig(options) {
110
108
  };
111
109
  }
112
110
  function validateConfig(config) {
113
- if (!config.apiKey && !config.accessToken) {
111
+ if (!config.apiKey && !config.accessToken && !config.adminKey) {
114
112
  throw new Error(
115
- "SpooledClient requires either apiKey or accessToken for authentication"
113
+ "SpooledClient requires either apiKey or accessToken for authentication (or adminKey for admin-only use)"
116
114
  );
117
115
  }
118
116
  if (config.apiKey && !config.apiKey.startsWith("sp_") && !config.apiKey.startsWith("sk_")) {
@@ -175,9 +173,15 @@ var SpooledError = class extends Error {
175
173
  Error.captureStackTrace(this, this.constructor);
176
174
  }
177
175
  }
178
- /** Whether this error is retryable */
176
+ /**
177
+ * Whether this error is retryable.
178
+ *
179
+ * Only 408 (Request Timeout) is retryable among the plain 4xx statuses: the
180
+ * server gave up waiting for the request, so it never ran. Subclasses
181
+ * override this (5xx, network, timeout, rate limit).
182
+ */
179
183
  isRetryable() {
180
- return false;
184
+ return this.statusCode === 408;
181
185
  }
182
186
  /** Convert to plain object for logging */
183
187
  toJSON() {
@@ -216,8 +220,8 @@ var ConflictError = class extends SpooledError {
216
220
  }
217
221
  };
218
222
  var ValidationError = class extends SpooledError {
219
- constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId) {
220
- super(message, 400, code, details, requestId);
223
+ constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId, statusCode = 400) {
224
+ super(message, statusCode, code, details, requestId);
221
225
  this.name = "ValidationError";
222
226
  }
223
227
  };
@@ -227,6 +231,7 @@ var PayloadTooLargeError = class extends SpooledError {
227
231
  this.name = "PayloadTooLargeError";
228
232
  }
229
233
  };
234
+ var QUOTA_MESSAGE = /\blimit reached\b/i;
230
235
  var RateLimitError = class extends SpooledError {
231
236
  /** Rate limit information */
232
237
  rateLimitInfo;
@@ -235,8 +240,27 @@ var RateLimitError = class extends SpooledError {
235
240
  this.name = "RateLimitError";
236
241
  this.rateLimitInfo = rateLimitInfo;
237
242
  }
243
+ /**
244
+ * True when this 429 is a plan quota or limit (daily job cap, active jobs,
245
+ * queue/worker/webhook caps) rather than per-second rate limiting.
246
+ *
247
+ * A quota does not clear for minutes or hours, so retrying it only blocks the
248
+ * caller for the whole backoff and, via the circuit breaker, takes every
249
+ * other call on the client down with it. It is never retried and never
250
+ * counted as a circuit breaker failure.
251
+ *
252
+ * Detection: the `QUOTA_EXCEEDED` code, or, for older plan-limit paths that
253
+ * answer with plain text wrapped as `RATE_LIMIT_EXCEEDED`, a "... limit
254
+ * reached" message that carries no `Retry-After`.
255
+ */
256
+ get isQuota() {
257
+ if (this.code === "QUOTA_EXCEEDED") {
258
+ return true;
259
+ }
260
+ return this.rateLimitInfo.retryAfter === void 0 && QUOTA_MESSAGE.test(this.message);
261
+ }
238
262
  isRetryable() {
239
- return true;
263
+ return !this.isQuota;
240
264
  }
241
265
  /** Get the number of seconds to wait before retrying */
242
266
  getRetryAfter() {
@@ -296,6 +320,20 @@ var CircuitBreakerOpenError = class extends SpooledError {
296
320
  return false;
297
321
  }
298
322
  };
323
+ var UnsupportedOperationError = class extends SpooledError {
324
+ /** The SDK method that is not supported, e.g. `ingest.github` */
325
+ operation;
326
+ constructor(operation, reason) {
327
+ super(
328
+ `${operation} is not supported by the Spooled API: ${reason}`,
329
+ 0,
330
+ "UNSUPPORTED_OPERATION",
331
+ { operation }
332
+ );
333
+ this.name = "UnsupportedOperationError";
334
+ this.operation = operation;
335
+ }
336
+ };
299
337
  function parseRateLimitHeaders(headers) {
300
338
  const info = {};
301
339
  const retryAfter = headers.get("Retry-After");
@@ -367,6 +405,10 @@ async function createErrorFromResponse(response) {
367
405
  switch (response.status) {
368
406
  case 400:
369
407
  return new ValidationError(message, code, details, requestId);
408
+ // A missing or mistyped JSON field is a 422 from the API's extractor; it is
409
+ // the same class of caller error as a 400 field-rule failure.
410
+ case 422:
411
+ return new ValidationError(message, code, details, requestId, 422);
370
412
  case 401:
371
413
  return new AuthenticationError(message, code, details, requestId);
372
414
  case 403:
@@ -595,8 +637,12 @@ var HttpClient = class {
595
637
  authToken;
596
638
  /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
597
639
  refreshTokenFn = null;
598
- /** Guard to prevent a refresh request from recursively triggering another refresh */
599
- isRefreshing = false;
640
+ /**
641
+ * The refresh currently in flight, shared by every request that gets a 401
642
+ * meanwhile. A boolean guard here made the second concurrent 401 skip the
643
+ * refresh and fail even though the token was about to be replaced.
644
+ */
645
+ refreshInFlight = null;
600
646
  constructor(config, circuitBreaker) {
601
647
  this.config = config;
602
648
  this.circuitBreaker = circuitBreaker;
@@ -618,6 +664,30 @@ var HttpClient = class {
618
664
  setRefreshTokenFn(fn) {
619
665
  this.refreshTokenFn = fn;
620
666
  }
667
+ /**
668
+ * Refresh the access token once for all callers.
669
+ *
670
+ * Resolves true when a new token is installed, false when the refresh failed
671
+ * (the caller then surfaces its original 401).
672
+ */
673
+ refreshAuthToken() {
674
+ if (!this.refreshInFlight) {
675
+ const refresh = this.refreshTokenFn;
676
+ this.refreshInFlight = (async () => {
677
+ try {
678
+ this.setAuthToken(await refresh());
679
+ return true;
680
+ } catch (refreshError) {
681
+ const message = refreshError instanceof Error ? refreshError.message : "unknown";
682
+ this.debug?.("Token refresh failed", { error: message });
683
+ return false;
684
+ } finally {
685
+ this.refreshInFlight = null;
686
+ }
687
+ })();
688
+ }
689
+ return this.refreshInFlight;
690
+ }
621
691
  /**
622
692
  * Build full URL with query parameters
623
693
  */
@@ -693,6 +763,7 @@ var HttpClient = class {
693
763
  */
694
764
  async executeRequest(url, options, hasRefreshed = false) {
695
765
  const method = options.method ?? "GET";
766
+ const tokenUsed = this.authToken;
696
767
  const headers = this.buildHeaders(options.headers);
697
768
  const timeout = options.timeout ?? this.config.timeout;
698
769
  const init = {
@@ -707,6 +778,9 @@ var HttpClient = class {
707
778
  init.body = JSON.stringify(body);
708
779
  }
709
780
  }
781
+ if (init.body === void 0) {
782
+ headers.delete("Content-Type");
783
+ }
710
784
  this.debug?.(`${method} ${url}`, { timeout, hasBody: !!options.body });
711
785
  let response;
712
786
  try {
@@ -728,20 +802,9 @@ var HttpClient = class {
728
802
  }
729
803
  this.debug?.(`Response: ${response.status}`, { url });
730
804
  if (!response.ok) {
731
- if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !this.isRefreshing) {
732
- let refreshed = false;
733
- this.isRefreshing = true;
734
- try {
735
- const newToken = await this.refreshTokenFn();
736
- this.setAuthToken(newToken);
737
- refreshed = true;
738
- } catch (refreshError) {
739
- const message = refreshError instanceof Error ? refreshError.message : "unknown";
740
- this.debug?.("Token refresh failed", { error: message });
741
- } finally {
742
- this.isRefreshing = false;
743
- }
744
- if (refreshed) {
805
+ if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !options.skipAuthRefresh) {
806
+ const alreadyRefreshed = this.authToken !== tokenUsed;
807
+ if (alreadyRefreshed || await this.refreshAuthToken()) {
745
808
  return this.executeRequest(url, options, true);
746
809
  }
747
810
  }
@@ -803,22 +866,26 @@ var HttpClient = class {
803
866
  /**
804
867
  * Resolve the retry configuration for a request.
805
868
  *
806
- * Idempotent methods (GET/PUT/DELETE) and any request carrying an
807
- * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
808
- * without an idempotency key must NOT be retried on ambiguous failures
869
+ * Idempotent methods (GET/PUT/DELETE), any request carrying an idempotency
870
+ * key and any request marked `idempotent` retry normally. Non-idempotent
871
+ * methods (POST/PATCH) otherwise must NOT be retried on ambiguous failures
809
872
  * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
810
- * etc. They are still retried on 429, which is rejected before the
811
- * server processes the request and is therefore side-effect-free.
873
+ * etc. They are still retried on a rate-limit 429, which is rejected before
874
+ * the server processes the request and is therefore side-effect-free. A
875
+ * quota 429 is never retried: it does not clear for hours.
812
876
  */
813
877
  resolveRetryConfig(method, options) {
814
878
  const base = this.config.retry;
815
- if (IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
879
+ if (options.idempotent || IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
816
880
  return base;
817
881
  }
818
882
  return {
819
883
  ...base,
820
884
  retryOn: (error, attempt) => {
821
885
  if (error instanceof RateLimitError) {
886
+ if (error.isQuota) {
887
+ return false;
888
+ }
822
889
  return base.retryOn ? base.retryOn(error, attempt) : true;
823
890
  }
824
891
  return false;
@@ -1049,13 +1116,17 @@ var AuthResource = class {
1049
1116
  * Exchange API key for JWT tokens
1050
1117
  */
1051
1118
  async login(params) {
1052
- return this.http.post("/auth/login", params);
1119
+ return this.http.post("/auth/login", params, {
1120
+ skipAuthRefresh: true
1121
+ });
1053
1122
  }
1054
1123
  /**
1055
1124
  * Refresh access token using refresh token
1056
1125
  */
1057
1126
  async refresh(params) {
1058
- return this.http.post("/auth/refresh", params);
1127
+ return this.http.post("/auth/refresh", params, {
1128
+ skipAuthRefresh: true
1129
+ });
1059
1130
  }
1060
1131
  /**
1061
1132
  * Logout and revoke the refresh token server-side.
@@ -1126,6 +1197,21 @@ function mapValidate(raw) {
1126
1197
  };
1127
1198
  }
1128
1199
 
1200
+ // src/utils/failure.ts
1201
+ var MAX_FAILURE_MESSAGE_CHARS = 2048;
1202
+ var DEFAULT_FAILURE_MESSAGE = "Job failed";
1203
+ function coerceFailureMessage(message) {
1204
+ const text = typeof message === "string" ? message : message == null ? "" : `${message}`;
1205
+ if (text === "") {
1206
+ return DEFAULT_FAILURE_MESSAGE;
1207
+ }
1208
+ if (text.length <= MAX_FAILURE_MESSAGE_CHARS) {
1209
+ return text;
1210
+ }
1211
+ const codePoints = Array.from(text);
1212
+ return codePoints.length <= MAX_FAILURE_MESSAGE_CHARS ? text : codePoints.slice(0, MAX_FAILURE_MESSAGE_CHARS).join("");
1213
+ }
1214
+
1129
1215
  // src/resources/jobs.ts
1130
1216
  var JobsResource = class {
1131
1217
  constructor(http) {
@@ -1142,7 +1228,9 @@ var JobsResource = class {
1142
1228
  * Create a new job
1143
1229
  */
1144
1230
  async create(params) {
1145
- return this.http.post("/jobs", params);
1231
+ return this.http.post("/jobs", params, {
1232
+ idempotent: params.idempotencyKey ? true : void 0
1233
+ });
1146
1234
  }
1147
1235
  /**
1148
1236
  * Create a job and return the full job object
@@ -1226,26 +1314,43 @@ var JobsResource = class {
1226
1314
  }
1227
1315
  /**
1228
1316
  * Complete a job (worker ack)
1317
+ *
1318
+ * Lease-fenced and idempotent on the server, so a network error, timeout or
1319
+ * 5xx is retried: a lost ack would otherwise re-run a finished job.
1229
1320
  */
1230
1321
  async complete(id, params) {
1231
- return this.http.post(`/jobs/${id}/complete`, params);
1322
+ return this.http.post(
1323
+ `/jobs/${id}/complete`,
1324
+ params,
1325
+ { idempotent: true }
1326
+ );
1232
1327
  }
1233
1328
  /**
1234
1329
  * Fail a job (worker nack)
1330
+ *
1331
+ * `params.error` is made acceptable first: empty text becomes "Job failed"
1332
+ * and text over 2048 characters is truncated, because the API rejects both
1333
+ * with a 400 that would leave the job processing until its lease expires.
1334
+ * Retried like {@link JobsResource.complete}.
1235
1335
  */
1236
1336
  async fail(id, params) {
1237
1337
  return this.http.post(
1238
1338
  `/jobs/${id}/fail`,
1239
- params
1339
+ { ...params, error: coerceFailureMessage(params.error) },
1340
+ { idempotent: true }
1240
1341
  );
1241
1342
  }
1242
1343
  /**
1243
1344
  * Extend job lease (heartbeat)
1345
+ *
1346
+ * Retried like {@link JobsResource.complete}: with the default 30 s lease and
1347
+ * 15 s heartbeat, one lost heartbeat leaves no slack.
1244
1348
  */
1245
1349
  async heartbeat(id, params) {
1246
1350
  return this.http.post(
1247
1351
  `/jobs/${id}/heartbeat`,
1248
- params
1352
+ params,
1353
+ { idempotent: true }
1249
1354
  );
1250
1355
  }
1251
1356
  // DLQ operations (private implementations)
@@ -1341,9 +1446,17 @@ var QueuesResource = class {
1341
1446
  }
1342
1447
  /**
1343
1448
  * Delete a queue configuration
1344
- */
1345
- async delete(name) {
1346
- await this.http.delete(`/queues/${encodeURIComponent(name)}`);
1449
+ *
1450
+ * Without `deleteJobs` the API only deletes an explicit queue config of an
1451
+ * idle queue, and answers 409 otherwise: a queue that exists only because
1452
+ * jobs were posted to it has no config to delete, and a queue with pending
1453
+ * or processing jobs is not idle. Pass `{ deleteJobs: true }` to delete the
1454
+ * queue's jobs (and DLQ rows) along with it.
1455
+ */
1456
+ async delete(name, options) {
1457
+ await this.http.delete(`/queues/${encodeURIComponent(name)}`, {
1458
+ params: options?.deleteJobs ? { deleteJobs: true } : void 0
1459
+ });
1347
1460
  }
1348
1461
  };
1349
1462
 
@@ -1372,9 +1485,13 @@ var WorkersResource = class {
1372
1485
  }
1373
1486
  /**
1374
1487
  * Send worker heartbeat
1488
+ *
1489
+ * Idempotent on the server, so network errors, timeouts and 5xx are retried.
1375
1490
  */
1376
1491
  async heartbeat(id, params) {
1377
- await this.http.post(`/workers/${id}/heartbeat`, params);
1492
+ await this.http.post(`/workers/${id}/heartbeat`, params, {
1493
+ idempotent: true
1494
+ });
1378
1495
  }
1379
1496
  /**
1380
1497
  * Deregister a worker
@@ -1523,7 +1640,7 @@ var WorkflowsResource = class {
1523
1640
  const jobs = await this.listWorkflowJobs(workflowId);
1524
1641
  return jobs.map((job) => ({
1525
1642
  jobId: job.id,
1526
- key: job.key,
1643
+ ...job.key !== void 0 && { key: job.key },
1527
1644
  status: job.status
1528
1645
  }));
1529
1646
  }
@@ -1581,7 +1698,10 @@ function mapWorkflowDetailJobs(detail) {
1581
1698
  return {
1582
1699
  id,
1583
1700
  workflowId: String(job.workflowId ?? workflowId),
1584
- key: String(job.key ?? ""),
1701
+ // The API does not return the creation-time key (jobs store only a
1702
+ // workflow step), so there is nothing to put here unless a future
1703
+ // version sends one. Never fabricate "".
1704
+ ...typeof job.key === "string" && job.key !== "" && { key: job.key },
1585
1705
  queueName: String(job.queueName ?? job.queue ?? ""),
1586
1706
  status: job.status,
1587
1707
  payload: job.payload ?? {},
@@ -1793,19 +1913,18 @@ var OrganizationsResource = class {
1793
1913
  /**
1794
1914
  * Clear/delete the webhook token for the current organization.
1795
1915
  *
1796
- * POST /organizations/webhook-token/clear requires `{ confirm: true }`.
1797
- * An empty body 422s. The API currently rejects the operation and tells
1798
- * callers to use regenerateWebhookToken() instead.
1799
- *
1800
- * @example
1801
- * ```typescript
1802
- * await client.organizations.clearWebhookToken();
1803
- * ```
1916
+ * @deprecated The API refuses this operation on purpose (an org without a
1917
+ * webhook token would have an open public ingestion endpoint) and always
1918
+ * answers with an error. This method now throws
1919
+ * {@link UnsupportedOperationError} without a network call; rotate the token
1920
+ * with {@link OrganizationsResource.regenerateWebhookToken} instead. It will
1921
+ * be removed in the next major release.
1804
1922
  */
1805
1923
  async clearWebhookToken() {
1806
- await this.http.post("/organizations/webhook-token/clear", {
1807
- confirm: true
1808
- });
1924
+ throw new UnsupportedOperationError(
1925
+ "organizations.clearWebhookToken",
1926
+ "clearing the webhook token is not allowed; use organizations.regenerateWebhookToken() to rotate it"
1927
+ );
1809
1928
  }
1810
1929
  };
1811
1930
 
@@ -2013,24 +2132,8 @@ var AdminResource = class {
2013
2132
  });
2014
2133
  }
2015
2134
  };
2016
- function toBytes(body) {
2017
- if (typeof body === "string") {
2018
- return buffer.Buffer.from(body, "utf8");
2019
- }
2020
- if (body instanceof Uint8Array) {
2021
- return body;
2022
- }
2023
- return new Uint8Array(body);
2024
- }
2025
- function githubSignature(secret, body) {
2026
- const hex = crypto.createHmac("sha256", secret).update(body).digest("hex");
2027
- return `sha256=${hex}`;
2028
- }
2029
- function stripeSignature(secret, body, timestamp) {
2030
- const payload = `${timestamp}.${buffer.Buffer.from(body).toString("utf8")}`;
2031
- const hex = crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
2032
- return `t=${timestamp},v1=${hex}`;
2033
- }
2135
+
2136
+ // src/resources/webhook-ingestion.ts
2034
2137
  var WebhookIngestionResource = class {
2035
2138
  constructor(http) {
2036
2139
  this.http = http;
@@ -2038,66 +2141,32 @@ var WebhookIngestionResource = class {
2038
2141
  /**
2039
2142
  * Ingest a GitHub webhook.
2040
2143
  *
2041
- * POST /api/v1/webhooks/{org_id}/github
2042
- */
2043
- async github(orgId, body, options) {
2044
- const bytes = toBytes(body);
2045
- const signature = options.signature ?? (options.secret ? githubSignature(options.secret, bytes) : void 0);
2046
- if (!signature) {
2047
- throw new ValidationError(
2048
- "GitHub webhook signature is required (provide `signature` or `secret`)"
2049
- );
2050
- }
2051
- const headers = {
2052
- "Content-Type": "application/json",
2053
- "X-GitHub-Event": options.githubEvent,
2054
- "X-Hub-Signature-256": signature
2055
- };
2056
- if (options.webhookToken) {
2057
- headers["X-Webhook-Token"] = options.webhookToken;
2058
- }
2059
- if (options.forwardedProto) {
2060
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2061
- }
2062
- await this.http.request(`/webhooks/${orgId}/github`, {
2063
- method: "POST",
2064
- rawBody: bytes,
2065
- headers,
2066
- skipRequestConversion: true,
2067
- skipResponseConversion: true
2068
- });
2144
+ * @deprecated The API has no `/webhooks/{org_id}/github` route (it always
2145
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2146
+ * without a network call; point the GitHub webhook at `ingest.custom` /
2147
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2148
+ * next major release.
2149
+ */
2150
+ async github(_orgId, _body, _options) {
2151
+ throw new UnsupportedOperationError(
2152
+ "ingest.github",
2153
+ "the API has no GitHub webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2154
+ );
2069
2155
  }
2070
2156
  /**
2071
2157
  * Ingest a Stripe webhook.
2072
2158
  *
2073
- * POST /api/v1/webhooks/{org_id}/stripe
2074
- */
2075
- async stripe(orgId, body, options = {}) {
2076
- const bytes = toBytes(body);
2077
- const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
2078
- const signature = options.signature ?? (options.secret ? stripeSignature(options.secret, bytes, timestamp) : void 0);
2079
- if (!signature) {
2080
- throw new ValidationError(
2081
- "Stripe webhook signature is required (provide `signature` or `secret`)"
2082
- );
2083
- }
2084
- const headers = {
2085
- "Content-Type": "application/json",
2086
- "Stripe-Signature": signature
2087
- };
2088
- if (options.webhookToken) {
2089
- headers["X-Webhook-Token"] = options.webhookToken;
2090
- }
2091
- if (options.forwardedProto) {
2092
- headers["X-Forwarded-Proto"] = options.forwardedProto;
2093
- }
2094
- await this.http.request(`/webhooks/${orgId}/stripe`, {
2095
- method: "POST",
2096
- rawBody: bytes,
2097
- headers,
2098
- skipRequestConversion: true,
2099
- skipResponseConversion: true
2100
- });
2159
+ * @deprecated The API has no `/webhooks/{org_id}/stripe` route (it always
2160
+ * answered 404). This method now throws {@link UnsupportedOperationError}
2161
+ * without a network call; point the Stripe webhook at `ingest.custom` /
2162
+ * `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
2163
+ * next major release.
2164
+ */
2165
+ async stripe(_orgId, _body, _options = {}) {
2166
+ throw new UnsupportedOperationError(
2167
+ "ingest.stripe",
2168
+ "the API has no Stripe webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
2169
+ );
2101
2170
  }
2102
2171
  /**
2103
2172
  * Ingest a custom webhook.
@@ -2474,6 +2543,13 @@ var SSE_EVENT_NAMES = [
2474
2543
  "ping",
2475
2544
  "error"
2476
2545
  ];
2546
+ var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
2547
+ "completed",
2548
+ "failed",
2549
+ "deadletter",
2550
+ "cancelled"
2551
+ ]);
2552
+ var STABLE_CONNECTION_MS = 3e4;
2477
2553
  var SseRealtimeClient = class {
2478
2554
  options;
2479
2555
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2482,6 +2558,12 @@ var SseRealtimeClient = class {
2482
2558
  reconnectAttempts = 0;
2483
2559
  reconnectTimer = null;
2484
2560
  filter = null;
2561
+ /** When the current connection opened (for the stable-connection check). */
2562
+ connectedAt = null;
2563
+ /** The server finished this stream on purpose (terminal job); do not reconnect. */
2564
+ streamEnded = false;
2565
+ /** The server rejected our token; mint a fresh one for the next connect. */
2566
+ forceTokenRefresh = false;
2485
2567
  // Event handlers
2486
2568
  eventHandlers = /* @__PURE__ */ new Map();
2487
2569
  allEventsHandlers = /* @__PURE__ */ new Set();
@@ -2520,6 +2602,7 @@ var SseRealtimeClient = class {
2520
2602
  return;
2521
2603
  }
2522
2604
  this.filter = filter || null;
2605
+ this.streamEnded = false;
2523
2606
  this.setState("connecting");
2524
2607
  const sseUrl = this.buildSseUrl(filter);
2525
2608
  this.options.debug(`Connecting to SSE: ${sseUrl}`);
@@ -2539,7 +2622,9 @@ var SseRealtimeClient = class {
2539
2622
  }
2540
2623
  let token;
2541
2624
  try {
2542
- token = await this.options.tokenProvider(this.reconnectAttempts > 0);
2625
+ const force = this.forceTokenRefresh;
2626
+ this.forceTokenRefresh = false;
2627
+ token = await this.options.tokenProvider(force);
2543
2628
  } catch (error) {
2544
2629
  this.setState("disconnected");
2545
2630
  throw new Error(`Failed to acquire realtime token: ${error}`);
@@ -2559,7 +2644,7 @@ var SseRealtimeClient = class {
2559
2644
  this.eventSource.onopen = () => {
2560
2645
  this.options.debug("SSE connected");
2561
2646
  this.setState("connected");
2562
- this.reconnectAttempts = 0;
2647
+ this.connectedAt = Date.now();
2563
2648
  resolve();
2564
2649
  };
2565
2650
  this.eventSource.onmessage = (event) => {
@@ -2570,8 +2655,11 @@ var SseRealtimeClient = class {
2570
2655
  if (typeof event?.data === "string") this.handleMessage(event.data);
2571
2656
  });
2572
2657
  }
2573
- this.eventSource.onerror = () => {
2658
+ this.eventSource.onerror = (event) => {
2574
2659
  this.options.debug("SSE error");
2660
+ if (event?.code === 401 || event?.code === 403) {
2661
+ this.forceTokenRefresh = true;
2662
+ }
2575
2663
  if (this.state === "connecting") {
2576
2664
  this.setState("disconnected");
2577
2665
  if (this.eventSource) {
@@ -2603,6 +2691,8 @@ var SseRealtimeClient = class {
2603
2691
  }
2604
2692
  this.setState("disconnected");
2605
2693
  this.filter = null;
2694
+ this.connectedAt = null;
2695
+ this.streamEnded = false;
2606
2696
  }
2607
2697
  /**
2608
2698
  * Reconnect with a new filter (SSE requires reconnection to change subscriptions)
@@ -2667,6 +2757,9 @@ var SseRealtimeClient = class {
2667
2757
  parsed.type = eventType;
2668
2758
  const event = parsed;
2669
2759
  this.options.debug(`Received SSE event: ${eventType}`, event);
2760
+ if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
2761
+ this.streamEnded = true;
2762
+ }
2670
2763
  const handlers = this.eventHandlers.get(eventType);
2671
2764
  if (handlers) {
2672
2765
  const eventData = event.data;
@@ -2694,6 +2787,15 @@ var SseRealtimeClient = class {
2694
2787
  this.eventSource.close();
2695
2788
  this.eventSource = null;
2696
2789
  }
2790
+ if (this.streamEnded) {
2791
+ this.connectedAt = null;
2792
+ this.setState("disconnected");
2793
+ return;
2794
+ }
2795
+ if (this.connectedAt !== null && Date.now() - this.connectedAt >= STABLE_CONNECTION_MS) {
2796
+ this.reconnectAttempts = 0;
2797
+ }
2798
+ this.connectedAt = null;
2697
2799
  if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
2698
2800
  this.scheduleReconnect();
2699
2801
  } else {
@@ -2725,6 +2827,19 @@ var SseRealtimeClient = class {
2725
2827
  }, delay);
2726
2828
  }
2727
2829
  };
2830
+ function isStreamEndingEvent(eventType, data) {
2831
+ if (eventType === "error" || eventType === "job.completed") {
2832
+ return true;
2833
+ }
2834
+ const record = data ?? {};
2835
+ if (eventType === "job.failed") {
2836
+ return record.willRetry === false;
2837
+ }
2838
+ if (eventType === "job.status_changed" || eventType === "job.status") {
2839
+ return typeof record.newStatus === "string" && TERMINAL_JOB_STATUSES.has(record.newStatus);
2840
+ }
2841
+ return false;
2842
+ }
2728
2843
 
2729
2844
  // src/realtime/index.ts
2730
2845
  var SpooledRealtime = class {
@@ -2911,6 +3026,11 @@ function clearProtoCache() {
2911
3026
  cachedDefinition = null;
2912
3027
  }
2913
3028
 
3029
+ // src/grpc/encode.ts
3030
+ function encodeFailRequest(params) {
3031
+ return { ...params, retry: params.retry ?? true };
3032
+ }
3033
+
2914
3034
  // src/grpc/streaming.ts
2915
3035
  async function* asyncIterableFromStream(stream, options) {
2916
3036
  const { signal, onConnected, onError, onEnd } = options ?? {};
@@ -2983,7 +3103,9 @@ function createProcessJobsStream(call, options) {
2983
3103
  }
2984
3104
  return {
2985
3105
  send(request) {
2986
- call.write(request);
3106
+ call.write(
3107
+ request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
3108
+ );
2987
3109
  },
2988
3110
  receive() {
2989
3111
  return new AsyncIterableStream(call, { onError, onEnd });
@@ -3171,12 +3293,19 @@ function mapGrpcError(error, timeoutMs) {
3171
3293
  return new ServerError(message, 500, "GRPC_ERROR");
3172
3294
  }
3173
3295
  }
3296
+ function grpcHost(address) {
3297
+ if (address.startsWith("[")) {
3298
+ const end = address.indexOf("]");
3299
+ return end === -1 ? address : address.slice(1, end);
3300
+ }
3301
+ return address.split(":").length > 2 ? address : address.split(":")[0];
3302
+ }
3174
3303
  function shouldUseTls(address, explicitTls) {
3175
3304
  if (explicitTls !== void 0) {
3176
3305
  return explicitTls;
3177
3306
  }
3178
- const host = address.split(":")[0].toLowerCase();
3179
- return host !== "localhost" && host !== "127.0.0.1" && !host.startsWith("[::1]");
3307
+ const host = grpcHost(address).toLowerCase();
3308
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
3180
3309
  }
3181
3310
  var GrpcQueueOperations = class {
3182
3311
  constructor(client, metadata, timeoutMs) {
@@ -3228,7 +3357,7 @@ var GrpcQueueOperations = class {
3228
3357
  return promisify(
3229
3358
  this.client,
3230
3359
  this.client.Fail,
3231
- params,
3360
+ encodeFailRequest(params),
3232
3361
  this.metadata,
3233
3362
  this.timeoutMs
3234
3363
  );
@@ -3618,6 +3747,13 @@ var SpooledClient = class _SpooledClient {
3618
3747
  /**
3619
3748
  * Exchange the API key for a JWT via /auth/login and cache the result.
3620
3749
  *
3750
+ * The JWT belongs to the realtime transport only. It must NOT replace the
3751
+ * REST bearer: an API-key client has no refresh token wired into its HTTP
3752
+ * layer, so after the JWT's 24 h lifetime every REST call (claim, complete,
3753
+ * heartbeat, create) would have failed with 401 for good. REST keeps using
3754
+ * the API key; realtime re-mints its own JWT near expiry (see
3755
+ * `getJwtToken`).
3756
+ *
3621
3757
  * Concurrent callers share a single in-flight login so a burst of reconnects
3622
3758
  * (e.g. WebSocket and SSE at once) issues just one request.
3623
3759
  */
@@ -3627,9 +3763,6 @@ var SpooledClient = class _SpooledClient {
3627
3763
  }
3628
3764
  this.jwtLoginPromise = (async () => {
3629
3765
  const response = await this.auth.login({ apiKey: this.config.apiKey });
3630
- this.http.setAuthToken(response.accessToken);
3631
- this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
3632
- this.config.refreshToken = response.refreshToken;
3633
3766
  this.cachedJwt = response.accessToken;
3634
3767
  return response.accessToken;
3635
3768
  })();
@@ -3825,6 +3958,9 @@ var DEFAULT_OPTIONS = {
3825
3958
  autoStart: false,
3826
3959
  shutdownTimeout: 3e4
3827
3960
  };
3961
+ var JOB_TIMEOUT_MESSAGE = "Job timeout exceeded";
3962
+ var SHUTDOWN_TIMEOUT_MESSAGE = "Worker shutdown timeout";
3963
+ var MAX_TIMER_MS = 2 ** 31 - 1;
3828
3964
  var SpooledWorker = class {
3829
3965
  client;
3830
3966
  /**
@@ -3841,6 +3977,10 @@ var SpooledWorker = class {
3841
3977
  pollTimer = null;
3842
3978
  workerHeartbeatTimer = null;
3843
3979
  shutdownPromise = null;
3980
+ /** The claim request currently on the wire, so a stop can wait for its jobs. */
3981
+ pollInFlight = null;
3982
+ /** Wakes the shutdown drain when the last active job has been acked. */
3983
+ drainWaiter = null;
3844
3984
  // Event handlers
3845
3985
  eventHandlers = /* @__PURE__ */ new Map();
3846
3986
  constructor(client, options) {
@@ -3857,7 +3997,11 @@ var SpooledWorker = class {
3857
3997
  this.debug = config.debug ?? (() => {
3858
3998
  });
3859
3999
  if (this.options.autoStart) {
3860
- setImmediate(() => this.start());
4000
+ setImmediate(() => {
4001
+ this.start().catch((error) => {
4002
+ this.debug("Auto-start failed", error);
4003
+ });
4004
+ });
3861
4005
  }
3862
4006
  }
3863
4007
  /**
@@ -3928,7 +4072,15 @@ var SpooledWorker = class {
3928
4072
  }
3929
4073
  }
3930
4074
  /**
3931
- * Stop the worker gracefully
4075
+ * Stop the worker gracefully.
4076
+ *
4077
+ * Drains: the worker stops claiming, then lets running handlers finish for
4078
+ * up to `shutdownTimeout`. A handler that returns completes its job and one
4079
+ * that throws fails it, even though a stop was requested meanwhile; its
4080
+ * `signal` is NOT aborted during the grace period. Only handlers still
4081
+ * running when the timeout ends are aborted and failed with "Worker shutdown
4082
+ * timeout". The worker deregisters last, because deregistering returns every
4083
+ * job the worker still holds to the queue.
3932
4084
  */
3933
4085
  async stop() {
3934
4086
  if (this.state !== "running") {
@@ -3941,39 +4093,37 @@ var SpooledWorker = class {
3941
4093
  return this.shutdownPromise;
3942
4094
  }
3943
4095
  async doStop() {
4096
+ const deadline = Date.now() + this.options.shutdownTimeout;
3944
4097
  this.state = "stopping";
3945
4098
  this.debug("Stopping worker...");
3946
4099
  if (this.pollTimer) {
3947
4100
  clearTimeout(this.pollTimer);
3948
4101
  this.pollTimer = null;
3949
4102
  }
3950
- if (this.workerHeartbeatTimer) {
3951
- clearInterval(this.workerHeartbeatTimer);
3952
- this.workerHeartbeatTimer = null;
3953
- }
3954
- for (const active of this.activeJobs.values()) {
3955
- active.abortController.abort();
4103
+ if (this.pollInFlight) {
4104
+ await this.pollInFlight;
3956
4105
  }
3957
4106
  if (this.activeJobs.size > 0) {
3958
4107
  this.debug(
3959
4108
  `Waiting for ${this.activeJobs.size} active jobs to complete...`
3960
4109
  );
3961
- await Promise.race([
3962
- this.waitForActiveJobs(),
3963
- this.sleep(this.options.shutdownTimeout)
3964
- ]);
3965
- const remaining = Array.from(this.activeJobs.values());
3966
- for (const active of remaining) {
3967
- this.debug(
3968
- `Force-failing job ${active.job.id} due to shutdown timeout`
3969
- );
3970
- await this.failJob(active.job, "Worker shutdown timeout");
3971
- this.cleanupJob(active);
3972
- }
4110
+ await this.waitForDrain(Math.max(0, deadline - Date.now()));
4111
+ const stragglers = Array.from(this.activeJobs.values()).filter(
4112
+ (active) => !active.settled
4113
+ );
4114
+ await Promise.all(stragglers.map((active) => this.abandonJob(active)));
4115
+ await Promise.all(
4116
+ Array.from(this.activeJobs.values()).map((active) => active.ack)
4117
+ );
3973
4118
  }
3974
- if (this.workerId) {
4119
+ if (this.workerHeartbeatTimer) {
4120
+ clearInterval(this.workerHeartbeatTimer);
4121
+ this.workerHeartbeatTimer = null;
4122
+ }
4123
+ const workerId = this.workerId;
4124
+ if (workerId) {
3975
4125
  try {
3976
- await this.client.workers.deregister(this.workerId);
4126
+ await this.client.workers.deregister(workerId);
3977
4127
  this.debug("Worker deregistered");
3978
4128
  } catch (error) {
3979
4129
  this.debug("Failed to deregister worker", error);
@@ -3981,7 +4131,7 @@ var SpooledWorker = class {
3981
4131
  }
3982
4132
  this.state = "stopped";
3983
4133
  this.workerId = null;
3984
- this.emit("stopped", { workerId: this.workerId ?? "", reason: "graceful" });
4134
+ this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
3985
4135
  this.shutdownPromise = null;
3986
4136
  }
3987
4137
  /**
@@ -4020,7 +4170,17 @@ var SpooledWorker = class {
4020
4170
  if (this.state !== "running") {
4021
4171
  return;
4022
4172
  }
4023
- this.pollTimer = setTimeout(() => this.poll(), this.options.pollInterval);
4173
+ this.pollTimer = setTimeout(() => {
4174
+ this.pollTimer = null;
4175
+ const run = this.poll();
4176
+ this.pollInFlight = run;
4177
+ const done = () => {
4178
+ if (this.pollInFlight === run) {
4179
+ this.pollInFlight = null;
4180
+ }
4181
+ };
4182
+ run.then(done, done);
4183
+ }, this.options.pollInterval);
4024
4184
  }
4025
4185
  async poll() {
4026
4186
  if (this.state !== "running" || !this.workerId) {
@@ -4054,7 +4214,8 @@ var SpooledWorker = class {
4054
4214
  executionId: Symbol(job.id),
4055
4215
  job,
4056
4216
  startedAt: /* @__PURE__ */ new Date(),
4057
- abortController
4217
+ abortController,
4218
+ settled: false
4058
4219
  };
4059
4220
  this.activeJobs.set(activeJob.executionId, activeJob);
4060
4221
  const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
@@ -4083,23 +4244,72 @@ var SpooledWorker = class {
4083
4244
  }
4084
4245
  };
4085
4246
  try {
4086
- const result = await this.handler(context);
4087
- if (signal.aborted) {
4088
- this.debug(`Job ${job.id} was aborted`);
4247
+ const outcome = await this.runHandler(activeJob, context);
4248
+ if (activeJob.settled) {
4249
+ this.debug(`Job ${job.id} was already settled; dropping late result`);
4089
4250
  return;
4090
4251
  }
4091
- await this.completeJob(job, result ?? void 0);
4092
- } catch (error) {
4093
- if (signal.aborted) {
4094
- this.debug(`Job ${job.id} was aborted`);
4095
- return;
4252
+ activeJob.settled = true;
4253
+ switch (outcome.kind) {
4254
+ case "returned":
4255
+ activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
4256
+ break;
4257
+ case "threw":
4258
+ activeJob.ack = this.failJob(
4259
+ job,
4260
+ outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
4261
+ );
4262
+ break;
4263
+ case "timeout":
4264
+ abortController.abort(new Error(JOB_TIMEOUT_MESSAGE));
4265
+ this.emit("job:timeout", {
4266
+ jobId: job.id,
4267
+ queueName: job.queueName
4268
+ });
4269
+ activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
4270
+ break;
4096
4271
  }
4097
- const errorMessage = error instanceof Error ? error.message : String(error);
4098
- await this.failJob(job, errorMessage);
4272
+ await activeJob.ack;
4099
4273
  } finally {
4100
4274
  this.cleanupJob(activeJob);
4101
4275
  }
4102
4276
  }
4277
+ /**
4278
+ * Run the handler, racing it against the job's `timeoutSeconds`.
4279
+ * Never rejects: a throw (sync or async) is reported as an outcome.
4280
+ */
4281
+ runHandler(activeJob, context) {
4282
+ const run = (async () => {
4283
+ try {
4284
+ return { kind: "returned", value: await this.handler(context) };
4285
+ } catch (error) {
4286
+ return { kind: "threw", error };
4287
+ }
4288
+ })();
4289
+ const { timeoutSeconds } = activeJob.job;
4290
+ if (typeof timeoutSeconds !== "number" || !(timeoutSeconds > 0)) {
4291
+ return run;
4292
+ }
4293
+ return new Promise((resolve) => {
4294
+ activeJob.timeoutTimer = setTimeout(
4295
+ () => resolve({ kind: "timeout" }),
4296
+ Math.min(timeoutSeconds * 1e3, MAX_TIMER_MS)
4297
+ );
4298
+ run.then((outcome) => {
4299
+ this.clearTimeoutTimer(activeJob);
4300
+ resolve(outcome);
4301
+ });
4302
+ });
4303
+ }
4304
+ /** Abort a handler that outlived the shutdown grace period and fail its job. */
4305
+ async abandonJob(activeJob) {
4306
+ this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
4307
+ activeJob.settled = true;
4308
+ activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
4309
+ activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
4310
+ await activeJob.ack;
4311
+ this.cleanupJob(activeJob);
4312
+ }
4103
4313
  async completeJob(job, result) {
4104
4314
  if (!this.workerId) return;
4105
4315
  try {
@@ -4115,25 +4325,34 @@ var SpooledWorker = class {
4115
4325
  });
4116
4326
  } catch (error) {
4117
4327
  this.debug(`Failed to complete job ${job.id}`, error);
4328
+ this.emit("error", { error });
4118
4329
  }
4119
4330
  }
4120
4331
  async failJob(job, errorMessage) {
4121
4332
  if (!this.workerId) return;
4122
4333
  const willRetry = job.retryCount < job.maxRetries;
4334
+ const message = coerceFailureMessage(errorMessage);
4123
4335
  try {
4124
4336
  await this.client.jobs.fail(job.id, {
4125
4337
  workerId: this.workerId,
4126
- error: errorMessage,
4338
+ error: message,
4127
4339
  ...job.leaseId != null && { leaseId: job.leaseId }
4128
4340
  });
4129
4341
  this.emit("job:failed", {
4130
4342
  jobId: job.id,
4131
4343
  queueName: job.queueName,
4132
- error: errorMessage,
4344
+ error: message,
4133
4345
  willRetry
4134
4346
  });
4135
4347
  } catch (error) {
4136
4348
  this.debug(`Failed to fail job ${job.id}`, error);
4349
+ this.emit("error", { error });
4350
+ }
4351
+ }
4352
+ clearTimeoutTimer(activeJob) {
4353
+ if (activeJob.timeoutTimer) {
4354
+ clearTimeout(activeJob.timeoutTimer);
4355
+ activeJob.timeoutTimer = void 0;
4137
4356
  }
4138
4357
  }
4139
4358
  cleanupJob(activeJob) {
@@ -4141,8 +4360,12 @@ var SpooledWorker = class {
4141
4360
  clearInterval(activeJob.heartbeatTimer);
4142
4361
  activeJob.heartbeatTimer = void 0;
4143
4362
  }
4363
+ this.clearTimeoutTimer(activeJob);
4144
4364
  if (this.activeJobs.get(activeJob.executionId) === activeJob) {
4145
4365
  this.activeJobs.delete(activeJob.executionId);
4366
+ if (this.activeJobs.size === 0) {
4367
+ this.drainWaiter?.();
4368
+ }
4146
4369
  }
4147
4370
  }
4148
4371
  async sendJobHeartbeat(activeJob) {
@@ -4159,7 +4382,9 @@ var SpooledWorker = class {
4159
4382
  }
4160
4383
  }
4161
4384
  async sendWorkerHeartbeat() {
4162
- if (!this.workerId || this.state !== "running") return;
4385
+ if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
4386
+ return;
4387
+ }
4163
4388
  try {
4164
4389
  await this.client.workers.heartbeat(this.workerId, {
4165
4390
  currentJobs: this.activeJobs.size,
@@ -4169,13 +4394,25 @@ var SpooledWorker = class {
4169
4394
  this.debug("Worker heartbeat failed", error);
4170
4395
  }
4171
4396
  }
4172
- async waitForActiveJobs() {
4173
- while (this.activeJobs.size > 0) {
4174
- await this.sleep(100);
4397
+ /**
4398
+ * Resolve once no job is active, or after `timeoutMs`. The timer is always
4399
+ * cleared, so a clean stop leaves nothing that keeps the process alive.
4400
+ */
4401
+ waitForDrain(timeoutMs) {
4402
+ if (this.activeJobs.size === 0) {
4403
+ return Promise.resolve();
4175
4404
  }
4176
- }
4177
- sleep(ms) {
4178
- return new Promise((resolve) => setTimeout(resolve, ms));
4405
+ return new Promise((resolve) => {
4406
+ const timer = setTimeout(() => {
4407
+ this.drainWaiter = null;
4408
+ resolve();
4409
+ }, timeoutMs);
4410
+ this.drainWaiter = () => {
4411
+ clearTimeout(timer);
4412
+ this.drainWaiter = null;
4413
+ resolve();
4414
+ };
4415
+ });
4179
4416
  }
4180
4417
  };
4181
4418
 
@@ -4217,6 +4454,7 @@ exports.SpooledRealtime = SpooledRealtime;
4217
4454
  exports.SpooledWorker = SpooledWorker;
4218
4455
  exports.SseRealtimeClient = SseRealtimeClient;
4219
4456
  exports.TimeoutError = TimeoutError;
4457
+ exports.UnsupportedOperationError = UnsupportedOperationError;
4220
4458
  exports.ValidationError = ValidationError;
4221
4459
  exports.WebSocketRealtimeClient = WebSocketRealtimeClient;
4222
4460
  exports.WebhookIngestionResource = WebhookIngestionResource;