@spooled/sdk 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -12
- package/dist/{index-CWVI9LRo.d.cts → index-BVWKEr6p.d.cts} +178 -45
- package/dist/{index-CWVI9LRo.d.ts → index-BVWKEr6p.d.ts} +178 -45
- package/dist/index.cjs +422 -184
- 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 +422 -185
- 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.
|
|
@@ -2474,6 +2543,13 @@ var SSE_EVENT_NAMES = [
|
|
|
2474
2543
|
"ping",
|
|
2475
2544
|
"error"
|
|
2476
2545
|
];
|
|
2546
|
+
var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set([
|
|
2547
|
+
"completed",
|
|
2548
|
+
"failed",
|
|
2549
|
+
"deadletter",
|
|
2550
|
+
"cancelled"
|
|
2551
|
+
]);
|
|
2552
|
+
var STABLE_CONNECTION_MS = 3e4;
|
|
2477
2553
|
var SseRealtimeClient = class {
|
|
2478
2554
|
options;
|
|
2479
2555
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2482,6 +2558,12 @@ var SseRealtimeClient = class {
|
|
|
2482
2558
|
reconnectAttempts = 0;
|
|
2483
2559
|
reconnectTimer = null;
|
|
2484
2560
|
filter = null;
|
|
2561
|
+
/** When the current connection opened (for the stable-connection check). */
|
|
2562
|
+
connectedAt = null;
|
|
2563
|
+
/** The server finished this stream on purpose (terminal job); do not reconnect. */
|
|
2564
|
+
streamEnded = false;
|
|
2565
|
+
/** The server rejected our token; mint a fresh one for the next connect. */
|
|
2566
|
+
forceTokenRefresh = false;
|
|
2485
2567
|
// Event handlers
|
|
2486
2568
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
2487
2569
|
allEventsHandlers = /* @__PURE__ */ new Set();
|
|
@@ -2520,6 +2602,7 @@ var SseRealtimeClient = class {
|
|
|
2520
2602
|
return;
|
|
2521
2603
|
}
|
|
2522
2604
|
this.filter = filter || null;
|
|
2605
|
+
this.streamEnded = false;
|
|
2523
2606
|
this.setState("connecting");
|
|
2524
2607
|
const sseUrl = this.buildSseUrl(filter);
|
|
2525
2608
|
this.options.debug(`Connecting to SSE: ${sseUrl}`);
|
|
@@ -2539,7 +2622,9 @@ var SseRealtimeClient = class {
|
|
|
2539
2622
|
}
|
|
2540
2623
|
let token;
|
|
2541
2624
|
try {
|
|
2542
|
-
|
|
2625
|
+
const force = this.forceTokenRefresh;
|
|
2626
|
+
this.forceTokenRefresh = false;
|
|
2627
|
+
token = await this.options.tokenProvider(force);
|
|
2543
2628
|
} catch (error) {
|
|
2544
2629
|
this.setState("disconnected");
|
|
2545
2630
|
throw new Error(`Failed to acquire realtime token: ${error}`);
|
|
@@ -2559,7 +2644,7 @@ var SseRealtimeClient = class {
|
|
|
2559
2644
|
this.eventSource.onopen = () => {
|
|
2560
2645
|
this.options.debug("SSE connected");
|
|
2561
2646
|
this.setState("connected");
|
|
2562
|
-
this.
|
|
2647
|
+
this.connectedAt = Date.now();
|
|
2563
2648
|
resolve();
|
|
2564
2649
|
};
|
|
2565
2650
|
this.eventSource.onmessage = (event) => {
|
|
@@ -2570,8 +2655,11 @@ var SseRealtimeClient = class {
|
|
|
2570
2655
|
if (typeof event?.data === "string") this.handleMessage(event.data);
|
|
2571
2656
|
});
|
|
2572
2657
|
}
|
|
2573
|
-
this.eventSource.onerror = () => {
|
|
2658
|
+
this.eventSource.onerror = (event) => {
|
|
2574
2659
|
this.options.debug("SSE error");
|
|
2660
|
+
if (event?.code === 401 || event?.code === 403) {
|
|
2661
|
+
this.forceTokenRefresh = true;
|
|
2662
|
+
}
|
|
2575
2663
|
if (this.state === "connecting") {
|
|
2576
2664
|
this.setState("disconnected");
|
|
2577
2665
|
if (this.eventSource) {
|
|
@@ -2603,6 +2691,8 @@ var SseRealtimeClient = class {
|
|
|
2603
2691
|
}
|
|
2604
2692
|
this.setState("disconnected");
|
|
2605
2693
|
this.filter = null;
|
|
2694
|
+
this.connectedAt = null;
|
|
2695
|
+
this.streamEnded = false;
|
|
2606
2696
|
}
|
|
2607
2697
|
/**
|
|
2608
2698
|
* Reconnect with a new filter (SSE requires reconnection to change subscriptions)
|
|
@@ -2667,6 +2757,9 @@ var SseRealtimeClient = class {
|
|
|
2667
2757
|
parsed.type = eventType;
|
|
2668
2758
|
const event = parsed;
|
|
2669
2759
|
this.options.debug(`Received SSE event: ${eventType}`, event);
|
|
2760
|
+
if (this.filter?.jobId && isStreamEndingEvent(eventType, parsed.data)) {
|
|
2761
|
+
this.streamEnded = true;
|
|
2762
|
+
}
|
|
2670
2763
|
const handlers = this.eventHandlers.get(eventType);
|
|
2671
2764
|
if (handlers) {
|
|
2672
2765
|
const eventData = event.data;
|
|
@@ -2694,6 +2787,15 @@ var SseRealtimeClient = class {
|
|
|
2694
2787
|
this.eventSource.close();
|
|
2695
2788
|
this.eventSource = null;
|
|
2696
2789
|
}
|
|
2790
|
+
if (this.streamEnded) {
|
|
2791
|
+
this.connectedAt = null;
|
|
2792
|
+
this.setState("disconnected");
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (this.connectedAt !== null && Date.now() - this.connectedAt >= STABLE_CONNECTION_MS) {
|
|
2796
|
+
this.reconnectAttempts = 0;
|
|
2797
|
+
}
|
|
2798
|
+
this.connectedAt = null;
|
|
2697
2799
|
if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
|
|
2698
2800
|
this.scheduleReconnect();
|
|
2699
2801
|
} else {
|
|
@@ -2725,6 +2827,19 @@ var SseRealtimeClient = class {
|
|
|
2725
2827
|
}, delay);
|
|
2726
2828
|
}
|
|
2727
2829
|
};
|
|
2830
|
+
function isStreamEndingEvent(eventType, data) {
|
|
2831
|
+
if (eventType === "error" || eventType === "job.completed") {
|
|
2832
|
+
return true;
|
|
2833
|
+
}
|
|
2834
|
+
const record = data ?? {};
|
|
2835
|
+
if (eventType === "job.failed") {
|
|
2836
|
+
return record.willRetry === false;
|
|
2837
|
+
}
|
|
2838
|
+
if (eventType === "job.status_changed" || eventType === "job.status") {
|
|
2839
|
+
return typeof record.newStatus === "string" && TERMINAL_JOB_STATUSES.has(record.newStatus);
|
|
2840
|
+
}
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2728
2843
|
|
|
2729
2844
|
// src/realtime/index.ts
|
|
2730
2845
|
var SpooledRealtime = class {
|
|
@@ -2911,6 +3026,11 @@ function clearProtoCache() {
|
|
|
2911
3026
|
cachedDefinition = null;
|
|
2912
3027
|
}
|
|
2913
3028
|
|
|
3029
|
+
// src/grpc/encode.ts
|
|
3030
|
+
function encodeFailRequest(params) {
|
|
3031
|
+
return { ...params, retry: params.retry ?? true };
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2914
3034
|
// src/grpc/streaming.ts
|
|
2915
3035
|
async function* asyncIterableFromStream(stream, options) {
|
|
2916
3036
|
const { signal, onConnected, onError, onEnd } = options ?? {};
|
|
@@ -2983,7 +3103,9 @@ function createProcessJobsStream(call, options) {
|
|
|
2983
3103
|
}
|
|
2984
3104
|
return {
|
|
2985
3105
|
send(request) {
|
|
2986
|
-
call.write(
|
|
3106
|
+
call.write(
|
|
3107
|
+
request.fail ? { ...request, fail: encodeFailRequest(request.fail) } : request
|
|
3108
|
+
);
|
|
2987
3109
|
},
|
|
2988
3110
|
receive() {
|
|
2989
3111
|
return new AsyncIterableStream(call, { onError, onEnd });
|
|
@@ -3171,12 +3293,19 @@ function mapGrpcError(error, timeoutMs) {
|
|
|
3171
3293
|
return new ServerError(message, 500, "GRPC_ERROR");
|
|
3172
3294
|
}
|
|
3173
3295
|
}
|
|
3296
|
+
function grpcHost(address) {
|
|
3297
|
+
if (address.startsWith("[")) {
|
|
3298
|
+
const end = address.indexOf("]");
|
|
3299
|
+
return end === -1 ? address : address.slice(1, end);
|
|
3300
|
+
}
|
|
3301
|
+
return address.split(":").length > 2 ? address : address.split(":")[0];
|
|
3302
|
+
}
|
|
3174
3303
|
function shouldUseTls(address, explicitTls) {
|
|
3175
3304
|
if (explicitTls !== void 0) {
|
|
3176
3305
|
return explicitTls;
|
|
3177
3306
|
}
|
|
3178
|
-
const host = address
|
|
3179
|
-
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";
|
|
3180
3309
|
}
|
|
3181
3310
|
var GrpcQueueOperations = class {
|
|
3182
3311
|
constructor(client, metadata, timeoutMs) {
|
|
@@ -3228,7 +3357,7 @@ var GrpcQueueOperations = class {
|
|
|
3228
3357
|
return promisify(
|
|
3229
3358
|
this.client,
|
|
3230
3359
|
this.client.Fail,
|
|
3231
|
-
params,
|
|
3360
|
+
encodeFailRequest(params),
|
|
3232
3361
|
this.metadata,
|
|
3233
3362
|
this.timeoutMs
|
|
3234
3363
|
);
|
|
@@ -3618,6 +3747,13 @@ var SpooledClient = class _SpooledClient {
|
|
|
3618
3747
|
/**
|
|
3619
3748
|
* Exchange the API key for a JWT via /auth/login and cache the result.
|
|
3620
3749
|
*
|
|
3750
|
+
* The JWT belongs to the realtime transport only. It must NOT replace the
|
|
3751
|
+
* REST bearer: an API-key client has no refresh token wired into its HTTP
|
|
3752
|
+
* layer, so after the JWT's 24 h lifetime every REST call (claim, complete,
|
|
3753
|
+
* heartbeat, create) would have failed with 401 for good. REST keeps using
|
|
3754
|
+
* the API key; realtime re-mints its own JWT near expiry (see
|
|
3755
|
+
* `getJwtToken`).
|
|
3756
|
+
*
|
|
3621
3757
|
* Concurrent callers share a single in-flight login so a burst of reconnects
|
|
3622
3758
|
* (e.g. WebSocket and SSE at once) issues just one request.
|
|
3623
3759
|
*/
|
|
@@ -3627,9 +3763,6 @@ var SpooledClient = class _SpooledClient {
|
|
|
3627
3763
|
}
|
|
3628
3764
|
this.jwtLoginPromise = (async () => {
|
|
3629
3765
|
const response = await this.auth.login({ apiKey: this.config.apiKey });
|
|
3630
|
-
this.http.setAuthToken(response.accessToken);
|
|
3631
|
-
this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
|
|
3632
|
-
this.config.refreshToken = response.refreshToken;
|
|
3633
3766
|
this.cachedJwt = response.accessToken;
|
|
3634
3767
|
return response.accessToken;
|
|
3635
3768
|
})();
|
|
@@ -3825,6 +3958,9 @@ var DEFAULT_OPTIONS = {
|
|
|
3825
3958
|
autoStart: false,
|
|
3826
3959
|
shutdownTimeout: 3e4
|
|
3827
3960
|
};
|
|
3961
|
+
var JOB_TIMEOUT_MESSAGE = "Job timeout exceeded";
|
|
3962
|
+
var SHUTDOWN_TIMEOUT_MESSAGE = "Worker shutdown timeout";
|
|
3963
|
+
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
3828
3964
|
var SpooledWorker = class {
|
|
3829
3965
|
client;
|
|
3830
3966
|
/**
|
|
@@ -3841,6 +3977,10 @@ var SpooledWorker = class {
|
|
|
3841
3977
|
pollTimer = null;
|
|
3842
3978
|
workerHeartbeatTimer = null;
|
|
3843
3979
|
shutdownPromise = null;
|
|
3980
|
+
/** The claim request currently on the wire, so a stop can wait for its jobs. */
|
|
3981
|
+
pollInFlight = null;
|
|
3982
|
+
/** Wakes the shutdown drain when the last active job has been acked. */
|
|
3983
|
+
drainWaiter = null;
|
|
3844
3984
|
// Event handlers
|
|
3845
3985
|
eventHandlers = /* @__PURE__ */ new Map();
|
|
3846
3986
|
constructor(client, options) {
|
|
@@ -3857,7 +3997,11 @@ var SpooledWorker = class {
|
|
|
3857
3997
|
this.debug = config.debug ?? (() => {
|
|
3858
3998
|
});
|
|
3859
3999
|
if (this.options.autoStart) {
|
|
3860
|
-
setImmediate(() =>
|
|
4000
|
+
setImmediate(() => {
|
|
4001
|
+
this.start().catch((error) => {
|
|
4002
|
+
this.debug("Auto-start failed", error);
|
|
4003
|
+
});
|
|
4004
|
+
});
|
|
3861
4005
|
}
|
|
3862
4006
|
}
|
|
3863
4007
|
/**
|
|
@@ -3928,7 +4072,15 @@ var SpooledWorker = class {
|
|
|
3928
4072
|
}
|
|
3929
4073
|
}
|
|
3930
4074
|
/**
|
|
3931
|
-
* Stop the worker gracefully
|
|
4075
|
+
* Stop the worker gracefully.
|
|
4076
|
+
*
|
|
4077
|
+
* Drains: the worker stops claiming, then lets running handlers finish for
|
|
4078
|
+
* up to `shutdownTimeout`. A handler that returns completes its job and one
|
|
4079
|
+
* that throws fails it, even though a stop was requested meanwhile; its
|
|
4080
|
+
* `signal` is NOT aborted during the grace period. Only handlers still
|
|
4081
|
+
* running when the timeout ends are aborted and failed with "Worker shutdown
|
|
4082
|
+
* timeout". The worker deregisters last, because deregistering returns every
|
|
4083
|
+
* job the worker still holds to the queue.
|
|
3932
4084
|
*/
|
|
3933
4085
|
async stop() {
|
|
3934
4086
|
if (this.state !== "running") {
|
|
@@ -3941,39 +4093,37 @@ var SpooledWorker = class {
|
|
|
3941
4093
|
return this.shutdownPromise;
|
|
3942
4094
|
}
|
|
3943
4095
|
async doStop() {
|
|
4096
|
+
const deadline = Date.now() + this.options.shutdownTimeout;
|
|
3944
4097
|
this.state = "stopping";
|
|
3945
4098
|
this.debug("Stopping worker...");
|
|
3946
4099
|
if (this.pollTimer) {
|
|
3947
4100
|
clearTimeout(this.pollTimer);
|
|
3948
4101
|
this.pollTimer = null;
|
|
3949
4102
|
}
|
|
3950
|
-
if (this.
|
|
3951
|
-
|
|
3952
|
-
this.workerHeartbeatTimer = null;
|
|
3953
|
-
}
|
|
3954
|
-
for (const active of this.activeJobs.values()) {
|
|
3955
|
-
active.abortController.abort();
|
|
4103
|
+
if (this.pollInFlight) {
|
|
4104
|
+
await this.pollInFlight;
|
|
3956
4105
|
}
|
|
3957
4106
|
if (this.activeJobs.size > 0) {
|
|
3958
4107
|
this.debug(
|
|
3959
4108
|
`Waiting for ${this.activeJobs.size} active jobs to complete...`
|
|
3960
4109
|
);
|
|
3961
|
-
await
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
this.
|
|
3968
|
-
|
|
3969
|
-
);
|
|
3970
|
-
await this.failJob(active.job, "Worker shutdown timeout");
|
|
3971
|
-
this.cleanupJob(active);
|
|
3972
|
-
}
|
|
4110
|
+
await this.waitForDrain(Math.max(0, deadline - Date.now()));
|
|
4111
|
+
const stragglers = Array.from(this.activeJobs.values()).filter(
|
|
4112
|
+
(active) => !active.settled
|
|
4113
|
+
);
|
|
4114
|
+
await Promise.all(stragglers.map((active) => this.abandonJob(active)));
|
|
4115
|
+
await Promise.all(
|
|
4116
|
+
Array.from(this.activeJobs.values()).map((active) => active.ack)
|
|
4117
|
+
);
|
|
3973
4118
|
}
|
|
3974
|
-
if (this.
|
|
4119
|
+
if (this.workerHeartbeatTimer) {
|
|
4120
|
+
clearInterval(this.workerHeartbeatTimer);
|
|
4121
|
+
this.workerHeartbeatTimer = null;
|
|
4122
|
+
}
|
|
4123
|
+
const workerId = this.workerId;
|
|
4124
|
+
if (workerId) {
|
|
3975
4125
|
try {
|
|
3976
|
-
await this.client.workers.deregister(
|
|
4126
|
+
await this.client.workers.deregister(workerId);
|
|
3977
4127
|
this.debug("Worker deregistered");
|
|
3978
4128
|
} catch (error) {
|
|
3979
4129
|
this.debug("Failed to deregister worker", error);
|
|
@@ -3981,7 +4131,7 @@ var SpooledWorker = class {
|
|
|
3981
4131
|
}
|
|
3982
4132
|
this.state = "stopped";
|
|
3983
4133
|
this.workerId = null;
|
|
3984
|
-
this.emit("stopped", { workerId:
|
|
4134
|
+
this.emit("stopped", { workerId: workerId ?? "", reason: "graceful" });
|
|
3985
4135
|
this.shutdownPromise = null;
|
|
3986
4136
|
}
|
|
3987
4137
|
/**
|
|
@@ -4020,7 +4170,17 @@ var SpooledWorker = class {
|
|
|
4020
4170
|
if (this.state !== "running") {
|
|
4021
4171
|
return;
|
|
4022
4172
|
}
|
|
4023
|
-
this.pollTimer = setTimeout(() =>
|
|
4173
|
+
this.pollTimer = setTimeout(() => {
|
|
4174
|
+
this.pollTimer = null;
|
|
4175
|
+
const run = this.poll();
|
|
4176
|
+
this.pollInFlight = run;
|
|
4177
|
+
const done = () => {
|
|
4178
|
+
if (this.pollInFlight === run) {
|
|
4179
|
+
this.pollInFlight = null;
|
|
4180
|
+
}
|
|
4181
|
+
};
|
|
4182
|
+
run.then(done, done);
|
|
4183
|
+
}, this.options.pollInterval);
|
|
4024
4184
|
}
|
|
4025
4185
|
async poll() {
|
|
4026
4186
|
if (this.state !== "running" || !this.workerId) {
|
|
@@ -4054,7 +4214,8 @@ var SpooledWorker = class {
|
|
|
4054
4214
|
executionId: Symbol(job.id),
|
|
4055
4215
|
job,
|
|
4056
4216
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4057
|
-
abortController
|
|
4217
|
+
abortController,
|
|
4218
|
+
settled: false
|
|
4058
4219
|
};
|
|
4059
4220
|
this.activeJobs.set(activeJob.executionId, activeJob);
|
|
4060
4221
|
const heartbeatInterval = this.options.leaseDuration * this.options.heartbeatFraction * 1e3;
|
|
@@ -4083,23 +4244,72 @@ var SpooledWorker = class {
|
|
|
4083
4244
|
}
|
|
4084
4245
|
};
|
|
4085
4246
|
try {
|
|
4086
|
-
const
|
|
4087
|
-
if (
|
|
4088
|
-
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`);
|
|
4089
4250
|
return;
|
|
4090
4251
|
}
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4252
|
+
activeJob.settled = true;
|
|
4253
|
+
switch (outcome.kind) {
|
|
4254
|
+
case "returned":
|
|
4255
|
+
activeJob.ack = this.completeJob(job, outcome.value ?? void 0);
|
|
4256
|
+
break;
|
|
4257
|
+
case "threw":
|
|
4258
|
+
activeJob.ack = this.failJob(
|
|
4259
|
+
job,
|
|
4260
|
+
outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
|
4261
|
+
);
|
|
4262
|
+
break;
|
|
4263
|
+
case "timeout":
|
|
4264
|
+
abortController.abort(new Error(JOB_TIMEOUT_MESSAGE));
|
|
4265
|
+
this.emit("job:timeout", {
|
|
4266
|
+
jobId: job.id,
|
|
4267
|
+
queueName: job.queueName
|
|
4268
|
+
});
|
|
4269
|
+
activeJob.ack = this.failJob(job, JOB_TIMEOUT_MESSAGE);
|
|
4270
|
+
break;
|
|
4096
4271
|
}
|
|
4097
|
-
|
|
4098
|
-
await this.failJob(job, errorMessage);
|
|
4272
|
+
await activeJob.ack;
|
|
4099
4273
|
} finally {
|
|
4100
4274
|
this.cleanupJob(activeJob);
|
|
4101
4275
|
}
|
|
4102
4276
|
}
|
|
4277
|
+
/**
|
|
4278
|
+
* Run the handler, racing it against the job's `timeoutSeconds`.
|
|
4279
|
+
* Never rejects: a throw (sync or async) is reported as an outcome.
|
|
4280
|
+
*/
|
|
4281
|
+
runHandler(activeJob, context) {
|
|
4282
|
+
const run = (async () => {
|
|
4283
|
+
try {
|
|
4284
|
+
return { kind: "returned", value: await this.handler(context) };
|
|
4285
|
+
} catch (error) {
|
|
4286
|
+
return { kind: "threw", error };
|
|
4287
|
+
}
|
|
4288
|
+
})();
|
|
4289
|
+
const { timeoutSeconds } = activeJob.job;
|
|
4290
|
+
if (typeof timeoutSeconds !== "number" || !(timeoutSeconds > 0)) {
|
|
4291
|
+
return run;
|
|
4292
|
+
}
|
|
4293
|
+
return new Promise((resolve) => {
|
|
4294
|
+
activeJob.timeoutTimer = setTimeout(
|
|
4295
|
+
() => resolve({ kind: "timeout" }),
|
|
4296
|
+
Math.min(timeoutSeconds * 1e3, MAX_TIMER_MS)
|
|
4297
|
+
);
|
|
4298
|
+
run.then((outcome) => {
|
|
4299
|
+
this.clearTimeoutTimer(activeJob);
|
|
4300
|
+
resolve(outcome);
|
|
4301
|
+
});
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
/** Abort a handler that outlived the shutdown grace period and fail its job. */
|
|
4305
|
+
async abandonJob(activeJob) {
|
|
4306
|
+
this.debug(`Force-failing job ${activeJob.job.id} due to shutdown timeout`);
|
|
4307
|
+
activeJob.settled = true;
|
|
4308
|
+
activeJob.abortController.abort(new Error(SHUTDOWN_TIMEOUT_MESSAGE));
|
|
4309
|
+
activeJob.ack = this.failJob(activeJob.job, SHUTDOWN_TIMEOUT_MESSAGE);
|
|
4310
|
+
await activeJob.ack;
|
|
4311
|
+
this.cleanupJob(activeJob);
|
|
4312
|
+
}
|
|
4103
4313
|
async completeJob(job, result) {
|
|
4104
4314
|
if (!this.workerId) return;
|
|
4105
4315
|
try {
|
|
@@ -4115,25 +4325,34 @@ var SpooledWorker = class {
|
|
|
4115
4325
|
});
|
|
4116
4326
|
} catch (error) {
|
|
4117
4327
|
this.debug(`Failed to complete job ${job.id}`, error);
|
|
4328
|
+
this.emit("error", { error });
|
|
4118
4329
|
}
|
|
4119
4330
|
}
|
|
4120
4331
|
async failJob(job, errorMessage) {
|
|
4121
4332
|
if (!this.workerId) return;
|
|
4122
4333
|
const willRetry = job.retryCount < job.maxRetries;
|
|
4334
|
+
const message = coerceFailureMessage(errorMessage);
|
|
4123
4335
|
try {
|
|
4124
4336
|
await this.client.jobs.fail(job.id, {
|
|
4125
4337
|
workerId: this.workerId,
|
|
4126
|
-
error:
|
|
4338
|
+
error: message,
|
|
4127
4339
|
...job.leaseId != null && { leaseId: job.leaseId }
|
|
4128
4340
|
});
|
|
4129
4341
|
this.emit("job:failed", {
|
|
4130
4342
|
jobId: job.id,
|
|
4131
4343
|
queueName: job.queueName,
|
|
4132
|
-
error:
|
|
4344
|
+
error: message,
|
|
4133
4345
|
willRetry
|
|
4134
4346
|
});
|
|
4135
4347
|
} catch (error) {
|
|
4136
4348
|
this.debug(`Failed to fail job ${job.id}`, error);
|
|
4349
|
+
this.emit("error", { error });
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
clearTimeoutTimer(activeJob) {
|
|
4353
|
+
if (activeJob.timeoutTimer) {
|
|
4354
|
+
clearTimeout(activeJob.timeoutTimer);
|
|
4355
|
+
activeJob.timeoutTimer = void 0;
|
|
4137
4356
|
}
|
|
4138
4357
|
}
|
|
4139
4358
|
cleanupJob(activeJob) {
|
|
@@ -4141,8 +4360,12 @@ var SpooledWorker = class {
|
|
|
4141
4360
|
clearInterval(activeJob.heartbeatTimer);
|
|
4142
4361
|
activeJob.heartbeatTimer = void 0;
|
|
4143
4362
|
}
|
|
4363
|
+
this.clearTimeoutTimer(activeJob);
|
|
4144
4364
|
if (this.activeJobs.get(activeJob.executionId) === activeJob) {
|
|
4145
4365
|
this.activeJobs.delete(activeJob.executionId);
|
|
4366
|
+
if (this.activeJobs.size === 0) {
|
|
4367
|
+
this.drainWaiter?.();
|
|
4368
|
+
}
|
|
4146
4369
|
}
|
|
4147
4370
|
}
|
|
4148
4371
|
async sendJobHeartbeat(activeJob) {
|
|
@@ -4159,7 +4382,9 @@ var SpooledWorker = class {
|
|
|
4159
4382
|
}
|
|
4160
4383
|
}
|
|
4161
4384
|
async sendWorkerHeartbeat() {
|
|
4162
|
-
if (!this.workerId || this.state !== "running")
|
|
4385
|
+
if (!this.workerId || this.state !== "running" && this.state !== "stopping") {
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4163
4388
|
try {
|
|
4164
4389
|
await this.client.workers.heartbeat(this.workerId, {
|
|
4165
4390
|
currentJobs: this.activeJobs.size,
|
|
@@ -4169,13 +4394,25 @@ var SpooledWorker = class {
|
|
|
4169
4394
|
this.debug("Worker heartbeat failed", error);
|
|
4170
4395
|
}
|
|
4171
4396
|
}
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4397
|
+
/**
|
|
4398
|
+
* Resolve once no job is active, or after `timeoutMs`. The timer is always
|
|
4399
|
+
* cleared, so a clean stop leaves nothing that keeps the process alive.
|
|
4400
|
+
*/
|
|
4401
|
+
waitForDrain(timeoutMs) {
|
|
4402
|
+
if (this.activeJobs.size === 0) {
|
|
4403
|
+
return Promise.resolve();
|
|
4175
4404
|
}
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4405
|
+
return new Promise((resolve) => {
|
|
4406
|
+
const timer = setTimeout(() => {
|
|
4407
|
+
this.drainWaiter = null;
|
|
4408
|
+
resolve();
|
|
4409
|
+
}, timeoutMs);
|
|
4410
|
+
this.drainWaiter = () => {
|
|
4411
|
+
clearTimeout(timer);
|
|
4412
|
+
this.drainWaiter = null;
|
|
4413
|
+
resolve();
|
|
4414
|
+
};
|
|
4415
|
+
});
|
|
4179
4416
|
}
|
|
4180
4417
|
};
|
|
4181
4418
|
|
|
@@ -4217,6 +4454,7 @@ exports.SpooledRealtime = SpooledRealtime;
|
|
|
4217
4454
|
exports.SpooledWorker = SpooledWorker;
|
|
4218
4455
|
exports.SseRealtimeClient = SseRealtimeClient;
|
|
4219
4456
|
exports.TimeoutError = TimeoutError;
|
|
4457
|
+
exports.UnsupportedOperationError = UnsupportedOperationError;
|
|
4220
4458
|
exports.ValidationError = ValidationError;
|
|
4221
4459
|
exports.WebSocketRealtimeClient = WebSocketRealtimeClient;
|
|
4222
4460
|
exports.WebhookIngestionResource = WebhookIngestionResource;
|