@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.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createHmac } from 'crypto';
|
|
2
|
-
import { Buffer } from 'buffer';
|
|
3
1
|
import * as grpc2 from '@grpc/grpc-js';
|
|
4
2
|
import * as protoLoader from '@grpc/proto-loader';
|
|
5
3
|
import { existsSync } from 'fs';
|
|
@@ -26,10 +24,10 @@ var DEFAULT_CONFIG = {
|
|
|
26
24
|
successThreshold: 3,
|
|
27
25
|
timeout: 3e4
|
|
28
26
|
},
|
|
29
|
-
userAgent: "@spooled/sdk-nodejs/1.
|
|
27
|
+
userAgent: "@spooled/sdk-nodejs/1.3.0",
|
|
30
28
|
autoRefreshToken: true
|
|
31
29
|
};
|
|
32
|
-
var SDK_VERSION = "1.
|
|
30
|
+
var SDK_VERSION = "1.3.0";
|
|
33
31
|
var API_VERSION = "v1";
|
|
34
32
|
var API_BASE_PATH = `/api/${API_VERSION}`;
|
|
35
33
|
function normalizeCredential(value) {
|
|
@@ -86,9 +84,9 @@ function resolveConfig(options) {
|
|
|
86
84
|
};
|
|
87
85
|
}
|
|
88
86
|
function validateConfig(config) {
|
|
89
|
-
if (!config.apiKey && !config.accessToken) {
|
|
87
|
+
if (!config.apiKey && !config.accessToken && !config.adminKey) {
|
|
90
88
|
throw new Error(
|
|
91
|
-
"SpooledClient requires either apiKey or accessToken for authentication"
|
|
89
|
+
"SpooledClient requires either apiKey or accessToken for authentication (or adminKey for admin-only use)"
|
|
92
90
|
);
|
|
93
91
|
}
|
|
94
92
|
if (config.apiKey && !config.apiKey.startsWith("sp_") && !config.apiKey.startsWith("sk_")) {
|
|
@@ -151,9 +149,15 @@ var SpooledError = class extends Error {
|
|
|
151
149
|
Error.captureStackTrace(this, this.constructor);
|
|
152
150
|
}
|
|
153
151
|
}
|
|
154
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Whether this error is retryable.
|
|
154
|
+
*
|
|
155
|
+
* Only 408 (Request Timeout) is retryable among the plain 4xx statuses: the
|
|
156
|
+
* server gave up waiting for the request, so it never ran. Subclasses
|
|
157
|
+
* override this (5xx, network, timeout, rate limit).
|
|
158
|
+
*/
|
|
155
159
|
isRetryable() {
|
|
156
|
-
return
|
|
160
|
+
return this.statusCode === 408;
|
|
157
161
|
}
|
|
158
162
|
/** Convert to plain object for logging */
|
|
159
163
|
toJSON() {
|
|
@@ -192,8 +196,8 @@ var ConflictError = class extends SpooledError {
|
|
|
192
196
|
}
|
|
193
197
|
};
|
|
194
198
|
var ValidationError = class extends SpooledError {
|
|
195
|
-
constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId) {
|
|
196
|
-
super(message,
|
|
199
|
+
constructor(message = "Validation failed", code = "VALIDATION_ERROR", details, requestId, statusCode = 400) {
|
|
200
|
+
super(message, statusCode, code, details, requestId);
|
|
197
201
|
this.name = "ValidationError";
|
|
198
202
|
}
|
|
199
203
|
};
|
|
@@ -203,6 +207,7 @@ var PayloadTooLargeError = class extends SpooledError {
|
|
|
203
207
|
this.name = "PayloadTooLargeError";
|
|
204
208
|
}
|
|
205
209
|
};
|
|
210
|
+
var QUOTA_MESSAGE = /\blimit reached\b/i;
|
|
206
211
|
var RateLimitError = class extends SpooledError {
|
|
207
212
|
/** Rate limit information */
|
|
208
213
|
rateLimitInfo;
|
|
@@ -211,8 +216,27 @@ var RateLimitError = class extends SpooledError {
|
|
|
211
216
|
this.name = "RateLimitError";
|
|
212
217
|
this.rateLimitInfo = rateLimitInfo;
|
|
213
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* True when this 429 is a plan quota or limit (daily job cap, active jobs,
|
|
221
|
+
* queue/worker/webhook caps) rather than per-second rate limiting.
|
|
222
|
+
*
|
|
223
|
+
* A quota does not clear for minutes or hours, so retrying it only blocks the
|
|
224
|
+
* caller for the whole backoff and, via the circuit breaker, takes every
|
|
225
|
+
* other call on the client down with it. It is never retried and never
|
|
226
|
+
* counted as a circuit breaker failure.
|
|
227
|
+
*
|
|
228
|
+
* Detection: the `QUOTA_EXCEEDED` code, or, for older plan-limit paths that
|
|
229
|
+
* answer with plain text wrapped as `RATE_LIMIT_EXCEEDED`, a "... limit
|
|
230
|
+
* reached" message that carries no `Retry-After`.
|
|
231
|
+
*/
|
|
232
|
+
get isQuota() {
|
|
233
|
+
if (this.code === "QUOTA_EXCEEDED") {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
return this.rateLimitInfo.retryAfter === void 0 && QUOTA_MESSAGE.test(this.message);
|
|
237
|
+
}
|
|
214
238
|
isRetryable() {
|
|
215
|
-
return
|
|
239
|
+
return !this.isQuota;
|
|
216
240
|
}
|
|
217
241
|
/** Get the number of seconds to wait before retrying */
|
|
218
242
|
getRetryAfter() {
|
|
@@ -272,6 +296,20 @@ var CircuitBreakerOpenError = class extends SpooledError {
|
|
|
272
296
|
return false;
|
|
273
297
|
}
|
|
274
298
|
};
|
|
299
|
+
var UnsupportedOperationError = class extends SpooledError {
|
|
300
|
+
/** The SDK method that is not supported, e.g. `ingest.github` */
|
|
301
|
+
operation;
|
|
302
|
+
constructor(operation, reason) {
|
|
303
|
+
super(
|
|
304
|
+
`${operation} is not supported by the Spooled API: ${reason}`,
|
|
305
|
+
0,
|
|
306
|
+
"UNSUPPORTED_OPERATION",
|
|
307
|
+
{ operation }
|
|
308
|
+
);
|
|
309
|
+
this.name = "UnsupportedOperationError";
|
|
310
|
+
this.operation = operation;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
275
313
|
function parseRateLimitHeaders(headers) {
|
|
276
314
|
const info = {};
|
|
277
315
|
const retryAfter = headers.get("Retry-After");
|
|
@@ -343,6 +381,10 @@ async function createErrorFromResponse(response) {
|
|
|
343
381
|
switch (response.status) {
|
|
344
382
|
case 400:
|
|
345
383
|
return new ValidationError(message, code, details, requestId);
|
|
384
|
+
// A missing or mistyped JSON field is a 422 from the API's extractor; it is
|
|
385
|
+
// the same class of caller error as a 400 field-rule failure.
|
|
386
|
+
case 422:
|
|
387
|
+
return new ValidationError(message, code, details, requestId, 422);
|
|
346
388
|
case 401:
|
|
347
389
|
return new AuthenticationError(message, code, details, requestId);
|
|
348
390
|
case 403:
|
|
@@ -571,8 +613,12 @@ var HttpClient = class {
|
|
|
571
613
|
authToken;
|
|
572
614
|
/** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
|
|
573
615
|
refreshTokenFn = null;
|
|
574
|
-
/**
|
|
575
|
-
|
|
616
|
+
/**
|
|
617
|
+
* The refresh currently in flight, shared by every request that gets a 401
|
|
618
|
+
* meanwhile. A boolean guard here made the second concurrent 401 skip the
|
|
619
|
+
* refresh and fail even though the token was about to be replaced.
|
|
620
|
+
*/
|
|
621
|
+
refreshInFlight = null;
|
|
576
622
|
constructor(config, circuitBreaker) {
|
|
577
623
|
this.config = config;
|
|
578
624
|
this.circuitBreaker = circuitBreaker;
|
|
@@ -594,6 +640,30 @@ var HttpClient = class {
|
|
|
594
640
|
setRefreshTokenFn(fn) {
|
|
595
641
|
this.refreshTokenFn = fn;
|
|
596
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* Refresh the access token once for all callers.
|
|
645
|
+
*
|
|
646
|
+
* Resolves true when a new token is installed, false when the refresh failed
|
|
647
|
+
* (the caller then surfaces its original 401).
|
|
648
|
+
*/
|
|
649
|
+
refreshAuthToken() {
|
|
650
|
+
if (!this.refreshInFlight) {
|
|
651
|
+
const refresh = this.refreshTokenFn;
|
|
652
|
+
this.refreshInFlight = (async () => {
|
|
653
|
+
try {
|
|
654
|
+
this.setAuthToken(await refresh());
|
|
655
|
+
return true;
|
|
656
|
+
} catch (refreshError) {
|
|
657
|
+
const message = refreshError instanceof Error ? refreshError.message : "unknown";
|
|
658
|
+
this.debug?.("Token refresh failed", { error: message });
|
|
659
|
+
return false;
|
|
660
|
+
} finally {
|
|
661
|
+
this.refreshInFlight = null;
|
|
662
|
+
}
|
|
663
|
+
})();
|
|
664
|
+
}
|
|
665
|
+
return this.refreshInFlight;
|
|
666
|
+
}
|
|
597
667
|
/**
|
|
598
668
|
* Build full URL with query parameters
|
|
599
669
|
*/
|
|
@@ -669,6 +739,7 @@ var HttpClient = class {
|
|
|
669
739
|
*/
|
|
670
740
|
async executeRequest(url, options, hasRefreshed = false) {
|
|
671
741
|
const method = options.method ?? "GET";
|
|
742
|
+
const tokenUsed = this.authToken;
|
|
672
743
|
const headers = this.buildHeaders(options.headers);
|
|
673
744
|
const timeout = options.timeout ?? this.config.timeout;
|
|
674
745
|
const init = {
|
|
@@ -683,6 +754,9 @@ var HttpClient = class {
|
|
|
683
754
|
init.body = JSON.stringify(body);
|
|
684
755
|
}
|
|
685
756
|
}
|
|
757
|
+
if (init.body === void 0) {
|
|
758
|
+
headers.delete("Content-Type");
|
|
759
|
+
}
|
|
686
760
|
this.debug?.(`${method} ${url}`, { timeout, hasBody: !!options.body });
|
|
687
761
|
let response;
|
|
688
762
|
try {
|
|
@@ -704,20 +778,9 @@ var HttpClient = class {
|
|
|
704
778
|
}
|
|
705
779
|
this.debug?.(`Response: ${response.status}`, { url });
|
|
706
780
|
if (!response.ok) {
|
|
707
|
-
if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !
|
|
708
|
-
|
|
709
|
-
this.
|
|
710
|
-
try {
|
|
711
|
-
const newToken = await this.refreshTokenFn();
|
|
712
|
-
this.setAuthToken(newToken);
|
|
713
|
-
refreshed = true;
|
|
714
|
-
} catch (refreshError) {
|
|
715
|
-
const message = refreshError instanceof Error ? refreshError.message : "unknown";
|
|
716
|
-
this.debug?.("Token refresh failed", { error: message });
|
|
717
|
-
} finally {
|
|
718
|
-
this.isRefreshing = false;
|
|
719
|
-
}
|
|
720
|
-
if (refreshed) {
|
|
781
|
+
if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !options.skipAuthRefresh) {
|
|
782
|
+
const alreadyRefreshed = this.authToken !== tokenUsed;
|
|
783
|
+
if (alreadyRefreshed || await this.refreshAuthToken()) {
|
|
721
784
|
return this.executeRequest(url, options, true);
|
|
722
785
|
}
|
|
723
786
|
}
|
|
@@ -779,22 +842,26 @@ var HttpClient = class {
|
|
|
779
842
|
/**
|
|
780
843
|
* Resolve the retry configuration for a request.
|
|
781
844
|
*
|
|
782
|
-
* Idempotent methods (GET/PUT/DELETE)
|
|
783
|
-
*
|
|
784
|
-
*
|
|
845
|
+
* Idempotent methods (GET/PUT/DELETE), any request carrying an idempotency
|
|
846
|
+
* key and any request marked `idempotent` retry normally. Non-idempotent
|
|
847
|
+
* methods (POST/PATCH) otherwise must NOT be retried on ambiguous failures
|
|
785
848
|
* (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
|
|
786
|
-
* etc. They are still retried on 429, which is rejected before
|
|
787
|
-
* server processes the request and is therefore side-effect-free.
|
|
849
|
+
* etc. They are still retried on a rate-limit 429, which is rejected before
|
|
850
|
+
* the server processes the request and is therefore side-effect-free. A
|
|
851
|
+
* quota 429 is never retried: it does not clear for hours.
|
|
788
852
|
*/
|
|
789
853
|
resolveRetryConfig(method, options) {
|
|
790
854
|
const base = this.config.retry;
|
|
791
|
-
if (IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
|
|
855
|
+
if (options.idempotent || IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
|
|
792
856
|
return base;
|
|
793
857
|
}
|
|
794
858
|
return {
|
|
795
859
|
...base,
|
|
796
860
|
retryOn: (error, attempt) => {
|
|
797
861
|
if (error instanceof RateLimitError) {
|
|
862
|
+
if (error.isQuota) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
798
865
|
return base.retryOn ? base.retryOn(error, attempt) : true;
|
|
799
866
|
}
|
|
800
867
|
return false;
|
|
@@ -1025,13 +1092,17 @@ var AuthResource = class {
|
|
|
1025
1092
|
* Exchange API key for JWT tokens
|
|
1026
1093
|
*/
|
|
1027
1094
|
async login(params) {
|
|
1028
|
-
return this.http.post("/auth/login", params
|
|
1095
|
+
return this.http.post("/auth/login", params, {
|
|
1096
|
+
skipAuthRefresh: true
|
|
1097
|
+
});
|
|
1029
1098
|
}
|
|
1030
1099
|
/**
|
|
1031
1100
|
* Refresh access token using refresh token
|
|
1032
1101
|
*/
|
|
1033
1102
|
async refresh(params) {
|
|
1034
|
-
return this.http.post("/auth/refresh", params
|
|
1103
|
+
return this.http.post("/auth/refresh", params, {
|
|
1104
|
+
skipAuthRefresh: true
|
|
1105
|
+
});
|
|
1035
1106
|
}
|
|
1036
1107
|
/**
|
|
1037
1108
|
* Logout and revoke the refresh token server-side.
|
|
@@ -1102,6 +1173,21 @@ function mapValidate(raw) {
|
|
|
1102
1173
|
};
|
|
1103
1174
|
}
|
|
1104
1175
|
|
|
1176
|
+
// src/utils/failure.ts
|
|
1177
|
+
var MAX_FAILURE_MESSAGE_CHARS = 2048;
|
|
1178
|
+
var DEFAULT_FAILURE_MESSAGE = "Job failed";
|
|
1179
|
+
function coerceFailureMessage(message) {
|
|
1180
|
+
const text = typeof message === "string" ? message : message == null ? "" : `${message}`;
|
|
1181
|
+
if (text === "") {
|
|
1182
|
+
return DEFAULT_FAILURE_MESSAGE;
|
|
1183
|
+
}
|
|
1184
|
+
if (text.length <= MAX_FAILURE_MESSAGE_CHARS) {
|
|
1185
|
+
return text;
|
|
1186
|
+
}
|
|
1187
|
+
const codePoints = Array.from(text);
|
|
1188
|
+
return codePoints.length <= MAX_FAILURE_MESSAGE_CHARS ? text : codePoints.slice(0, MAX_FAILURE_MESSAGE_CHARS).join("");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1105
1191
|
// src/resources/jobs.ts
|
|
1106
1192
|
var JobsResource = class {
|
|
1107
1193
|
constructor(http) {
|
|
@@ -1118,7 +1204,9 @@ var JobsResource = class {
|
|
|
1118
1204
|
* Create a new job
|
|
1119
1205
|
*/
|
|
1120
1206
|
async create(params) {
|
|
1121
|
-
return this.http.post("/jobs", params
|
|
1207
|
+
return this.http.post("/jobs", params, {
|
|
1208
|
+
idempotent: params.idempotencyKey ? true : void 0
|
|
1209
|
+
});
|
|
1122
1210
|
}
|
|
1123
1211
|
/**
|
|
1124
1212
|
* Create a job and return the full job object
|
|
@@ -1202,26 +1290,43 @@ var JobsResource = class {
|
|
|
1202
1290
|
}
|
|
1203
1291
|
/**
|
|
1204
1292
|
* Complete a job (worker ack)
|
|
1293
|
+
*
|
|
1294
|
+
* Lease-fenced and idempotent on the server, so a network error, timeout or
|
|
1295
|
+
* 5xx is retried: a lost ack would otherwise re-run a finished job.
|
|
1205
1296
|
*/
|
|
1206
1297
|
async complete(id, params) {
|
|
1207
|
-
return this.http.post(
|
|
1298
|
+
return this.http.post(
|
|
1299
|
+
`/jobs/${id}/complete`,
|
|
1300
|
+
params,
|
|
1301
|
+
{ idempotent: true }
|
|
1302
|
+
);
|
|
1208
1303
|
}
|
|
1209
1304
|
/**
|
|
1210
1305
|
* Fail a job (worker nack)
|
|
1306
|
+
*
|
|
1307
|
+
* `params.error` is made acceptable first: empty text becomes "Job failed"
|
|
1308
|
+
* and text over 2048 characters is truncated, because the API rejects both
|
|
1309
|
+
* with a 400 that would leave the job processing until its lease expires.
|
|
1310
|
+
* Retried like {@link JobsResource.complete}.
|
|
1211
1311
|
*/
|
|
1212
1312
|
async fail(id, params) {
|
|
1213
1313
|
return this.http.post(
|
|
1214
1314
|
`/jobs/${id}/fail`,
|
|
1215
|
-
params
|
|
1315
|
+
{ ...params, error: coerceFailureMessage(params.error) },
|
|
1316
|
+
{ idempotent: true }
|
|
1216
1317
|
);
|
|
1217
1318
|
}
|
|
1218
1319
|
/**
|
|
1219
1320
|
* Extend job lease (heartbeat)
|
|
1321
|
+
*
|
|
1322
|
+
* Retried like {@link JobsResource.complete}: with the default 30 s lease and
|
|
1323
|
+
* 15 s heartbeat, one lost heartbeat leaves no slack.
|
|
1220
1324
|
*/
|
|
1221
1325
|
async heartbeat(id, params) {
|
|
1222
1326
|
return this.http.post(
|
|
1223
1327
|
`/jobs/${id}/heartbeat`,
|
|
1224
|
-
params
|
|
1328
|
+
params,
|
|
1329
|
+
{ idempotent: true }
|
|
1225
1330
|
);
|
|
1226
1331
|
}
|
|
1227
1332
|
// DLQ operations (private implementations)
|
|
@@ -1317,9 +1422,17 @@ var QueuesResource = class {
|
|
|
1317
1422
|
}
|
|
1318
1423
|
/**
|
|
1319
1424
|
* Delete a queue configuration
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1425
|
+
*
|
|
1426
|
+
* Without `deleteJobs` the API only deletes an explicit queue config of an
|
|
1427
|
+
* idle queue, and answers 409 otherwise: a queue that exists only because
|
|
1428
|
+
* jobs were posted to it has no config to delete, and a queue with pending
|
|
1429
|
+
* or processing jobs is not idle. Pass `{ deleteJobs: true }` to delete the
|
|
1430
|
+
* queue's jobs (and DLQ rows) along with it.
|
|
1431
|
+
*/
|
|
1432
|
+
async delete(name, options) {
|
|
1433
|
+
await this.http.delete(`/queues/${encodeURIComponent(name)}`, {
|
|
1434
|
+
params: options?.deleteJobs ? { deleteJobs: true } : void 0
|
|
1435
|
+
});
|
|
1323
1436
|
}
|
|
1324
1437
|
};
|
|
1325
1438
|
|
|
@@ -1348,9 +1461,13 @@ var WorkersResource = class {
|
|
|
1348
1461
|
}
|
|
1349
1462
|
/**
|
|
1350
1463
|
* Send worker heartbeat
|
|
1464
|
+
*
|
|
1465
|
+
* Idempotent on the server, so network errors, timeouts and 5xx are retried.
|
|
1351
1466
|
*/
|
|
1352
1467
|
async heartbeat(id, params) {
|
|
1353
|
-
await this.http.post(`/workers/${id}/heartbeat`, params
|
|
1468
|
+
await this.http.post(`/workers/${id}/heartbeat`, params, {
|
|
1469
|
+
idempotent: true
|
|
1470
|
+
});
|
|
1354
1471
|
}
|
|
1355
1472
|
/**
|
|
1356
1473
|
* Deregister a worker
|
|
@@ -1499,7 +1616,7 @@ var WorkflowsResource = class {
|
|
|
1499
1616
|
const jobs = await this.listWorkflowJobs(workflowId);
|
|
1500
1617
|
return jobs.map((job) => ({
|
|
1501
1618
|
jobId: job.id,
|
|
1502
|
-
key: job.key,
|
|
1619
|
+
...job.key !== void 0 && { key: job.key },
|
|
1503
1620
|
status: job.status
|
|
1504
1621
|
}));
|
|
1505
1622
|
}
|
|
@@ -1557,7 +1674,10 @@ function mapWorkflowDetailJobs(detail) {
|
|
|
1557
1674
|
return {
|
|
1558
1675
|
id,
|
|
1559
1676
|
workflowId: String(job.workflowId ?? workflowId),
|
|
1560
|
-
key
|
|
1677
|
+
// The API does not return the creation-time key (jobs store only a
|
|
1678
|
+
// workflow step), so there is nothing to put here unless a future
|
|
1679
|
+
// version sends one. Never fabricate "".
|
|
1680
|
+
...typeof job.key === "string" && job.key !== "" && { key: job.key },
|
|
1561
1681
|
queueName: String(job.queueName ?? job.queue ?? ""),
|
|
1562
1682
|
status: job.status,
|
|
1563
1683
|
payload: job.payload ?? {},
|
|
@@ -1769,19 +1889,18 @@ var OrganizationsResource = class {
|
|
|
1769
1889
|
/**
|
|
1770
1890
|
* Clear/delete the webhook token for the current organization.
|
|
1771
1891
|
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
* @
|
|
1777
|
-
*
|
|
1778
|
-
* await client.organizations.clearWebhookToken();
|
|
1779
|
-
* ```
|
|
1892
|
+
* @deprecated The API refuses this operation on purpose (an org without a
|
|
1893
|
+
* webhook token would have an open public ingestion endpoint) and always
|
|
1894
|
+
* answers with an error. This method now throws
|
|
1895
|
+
* {@link UnsupportedOperationError} without a network call; rotate the token
|
|
1896
|
+
* with {@link OrganizationsResource.regenerateWebhookToken} instead. It will
|
|
1897
|
+
* be removed in the next major release.
|
|
1780
1898
|
*/
|
|
1781
1899
|
async clearWebhookToken() {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1900
|
+
throw new UnsupportedOperationError(
|
|
1901
|
+
"organizations.clearWebhookToken",
|
|
1902
|
+
"clearing the webhook token is not allowed; use organizations.regenerateWebhookToken() to rotate it"
|
|
1903
|
+
);
|
|
1785
1904
|
}
|
|
1786
1905
|
};
|
|
1787
1906
|
|
|
@@ -1989,24 +2108,8 @@ var AdminResource = class {
|
|
|
1989
2108
|
});
|
|
1990
2109
|
}
|
|
1991
2110
|
};
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
return Buffer.from(body, "utf8");
|
|
1995
|
-
}
|
|
1996
|
-
if (body instanceof Uint8Array) {
|
|
1997
|
-
return body;
|
|
1998
|
-
}
|
|
1999
|
-
return new Uint8Array(body);
|
|
2000
|
-
}
|
|
2001
|
-
function githubSignature(secret, body) {
|
|
2002
|
-
const hex = createHmac("sha256", secret).update(body).digest("hex");
|
|
2003
|
-
return `sha256=${hex}`;
|
|
2004
|
-
}
|
|
2005
|
-
function stripeSignature(secret, body, timestamp) {
|
|
2006
|
-
const payload = `${timestamp}.${Buffer.from(body).toString("utf8")}`;
|
|
2007
|
-
const hex = createHmac("sha256", secret).update(payload, "utf8").digest("hex");
|
|
2008
|
-
return `t=${timestamp},v1=${hex}`;
|
|
2009
|
-
}
|
|
2111
|
+
|
|
2112
|
+
// src/resources/webhook-ingestion.ts
|
|
2010
2113
|
var WebhookIngestionResource = class {
|
|
2011
2114
|
constructor(http) {
|
|
2012
2115
|
this.http = http;
|
|
@@ -2014,66 +2117,32 @@ var WebhookIngestionResource = class {
|
|
|
2014
2117
|
/**
|
|
2015
2118
|
* Ingest a GitHub webhook.
|
|
2016
2119
|
*
|
|
2017
|
-
*
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
"Content-Type": "application/json",
|
|
2029
|
-
"X-GitHub-Event": options.githubEvent,
|
|
2030
|
-
"X-Hub-Signature-256": signature
|
|
2031
|
-
};
|
|
2032
|
-
if (options.webhookToken) {
|
|
2033
|
-
headers["X-Webhook-Token"] = options.webhookToken;
|
|
2034
|
-
}
|
|
2035
|
-
if (options.forwardedProto) {
|
|
2036
|
-
headers["X-Forwarded-Proto"] = options.forwardedProto;
|
|
2037
|
-
}
|
|
2038
|
-
await this.http.request(`/webhooks/${orgId}/github`, {
|
|
2039
|
-
method: "POST",
|
|
2040
|
-
rawBody: bytes,
|
|
2041
|
-
headers,
|
|
2042
|
-
skipRequestConversion: true,
|
|
2043
|
-
skipResponseConversion: true
|
|
2044
|
-
});
|
|
2120
|
+
* @deprecated The API has no `/webhooks/{org_id}/github` route (it always
|
|
2121
|
+
* answered 404). This method now throws {@link UnsupportedOperationError}
|
|
2122
|
+
* without a network call; point the GitHub webhook at `ingest.custom` /
|
|
2123
|
+
* `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
|
|
2124
|
+
* next major release.
|
|
2125
|
+
*/
|
|
2126
|
+
async github(_orgId, _body, _options) {
|
|
2127
|
+
throw new UnsupportedOperationError(
|
|
2128
|
+
"ingest.github",
|
|
2129
|
+
"the API has no GitHub webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
|
|
2130
|
+
);
|
|
2045
2131
|
}
|
|
2046
2132
|
/**
|
|
2047
2133
|
* Ingest a Stripe webhook.
|
|
2048
2134
|
*
|
|
2049
|
-
*
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
)
|
|
2059
|
-
|
|
2060
|
-
const headers = {
|
|
2061
|
-
"Content-Type": "application/json",
|
|
2062
|
-
"Stripe-Signature": signature
|
|
2063
|
-
};
|
|
2064
|
-
if (options.webhookToken) {
|
|
2065
|
-
headers["X-Webhook-Token"] = options.webhookToken;
|
|
2066
|
-
}
|
|
2067
|
-
if (options.forwardedProto) {
|
|
2068
|
-
headers["X-Forwarded-Proto"] = options.forwardedProto;
|
|
2069
|
-
}
|
|
2070
|
-
await this.http.request(`/webhooks/${orgId}/stripe`, {
|
|
2071
|
-
method: "POST",
|
|
2072
|
-
rawBody: bytes,
|
|
2073
|
-
headers,
|
|
2074
|
-
skipRequestConversion: true,
|
|
2075
|
-
skipResponseConversion: true
|
|
2076
|
-
});
|
|
2135
|
+
* @deprecated The API has no `/webhooks/{org_id}/stripe` route (it always
|
|
2136
|
+
* answered 404). This method now throws {@link UnsupportedOperationError}
|
|
2137
|
+
* without a network call; point the Stripe webhook at `ingest.custom` /
|
|
2138
|
+
* `POST /api/v1/webhooks/{org_id}/custom` instead. It will be removed in the
|
|
2139
|
+
* next major release.
|
|
2140
|
+
*/
|
|
2141
|
+
async stripe(_orgId, _body, _options = {}) {
|
|
2142
|
+
throw new UnsupportedOperationError(
|
|
2143
|
+
"ingest.stripe",
|
|
2144
|
+
"the API has no Stripe webhook route; use ingest.custom (POST /webhooks/{org_id}/custom)"
|
|
2145
|
+
);
|
|
2077
2146
|
}
|
|
2078
2147
|
/**
|
|
2079
2148
|
* Ingest a custom webhook.
|
|
@@ -2437,6 +2506,26 @@ function isAuthFailureEvent(event) {
|
|
|
2437
2506
|
}
|
|
2438
2507
|
|
|
2439
2508
|
// src/realtime/sse.ts
|
|
2509
|
+
var SSE_EVENT_NAMES = [
|
|
2510
|
+
"job.created",
|
|
2511
|
+
"job.status",
|
|
2512
|
+
"job.completed",
|
|
2513
|
+
"job.failed",
|
|
2514
|
+
"queue.stats",
|
|
2515
|
+
"worker.heartbeat",
|
|
2516
|
+
"worker.registered",
|
|
2517
|
+
"worker.deregistered",
|
|
2518
|
+
"system.health",
|
|
2519
|
+
"ping",
|
|
2520
|
+
"error"
|
|
2521
|
+
];
|
|
2522
|
+
var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
|
|
2523
|
+
"completed",
|
|
2524
|
+
"failed",
|
|
2525
|
+
"deadletter",
|
|
2526
|
+
"cancelled"
|
|
2527
|
+
]);
|
|
2528
|
+
var STABLE_CONNECTION_MS = 3e4;
|
|
2440
2529
|
var SseRealtimeClient = class {
|
|
2441
2530
|
options;
|
|
2442
2531
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2445,6 +2534,12 @@ var SseRealtimeClient = class {
|
|
|
2445
2534
|
reconnectAttempts = 0;
|
|
2446
2535
|
reconnectTimer = null;
|
|
2447
2536
|
filter = null;
|
|
2537
|
+
/** When the current connection opened (for the stable-connection check). */
|
|
2538
|
+
connectedAt = null;
|
|
2539
|
+
/** The server finished this stream on purpose (terminal job); do not reconnect. */
|
|
2540
|
+
streamEnded = false;
|
|
2541
|
+
/** The server rejected our token; mint a fresh one for the next connect. */
|
|
2542
|
+
forceTokenRefresh = false;
|
|
2448
2543
|
// Event handlers
|
|
2449
2544
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
2450
2545
|
allEventsHandlers = /* @__PURE__ */ new Set();
|
|
@@ -2483,6 +2578,7 @@ var SseRealtimeClient = class {
|
|
|
2483
2578
|
return;
|
|
2484
2579
|
}
|
|
2485
2580
|
this.filter = filter || null;
|
|
2581
|
+
this.streamEnded = false;
|
|
2486
2582
|
this.setState("connecting");
|
|
2487
2583
|
const sseUrl = this.buildSseUrl(filter);
|
|
2488
2584
|
this.options.debug(`Connecting to SSE: ${sseUrl}`);
|
|
@@ -2502,7 +2598,9 @@ var SseRealtimeClient = class {
|
|
|
2502
2598
|
}
|
|
2503
2599
|
let token;
|
|
2504
2600
|
try {
|
|
2505
|
-
|
|
2601
|
+
const force = this.forceTokenRefresh;
|
|
2602
|
+
this.forceTokenRefresh = false;
|
|
2603
|
+
token = await this.options.tokenProvider(force);
|
|
2506
2604
|
} catch (error) {
|
|
2507
2605
|
this.setState("disconnected");
|
|
2508
2606
|
throw new Error(`Failed to acquire realtime token: ${error}`);
|
|
@@ -2522,14 +2620,22 @@ var SseRealtimeClient = class {
|
|
|
2522
2620
|
this.eventSource.onopen = () => {
|
|
2523
2621
|
this.options.debug("SSE connected");
|
|
2524
2622
|
this.setState("connected");
|
|
2525
|
-
this.
|
|
2623
|
+
this.connectedAt = Date.now();
|
|
2526
2624
|
resolve();
|
|
2527
2625
|
};
|
|
2528
2626
|
this.eventSource.onmessage = (event) => {
|
|
2529
2627
|
this.handleMessage(event.data);
|
|
2530
2628
|
};
|
|
2531
|
-
|
|
2629
|
+
for (const name of SSE_EVENT_NAMES) {
|
|
2630
|
+
this.eventSource.addEventListener(name, (event) => {
|
|
2631
|
+
if (typeof event?.data === "string") this.handleMessage(event.data);
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
this.eventSource.onerror = (event) => {
|
|
2532
2635
|
this.options.debug("SSE error");
|
|
2636
|
+
if (event?.code === 401 || event?.code === 403) {
|
|
2637
|
+
this.forceTokenRefresh = true;
|
|
2638
|
+
}
|
|
2533
2639
|
if (this.state === "connecting") {
|
|
2534
2640
|
this.setState("disconnected");
|
|
2535
2641
|
if (this.eventSource) {
|
|
@@ -2561,6 +2667,8 @@ var SseRealtimeClient = class {
|
|
|
2561
2667
|
}
|
|
2562
2668
|
this.setState("disconnected");
|
|
2563
2669
|
this.filter = null;
|
|
2670
|
+
this.connectedAt = null;
|
|
2671
|
+
this.streamEnded = false;
|
|
2564
2672
|
}
|
|
2565
2673
|
/**
|
|
2566
2674
|
* Reconnect with a new filter (SSE requires reconnection to change subscriptions)
|
|
@@ -2608,7 +2716,7 @@ var SseRealtimeClient = class {
|
|
|
2608
2716
|
return `${this.options.baseUrl}/api/v1/events/jobs/${encodeURIComponent(filter.jobId)}`;
|
|
2609
2717
|
}
|
|
2610
2718
|
if (filter?.queueName) {
|
|
2611
|
-
return `${this.options.baseUrl}/api/v1/events
|
|
2719
|
+
return `${this.options.baseUrl}/api/v1/events?queue=${encodeURIComponent(filter.queueName)}`;
|
|
2612
2720
|
}
|
|
2613
2721
|
return `${this.options.baseUrl}/api/v1/events`;
|
|
2614
2722
|
}
|
|
@@ -2625,6 +2733,9 @@ var SseRealtimeClient = class {
|
|
|
2625
2733
|
parsed.type = eventType;
|
|
2626
2734
|
const event = parsed;
|
|
2627
2735
|
this.options.debug(`Received SSE event: ${eventType}`, event);
|
|
2736
|
+
if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
|
|
2737
|
+
this.streamEnded = true;
|
|
2738
|
+
}
|
|
2628
2739
|
const handlers = this.eventHandlers.get(eventType);
|
|
2629
2740
|
if (handlers) {
|
|
2630
2741
|
const eventData = event.data;
|
|
@@ -2652,6 +2763,15 @@ var SseRealtimeClient = class {
|
|
|
2652
2763
|
this.eventSource.close();
|
|
2653
2764
|
this.eventSource = null;
|
|
2654
2765
|
}
|
|
2766
|
+
if (this.streamEnded) {
|
|
2767
|
+
this.connectedAt = null;
|
|
2768
|
+
this.setState("disconnected");
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (this.connectedAt !== null && Date.now() - this.connectedAt >= STABLE_CONNECTION_MS) {
|
|
2772
|
+
this.reconnectAttempts = 0;
|
|
2773
|
+
}
|
|
2774
|
+
this.connectedAt = null;
|
|
2655
2775
|
if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
|
|
2656
2776
|
this.scheduleReconnect();
|
|
2657
2777
|
} else {
|
|
@@ -2683,6 +2803,19 @@ var SseRealtimeClient = class {
|
|
|
2683
2803
|
}, delay);
|
|
2684
2804
|
}
|
|
2685
2805
|
};
|
|
2806
|
+
function isStreamEndingEvent(eventType, data) {
|
|
2807
|
+
if (eventType === "error" || eventType === "job.completed") {
|
|
2808
|
+
return true;
|
|
2809
|
+
}
|
|
2810
|
+
const record = data ?? {};
|
|
2811
|
+
if (eventType === "job.failed") {
|
|
2812
|
+
return record.willRetry === false;
|
|
2813
|
+
}
|
|
2814
|
+
if (eventType === "job.status_changed" || eventType === "job.status") {
|
|
2815
|
+
return typeof record.newStatus === "string" && TERMINAL_JOB_STATUSES.has(record.newStatus);
|
|
2816
|
+
}
|
|
2817
|
+
return false;
|
|
2818
|
+
}
|
|
2686
2819
|
|
|
2687
2820
|
// src/realtime/index.ts
|
|
2688
2821
|
var SpooledRealtime = class {
|
|
@@ -2869,6 +3002,11 @@ function clearProtoCache() {
|
|
|
2869
3002
|
cachedDefinition = null;
|
|
2870
3003
|
}
|
|
2871
3004
|
|
|
3005
|
+
// src/grpc/encode.ts
|
|
3006
|
+
function encodeFailRequest(params) {
|
|
3007
|
+
return { ...params, retry: params.retry ?? true };
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2872
3010
|
// src/grpc/streaming.ts
|
|
2873
3011
|
async function* asyncIterableFromStream(stream, options) {
|
|
2874
3012
|
const { signal, onConnected, onError, onEnd } = options ?? {};
|
|
@@ -2941,7 +3079,9 @@ function createProcessJobsStream(call, options) {
|
|
|
2941
3079
|
}
|
|
2942
3080
|
return {
|
|
2943
3081
|
send(request) {
|
|
2944
|
-
call.write(
|
|
3082
|
+
call.write(
|
|
3083
|
+
request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
|
|
3084
|
+
);
|
|
2945
3085
|
},
|
|
2946
3086
|
receive() {
|
|
2947
3087
|
return new AsyncIterableStream(call, { onError, onEnd });
|
|
@@ -3129,12 +3269,19 @@ function mapGrpcError(error, timeoutMs) {
|
|
|
3129
3269
|
return new ServerError(message, 500, "GRPC_ERROR");
|
|
3130
3270
|
}
|
|
3131
3271
|
}
|
|
3272
|
+
function grpcHost(address) {
|
|
3273
|
+
if (address.startsWith("[")) {
|
|
3274
|
+
const end = address.indexOf("]");
|
|
3275
|
+
return end === -1 ? address : address.slice(1, end);
|
|
3276
|
+
}
|
|
3277
|
+
return address.split(":").length > 2 ? address : address.split(":")[0];
|
|
3278
|
+
}
|
|
3132
3279
|
function shouldUseTls(address, explicitTls) {
|
|
3133
3280
|
if (explicitTls !== void 0) {
|
|
3134
3281
|
return explicitTls;
|
|
3135
3282
|
}
|
|
3136
|
-
const host = address
|
|
3137
|
-
return host !== "localhost" && host !== "127.0.0.1" &&
|
|
3283
|
+
const host = grpcHost(address).toLowerCase();
|
|
3284
|
+
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
|
3138
3285
|
}
|
|
3139
3286
|
var GrpcQueueOperations = class {
|
|
3140
3287
|
constructor(client, metadata, timeoutMs) {
|
|
@@ -3186,7 +3333,7 @@ var GrpcQueueOperations = class {
|
|
|
3186
3333
|
return promisify(
|
|
3187
3334
|
this.client,
|
|
3188
3335
|
this.client.Fail,
|
|
3189
|
-
params,
|
|
3336
|
+
encodeFailRequest(params),
|
|
3190
3337
|
this.metadata,
|
|
3191
3338
|
this.timeoutMs
|
|
3192
3339
|
);
|
|
@@ -3576,6 +3723,13 @@ var SpooledClient = class _SpooledClient {
|
|
|
3576
3723
|
/**
|
|
3577
3724
|
* Exchange the API key for a JWT via /auth/login and cache the result.
|
|
3578
3725
|
*
|
|
3726
|
+
* The JWT belongs to the realtime transport only. It must NOT replace the
|
|
3727
|
+
* REST bearer: an API-key client has no refresh token wired into its HTTP
|
|
3728
|
+
* layer, so after the JWT's 24 h lifetime every REST call (claim, complete,
|
|
3729
|
+
* heartbeat, create) would have failed with 401 for good. REST keeps using
|
|
3730
|
+
* the API key; realtime re-mints its own JWT near expiry (see
|
|
3731
|
+
* `getJwtToken`).
|
|
3732
|
+
*
|
|
3579
3733
|
* Concurrent callers share a single in-flight login so a burst of reconnects
|
|
3580
3734
|
* (e.g. WebSocket and SSE at once) issues just one request.
|
|
3581
3735
|
*/
|
|
@@ -3585,9 +3739,6 @@ var SpooledClient = class _SpooledClient {
|
|
|
3585
3739
|
}
|
|
3586
3740
|
this.jwtLoginPromise = (async () => {
|
|
3587
3741
|
const response = await this.auth.login({ apiKey: this.config.apiKey });
|
|
3588
|
-
this.http.setAuthToken(response.accessToken);
|
|
3589
|
-
this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
|
|
3590
|
-
this.config.refreshToken = response.refreshToken;
|
|
3591
3742
|
this.cachedJwt = response.accessToken;
|
|
3592
3743
|
return response.accessToken;
|
|
3593
3744
|
})();
|
|
@@ -3783,6 +3934,9 @@ var DEFAULT_OPTIONS = {
|
|
|
3783
3934
|
autoStart: false,
|
|
3784
3935
|
shutdownTimeout: 3e4
|
|
3785
3936
|
};
|
|
3937
|
+
var JOB_TIMEOUT_MESSAGE = "Job timeout exceeded";
|
|
3938
|
+
var SHUTDOWN_TIMEOUT_MESSAGE = "Worker shutdown timeout";
|
|
3939
|
+
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
3786
3940
|
var SpooledWorker = class {
|
|
3787
3941
|
client;
|
|
3788
3942
|
/**
|
|
@@ -3799,6 +3953,10 @@ var SpooledWorker = class {
|
|
|
3799
3953
|
pollTimer = null;
|
|
3800
3954
|
workerHeartbeatTimer = null;
|
|
3801
3955
|
shutdownPromise = null;
|
|
3956
|
+
/** The claim request currently on the wire, so a stop can wait for its jobs. */
|
|
3957
|
+
pollInFlight = null;
|
|
3958
|
+
/** Wakes the shutdown drain when the last active job has been acked. */
|
|
3959
|
+
drainWaiter = null;
|
|
3802
3960
|
// Event handlers
|
|
3803
3961
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
3804
3962
|
constructor(client, options) {
|
|
@@ -3815,7 +3973,11 @@ var SpooledWorker = class {
|
|
|
3815
3973
|
this.debug = config.debug ?? (() => {
|
|
3816
3974
|
});
|
|
3817
3975
|
if (this.options.autoStart) {
|
|
3818
|
-
setImmediate(() =>
|
|
3976
|
+
setImmediate(() => {
|
|
3977
|
+
this.start().catch((error) => {
|
|
3978
|
+
this.debug("Auto-start failed", error);
|
|
3979
|
+
});
|
|
3980
|
+
});
|
|
3819
3981
|
}
|
|
3820
3982
|
}
|
|
3821
3983
|
/**
|
|
@@ -3886,7 +4048,15 @@ var SpooledWorker = class {
|
|
|
3886
4048
|
}
|
|
3887
4049
|
}
|
|
3888
4050
|
/**
|
|
3889
|
-
* Stop the worker gracefully
|
|
4051
|
+
* Stop the worker gracefully.
|
|
4052
|
+
*
|
|
4053
|
+
* Drains: the worker stops claiming, then lets running handlers finish for
|
|
4054
|
+
* up to `shutdownTimeout`. A handler that returns completes its job and one
|
|
4055
|
+
* that throws fails it, even though a stop was requested meanwhile; its
|
|
4056
|
+
* `signal` is NOT aborted during the grace period. Only handlers still
|
|
4057
|
+
* running when the timeout ends are aborted and failed with "Worker shutdown
|
|
4058
|
+
* timeout". The worker deregisters last, because deregistering returns every
|
|
4059
|
+
* job the worker still holds to the queue.
|
|
3890
4060
|
*/
|
|
3891
4061
|
async stop() {
|
|
3892
4062
|
if (this.state !== "running") {
|
|
@@ -3899,39 +4069,37 @@ var SpooledWorker = class {
|
|
|
3899
4069
|
return this.shutdownPromise;
|
|
3900
4070
|
}
|
|
3901
4071
|
async doStop() {
|
|
4072
|
+
const deadline = Date.now() + this.options.shutdownTimeout;
|
|
3902
4073
|
this.state = "stopping";
|
|
3903
4074
|
this.debug("Stopping worker...");
|
|
3904
4075
|
if (this.pollTimer) {
|
|
3905
4076
|
clearTimeout(this.pollTimer);
|
|
3906
4077
|
this.pollTimer = null;
|
|
3907
4078
|
}
|
|
3908
|
-
if (this.
|
|
3909
|
-
|
|
3910
|
-
this.workerHeartbeatTimer = null;
|
|
3911
|
-
}
|
|
3912
|
-
for (const active of this.activeJobs.values()) {
|
|
3913
|
-
active.abortController.abort();
|
|
4079
|
+
if (this.pollInFlight) {
|
|
4080
|
+
await this.pollInFlight;
|
|
3914
4081
|
}
|
|
3915
4082
|
if (this.activeJobs.size > 0) {
|
|
3916
4083
|
this.debug(
|
|
3917
4084
|
`Waiting for ${this.activeJobs.size} active jobs to complete...`
|
|
3918
4085
|
);
|
|
3919
|
-
await
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
this.
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
4086
|
+
await this.waitForDrain(Math.max(0, deadline - Date.now()));
|
|
4087
|
+
const stragglers = Array.from(this.activeJobs.values()).filter(
|
|
4088
|
+
(active) => !active.settled
|
|
4089
|
+
);
|
|
4090
|
+
await Promise.all(stragglers.map((active) => this.abandonJob(active)));
|
|
4091
|
+
await Promise.all(
|
|
4092
|
+
Array.from(this.activeJobs.values()).map((active) => active.ack)
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
if (this.workerHeartbeatTimer) {
|
|
4096
|
+
clearInterval(this.workerHeartbeatTimer);
|
|
4097
|
+
this.workerHeartbeatTimer = null;
|
|
3931
4098
|
}
|
|
3932
|
-
|
|
4099
|
+
const workerId = this.workerId;
|
|
4100
|
+
if (workerId) {
|
|
3933
4101
|
try {
|
|
3934
|
-
await this.client.workers.deregister(
|
|
4102
|
+
await this.client.workers.deregister(workerId);
|
|
3935
4103
|
this.debug("Worker deregistered");
|
|
3936
4104
|
} catch (error) {
|
|
3937
4105
|
this.debug("Failed to deregister worker", error);
|
|
@@ -3939,7 +4107,7 @@ var SpooledWorker = class {
|
|
|
3939
4107
|
}
|
|
3940
4108
|
this.state = "stopped";
|
|
3941
4109
|
this.workerId = null;
|
|
3942
|
-
this.emit("stopped", { workerId:
|
|
4110
|
+
this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
|
|
3943
4111
|
this.shutdownPromise = null;
|
|
3944
4112
|
}
|
|
3945
4113
|
/**
|
|
@@ -3978,7 +4146,17 @@ var SpooledWorker = class {
|
|
|
3978
4146
|
if (this.state !== "running") {
|
|
3979
4147
|
return;
|
|
3980
4148
|
}
|
|
3981
|
-
this.pollTimer = setTimeout(() =>
|
|
4149
|
+
this.pollTimer = setTimeout(() => {
|
|
4150
|
+
this.pollTimer = null;
|
|
4151
|
+
const run = this.poll();
|
|
4152
|
+
this.pollInFlight = run;
|
|
4153
|
+
const done = () => {
|
|
4154
|
+
if (this.pollInFlight === run) {
|
|
4155
|
+
this.pollInFlight = null;
|
|
4156
|
+
}
|
|
4157
|
+
};
|
|
4158
|
+
run.then(done, done);
|
|
4159
|
+
}, this.options.pollInterval);
|
|
3982
4160
|
}
|
|
3983
4161
|
async poll() {
|
|
3984
4162
|
if (this.state !== "running" || !this.workerId) {
|
|
@@ -4012,7 +4190,8 @@ var SpooledWorker = class {
|
|
|
4012
4190
|
executionId: Symbol(job.id),
|
|
4013
4191
|
job,
|
|
4014
4192
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4015
|
-
abortController
|
|
4193
|
+
abortController,
|
|
4194
|
+
settled: false
|
|
4016
4195
|
};
|
|
4017
4196
|
this.activeJobs.set(activeJob.executionId, activeJob);
|
|
4018
4197
|
const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
|
|
@@ -4041,23 +4220,72 @@ var SpooledWorker = class {
|
|
|
4041
4220
|
}
|
|
4042
4221
|
};
|
|
4043
4222
|
try {
|
|
4044
|
-
const
|
|
4045
|
-
if (
|
|
4046
|
-
this.debug(`Job ${job.id} was
|
|
4223
|
+
const outcome = await this.runHandler(activeJob, context);
|
|
4224
|
+
if (activeJob.settled) {
|
|
4225
|
+
this.debug(`Job ${job.id} was already settled; dropping late result`);
|
|
4047
4226
|
return;
|
|
4048
4227
|
}
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4228
|
+
activeJob.settled = true;
|
|
4229
|
+
switch (outcome.kind) {
|
|
4230
|
+
case "returned":
|
|
4231
|
+
activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
|
|
4232
|
+
break;
|
|
4233
|
+
case "threw":
|
|
4234
|
+
activeJob.ack = this.failJob(
|
|
4235
|
+
job,
|
|
4236
|
+
outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
|
4237
|
+
);
|
|
4238
|
+
break;
|
|
4239
|
+
case "timeout":
|
|
4240
|
+
abortController.abort(new Error(JOB_TIMEOUT_MESSAGE));
|
|
4241
|
+
this.emit("job:timeout", {
|
|
4242
|
+
jobId: job.id,
|
|
4243
|
+
queueName: job.queueName
|
|
4244
|
+
});
|
|
4245
|
+
activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
|
|
4246
|
+
break;
|
|
4054
4247
|
}
|
|
4055
|
-
|
|
4056
|
-
await this.failJob(job, errorMessage);
|
|
4248
|
+
await activeJob.ack;
|
|
4057
4249
|
} finally {
|
|
4058
4250
|
this.cleanupJob(activeJob);
|
|
4059
4251
|
}
|
|
4060
4252
|
}
|
|
4253
|
+
/**
|
|
4254
|
+
* Run the handler, racing it against the job's `timeoutSeconds`.
|
|
4255
|
+
* Never rejects: a throw (sync or async) is reported as an outcome.
|
|
4256
|
+
*/
|
|
4257
|
+
runHandler(activeJob, context) {
|
|
4258
|
+
const run = (async () => {
|
|
4259
|
+
try {
|
|
4260
|
+
return { kind: "returned", value: await this.handler(context) };
|
|
4261
|
+
} catch (error) {
|
|
4262
|
+
return { kind: "threw", error };
|
|
4263
|
+
}
|
|
4264
|
+
})();
|
|
4265
|
+
const { timeoutSeconds } = activeJob.job;
|
|
4266
|
+
if (typeof timeoutSeconds !== "number" || !(timeoutSeconds > 0)) {
|
|
4267
|
+
return run;
|
|
4268
|
+
}
|
|
4269
|
+
return new Promise((resolve) => {
|
|
4270
|
+
activeJob.timeoutTimer = setTimeout(
|
|
4271
|
+
() => resolve({ kind: "timeout" }),
|
|
4272
|
+
Math.min(timeoutSeconds * 1e3, MAX_TIMER_MS)
|
|
4273
|
+
);
|
|
4274
|
+
run.then((outcome) => {
|
|
4275
|
+
this.clearTimeoutTimer(activeJob);
|
|
4276
|
+
resolve(outcome);
|
|
4277
|
+
});
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
4280
|
+
/** Abort a handler that outlived the shutdown grace period and fail its job. */
|
|
4281
|
+
async abandonJob(activeJob) {
|
|
4282
|
+
this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
|
|
4283
|
+
activeJob.settled = true;
|
|
4284
|
+
activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
|
|
4285
|
+
activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
|
|
4286
|
+
await activeJob.ack;
|
|
4287
|
+
this.cleanupJob(activeJob);
|
|
4288
|
+
}
|
|
4061
4289
|
async completeJob(job, result) {
|
|
4062
4290
|
if (!this.workerId) return;
|
|
4063
4291
|
try {
|
|
@@ -4073,25 +4301,34 @@ var SpooledWorker = class {
|
|
|
4073
4301
|
});
|
|
4074
4302
|
} catch (error) {
|
|
4075
4303
|
this.debug(`Failed to complete job ${job.id}`, error);
|
|
4304
|
+
this.emit("error", { error });
|
|
4076
4305
|
}
|
|
4077
4306
|
}
|
|
4078
4307
|
async failJob(job, errorMessage) {
|
|
4079
4308
|
if (!this.workerId) return;
|
|
4080
4309
|
const willRetry = job.retryCount < job.maxRetries;
|
|
4310
|
+
const message = coerceFailureMessage(errorMessage);
|
|
4081
4311
|
try {
|
|
4082
4312
|
await this.client.jobs.fail(job.id, {
|
|
4083
4313
|
workerId: this.workerId,
|
|
4084
|
-
error:
|
|
4314
|
+
error: message,
|
|
4085
4315
|
...job.leaseId != null && { leaseId: job.leaseId }
|
|
4086
4316
|
});
|
|
4087
4317
|
this.emit("job:failed", {
|
|
4088
4318
|
jobId: job.id,
|
|
4089
4319
|
queueName: job.queueName,
|
|
4090
|
-
error:
|
|
4320
|
+
error: message,
|
|
4091
4321
|
willRetry
|
|
4092
4322
|
});
|
|
4093
4323
|
} catch (error) {
|
|
4094
4324
|
this.debug(`Failed to fail job ${job.id}`, error);
|
|
4325
|
+
this.emit("error", { error });
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
clearTimeoutTimer(activeJob) {
|
|
4329
|
+
if (activeJob.timeoutTimer) {
|
|
4330
|
+
clearTimeout(activeJob.timeoutTimer);
|
|
4331
|
+
activeJob.timeoutTimer = void 0;
|
|
4095
4332
|
}
|
|
4096
4333
|
}
|
|
4097
4334
|
cleanupJob(activeJob) {
|
|
@@ -4099,8 +4336,12 @@ var SpooledWorker = class {
|
|
|
4099
4336
|
clearInterval(activeJob.heartbeatTimer);
|
|
4100
4337
|
activeJob.heartbeatTimer = void 0;
|
|
4101
4338
|
}
|
|
4339
|
+
this.clearTimeoutTimer(activeJob);
|
|
4102
4340
|
if (this.activeJobs.get(activeJob.executionId) === activeJob) {
|
|
4103
4341
|
this.activeJobs.delete(activeJob.executionId);
|
|
4342
|
+
if (this.activeJobs.size === 0) {
|
|
4343
|
+
this.drainWaiter?.();
|
|
4344
|
+
}
|
|
4104
4345
|
}
|
|
4105
4346
|
}
|
|
4106
4347
|
async sendJobHeartbeat(activeJob) {
|
|
@@ -4117,7 +4358,9 @@ var SpooledWorker = class {
|
|
|
4117
4358
|
}
|
|
4118
4359
|
}
|
|
4119
4360
|
async sendWorkerHeartbeat() {
|
|
4120
|
-
if (!this.workerId || this.state !== "running")
|
|
4361
|
+
if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
|
|
4362
|
+
return;
|
|
4363
|
+
}
|
|
4121
4364
|
try {
|
|
4122
4365
|
await this.client.workers.heartbeat(this.workerId, {
|
|
4123
4366
|
currentJobs: this.activeJobs.size,
|
|
@@ -4127,16 +4370,28 @@ var SpooledWorker = class {
|
|
|
4127
4370
|
this.debug("Worker heartbeat failed", error);
|
|
4128
4371
|
}
|
|
4129
4372
|
}
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4373
|
+
/**
|
|
4374
|
+
* Resolve once no job is active, or after `timeoutMs`. The timer is always
|
|
4375
|
+
* cleared, so a clean stop leaves nothing that keeps the process alive.
|
|
4376
|
+
*/
|
|
4377
|
+
waitForDrain(timeoutMs) {
|
|
4378
|
+
if (this.activeJobs.size === 0) {
|
|
4379
|
+
return Promise.resolve();
|
|
4133
4380
|
}
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4381
|
+
return new Promise((resolve) => {
|
|
4382
|
+
const timer = setTimeout(() => {
|
|
4383
|
+
this.drainWaiter = null;
|
|
4384
|
+
resolve();
|
|
4385
|
+
}, timeoutMs);
|
|
4386
|
+
this.drainWaiter = () => {
|
|
4387
|
+
clearTimeout(timer);
|
|
4388
|
+
this.drainWaiter = null;
|
|
4389
|
+
resolve();
|
|
4390
|
+
};
|
|
4391
|
+
});
|
|
4137
4392
|
}
|
|
4138
4393
|
};
|
|
4139
4394
|
|
|
4140
|
-
export { API_VERSION, AdminResource, ApiKeysResource, AuthResource, AuthenticationError, AuthorizationError, BillingResource, CircuitBreaker, CircuitBreakerOpenError, CircuitState, ConflictError, DEFAULT_CONFIG, DashboardResource, GrpcJobStatus, GrpcJobStatusNumber, GrpcQueueOperations, GrpcWorkerOperations, HealthResource, HttpClient, JobsResource, MetricsResource, NetworkError, NotFoundError, OrganizationsResource, PROTO_LOADER_OPTIONS, PayloadTooLargeError, QueuesResource, RateLimitError, SDK_VERSION, SchedulesResource, ServerError, SpooledClient, SpooledError, SpooledGrpcClient, SpooledRealtime, SpooledWorker, SseRealtimeClient, TimeoutError, ValidationError, WebSocketRealtimeClient, WebhookIngestionResource, WebhooksResource, WorkersResource, WorkflowsResource, asyncIterableFromStream, calculateDelay, camelToSnake, clearProtoCache, convertQueryParams, convertRequest, convertResponse, createCircuitBreaker, createClient, createErrorFromResponse, createHttpClient, createJobStream, createPaginatedResponse, createProcessJobsStream, createRetryWrapper, dateToTimestamp, getProtoPath, isRetryableError, isSpooledError, loadProtoDefinition, loadProtoDefinitionAsync, parseRateLimitHeaders, resolveConfig, shouldRetry, sleep, snakeToCamel, timestampToDate, toCamelCase, toSnakeCase, validateConfig, withRetry };
|
|
4395
|
+
export { API_VERSION, AdminResource, ApiKeysResource, AuthResource, AuthenticationError, AuthorizationError, BillingResource, CircuitBreaker, CircuitBreakerOpenError, CircuitState, ConflictError, DEFAULT_CONFIG, DashboardResource, GrpcJobStatus, GrpcJobStatusNumber, GrpcQueueOperations, GrpcWorkerOperations, HealthResource, HttpClient, JobsResource, MetricsResource, NetworkError, NotFoundError, OrganizationsResource, PROTO_LOADER_OPTIONS, PayloadTooLargeError, QueuesResource, RateLimitError, SDK_VERSION, SchedulesResource, ServerError, SpooledClient, SpooledError, SpooledGrpcClient, SpooledRealtime, SpooledWorker, SseRealtimeClient, TimeoutError, UnsupportedOperationError, ValidationError, WebSocketRealtimeClient, WebhookIngestionResource, WebhooksResource, WorkersResource, WorkflowsResource, asyncIterableFromStream, calculateDelay, camelToSnake, clearProtoCache, convertQueryParams, convertRequest, convertResponse, createCircuitBreaker, createClient, createErrorFromResponse, createHttpClient, createJobStream, createPaginatedResponse, createProcessJobsStream, createRetryWrapper, dateToTimestamp, getProtoPath, isRetryableError, isSpooledError, loadProtoDefinition, loadProtoDefinitionAsync, parseRateLimitHeaders, resolveConfig, shouldRetry, sleep, snakeToCamel, timestampToDate, toCamelCase, toSnakeCase, validateConfig, withRetry };
|
|
4141
4396
|
//# sourceMappingURL=index.js.map
|
|
4142
4397
|
//# sourceMappingURL=index.js.map
|