@spooled/sdk 1.2.0 → 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/README.md +42 -12
- package/dist/{index-BzfyWvfc.d.cts → index-BVWKEr6p.d.cts} +178 -45
- package/dist/{index-BzfyWvfc.d.ts → index-BVWKEr6p.d.ts} +178 -45
- package/dist/index.cjs +441 -185
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -8
- package/dist/index.d.ts +49 -8
- package/dist/index.js +441 -186
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +164 -45
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +1 -1
- package/dist/worker/index.d.ts +1 -1
- package/dist/worker/index.js +164 -45
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
51
|
+
userAgent: "@spooled/sdk-nodejs/1.3.0",
|
|
54
52
|
autoRefreshToken: true
|
|
55
53
|
};
|
|
56
|
-
var SDK_VERSION = "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
|
-
/**
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
599
|
-
|
|
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 && !
|
|
732
|
-
|
|
733
|
-
this.
|
|
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)
|
|
807
|
-
*
|
|
808
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
1346
|
-
|
|
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
|
|
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
|
-
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
1800
|
-
* @
|
|
1801
|
-
*
|
|
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
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
2017
|
-
|
|
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
|
-
*
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
-
*
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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.
|
|
@@ -2461,6 +2530,26 @@ function isAuthFailureEvent(event) {
|
|
|
2461
2530
|
}
|
|
2462
2531
|
|
|
2463
2532
|
// src/realtime/sse.ts
|
|
2533
|
+
var SSE_EVENT_NAMES = [
|
|
2534
|
+
"job.created",
|
|
2535
|
+
"job.status",
|
|
2536
|
+
"job.completed",
|
|
2537
|
+
"job.failed",
|
|
2538
|
+
"queue.stats",
|
|
2539
|
+
"worker.heartbeat",
|
|
2540
|
+
"worker.registered",
|
|
2541
|
+
"worker.deregistered",
|
|
2542
|
+
"system.health",
|
|
2543
|
+
"ping",
|
|
2544
|
+
"error"
|
|
2545
|
+
];
|
|
2546
|
+
var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
|
|
2547
|
+
"completed",
|
|
2548
|
+
"failed",
|
|
2549
|
+
"deadletter",
|
|
2550
|
+
"cancelled"
|
|
2551
|
+
]);
|
|
2552
|
+
var STABLE_CONNECTION_MS = 3e4;
|
|
2464
2553
|
var SseRealtimeClient = class {
|
|
2465
2554
|
options;
|
|
2466
2555
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2469,6 +2558,12 @@ var SseRealtimeClient = class {
|
|
|
2469
2558
|
reconnectAttempts = 0;
|
|
2470
2559
|
reconnectTimer = null;
|
|
2471
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;
|
|
2472
2567
|
// Event handlers
|
|
2473
2568
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
2474
2569
|
allEventsHandlers = /* @__PURE__ */ new Set();
|
|
@@ -2507,6 +2602,7 @@ var SseRealtimeClient = class {
|
|
|
2507
2602
|
return;
|
|
2508
2603
|
}
|
|
2509
2604
|
this.filter = filter || null;
|
|
2605
|
+
this.streamEnded = false;
|
|
2510
2606
|
this.setState("connecting");
|
|
2511
2607
|
const sseUrl = this.buildSseUrl(filter);
|
|
2512
2608
|
this.options.debug(`Connecting to SSE: ${sseUrl}`);
|
|
@@ -2526,7 +2622,9 @@ var SseRealtimeClient = class {
|
|
|
2526
2622
|
}
|
|
2527
2623
|
let token;
|
|
2528
2624
|
try {
|
|
2529
|
-
|
|
2625
|
+
const force = this.forceTokenRefresh;
|
|
2626
|
+
this.forceTokenRefresh = false;
|
|
2627
|
+
token = await this.options.tokenProvider(force);
|
|
2530
2628
|
} catch (error) {
|
|
2531
2629
|
this.setState("disconnected");
|
|
2532
2630
|
throw new Error(`Failed to acquire realtime token: ${error}`);
|
|
@@ -2546,14 +2644,22 @@ var SseRealtimeClient = class {
|
|
|
2546
2644
|
this.eventSource.onopen = () => {
|
|
2547
2645
|
this.options.debug("SSE connected");
|
|
2548
2646
|
this.setState("connected");
|
|
2549
|
-
this.
|
|
2647
|
+
this.connectedAt = Date.now();
|
|
2550
2648
|
resolve();
|
|
2551
2649
|
};
|
|
2552
2650
|
this.eventSource.onmessage = (event) => {
|
|
2553
2651
|
this.handleMessage(event.data);
|
|
2554
2652
|
};
|
|
2555
|
-
|
|
2653
|
+
for (const name of SSE_EVENT_NAMES) {
|
|
2654
|
+
this.eventSource.addEventListener(name, (event) => {
|
|
2655
|
+
if (typeof event?.data === "string") this.handleMessage(event.data);
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
this.eventSource.onerror = (event) => {
|
|
2556
2659
|
this.options.debug("SSE error");
|
|
2660
|
+
if (event?.code === 401 || event?.code === 403) {
|
|
2661
|
+
this.forceTokenRefresh = true;
|
|
2662
|
+
}
|
|
2557
2663
|
if (this.state === "connecting") {
|
|
2558
2664
|
this.setState("disconnected");
|
|
2559
2665
|
if (this.eventSource) {
|
|
@@ -2585,6 +2691,8 @@ var SseRealtimeClient = class {
|
|
|
2585
2691
|
}
|
|
2586
2692
|
this.setState("disconnected");
|
|
2587
2693
|
this.filter = null;
|
|
2694
|
+
this.connectedAt = null;
|
|
2695
|
+
this.streamEnded = false;
|
|
2588
2696
|
}
|
|
2589
2697
|
/**
|
|
2590
2698
|
* Reconnect with a new filter (SSE requires reconnection to change subscriptions)
|
|
@@ -2632,7 +2740,7 @@ var SseRealtimeClient = class {
|
|
|
2632
2740
|
return `${this.options.baseUrl}/api/v1/events/jobs/${encodeURIComponent(filter.jobId)}`;
|
|
2633
2741
|
}
|
|
2634
2742
|
if (filter?.queueName) {
|
|
2635
|
-
return `${this.options.baseUrl}/api/v1/events
|
|
2743
|
+
return `${this.options.baseUrl}/api/v1/events?queue=${encodeURIComponent(filter.queueName)}`;
|
|
2636
2744
|
}
|
|
2637
2745
|
return `${this.options.baseUrl}/api/v1/events`;
|
|
2638
2746
|
}
|
|
@@ -2649,6 +2757,9 @@ var SseRealtimeClient = class {
|
|
|
2649
2757
|
parsed.type = eventType;
|
|
2650
2758
|
const event = parsed;
|
|
2651
2759
|
this.options.debug(`Received SSE event: ${eventType}`, event);
|
|
2760
|
+
if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
|
|
2761
|
+
this.streamEnded = true;
|
|
2762
|
+
}
|
|
2652
2763
|
const handlers = this.eventHandlers.get(eventType);
|
|
2653
2764
|
if (handlers) {
|
|
2654
2765
|
const eventData = event.data;
|
|
@@ -2676,6 +2787,15 @@ var SseRealtimeClient = class {
|
|
|
2676
2787
|
this.eventSource.close();
|
|
2677
2788
|
this.eventSource = null;
|
|
2678
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;
|
|
2679
2799
|
if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
|
|
2680
2800
|
this.scheduleReconnect();
|
|
2681
2801
|
} else {
|
|
@@ -2707,6 +2827,19 @@ var SseRealtimeClient = class {
|
|
|
2707
2827
|
}, delay);
|
|
2708
2828
|
}
|
|
2709
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
|
+
}
|
|
2710
2843
|
|
|
2711
2844
|
// src/realtime/index.ts
|
|
2712
2845
|
var SpooledRealtime = class {
|
|
@@ -2893,6 +3026,11 @@ function clearProtoCache() {
|
|
|
2893
3026
|
cachedDefinition = null;
|
|
2894
3027
|
}
|
|
2895
3028
|
|
|
3029
|
+
// src/grpc/encode.ts
|
|
3030
|
+
function encodeFailRequest(params) {
|
|
3031
|
+
return { ...params, retry: params.retry ?? true };
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2896
3034
|
// src/grpc/streaming.ts
|
|
2897
3035
|
async function* asyncIterableFromStream(stream, options) {
|
|
2898
3036
|
const { signal, onConnected, onError, onEnd } = options ?? {};
|
|
@@ -2965,7 +3103,9 @@ function createProcessJobsStream(call, options) {
|
|
|
2965
3103
|
}
|
|
2966
3104
|
return {
|
|
2967
3105
|
send(request) {
|
|
2968
|
-
call.write(
|
|
3106
|
+
call.write(
|
|
3107
|
+
request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
|
|
3108
|
+
);
|
|
2969
3109
|
},
|
|
2970
3110
|
receive() {
|
|
2971
3111
|
return new AsyncIterableStream(call, { onError, onEnd });
|
|
@@ -3153,12 +3293,19 @@ function mapGrpcError(error, timeoutMs) {
|
|
|
3153
3293
|
return new ServerError(message, 500, "GRPC_ERROR");
|
|
3154
3294
|
}
|
|
3155
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
|
+
}
|
|
3156
3303
|
function shouldUseTls(address, explicitTls) {
|
|
3157
3304
|
if (explicitTls !== void 0) {
|
|
3158
3305
|
return explicitTls;
|
|
3159
3306
|
}
|
|
3160
|
-
const host = address
|
|
3161
|
-
return host !== "localhost" && host !== "127.0.0.1" &&
|
|
3307
|
+
const host = grpcHost(address).toLowerCase();
|
|
3308
|
+
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
|
3162
3309
|
}
|
|
3163
3310
|
var GrpcQueueOperations = class {
|
|
3164
3311
|
constructor(client, metadata, timeoutMs) {
|
|
@@ -3210,7 +3357,7 @@ var GrpcQueueOperations = class {
|
|
|
3210
3357
|
return promisify(
|
|
3211
3358
|
this.client,
|
|
3212
3359
|
this.client.Fail,
|
|
3213
|
-
params,
|
|
3360
|
+
encodeFailRequest(params),
|
|
3214
3361
|
this.metadata,
|
|
3215
3362
|
this.timeoutMs
|
|
3216
3363
|
);
|
|
@@ -3600,6 +3747,13 @@ var SpooledClient = class _SpooledClient {
|
|
|
3600
3747
|
/**
|
|
3601
3748
|
* Exchange the API key for a JWT via /auth/login and cache the result.
|
|
3602
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
|
+
*
|
|
3603
3757
|
* Concurrent callers share a single in-flight login so a burst of reconnects
|
|
3604
3758
|
* (e.g. WebSocket and SSE at once) issues just one request.
|
|
3605
3759
|
*/
|
|
@@ -3609,9 +3763,6 @@ var SpooledClient = class _SpooledClient {
|
|
|
3609
3763
|
}
|
|
3610
3764
|
this.jwtLoginPromise = (async () => {
|
|
3611
3765
|
const response = await this.auth.login({ apiKey: this.config.apiKey });
|
|
3612
|
-
this.http.setAuthToken(response.accessToken);
|
|
3613
|
-
this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
|
|
3614
|
-
this.config.refreshToken = response.refreshToken;
|
|
3615
3766
|
this.cachedJwt = response.accessToken;
|
|
3616
3767
|
return response.accessToken;
|
|
3617
3768
|
})();
|
|
@@ -3807,6 +3958,9 @@ var DEFAULT_OPTIONS = {
|
|
|
3807
3958
|
autoStart: false,
|
|
3808
3959
|
shutdownTimeout: 3e4
|
|
3809
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;
|
|
3810
3964
|
var SpooledWorker = class {
|
|
3811
3965
|
client;
|
|
3812
3966
|
/**
|
|
@@ -3823,6 +3977,10 @@ var SpooledWorker = class {
|
|
|
3823
3977
|
pollTimer = null;
|
|
3824
3978
|
workerHeartbeatTimer = null;
|
|
3825
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;
|
|
3826
3984
|
// Event handlers
|
|
3827
3985
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
3828
3986
|
constructor(client, options) {
|
|
@@ -3839,7 +3997,11 @@ var SpooledWorker = class {
|
|
|
3839
3997
|
this.debug = config.debug ?? (() => {
|
|
3840
3998
|
});
|
|
3841
3999
|
if (this.options.autoStart) {
|
|
3842
|
-
setImmediate(() =>
|
|
4000
|
+
setImmediate(() => {
|
|
4001
|
+
this.start().catch((error) => {
|
|
4002
|
+
this.debug("Auto-start failed", error);
|
|
4003
|
+
});
|
|
4004
|
+
});
|
|
3843
4005
|
}
|
|
3844
4006
|
}
|
|
3845
4007
|
/**
|
|
@@ -3910,7 +4072,15 @@ var SpooledWorker = class {
|
|
|
3910
4072
|
}
|
|
3911
4073
|
}
|
|
3912
4074
|
/**
|
|
3913
|
-
* 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.
|
|
3914
4084
|
*/
|
|
3915
4085
|
async stop() {
|
|
3916
4086
|
if (this.state !== "running") {
|
|
@@ -3923,39 +4093,37 @@ var SpooledWorker = class {
|
|
|
3923
4093
|
return this.shutdownPromise;
|
|
3924
4094
|
}
|
|
3925
4095
|
async doStop() {
|
|
4096
|
+
const deadline = Date.now() + this.options.shutdownTimeout;
|
|
3926
4097
|
this.state = "stopping";
|
|
3927
4098
|
this.debug("Stopping worker...");
|
|
3928
4099
|
if (this.pollTimer) {
|
|
3929
4100
|
clearTimeout(this.pollTimer);
|
|
3930
4101
|
this.pollTimer = null;
|
|
3931
4102
|
}
|
|
3932
|
-
if (this.
|
|
3933
|
-
|
|
3934
|
-
this.workerHeartbeatTimer = null;
|
|
3935
|
-
}
|
|
3936
|
-
for (const active of this.activeJobs.values()) {
|
|
3937
|
-
active.abortController.abort();
|
|
4103
|
+
if (this.pollInFlight) {
|
|
4104
|
+
await this.pollInFlight;
|
|
3938
4105
|
}
|
|
3939
4106
|
if (this.activeJobs.size > 0) {
|
|
3940
4107
|
this.debug(
|
|
3941
4108
|
`Waiting for ${this.activeJobs.size} active jobs to complete...`
|
|
3942
4109
|
);
|
|
3943
|
-
await
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
this.
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
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
|
+
);
|
|
4118
|
+
}
|
|
4119
|
+
if (this.workerHeartbeatTimer) {
|
|
4120
|
+
clearInterval(this.workerHeartbeatTimer);
|
|
4121
|
+
this.workerHeartbeatTimer = null;
|
|
3955
4122
|
}
|
|
3956
|
-
|
|
4123
|
+
const workerId = this.workerId;
|
|
4124
|
+
if (workerId) {
|
|
3957
4125
|
try {
|
|
3958
|
-
await this.client.workers.deregister(
|
|
4126
|
+
await this.client.workers.deregister(workerId);
|
|
3959
4127
|
this.debug("Worker deregistered");
|
|
3960
4128
|
} catch (error) {
|
|
3961
4129
|
this.debug("Failed to deregister worker", error);
|
|
@@ -3963,7 +4131,7 @@ var SpooledWorker = class {
|
|
|
3963
4131
|
}
|
|
3964
4132
|
this.state = "stopped";
|
|
3965
4133
|
this.workerId = null;
|
|
3966
|
-
this.emit("stopped", { workerId:
|
|
4134
|
+
this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
|
|
3967
4135
|
this.shutdownPromise = null;
|
|
3968
4136
|
}
|
|
3969
4137
|
/**
|
|
@@ -4002,7 +4170,17 @@ var SpooledWorker = class {
|
|
|
4002
4170
|
if (this.state !== "running") {
|
|
4003
4171
|
return;
|
|
4004
4172
|
}
|
|
4005
|
-
this.pollTimer = setTimeout(() =>
|
|
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);
|
|
4006
4184
|
}
|
|
4007
4185
|
async poll() {
|
|
4008
4186
|
if (this.state !== "running" || !this.workerId) {
|
|
@@ -4036,7 +4214,8 @@ var SpooledWorker = class {
|
|
|
4036
4214
|
executionId: Symbol(job.id),
|
|
4037
4215
|
job,
|
|
4038
4216
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4039
|
-
abortController
|
|
4217
|
+
abortController,
|
|
4218
|
+
settled: false
|
|
4040
4219
|
};
|
|
4041
4220
|
this.activeJobs.set(activeJob.executionId, activeJob);
|
|
4042
4221
|
const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
|
|
@@ -4065,23 +4244,72 @@ var SpooledWorker = class {
|
|
|
4065
4244
|
}
|
|
4066
4245
|
};
|
|
4067
4246
|
try {
|
|
4068
|
-
const
|
|
4069
|
-
if (
|
|
4070
|
-
this.debug(`Job ${job.id} was
|
|
4247
|
+
const outcome = await this.runHandler(activeJob, context);
|
|
4248
|
+
if (activeJob.settled) {
|
|
4249
|
+
this.debug(`Job ${job.id} was already settled; dropping late result`);
|
|
4071
4250
|
return;
|
|
4072
4251
|
}
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
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;
|
|
4078
4271
|
}
|
|
4079
|
-
|
|
4080
|
-
await this.failJob(job, errorMessage);
|
|
4272
|
+
await activeJob.ack;
|
|
4081
4273
|
} finally {
|
|
4082
4274
|
this.cleanupJob(activeJob);
|
|
4083
4275
|
}
|
|
4084
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
|
+
}
|
|
4085
4313
|
async completeJob(job, result) {
|
|
4086
4314
|
if (!this.workerId) return;
|
|
4087
4315
|
try {
|
|
@@ -4097,25 +4325,34 @@ var SpooledWorker = class {
|
|
|
4097
4325
|
});
|
|
4098
4326
|
} catch (error) {
|
|
4099
4327
|
this.debug(`Failed to complete job ${job.id}`, error);
|
|
4328
|
+
this.emit("error", { error });
|
|
4100
4329
|
}
|
|
4101
4330
|
}
|
|
4102
4331
|
async failJob(job, errorMessage) {
|
|
4103
4332
|
if (!this.workerId) return;
|
|
4104
4333
|
const willRetry = job.retryCount < job.maxRetries;
|
|
4334
|
+
const message = coerceFailureMessage(errorMessage);
|
|
4105
4335
|
try {
|
|
4106
4336
|
await this.client.jobs.fail(job.id, {
|
|
4107
4337
|
workerId: this.workerId,
|
|
4108
|
-
error:
|
|
4338
|
+
error: message,
|
|
4109
4339
|
...job.leaseId != null && { leaseId: job.leaseId }
|
|
4110
4340
|
});
|
|
4111
4341
|
this.emit("job:failed", {
|
|
4112
4342
|
jobId: job.id,
|
|
4113
4343
|
queueName: job.queueName,
|
|
4114
|
-
error:
|
|
4344
|
+
error: message,
|
|
4115
4345
|
willRetry
|
|
4116
4346
|
});
|
|
4117
4347
|
} catch (error) {
|
|
4118
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;
|
|
4119
4356
|
}
|
|
4120
4357
|
}
|
|
4121
4358
|
cleanupJob(activeJob) {
|
|
@@ -4123,8 +4360,12 @@ var SpooledWorker = class {
|
|
|
4123
4360
|
clearInterval(activeJob.heartbeatTimer);
|
|
4124
4361
|
activeJob.heartbeatTimer = void 0;
|
|
4125
4362
|
}
|
|
4363
|
+
this.clearTimeoutTimer(activeJob);
|
|
4126
4364
|
if (this.activeJobs.get(activeJob.executionId) === activeJob) {
|
|
4127
4365
|
this.activeJobs.delete(activeJob.executionId);
|
|
4366
|
+
if (this.activeJobs.size === 0) {
|
|
4367
|
+
this.drainWaiter?.();
|
|
4368
|
+
}
|
|
4128
4369
|
}
|
|
4129
4370
|
}
|
|
4130
4371
|
async sendJobHeartbeat(activeJob) {
|
|
@@ -4141,7 +4382,9 @@ var SpooledWorker = class {
|
|
|
4141
4382
|
}
|
|
4142
4383
|
}
|
|
4143
4384
|
async sendWorkerHeartbeat() {
|
|
4144
|
-
if (!this.workerId || this.state !== "running")
|
|
4385
|
+
if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4145
4388
|
try {
|
|
4146
4389
|
await this.client.workers.heartbeat(this.workerId, {
|
|
4147
4390
|
currentJobs: this.activeJobs.size,
|
|
@@ -4151,13 +4394,25 @@ var SpooledWorker = class {
|
|
|
4151
4394
|
this.debug("Worker heartbeat failed", error);
|
|
4152
4395
|
}
|
|
4153
4396
|
}
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
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();
|
|
4157
4404
|
}
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
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
|
+
});
|
|
4161
4416
|
}
|
|
4162
4417
|
};
|
|
4163
4418
|
|
|
@@ -4199,6 +4454,7 @@ exports.SpooledRealtime = SpooledRealtime;
|
|
|
4199
4454
|
exports.SpooledWorker = SpooledWorker;
|
|
4200
4455
|
exports.SseRealtimeClient = SseRealtimeClient;
|
|
4201
4456
|
exports.TimeoutError = TimeoutError;
|
|
4457
|
+
exports.UnsupportedOperationError = UnsupportedOperationError;
|
|
4202
4458
|
exports.ValidationError = ValidationError;
|
|
4203
4459
|
exports.WebSocketRealtimeClient = WebSocketRealtimeClient;
|
|
4204
4460
|
exports.WebhookIngestionResource = WebhookIngestionResource;
|