@forgezero/runtime 0.1.0 → 0.1.2
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 +27 -4
- package/dist/audit.js +262 -23
- package/dist/custody-crypto.d.ts +53 -0
- package/dist/custody-crypto.js +89 -0
- package/dist/custody-share.d.ts +117 -0
- package/dist/custody-share.js +313 -0
- package/dist/jobs.d.ts +3 -2
- package/dist/jobs.js +288 -13
- package/dist/outbox.d.ts +10 -1
- package/dist/outbox.js +8 -3
- package/dist/pipeline.d.ts +11 -37
- package/dist/pipeline.js +0 -27
- package/dist/queue.d.ts +101 -228
- package/dist/queue.js +246 -210
- package/dist/snp.d.ts +7 -6
- package/dist/snp.js +6 -1
- package/dist/ssh-agent.d.ts +13 -0
- package/dist/ssh-agent.js +6 -0
- package/package.json +254 -246
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/custody-crypto.ts
|
|
10
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
11
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
12
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
13
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
14
|
+
var KEY_BYTES = 32;
|
|
15
|
+
var NONCE_BYTES = 12;
|
|
16
|
+
var toBase64 = (bytes) => {
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (const byte of bytes)
|
|
19
|
+
binary += String.fromCharCode(byte);
|
|
20
|
+
return btoa(binary);
|
|
21
|
+
};
|
|
22
|
+
var fromBase64 = (value) => {
|
|
23
|
+
const binary = atob(value);
|
|
24
|
+
const bytes = new Uint8Array(binary.length);
|
|
25
|
+
for (let index = 0;index < binary.length; index += 1)
|
|
26
|
+
bytes[index] = binary.charCodeAt(index);
|
|
27
|
+
return bytes;
|
|
28
|
+
};
|
|
29
|
+
var utf8 = (value) => new TextEncoder().encode(value);
|
|
30
|
+
function deriveKey(secret, salt, info, length = KEY_BYTES) {
|
|
31
|
+
return hkdf(sha256, secret, salt, utf8(info), length);
|
|
32
|
+
}
|
|
33
|
+
function sealWithKey(key, plaintext, aad) {
|
|
34
|
+
if (key.length !== KEY_BYTES)
|
|
35
|
+
throw new Error("custody: key must be 32 bytes");
|
|
36
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
37
|
+
const sealed = gcm(key, nonce, utf8(aad)).encrypt(plaintext);
|
|
38
|
+
return { alg: "aes-256-gcm", nonce: toBase64(nonce), ciphertext: toBase64(sealed) };
|
|
39
|
+
}
|
|
40
|
+
function openWithKey(key, box, aad) {
|
|
41
|
+
if (key.length !== KEY_BYTES)
|
|
42
|
+
throw new Error("custody: key must be 32 bytes");
|
|
43
|
+
if (box.alg !== "aes-256-gcm")
|
|
44
|
+
throw new Error(`custody: unknown algorithm ${box.alg}`);
|
|
45
|
+
return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
|
|
46
|
+
}
|
|
47
|
+
var WRAP_INFO = "forgezero:custody:wrap:v1";
|
|
48
|
+
var wrapKey = (shared, ephemeral, recipient) => hkdf(sha256, shared, concatBytes(ephemeral, recipient), utf8(WRAP_INFO), 32);
|
|
49
|
+
function concatBytes(left, right) {
|
|
50
|
+
const out = new Uint8Array(left.length + right.length);
|
|
51
|
+
out.set(left, 0);
|
|
52
|
+
out.set(right, left.length);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function sealToKey(recipientPublicKey, plaintext, aad) {
|
|
56
|
+
const ephemeralSecret = x25519.utils.randomSecretKey();
|
|
57
|
+
const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
|
|
58
|
+
const shared = x25519.getSharedSecret(ephemeralSecret, recipientPublicKey);
|
|
59
|
+
const key = wrapKey(shared, ephemeralPublic, recipientPublicKey);
|
|
60
|
+
try {
|
|
61
|
+
return { ...sealWithKey(key, plaintext, aad), ephemeral: toBase64(ephemeralPublic) };
|
|
62
|
+
} finally {
|
|
63
|
+
key.fill(0);
|
|
64
|
+
ephemeralSecret.fill(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function openFromKey(recipientSecretKey, box, aad) {
|
|
68
|
+
const ephemeralPublic = fromBase64(box.ephemeral);
|
|
69
|
+
const recipientPublic = x25519.getPublicKey(recipientSecretKey);
|
|
70
|
+
const shared = x25519.getSharedSecret(recipientSecretKey, ephemeralPublic);
|
|
71
|
+
const key = wrapKey(shared, ephemeralPublic, recipientPublic);
|
|
72
|
+
try {
|
|
73
|
+
return openWithKey(key, box, aad);
|
|
74
|
+
} finally {
|
|
75
|
+
key.fill(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function wrappingKeyPair(factorMaterial, info) {
|
|
79
|
+
const secretKey = hkdf(sha256, factorMaterial, utf8("forgezero:custody:wrapkey:v1"), utf8(info), 32);
|
|
80
|
+
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/phrase.ts
|
|
84
|
+
import { entropyToMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
85
|
+
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
86
|
+
import { hkdf as hkdf2 } from "@noble/hashes/hkdf.js";
|
|
87
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
88
|
+
import { toHex } from "@forgezero/access/security";
|
|
89
|
+
var ENCODER = new TextEncoder;
|
|
90
|
+
var randomBytes = (length) => crypto.getRandomValues(new Uint8Array(length));
|
|
91
|
+
var PHRASE_WORDS = 24;
|
|
92
|
+
var ENTROPY_BYTES = 32;
|
|
93
|
+
var PHRASE_SALT_BYTES = 16;
|
|
94
|
+
var KEY_BYTES2 = 32;
|
|
95
|
+
var KEY_INFO = "forgezero:custodian:phrase-key:v1";
|
|
96
|
+
var VERIFIER_INFO = "forgezero:custodian:phrase-verifier:v1";
|
|
97
|
+
function canonical(words) {
|
|
98
|
+
return words.map((word) => word.normalize("NFKD").trim().toLowerCase()).join(" ");
|
|
99
|
+
}
|
|
100
|
+
function shapeIsValid(words) {
|
|
101
|
+
if (!Array.isArray(words) || words.length !== PHRASE_WORDS)
|
|
102
|
+
return false;
|
|
103
|
+
return words.every((word) => typeof word === "string" && word.trim().length > 0);
|
|
104
|
+
}
|
|
105
|
+
function generatePhrase() {
|
|
106
|
+
const entropy = randomBytes(ENTROPY_BYTES);
|
|
107
|
+
try {
|
|
108
|
+
const words = entropyToMnemonic(entropy, wordlist).split(" ");
|
|
109
|
+
if (words.length !== PHRASE_WORDS) {
|
|
110
|
+
throw new Error("phrase: wordlist produced an unexpected phrase length");
|
|
111
|
+
}
|
|
112
|
+
return words;
|
|
113
|
+
} finally {
|
|
114
|
+
entropy.fill(0);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validatePhrase(words) {
|
|
118
|
+
if (!shapeIsValid(words))
|
|
119
|
+
return false;
|
|
120
|
+
return validateMnemonic(canonical(words), wordlist);
|
|
121
|
+
}
|
|
122
|
+
function newSalt() {
|
|
123
|
+
return Uint8Array.from(randomBytes(PHRASE_SALT_BYTES));
|
|
124
|
+
}
|
|
125
|
+
function assertSalt(salt) {
|
|
126
|
+
if (!(salt instanceof Uint8Array) || salt.length < PHRASE_SALT_BYTES) {
|
|
127
|
+
throw new Error(`phrase: salt must be at least ${PHRASE_SALT_BYTES} bytes`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function bip39Seed(words) {
|
|
131
|
+
if (!validatePhrase(words))
|
|
132
|
+
throw new Error("INVALID_PHRASE");
|
|
133
|
+
return mnemonicToSeedSync(canonical(words));
|
|
134
|
+
}
|
|
135
|
+
function phraseToKey(words, salt) {
|
|
136
|
+
assertSalt(salt);
|
|
137
|
+
const seed = bip39Seed(words);
|
|
138
|
+
try {
|
|
139
|
+
return hkdf2(sha2562, seed, salt, ENCODER.encode(KEY_INFO), KEY_BYTES2);
|
|
140
|
+
} finally {
|
|
141
|
+
seed.fill(0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function phraseVerifier(words, salt) {
|
|
145
|
+
assertSalt(salt);
|
|
146
|
+
const seed = bip39Seed(words);
|
|
147
|
+
try {
|
|
148
|
+
return toHex(hkdf2(sha2562, seed, salt, ENCODER.encode(VERIFIER_INFO), KEY_BYTES2));
|
|
149
|
+
} finally {
|
|
150
|
+
seed.fill(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/custody-share.ts
|
|
155
|
+
var KEY_BYTES3 = 32;
|
|
156
|
+
var MIN_PRF_BYTES = 32;
|
|
157
|
+
var PASSKEY_KEY_SALT = "forgezero:custodian:passkey:v1";
|
|
158
|
+
var utf82 = (value) => new TextEncoder().encode(value);
|
|
159
|
+
var toBase642 = (bytes) => {
|
|
160
|
+
let binary = "";
|
|
161
|
+
for (const byte of bytes)
|
|
162
|
+
binary += String.fromCharCode(byte);
|
|
163
|
+
return btoa(binary);
|
|
164
|
+
};
|
|
165
|
+
var fromBase642 = (value) => {
|
|
166
|
+
const binary = atob(value);
|
|
167
|
+
const bytes = new Uint8Array(binary.length);
|
|
168
|
+
for (let index = 0;index < binary.length; index += 1)
|
|
169
|
+
bytes[index] = binary.charCodeAt(index);
|
|
170
|
+
return bytes;
|
|
171
|
+
};
|
|
172
|
+
function passkeyKey(custodianKey, prfOutput) {
|
|
173
|
+
if (!(prfOutput instanceof Uint8Array) || prfOutput.length < MIN_PRF_BYTES) {
|
|
174
|
+
throw new Error(`custody-share: PRF output must be at least ${MIN_PRF_BYTES} bytes`);
|
|
175
|
+
}
|
|
176
|
+
const info = `forgezero:custodian:passkey-key:v1:${custodianKey.length}:${custodianKey}`;
|
|
177
|
+
return deriveKey(prfOutput, utf82(PASSKEY_KEY_SALT), info, KEY_BYTES3);
|
|
178
|
+
}
|
|
179
|
+
function shareAad(custodianKey, kind, shareIndex) {
|
|
180
|
+
return `forgezero:share:v1:${kind}:${shareIndex}:${custodianKey.length}:${custodianKey}`;
|
|
181
|
+
}
|
|
182
|
+
function assertCustodianKey(custodianKey) {
|
|
183
|
+
if (typeof custodianKey !== "string" || custodianKey.length === 0) {
|
|
184
|
+
throw new Error("custody-share: custodianKey must be a non-empty string");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function shareIndexOf(share) {
|
|
188
|
+
if (share.length < 2)
|
|
189
|
+
throw new Error("custody-share: malformed share");
|
|
190
|
+
const x = share[0];
|
|
191
|
+
if (x < 1)
|
|
192
|
+
throw new Error("custody-share: share index 0 is reserved");
|
|
193
|
+
return x;
|
|
194
|
+
}
|
|
195
|
+
function sealShare(args) {
|
|
196
|
+
const { share, custodianKey, passkeyPrfOutput, phraseWords } = args;
|
|
197
|
+
assertCustodianKey(custodianKey);
|
|
198
|
+
const shareIndex = shareIndexOf(share);
|
|
199
|
+
if (!validatePhrase(phraseWords))
|
|
200
|
+
throw new Error("INVALID_PHRASE");
|
|
201
|
+
const salt = newSalt();
|
|
202
|
+
const keyFromPasskey = passkeyKey(custodianKey, passkeyPrfOutput);
|
|
203
|
+
const keyFromPhrase = phraseToKey(phraseWords, salt);
|
|
204
|
+
try {
|
|
205
|
+
return {
|
|
206
|
+
shareIndex,
|
|
207
|
+
passkeyEnvelope: sealWithKey(keyFromPasskey, share, shareAad(custodianKey, "passkey", shareIndex)),
|
|
208
|
+
phraseEnvelope: sealWithKey(keyFromPhrase, share, shareAad(custodianKey, "phrase", shareIndex)),
|
|
209
|
+
phraseSalt: toBase642(salt),
|
|
210
|
+
phraseVerifier: phraseVerifier(phraseWords, salt)
|
|
211
|
+
};
|
|
212
|
+
} finally {
|
|
213
|
+
keyFromPasskey.fill(0);
|
|
214
|
+
keyFromPhrase.fill(0);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function openShareWithPasskey(sealed, custodianKey, passkeyPrfOutput) {
|
|
218
|
+
assertCustodianKey(custodianKey);
|
|
219
|
+
const key = passkeyKey(custodianKey, passkeyPrfOutput);
|
|
220
|
+
try {
|
|
221
|
+
return openWithKey(key, sealed.passkeyEnvelope, shareAad(custodianKey, "passkey", sealed.shareIndex));
|
|
222
|
+
} finally {
|
|
223
|
+
key.fill(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function openShareWithPhrase(sealed, custodianKey, phraseWords) {
|
|
227
|
+
assertCustodianKey(custodianKey);
|
|
228
|
+
if (!validatePhrase(phraseWords))
|
|
229
|
+
throw new Error("INVALID_PHRASE");
|
|
230
|
+
const salt = fromBase642(sealed.phraseSalt);
|
|
231
|
+
const key = phraseToKey(phraseWords, salt);
|
|
232
|
+
try {
|
|
233
|
+
return openWithKey(key, sealed.phraseEnvelope, shareAad(custodianKey, "phrase", sealed.shareIndex));
|
|
234
|
+
} finally {
|
|
235
|
+
key.fill(0);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
var wrapInfo = (custodianKey, kind) => `forgezero:custody:wrap:v1:${kind}:${custodianKey.length}:${custodianKey}`;
|
|
239
|
+
function passkeyWrappingKey(custodianKey, passkeyPrfOutput) {
|
|
240
|
+
assertCustodianKey(custodianKey);
|
|
241
|
+
return wrappingKeyPair(passkeyKey(custodianKey, passkeyPrfOutput), wrapInfo(custodianKey, "passkey"));
|
|
242
|
+
}
|
|
243
|
+
function phraseWrappingKey(custodianKey, phraseWords, salt) {
|
|
244
|
+
assertCustodianKey(custodianKey);
|
|
245
|
+
if (!validatePhrase(phraseWords))
|
|
246
|
+
throw new Error("INVALID_PHRASE");
|
|
247
|
+
return wrappingKeyPair(phraseToKey(phraseWords, salt), wrapInfo(custodianKey, "phrase"));
|
|
248
|
+
}
|
|
249
|
+
function wrappingKeysFor(args) {
|
|
250
|
+
const salt = newSalt();
|
|
251
|
+
const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
|
|
252
|
+
const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
|
|
253
|
+
try {
|
|
254
|
+
return {
|
|
255
|
+
passkeyPublicKey: toBase642(passkey.publicKey),
|
|
256
|
+
phrasePublicKey: toBase642(phrase.publicKey),
|
|
257
|
+
phraseSalt: toBase642(salt),
|
|
258
|
+
phraseVerifier: phraseVerifier(args.phraseWords, salt)
|
|
259
|
+
};
|
|
260
|
+
} finally {
|
|
261
|
+
passkey.secretKey.fill(0);
|
|
262
|
+
phrase.secretKey.fill(0);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function openSealedToFactor(args) {
|
|
266
|
+
const pair = args.factor === "passkey" ? passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput) : phraseWrappingKey(args.custodianKey, args.phraseWords, fromBase642(args.phraseSalt));
|
|
267
|
+
try {
|
|
268
|
+
return openFromKey(pair.secretKey, args.box, wrapInfo(args.custodianKey, args.factor));
|
|
269
|
+
} finally {
|
|
270
|
+
pair.secretKey.fill(0);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
var factorWrapAad = (custodianKey, factor) => wrapInfo(custodianKey, factor);
|
|
274
|
+
var PROBE_BYTES = 32;
|
|
275
|
+
function joinProbeAndShare(probe, share) {
|
|
276
|
+
if (probe.length !== PROBE_BYTES)
|
|
277
|
+
throw new Error("custody-share: probe must be 32 bytes");
|
|
278
|
+
const out = new Uint8Array(probe.length + share.length);
|
|
279
|
+
out.set(probe, 0);
|
|
280
|
+
out.set(share, probe.length);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function splitProbeAndShare(opened) {
|
|
284
|
+
if (opened.length <= PROBE_BYTES)
|
|
285
|
+
throw new Error("custody-share: envelope is too short");
|
|
286
|
+
return { probe: opened.slice(0, PROBE_BYTES), share: opened.slice(PROBE_BYTES) };
|
|
287
|
+
}
|
|
288
|
+
function openFactorEnvelope(args) {
|
|
289
|
+
const box = args.factor === "passkey" ? args.sealed.passkeyEnvelope : args.sealed.phraseEnvelope;
|
|
290
|
+
return splitProbeAndShare(openSealedToFactor({
|
|
291
|
+
custodianKey: args.custodianKey,
|
|
292
|
+
factor: args.factor,
|
|
293
|
+
box,
|
|
294
|
+
passkeyPrfOutput: args.passkeyPrfOutput,
|
|
295
|
+
phraseWords: args.phraseWords,
|
|
296
|
+
phraseSalt: args.sealed.phraseSalt
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
export {
|
|
300
|
+
wrappingKeysFor,
|
|
301
|
+
splitProbeAndShare,
|
|
302
|
+
shareIndexOf,
|
|
303
|
+
sealShare,
|
|
304
|
+
phraseWrappingKey,
|
|
305
|
+
passkeyWrappingKey,
|
|
306
|
+
openShareWithPhrase,
|
|
307
|
+
openShareWithPasskey,
|
|
308
|
+
openSealedToFactor,
|
|
309
|
+
openFactorEnvelope,
|
|
310
|
+
joinProbeAndShare,
|
|
311
|
+
factorWrapAad,
|
|
312
|
+
PROBE_BYTES
|
|
313
|
+
};
|
package/dist/jobs.d.ts
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Each has a specific answer here: reschedule after completion rather than on
|
|
17
17
|
* an interval, advance only on a fully successful batch, take a fenced lock,
|
|
18
|
-
* and
|
|
18
|
+
* and submit every run through the common awaited queue, which drains on stop.
|
|
19
19
|
*
|
|
20
20
|
* Zero dependencies. The lock and the cursor store are interfaces, so this runs
|
|
21
21
|
* against a database, Redis, or nothing at all in a test.
|
|
22
22
|
*/
|
|
23
|
+
import { type DrainReport } from './queue';
|
|
23
24
|
/** Injected so a test does not sleep and a resumed run is reproducible. */
|
|
24
25
|
export interface Clock {
|
|
25
26
|
now(): number;
|
|
@@ -122,7 +123,7 @@ export declare function createScheduler(options: SchedulerOptions): {
|
|
|
122
123
|
* leaves a record half-written — the process exits mid-transaction and the
|
|
123
124
|
* next boot finds state nothing can explain.
|
|
124
125
|
*/
|
|
125
|
-
stop(): Promise<
|
|
126
|
+
stop(deadlineMs?: number): Promise<DrainReport>;
|
|
126
127
|
/** Timers stop firing; in-flight work is left to finish. */
|
|
127
128
|
pause(): void;
|
|
128
129
|
resume(): void;
|
package/dist/jobs.js
CHANGED
|
@@ -6,6 +6,275 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
6
6
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
// src/queue.ts
|
|
10
|
+
class QueueStoppedError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("queue: stopped before this task could run");
|
|
13
|
+
this.name = "QueueStoppedError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class QueueKeyStoppedError extends Error {
|
|
18
|
+
key;
|
|
19
|
+
constructor(key) {
|
|
20
|
+
super(`queue: key ${key} is stopped`);
|
|
21
|
+
this.key = key;
|
|
22
|
+
this.name = "QueueKeyStoppedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class TaskCancelledError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("queue: task cancelled");
|
|
29
|
+
this.name = "TaskCancelledError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
var DEFAULT_RETRY = {
|
|
33
|
+
attempts: 1,
|
|
34
|
+
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
35
|
+
};
|
|
36
|
+
function createQueue(options = {}) {
|
|
37
|
+
const width = options.width ?? 8;
|
|
38
|
+
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
39
|
+
if (!Number.isSafeInteger(width) || width < 1) {
|
|
40
|
+
throw new RangeError("queue: width must be a positive integer");
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
|
|
43
|
+
throw new RangeError("queue: retry attempts must be a positive integer");
|
|
44
|
+
}
|
|
45
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
46
|
+
const lanes = new Map;
|
|
47
|
+
const running = new Set;
|
|
48
|
+
const paused = new Set;
|
|
49
|
+
const stoppedKeys = new Set;
|
|
50
|
+
let sequence = 0;
|
|
51
|
+
let globallyPaused = false;
|
|
52
|
+
let accepting = true;
|
|
53
|
+
let aborted = false;
|
|
54
|
+
let completed = 0;
|
|
55
|
+
let failed = 0;
|
|
56
|
+
const idle = [];
|
|
57
|
+
const announceIdle = () => {
|
|
58
|
+
if (running.size > 0)
|
|
59
|
+
return;
|
|
60
|
+
for (const lane of lanes.values())
|
|
61
|
+
if (lane.length > 0)
|
|
62
|
+
return;
|
|
63
|
+
while (idle.length > 0)
|
|
64
|
+
idle.shift()();
|
|
65
|
+
};
|
|
66
|
+
function pump() {
|
|
67
|
+
if (globallyPaused || aborted) {
|
|
68
|
+
announceIdle();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const [key, lane] of lanes) {
|
|
72
|
+
if (running.size >= width)
|
|
73
|
+
break;
|
|
74
|
+
if (running.has(key) || paused.has(key) || lane.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
execute(key);
|
|
77
|
+
}
|
|
78
|
+
announceIdle();
|
|
79
|
+
}
|
|
80
|
+
async function execute(key) {
|
|
81
|
+
running.add(key);
|
|
82
|
+
try {
|
|
83
|
+
const lane = lanes.get(key);
|
|
84
|
+
const entry = lane?.[0];
|
|
85
|
+
if (entry && !globallyPaused && !paused.has(key) && !aborted) {
|
|
86
|
+
lane.shift();
|
|
87
|
+
await attempt(entry);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
running.delete(key);
|
|
91
|
+
const remaining = lanes.get(key);
|
|
92
|
+
if (remaining?.length === 0)
|
|
93
|
+
lanes.delete(key);
|
|
94
|
+
else if (remaining) {
|
|
95
|
+
lanes.delete(key);
|
|
96
|
+
lanes.set(key, remaining);
|
|
97
|
+
}
|
|
98
|
+
pump();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function attempt(entry) {
|
|
102
|
+
if (entry.cancelled) {
|
|
103
|
+
entry.reject(new TaskCancelledError);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (;; ) {
|
|
107
|
+
entry.attempt += 1;
|
|
108
|
+
try {
|
|
109
|
+
const value = await entry.run();
|
|
110
|
+
completed += 1;
|
|
111
|
+
entry.resolve(value);
|
|
112
|
+
return;
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
if (entry.attempt >= retry.attempts || entry.cancelled || aborted) {
|
|
115
|
+
failed += 1;
|
|
116
|
+
entry.reject(cause);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const delay = retry.backoffMs(entry.attempt);
|
|
120
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
121
|
+
failed += 1;
|
|
122
|
+
entry.reject(new RangeError("queue: retry backoff must be a non-negative finite number"));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await sleep(delay);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
run(key, handler, ...args) {
|
|
131
|
+
const id = `q_${++sequence}`;
|
|
132
|
+
if (!accepting || stoppedKeys.has(key)) {
|
|
133
|
+
const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
|
|
134
|
+
refused.catch(() => {
|
|
135
|
+
return;
|
|
136
|
+
});
|
|
137
|
+
return { id, key, result: refused };
|
|
138
|
+
}
|
|
139
|
+
let resolve;
|
|
140
|
+
let reject;
|
|
141
|
+
const result = new Promise((ok, no) => {
|
|
142
|
+
resolve = ok;
|
|
143
|
+
reject = no;
|
|
144
|
+
});
|
|
145
|
+
const entry = {
|
|
146
|
+
id,
|
|
147
|
+
key,
|
|
148
|
+
run: async () => handler(...args),
|
|
149
|
+
resolve,
|
|
150
|
+
reject,
|
|
151
|
+
attempt: 0,
|
|
152
|
+
cancelled: false
|
|
153
|
+
};
|
|
154
|
+
const lane = lanes.get(key);
|
|
155
|
+
if (lane)
|
|
156
|
+
lane.push(entry);
|
|
157
|
+
else
|
|
158
|
+
lanes.set(key, [entry]);
|
|
159
|
+
pump();
|
|
160
|
+
return { id, key, result };
|
|
161
|
+
},
|
|
162
|
+
cancel(id) {
|
|
163
|
+
for (const [key, lane] of lanes) {
|
|
164
|
+
const index = lane.findIndex((entry2) => entry2.id === id);
|
|
165
|
+
if (index === -1)
|
|
166
|
+
continue;
|
|
167
|
+
const [entry] = lane.splice(index, 1);
|
|
168
|
+
entry.cancelled = true;
|
|
169
|
+
entry.reject(new TaskCancelledError);
|
|
170
|
+
if (lane.length === 0 && !running.has(key))
|
|
171
|
+
lanes.delete(key);
|
|
172
|
+
announceIdle();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
},
|
|
177
|
+
pauseKey(key) {
|
|
178
|
+
paused.add(key);
|
|
179
|
+
},
|
|
180
|
+
resumeKey(key) {
|
|
181
|
+
paused.delete(key);
|
|
182
|
+
pump();
|
|
183
|
+
},
|
|
184
|
+
stopKey(key) {
|
|
185
|
+
stoppedKeys.add(key);
|
|
186
|
+
paused.delete(key);
|
|
187
|
+
const lane = lanes.get(key);
|
|
188
|
+
if (!lane)
|
|
189
|
+
return 0;
|
|
190
|
+
let removed = 0;
|
|
191
|
+
for (const entry of lane.splice(0)) {
|
|
192
|
+
removed += 1;
|
|
193
|
+
entry.cancelled = true;
|
|
194
|
+
entry.reject(new QueueKeyStoppedError(key));
|
|
195
|
+
}
|
|
196
|
+
if (!running.has(key))
|
|
197
|
+
lanes.delete(key);
|
|
198
|
+
announceIdle();
|
|
199
|
+
return removed;
|
|
200
|
+
},
|
|
201
|
+
startKey(key) {
|
|
202
|
+
const changed = stoppedKeys.delete(key);
|
|
203
|
+
pump();
|
|
204
|
+
return changed;
|
|
205
|
+
},
|
|
206
|
+
pause() {
|
|
207
|
+
globallyPaused = true;
|
|
208
|
+
},
|
|
209
|
+
resume() {
|
|
210
|
+
globallyPaused = false;
|
|
211
|
+
pump();
|
|
212
|
+
},
|
|
213
|
+
snapshot() {
|
|
214
|
+
let queued = 0;
|
|
215
|
+
for (const lane of lanes.values())
|
|
216
|
+
queued += lane.length;
|
|
217
|
+
return {
|
|
218
|
+
running: running.size,
|
|
219
|
+
queued,
|
|
220
|
+
keys: lanes.size,
|
|
221
|
+
paused: globallyPaused,
|
|
222
|
+
pausedKeys: [...paused],
|
|
223
|
+
stoppedKeys: [...stoppedKeys],
|
|
224
|
+
completed,
|
|
225
|
+
failed
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
whenIdle() {
|
|
229
|
+
if (running.size === 0 && [...lanes.values()].every((lane) => lane.length === 0)) {
|
|
230
|
+
return Promise.resolve();
|
|
231
|
+
}
|
|
232
|
+
return new Promise((resolve) => idle.push(resolve));
|
|
233
|
+
},
|
|
234
|
+
async stop(deadlineMs = 30000) {
|
|
235
|
+
if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) {
|
|
236
|
+
throw new RangeError("queue: stop deadline must be a non-negative integer");
|
|
237
|
+
}
|
|
238
|
+
accepting = false;
|
|
239
|
+
const before = { completed, failed };
|
|
240
|
+
globallyPaused = false;
|
|
241
|
+
paused.clear();
|
|
242
|
+
pump();
|
|
243
|
+
let timedOut = false;
|
|
244
|
+
let deadlineHandle;
|
|
245
|
+
const deadline = new Promise((resolve) => {
|
|
246
|
+
deadlineHandle = setTimeout(() => {
|
|
247
|
+
timedOut = true;
|
|
248
|
+
resolve();
|
|
249
|
+
}, deadlineMs);
|
|
250
|
+
});
|
|
251
|
+
await Promise.race([this.whenIdle(), deadline]);
|
|
252
|
+
if (!timedOut && deadlineHandle !== undefined)
|
|
253
|
+
clearTimeout(deadlineHandle);
|
|
254
|
+
if (timedOut)
|
|
255
|
+
aborted = true;
|
|
256
|
+
let abandoned = 0;
|
|
257
|
+
for (const lane of lanes.values()) {
|
|
258
|
+
abandoned += lane.length;
|
|
259
|
+
for (const entry of lane.splice(0))
|
|
260
|
+
entry.reject(new QueueStoppedError);
|
|
261
|
+
}
|
|
262
|
+
abandoned += running.size;
|
|
263
|
+
for (const [key, lane] of lanes) {
|
|
264
|
+
if (lane.length === 0 && !running.has(key))
|
|
265
|
+
lanes.delete(key);
|
|
266
|
+
}
|
|
267
|
+
announceIdle();
|
|
268
|
+
return {
|
|
269
|
+
completed: completed - before.completed,
|
|
270
|
+
failed: failed - before.failed,
|
|
271
|
+
abandoned,
|
|
272
|
+
timedOut
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
9
278
|
// src/jobs.ts
|
|
10
279
|
var systemClock = {
|
|
11
280
|
now: () => Date.now(),
|
|
@@ -83,10 +352,13 @@ function createScheduler(options) {
|
|
|
83
352
|
{ key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
|
|
84
353
|
]));
|
|
85
354
|
const timers = new Map;
|
|
86
|
-
|
|
355
|
+
let work = createQueue({ width: Math.max(1, jobs.size) });
|
|
356
|
+
let workStopped = false;
|
|
357
|
+
let restartBlocked = false;
|
|
87
358
|
let controller = new AbortController;
|
|
88
359
|
let paused = false;
|
|
89
360
|
let running = false;
|
|
361
|
+
const submit = (job) => work.run(job.key, execute, job).result;
|
|
90
362
|
async function execute(job) {
|
|
91
363
|
const report = reports.get(job.key);
|
|
92
364
|
const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
|
|
@@ -132,17 +404,22 @@ function createScheduler(options) {
|
|
|
132
404
|
timers.delete(job.key);
|
|
133
405
|
if (!running || paused)
|
|
134
406
|
return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
schedule(job, everyMs(job.every));
|
|
407
|
+
submit(job).then(() => schedule(job, everyMs(job.every)), () => {
|
|
408
|
+
return;
|
|
138
409
|
});
|
|
139
|
-
inFlight.set(job.key, work);
|
|
140
410
|
}, delayMs));
|
|
141
411
|
}
|
|
142
412
|
return {
|
|
143
413
|
start() {
|
|
144
414
|
if (running)
|
|
145
415
|
return;
|
|
416
|
+
if (restartBlocked) {
|
|
417
|
+
throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
|
|
418
|
+
}
|
|
419
|
+
if (workStopped) {
|
|
420
|
+
work = createQueue({ width: Math.max(1, jobs.size) });
|
|
421
|
+
workStopped = false;
|
|
422
|
+
}
|
|
146
423
|
running = true;
|
|
147
424
|
paused = false;
|
|
148
425
|
controller = new AbortController;
|
|
@@ -152,15 +429,18 @@ function createScheduler(options) {
|
|
|
152
429
|
schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
|
|
153
430
|
}
|
|
154
431
|
},
|
|
155
|
-
async stop() {
|
|
432
|
+
async stop(deadlineMs = 30000) {
|
|
156
433
|
running = false;
|
|
157
434
|
controller.abort();
|
|
158
435
|
for (const timer of timers.values())
|
|
159
436
|
clearTimeout(timer);
|
|
160
437
|
timers.clear();
|
|
161
|
-
await
|
|
438
|
+
const drained = await work.stop(deadlineMs);
|
|
439
|
+
workStopped = true;
|
|
440
|
+
restartBlocked = drained.timedOut;
|
|
162
441
|
for (const report of reports.values())
|
|
163
442
|
report.state = "stopped";
|
|
443
|
+
return drained;
|
|
164
444
|
},
|
|
165
445
|
pause() {
|
|
166
446
|
paused = true;
|
|
@@ -185,12 +465,7 @@ function createScheduler(options) {
|
|
|
185
465
|
const job = jobs.get(key);
|
|
186
466
|
if (!job)
|
|
187
467
|
throw new Error(`No job named "${key}".`);
|
|
188
|
-
|
|
189
|
-
if (existing)
|
|
190
|
-
await existing;
|
|
191
|
-
const work = execute(job).finally(() => inFlight.delete(key));
|
|
192
|
-
inFlight.set(key, work);
|
|
193
|
-
await work;
|
|
468
|
+
await submit(job);
|
|
194
469
|
return { ...reports.get(key) };
|
|
195
470
|
},
|
|
196
471
|
status: () => [...reports.values()].map((report) => ({ ...report })),
|