@listen-fire/module-runtime 0.12.0-rc.7 → 0.12.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/module-wire.d.ts +1 -0
- package/dist/module-wire.js +1 -0
- package/dist/module_wire/conformance.d.ts +9 -0
- package/dist/module_wire/conformance.js +139 -2
- package/dist/module_wire/secrets.d.ts +14 -0
- package/dist/module_wire/secrets.js +52 -0
- package/dist/vendor_wait.d.ts +8 -0
- package/dist/vendor_wait.js +64 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// bytes as a stream, the files slot, the secrets family (resolve a credential,
|
|
5
5
|
// write it back at a version, refresh it once at a time) with the generic
|
|
6
6
|
// OAuth 2 refresh and connect, the in-memory vault an embedder starts from,
|
|
7
|
-
// the browser slot,
|
|
7
|
+
// the browser slot, a bounded concurrency queue, and how long a
|
|
8
|
+
// rate-limited vendor asked to wait.
|
|
8
9
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
10
|
if (k2 === undefined) k2 = k;
|
|
10
11
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -28,3 +29,4 @@ __exportStar(require("./secrets/connect"), exports);
|
|
|
28
29
|
__exportStar(require("./secrets/family"), exports);
|
|
29
30
|
__exportStar(require("./secrets/memory"), exports);
|
|
30
31
|
__exportStar(require("./secrets/oauth2"), exports);
|
|
32
|
+
__exportStar(require("./vendor_wait"), exports);
|
package/dist/module-wire.d.ts
CHANGED
package/dist/module-wire.js
CHANGED
|
@@ -24,3 +24,4 @@ __exportStar(require("./module_wire/wire"), exports);
|
|
|
24
24
|
__exportStar(require("./module_wire/conformance"), exports);
|
|
25
25
|
__exportStar(require("./module_wire/browser"), exports);
|
|
26
26
|
__exportStar(require("./module_wire/scanned"), exports);
|
|
27
|
+
__exportStar(require("./module_wire/secrets"), exports);
|
|
@@ -33,3 +33,12 @@ export interface WakeConformanceCase {
|
|
|
33
33
|
}
|
|
34
34
|
/** The wake slot (docs/contracts/wake-slot.md). */
|
|
35
35
|
export declare const wakeSlotCases: WakeConformanceCase[];
|
|
36
|
+
/** The secrets slot's own cases (docs/contracts/secrets-slot.md): the list,
|
|
37
|
+
* the lease and whole entries. `client` reaches a module that can change its
|
|
38
|
+
* entries; the family's cases (`secretsContractCases`) hold its resolve and
|
|
39
|
+
* write-back to the meaning every store has. */
|
|
40
|
+
export declare const secretsSlotCases: SlotConformanceCase[];
|
|
41
|
+
/** What a module that keeps its credentials where lf cannot change them
|
|
42
|
+
* answers: `client` reaches one. Entries are refused by name with a reason;
|
|
43
|
+
* everything a refresh needs still works. */
|
|
44
|
+
export declare const secretsReadOnlySlotCases: SlotConformanceCase[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// The conformance suites of the slots with no family contract of their own
|
|
3
|
-
// (models, judge, files, wake, ocr), and the
|
|
3
|
+
// (models, judge, files, wake, ocr), and the secrets slot's own cases beside
|
|
4
|
+
// the family's (`services/secrets/contract.ts`), and the cases every slot module answers alike (its
|
|
4
5
|
// manifest, the envelope's refusals). Each runs against any server over the
|
|
5
6
|
// wire: written once here, they hold lf's built-ins (`lf --contract-<slot>`)
|
|
6
7
|
// and a third-party module in any language to the same meaning. The store,
|
|
@@ -9,12 +10,14 @@
|
|
|
9
10
|
// `loop_guard/contract.ts`) driven over this same wire; the browser to
|
|
10
11
|
// `services/browser/contract.ts` through its wire client.
|
|
11
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
|
|
13
|
+
exports.secretsReadOnlySlotCases = exports.secretsSlotCases = exports.wakeSlotCases = exports.ocrSlotCases = exports.filesSlotCases = exports.judgeSlotCases = exports.modelsSlotCases = void 0;
|
|
13
14
|
exports.commonSlotCases = commonSlotCases;
|
|
14
15
|
const node_assert_1 = require("node:assert");
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
17
|
const engine_contracts_1 = require("@listen-fire/engine-contracts");
|
|
16
18
|
const slots_1 = require("@listen-fire/engine-contracts/slots");
|
|
17
19
|
const scanned_1 = require("./scanned");
|
|
20
|
+
const zod_1 = require("zod");
|
|
18
21
|
const wire_1 = require("./wire");
|
|
19
22
|
async function refusedWith(code, call) {
|
|
20
23
|
try {
|
|
@@ -314,3 +317,137 @@ exports.wakeSlotCases = [
|
|
|
314
317
|
},
|
|
315
318
|
},
|
|
316
319
|
];
|
|
320
|
+
// ── The secrets slot ────────────────────────────────────────────────────────
|
|
321
|
+
const NEW_REF = () => `conf_${(0, node_crypto_1.randomUUID)().replaceAll('-', '').slice(0, 12)}`;
|
|
322
|
+
const SecretsList = zod_1.z.array(slots_1.SecretsListed);
|
|
323
|
+
async function listed(client) {
|
|
324
|
+
return client.read('secrets.list', {}, SecretsList);
|
|
325
|
+
}
|
|
326
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
327
|
+
/** The secrets slot's own cases (docs/contracts/secrets-slot.md): the list,
|
|
328
|
+
* the lease and whole entries. `client` reaches a module that can change its
|
|
329
|
+
* entries; the family's cases (`secretsContractCases`) hold its resolve and
|
|
330
|
+
* write-back to the meaning every store has. */
|
|
331
|
+
exports.secretsSlotCases = [
|
|
332
|
+
{
|
|
333
|
+
name: 'secrets: list names each credential with its external, binding and the version resolve answers',
|
|
334
|
+
async run(client) {
|
|
335
|
+
const ref = NEW_REF();
|
|
336
|
+
const binding = { credential: 'oauth' };
|
|
337
|
+
await client.read('secrets.create', { ref, external: 'acme_crm', binding, fields: { access_token: 'a1', workspace: 'w1' } }, slots_1.SecretsEntryAnswer);
|
|
338
|
+
const one = (await listed(client)).find((entry) => entry.ref === ref);
|
|
339
|
+
node_assert_1.strict.ok(one !== undefined, 'a created entry is listed');
|
|
340
|
+
node_assert_1.strict.equal(one.external, 'acme_crm');
|
|
341
|
+
node_assert_1.strict.deepEqual(one.binding, binding);
|
|
342
|
+
node_assert_1.strict.equal(one.version, (await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.version);
|
|
343
|
+
await client.call('secrets.delete', { ref });
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: 'secrets: create, replace and delete change whole entries, each answered null',
|
|
348
|
+
async run(client) {
|
|
349
|
+
const ref = NEW_REF();
|
|
350
|
+
node_assert_1.strict.equal(await client.call('secrets.create', { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } }), null);
|
|
351
|
+
node_assert_1.strict.deepEqual((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields, { api_key: 'k1' });
|
|
352
|
+
node_assert_1.strict.equal(await client.call('secrets.replace', { ref, external: 'acme_crm', binding: 'install', fields: { api_key: 'k2', workspace: 'w2' } }), null);
|
|
353
|
+
node_assert_1.strict.deepEqual((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields, { api_key: 'k2', workspace: 'w2' }, 'replace is the whole entry, not a merge');
|
|
354
|
+
node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref)?.binding, 'install');
|
|
355
|
+
node_assert_1.strict.equal(await client.call('secrets.delete', { ref }), null);
|
|
356
|
+
node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
|
|
357
|
+
node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref), undefined, 'a deleted entry is not listed');
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'secrets: create of an entry that exists, and delete of one that does not, are refused',
|
|
362
|
+
async run(client) {
|
|
363
|
+
const ref = NEW_REF();
|
|
364
|
+
const entry = { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } };
|
|
365
|
+
await client.call('secrets.create', entry);
|
|
366
|
+
await node_assert_1.strict.rejects(client.call('secrets.create', { ...entry, fields: { api_key: 'k9' } }), wire_1.SlotRefused);
|
|
367
|
+
node_assert_1.strict.equal((await client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable()))?.fields.api_key, 'k1', 'the refused create changed nothing');
|
|
368
|
+
await client.call('secrets.delete', { ref });
|
|
369
|
+
await node_assert_1.strict.rejects(client.call('secrets.delete', { ref }), wire_1.SlotRefused);
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: 'secrets: a second lock on a credential while one is held answers null; unlock frees it',
|
|
374
|
+
async run(client) {
|
|
375
|
+
const ref = NEW_REF();
|
|
376
|
+
const held = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
|
|
377
|
+
node_assert_1.strict.ok(held !== null, 'a free credential is locked');
|
|
378
|
+
node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 30_000 }), null);
|
|
379
|
+
node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: `${held.token}-not` }), null, 'a token that is not the lease\'s unlocks nothing');
|
|
380
|
+
node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 30_000 }), null);
|
|
381
|
+
node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: held.token }), null);
|
|
382
|
+
const again = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
|
|
383
|
+
node_assert_1.strict.ok(again !== null && again.token !== held.token, 'an unlocked credential is locked afresh, under a new token');
|
|
384
|
+
await client.call('secrets.unlock', { ref, token: again.token });
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'secrets: a lease whose ttl has lapsed is free, without an unlock',
|
|
389
|
+
async run(client) {
|
|
390
|
+
const ref = NEW_REF();
|
|
391
|
+
node_assert_1.strict.ok((await client.read('secrets.lock', { ref, ttlMs: 150 }, slots_1.SecretsLockAnswer)) !== null);
|
|
392
|
+
node_assert_1.strict.equal(await client.call('secrets.lock', { ref, ttlMs: 150 }), null, 'held until its ttl');
|
|
393
|
+
await sleep(400);
|
|
394
|
+
const taken = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
|
|
395
|
+
node_assert_1.strict.ok(taken !== null, 'a lapsed lease is taken over');
|
|
396
|
+
await client.call('secrets.unlock', { ref, token: taken.token });
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'secrets: assertWritable of a module that changes its entries answers null',
|
|
401
|
+
async run(client) {
|
|
402
|
+
node_assert_1.strict.equal(await client.call('secrets.assertWritable', { ref: NEW_REF() }), null);
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: 'secrets: a request without its ref, a lock without its ttl and an entry without its binding are refused as bad_request',
|
|
407
|
+
async run(client) {
|
|
408
|
+
await refusedWith('bad_request', client.call('secrets.resolve', {}));
|
|
409
|
+
await refusedWith('bad_request', client.call('secrets.lock', { ref: NEW_REF() }));
|
|
410
|
+
await refusedWith('bad_request', client.call('secrets.create', { ref: NEW_REF(), external: 'acme_crm', fields: {} }));
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
/** What a module that keeps its credentials where lf cannot change them
|
|
415
|
+
* answers: `client` reaches one. Entries are refused by name with a reason;
|
|
416
|
+
* everything a refresh needs still works. */
|
|
417
|
+
exports.secretsReadOnlySlotCases = [
|
|
418
|
+
...['secrets.create', 'secrets.replace'].map((method) => ({
|
|
419
|
+
name: `secrets: a read-only module answers ${method} with read_only and its reason, and keeps nothing`,
|
|
420
|
+
async run(client) {
|
|
421
|
+
const ref = NEW_REF();
|
|
422
|
+
const answer = await client.read(method, { ref, external: 'acme_crm', binding: 'legacy', fields: { api_key: 'k1' } }, slots_1.SecretsEntryAnswer);
|
|
423
|
+
node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `${method} answered ${JSON.stringify(answer)}`);
|
|
424
|
+
node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
|
|
425
|
+
node_assert_1.strict.equal((await listed(client)).find((entry) => entry.ref === ref), undefined);
|
|
426
|
+
},
|
|
427
|
+
})),
|
|
428
|
+
{
|
|
429
|
+
name: 'secrets: a read-only module answers secrets.delete with read_only and its reason',
|
|
430
|
+
async run(client) {
|
|
431
|
+
const answer = await client.read('secrets.delete', { ref: NEW_REF() }, slots_1.SecretsEntryAnswer);
|
|
432
|
+
node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `secrets.delete answered ${JSON.stringify(answer)}`);
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: 'secrets: a read-only module answers secrets.assertWritable with read_only and its reason',
|
|
437
|
+
async run(client) {
|
|
438
|
+
const answer = await client.read('secrets.assertWritable', { ref: NEW_REF() }, slots_1.SecretsEntryAnswer);
|
|
439
|
+
node_assert_1.strict.ok(answer !== null && answer.kind === 'read_only' && answer.reason.length > 0, `secrets.assertWritable answered ${JSON.stringify(answer)}`);
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: 'secrets: a read-only module still lists, resolves and leases',
|
|
444
|
+
async run(client) {
|
|
445
|
+
const ref = NEW_REF();
|
|
446
|
+
node_assert_1.strict.ok(Array.isArray(await client.call('secrets.list', {})));
|
|
447
|
+
node_assert_1.strict.equal(await client.call('secrets.resolve', { ref }), null);
|
|
448
|
+
const held = await client.read('secrets.lock', { ref, ttlMs: 30_000 }, slots_1.SecretsLockAnswer);
|
|
449
|
+
node_assert_1.strict.ok(held !== null);
|
|
450
|
+
node_assert_1.strict.equal(await client.call('secrets.unlock', { ref, token: held.token }), null);
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SecretFields, SecretRecord, SecretsBackend, WriteBackOutcome } from '../secrets/family';
|
|
2
|
+
import { type SlotAddress } from './wire';
|
|
3
|
+
export declare class SecretsSlotBackend implements SecretsBackend {
|
|
4
|
+
private readonly client;
|
|
5
|
+
constructor(address: SlotAddress, settings?: Record<string, string>);
|
|
6
|
+
resolve(ref: string): Promise<SecretRecord | null>;
|
|
7
|
+
writeBack(ref: string, fields: SecretFields, expectedVersion: string): Promise<WriteBackOutcome>;
|
|
8
|
+
/** The module's lease, asked for again until it is free, held for `body`. */
|
|
9
|
+
exclusive<T>(ref: string, body: (store: SecretsBackend) => Promise<T>): Promise<T>;
|
|
10
|
+
/** A write-back is not what `read_only` answers for: only a refusal fails here. */
|
|
11
|
+
assertWritable(ref: string): Promise<void>;
|
|
12
|
+
/** Store a new credential (a legacy entry bound to `external`). */
|
|
13
|
+
seed(ref: string, external: string, fields: SecretFields): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A secrets module spoken to over the wire as the secrets family's store
|
|
3
|
+
// (`SecretsBackend`, secrets/family.ts): how the family's contract cases
|
|
4
|
+
// (movement-engine services/secrets/contract.ts) hold any module of the
|
|
5
|
+
// secrets slot to the meaning every store has, whether the module is served
|
|
6
|
+
// by lf (`lf --contract-secrets-slot`) or is a third party's.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.SecretsSlotBackend = void 0;
|
|
9
|
+
const slots_1 = require("@listen-fire/engine-contracts/slots");
|
|
10
|
+
const wire_1 = require("./wire");
|
|
11
|
+
const LOCK_POLL_MS = 20;
|
|
12
|
+
const LEASE_TTL_MS = 30_000;
|
|
13
|
+
const LOCK_WAIT_MS = 10_000;
|
|
14
|
+
class SecretsSlotBackend {
|
|
15
|
+
client;
|
|
16
|
+
constructor(address, settings = {}) {
|
|
17
|
+
this.client = new wire_1.SlotClient(address, settings);
|
|
18
|
+
}
|
|
19
|
+
resolve(ref) {
|
|
20
|
+
return this.client.read('secrets.resolve', { ref }, slots_1.SecretsRecord.nullable());
|
|
21
|
+
}
|
|
22
|
+
async writeBack(ref, fields, expectedVersion) {
|
|
23
|
+
return this.client.read('secrets.writeBack', { ref, fields, expectedVersion }, slots_1.SecretsWriteBackAnswer);
|
|
24
|
+
}
|
|
25
|
+
/** The module's lease, asked for again until it is free, held for `body`. */
|
|
26
|
+
async exclusive(ref, body) {
|
|
27
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const lease = await this.client.read('secrets.lock', { ref, ttlMs: LEASE_TTL_MS }, slots_1.SecretsLockAnswer);
|
|
30
|
+
if (lease !== null) {
|
|
31
|
+
try {
|
|
32
|
+
return await body(this);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await this.client.call('secrets.unlock', { ref, token: lease.token });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Date.now() > until)
|
|
39
|
+
throw new Error(`the secrets module kept credential "${ref}" locked for ${LOCK_WAIT_MS / 1000}s`);
|
|
40
|
+
await new Promise((done) => setTimeout(done, LOCK_POLL_MS));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** A write-back is not what `read_only` answers for: only a refusal fails here. */
|
|
44
|
+
async assertWritable(ref) {
|
|
45
|
+
await this.client.call('secrets.assertWritable', { ref });
|
|
46
|
+
}
|
|
47
|
+
/** Store a new credential (a legacy entry bound to `external`). */
|
|
48
|
+
async seed(ref, external, fields) {
|
|
49
|
+
await this.client.call('secrets.create', { ref, external, binding: 'legacy', fields });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.SecretsSlotBackend = SecretsSlotBackend;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long the vendor asked a rate-limited caller to wait, or undefined when
|
|
3
|
+
* it did not say. Read from `e.headers` (a plain object or `Headers`; a
|
|
4
|
+
* `Response` is one) or `e.response.headers`, else Google's `RetryInfo` in an
|
|
5
|
+
* error's message. Clamped to [1s, 120s] so a hostile or absurd value can
|
|
6
|
+
* neither spin nor stall a caller.
|
|
7
|
+
*/
|
|
8
|
+
export declare function vendorWaitMs(e: unknown, now?: number): number | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// How long a rate-limited vendor asked its caller to wait. Here, below both
|
|
3
|
+
// the engine and the module kit, so the kit's limits door and the engine's
|
|
4
|
+
// limiter (`throttleAfter`, movement-engine/public/limiter) read one parser.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.vendorWaitMs = vendorWaitMs;
|
|
7
|
+
const SECOND = 1000;
|
|
8
|
+
const MIN_WAIT_MS = 1 * SECOND;
|
|
9
|
+
const MAX_WAIT_MS = 120 * SECOND;
|
|
10
|
+
function clamp(ms) {
|
|
11
|
+
return Math.min(MAX_WAIT_MS, Math.max(MIN_WAIT_MS, Math.round(ms)));
|
|
12
|
+
}
|
|
13
|
+
/** `Retry-After` is either whole seconds or an HTTP date. */
|
|
14
|
+
function fromHeaderValue(value, now) {
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
17
|
+
return Number(trimmed) * SECOND;
|
|
18
|
+
const date = Date.parse(trimmed);
|
|
19
|
+
return Number.isNaN(date) ? undefined : date - now;
|
|
20
|
+
}
|
|
21
|
+
function readHeader(headers, name) {
|
|
22
|
+
if (!headers || typeof headers !== 'object')
|
|
23
|
+
return undefined;
|
|
24
|
+
if ('get' in headers && typeof headers.get === 'function') {
|
|
25
|
+
const value = headers.get(name);
|
|
26
|
+
return typeof value === 'string' ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
29
|
+
if (key.toLowerCase() === name && typeof value === 'string')
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function field(value, name) {
|
|
35
|
+
return typeof value === 'object' && value !== null ? Reflect.get(value, name) : undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Google's `google.rpc.RetryInfo` detail: `"retryDelay": "17s"` (or
|
|
39
|
+
* `"17.5s"`) on the wire, `{ seconds: 17 }` in a decoded form. The genai SDK's
|
|
40
|
+
* `ApiError` carries the JSON body only as text in its message.
|
|
41
|
+
*/
|
|
42
|
+
function fromRetryInfo(text) {
|
|
43
|
+
const quoted = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(text);
|
|
44
|
+
if (quoted)
|
|
45
|
+
return Number(quoted[1]) * SECOND;
|
|
46
|
+
const object = /"retryDelay"\s*:\s*\{\s*"seconds"\s*:\s*"?(\d+)"?/.exec(text);
|
|
47
|
+
return object ? Number(object[1]) * SECOND : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How long the vendor asked a rate-limited caller to wait, or undefined when
|
|
51
|
+
* it did not say. Read from `e.headers` (a plain object or `Headers`; a
|
|
52
|
+
* `Response` is one) or `e.response.headers`, else Google's `RetryInfo` in an
|
|
53
|
+
* error's message. Clamped to [1s, 120s] so a hostile or absurd value can
|
|
54
|
+
* neither spin nor stall a caller.
|
|
55
|
+
*/
|
|
56
|
+
function vendorWaitMs(e, now = Date.now()) {
|
|
57
|
+
const header = readHeader(field(e, 'headers'), 'retry-after') ?? readHeader(field(field(e, 'response'), 'headers'), 'retry-after');
|
|
58
|
+
const fromHeader = header === undefined ? undefined : fromHeaderValue(header, now);
|
|
59
|
+
if (fromHeader !== undefined)
|
|
60
|
+
return clamp(fromHeader);
|
|
61
|
+
const text = e instanceof Error ? e.message : undefined;
|
|
62
|
+
const fromBody = text === undefined ? undefined : fromRetryInfo(text);
|
|
63
|
+
return fromBody === undefined ? undefined : clamp(fromBody);
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@listen-fire/module-runtime",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "0.12.0-rc.
|
|
4
|
+
"version": "0.12.0-rc.8",
|
|
5
5
|
"description": "What a module and the host that loads it share at run time on Node, beside the runtime-neutral contracts (@listen-fire/engine-contracts): a live adapter's file bytes as a stream, the files slot, the secrets family with the generic OAuth 2 refresh and connect, the in-memory vault, the browser slot and a bounded concurrency queue. The engine and the module kit both depend on it, so the kit never depends on the engine",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "22.20.0"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"type": "commonjs",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"zod": "4.1.12",
|
|
25
|
-
"@listen-fire/engine-contracts": "^0.12.0-rc.
|
|
25
|
+
"@listen-fire/engine-contracts": "^0.12.0-rc.8"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "20.16.5",
|