@jarenjs/contract 0.73.0 → 0.83.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.
@@ -0,0 +1,191 @@
1
+ //@ts-check
2
+ /** One bounded, single-attempt transport seam with explicit replay safety. */
3
+ import { createScheduler } from '@jarenjs/core/schedule';
4
+ import { backoffDelay, createAttemptBudget, parseRetryAfter, sleep as defaultSleep } from '@jarenjs/core/retry';
5
+ import { ContractHostError } from '../errors.js';
6
+
7
+ /** @param {string} reason @returns {ContractHostError} */
8
+ export const providerHostError = (reason) => new ContractHostError('JC1012', reason);
9
+
10
+ /**
11
+ * @typedef {Object} ProviderRequest
12
+ * @property {string} url
13
+ * @property {string} [method]
14
+ * @property {Record<string, string>} [headers] - public headers only; credentials belong to host transport
15
+ * @property {string} [body]
16
+ * @property {'safe-read' | 'provider-idempotent' | 'single-send'} safety
17
+ * @property {string} [idempotencyKey] - required evidence for provider-idempotent
18
+ * @property {string} [account] - opaque scheduler scope, never credentials
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} ProviderExecutorOptions
23
+ * @property {(request: ProviderRequest, context: { signal: AbortSignal, attempt: number, maxAttempts: 1 }) => Promise<Response>} [transport]
24
+ * @property {number} [attempts]
25
+ * @property {number} [overallMs]
26
+ * @property {number} [attemptMs]
27
+ * @property {number} [maxBytes] - response bytes across all attempts
28
+ * @property {number} [maxRequestBytes]
29
+ * @property {number} [baseMs]
30
+ * @property {number} [maxMs]
31
+ * @property {'http' | 'milliseconds' | 'none'} [retryAfter]
32
+ * @property {string} [retryAfterHeader]
33
+ * @property {number} [concurrency]
34
+ * @property {number} [maxQueue]
35
+ * @property {number} [maxScopes]
36
+ * @property {number} [spacingMs]
37
+ * @property {() => number} [now]
38
+ * @property {() => number} [random]
39
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep]
40
+ */
41
+
42
+ /**
43
+ * Injected transports must issue exactly one request and honor the supplied
44
+ * signal; SDK retry loops must be disabled. Shutdown awaits transport and body
45
+ * settlement even when a transport ignores its signal. JSON outcomes never
46
+ * contain exceptions, request headers, controllers or live response handles.
47
+ * @param {ProviderExecutorOptions} [options]
48
+ */
49
+ export function createProviderExecutor(options = {}) {
50
+ const { attempts = 3, overallMs = 30000, attemptMs = 10000, maxBytes = 262144,
51
+ maxRequestBytes = 262144, baseMs = 500, maxMs = 8000, now = Date.now,
52
+ random = Math.random, sleep = defaultSleep, retryAfter = 'http', retryAfterHeader = 'retry-after' } = options;
53
+ for (const [key, value] of Object.entries({ attempts, overallMs, attemptMs, maxBytes, maxRequestBytes })) {
54
+ if (!Number.isSafeInteger(value) || value < 1) throw providerHostError(`${key} must be a positive finite integer`);
55
+ }
56
+ if (![baseMs, maxMs].every((value) => Number.isFinite(value) && value >= 0)
57
+ || typeof now !== 'function' || typeof random !== 'function' || typeof sleep !== 'function'
58
+ || !['http', 'milliseconds', 'none'].includes(retryAfter) || typeof retryAfterHeader !== 'string')
59
+ throw providerHostError('invalid retry policy');
60
+ const transport = options.transport ?? ((request, context) => fetch(request.url, {
61
+ method: request.method, headers: request.headers, body: request.body, signal: context.signal, redirect: 'manual',
62
+ }));
63
+ if (typeof transport !== 'function') throw providerHostError('transport must be a single-attempt function');
64
+ const scheduler = createScheduler({ ...options, now, sleep });
65
+ const closer = new AbortController();
66
+ /** @type {Set<Promise<any>>} */
67
+ const running = new Set();
68
+ const encoder = new TextEncoder();
69
+
70
+ /** @param {ProviderRequest} request @param {any} context */
71
+ async function execute(request, context) {
72
+ if (!request || !['safe-read', 'provider-idempotent', 'single-send'].includes(request.safety))
73
+ throw providerHostError('request must declare replay safety');
74
+ if (request.safety === 'provider-idempotent' && (typeof request.idempotencyKey !== 'string' || !request.idempotencyKey))
75
+ throw providerHostError('provider-idempotent requires the provider key');
76
+ let url;
77
+ try { url = new URL(request.url); }
78
+ catch { throw providerHostError('request URL must be absolute HTTP(S)'); }
79
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
80
+ throw providerHostError('request URL must be absolute HTTP(S) without credentials');
81
+ if (request.body !== undefined && typeof request.body !== 'string') throw providerHostError('only bounded text requests are supported');
82
+ const budget = context.budget ?? createAttemptBudget(attempts, request.safety);
83
+ if (budget.safety !== request.safety || typeof budget.take !== 'function') throw providerHostError('attempt budget safety must match the request');
84
+ const signal = AbortSignal.any([closer.signal, ...(context.signal ? [context.signal] : [])]);
85
+ if (context.deadline !== undefined && (typeof context.deadline !== 'number' || Number.isNaN(context.deadline)))
86
+ throw providerHostError('deadline must be a numeric instant');
87
+ const deadline = Math.min(now() + overallMs, context.deadline ?? Infinity);
88
+ const byteLimit = Math.min(maxBytes, context.maxBytes ?? maxBytes);
89
+ if (!Number.isSafeInteger(byteLimit) || byteLimit < 0) throw providerHostError('per-request byte credit must be a nonnegative finite integer');
90
+ const scope = JSON.stringify([url.origin, request.account ?? '']);
91
+ let count = 0;
92
+ let bytes = 0;
93
+ /** @param {string} state @param {string} reason @param {any} [extra] */
94
+ const outcome = (state, reason, extra = {}) => ({ state, reason, attempts: count, bytes, ...extra });
95
+ if (request.safety === 'provider-idempotent' && options.transport === undefined)
96
+ return outcome('refused', 'idempotency-transport-required');
97
+ if (encoder.encode(request.body ?? '').byteLength > maxRequestBytes) return outcome('refused', 'request-byte-limit');
98
+ for (;;) {
99
+ if (signal.aborted) return outcome('cancelled', 'cancelled');
100
+ if (now() >= deadline) return outcome('refused', 'deadline');
101
+ let result;
102
+ try {
103
+ result = await scheduler.run(async () => {
104
+ const controller = new AbortController();
105
+ const attemptSignal = AbortSignal.any([signal, controller.signal]);
106
+ const timerStop = new AbortController();
107
+ const until = Math.min(deadline, now() + attemptMs);
108
+ const timer = sleep(Math.max(0, until - now()), timerStop.signal).then(() => controller.abort(), () => {});
109
+ try {
110
+ if (context.beforeDispatch && await context.beforeDispatch(request) !== true) return outcome('refused', 'authority-changed');
111
+ if (attemptSignal.aborted || now() >= until) return outcome(signal.aborted ? 'cancelled' : 'refused', signal.aborted ? 'cancelled' : 'deadline');
112
+ if (count >= attempts || !budget.take()) return outcome('refused', 'attempt-budget');
113
+ count++;
114
+ let response;
115
+ try { response = await transport(request, { signal: attemptSignal, attempt: budget.used, maxAttempts: 1 }); }
116
+ catch { return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'transport', { retryable: true }); }
117
+ if (!response || typeof response.status !== 'number' || typeof response.headers?.get !== 'function')
118
+ return outcome('failed', 'transport-shape');
119
+ const rate = parseRetryAfter(response.headers.get(retryAfterHeader), { dialect: retryAfter, now: now() });
120
+ if (rate !== undefined) scheduler.observe(scope, rate);
121
+ const reader = response.body?.getReader();
122
+ let text = '';
123
+ let readFailure = false;
124
+ if (reader) {
125
+ const cancel = () => { reader.cancel().catch(() => {}); };
126
+ attemptSignal.addEventListener('abort', cancel, { once: true });
127
+ const decoder = new TextDecoder('utf-8', { fatal: true });
128
+ try {
129
+ for (;;) {
130
+ if (attemptSignal.aborted) { await reader.cancel(); break; }
131
+ const part = await reader.read();
132
+ if (part.done) { text += decoder.decode(); break; }
133
+ bytes += part.value.byteLength;
134
+ if (bytes > byteLimit) { await reader.cancel(); return outcome('refused', 'byte-limit'); }
135
+ text += decoder.decode(part.value, { stream: true });
136
+ }
137
+ }
138
+ catch { readFailure = true; await reader.cancel().catch(() => {}); }
139
+ finally { attemptSignal.removeEventListener('abort', cancel); reader.releaseLock(); }
140
+ }
141
+ if (signal.aborted) return outcome('cancelled', 'cancelled');
142
+ if (attemptSignal.aborted || now() >= until)
143
+ return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'deadline', { retryable: true });
144
+ if (readFailure) return outcome(request.safety === 'single-send' ? 'unresolved' : 'failed', 'body', { retryable: true });
145
+ const status = response.status;
146
+ const ok = status >= 200 && status < 300;
147
+ return outcome(ok ? 'ok' : 'failed', ok ? 'response' : 'http', {
148
+ status, text, retryAfterMs: rate ?? null,
149
+ retryable: status === 408 || status === 429 || status >= 500,
150
+ });
151
+ }
152
+ finally { timerStop.abort(); await timer; }
153
+ }, { scope, deadline, signal });
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : '';
157
+ const reason = ['closed', 'cancelled', 'deadline', 'queue-full', 'scope-limit'].includes(message) ? message : 'host-fault';
158
+ return outcome(signal.aborted ? 'cancelled' : 'refused', signal.aborted ? 'cancelled' : reason);
159
+ }
160
+ if (signal.aborted) return outcome('cancelled', 'cancelled');
161
+ if (!result.retryable || request.safety === 'single-send' || !budget.remaining || count >= attempts) return result;
162
+ const delay = backoffDelay({ baseMs, maxMs, random }, count, result.retryAfterMs ?? undefined);
163
+ if (delay >= deadline - now()) return outcome('refused', 'deadline', { retryAfterMs: result.retryAfterMs ?? null });
164
+ try { await sleep(delay, signal); }
165
+ catch { return outcome('cancelled', 'cancelled'); }
166
+ }
167
+ }
168
+
169
+ return Object.freeze({
170
+ /** @param {ProviderRequest} request
171
+ * @param {{ signal?: AbortSignal, deadline?: number, maxBytes?: number, budget?: ReturnType<typeof createAttemptBudget>, beforeDispatch?: (request: ProviderRequest) => boolean | Promise<boolean> }} [context]
172
+ * @returns {Promise<any>} */
173
+ execute(request, context = {}) {
174
+ // Admission and dispatch use one immutable request, even if its caller
175
+ // changes the original object while queued or refreshing authority.
176
+ const captured = request && Object.freeze({ ...request,
177
+ ...(request.headers === undefined ? {} : { headers: Object.freeze({ ...request.headers }) }) });
178
+ const promise = execute(captured, context);
179
+ running.add(promise);
180
+ promise.then(() => running.delete(promise), () => running.delete(promise));
181
+ return promise;
182
+ },
183
+ /** Stop new work and drain requests, body readers and retry waits. */
184
+ async close() {
185
+ closer.abort();
186
+ await scheduler.close();
187
+ await Promise.allSettled([...running]);
188
+ },
189
+ stats: scheduler.stats,
190
+ });
191
+ }
@@ -0,0 +1,5 @@
1
+ //@ts-check
2
+ /** Bounded provider protocols with host-injected transport and authority. */
3
+ export { createProviderExecutor } from './execute.js';
4
+ export { compileProvider } from './compile.js';
5
+ export { withProviderRun } from './run.js';
@@ -0,0 +1,138 @@
1
+ //@ts-check
2
+ /** Run lifetimes over the existing identify/acquire/release coordinator. */
3
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { deepFreeze } from '@jarenjs/core/object';
5
+ import { resolveLifecycle, identify, acquire, once } from '../host.js';
6
+ import { createProviderExecutor, providerHostError } from './execute.js';
7
+
8
+ /** Privileged host objects cannot belong to two concurrent runs.
9
+ * @type {WeakSet<object>} */
10
+ const leased = new WeakSet();
11
+ const FIELDS = ['runId', 'actor', 'environment', 'destination', 'revision', 'lease'];
12
+
13
+ /**
14
+ * @typedef {{ runId: string, actor: string, environment: string,
15
+ * destination: string, revision: string, lease: string }} ProviderAuthority
16
+ */
17
+
18
+ /**
19
+ * Run a callback with private, isolated transport resources. current() must
20
+ * re-read live authority and return the matching evidence, or null to revoke.
21
+ * The host owns membership, destination resolution, OAuth and account locks.
22
+ * Only opaque evidence and JSON callback results can leave this lifetime.
23
+ * @param {ProviderAuthority} authority
24
+ * @param {{ identify: Function, acquire: Function,
25
+ * current: (evidence: ProviderAuthority, host: any, operation: any) => ProviderAuthority | null | Promise<ProviderAuthority | null>,
26
+ * transport: (request: import('./execute.js').ProviderRequest, context: any) => Promise<Response> }} host
27
+ * @param {(run: any) => any} work
28
+ * @param {import('./execute.js').ProviderExecutorOptions & { signal?: AbortSignal }} [options]
29
+ * @returns {Promise<any>}
30
+ */
31
+ export async function withProviderRun(authority, host, work, options = {}) {
32
+ if (!authority || Object.keys(authority).length !== FIELDS.length
33
+ || FIELDS.some((key) => typeof authority[key] !== 'string' || !authority[key]))
34
+ throw providerHostError('authority contains only runId, actor, environment, destination, revision and lease opaque strings');
35
+ if (!host || ['identify', 'acquire', 'current', 'transport'].some((name) => typeof host[name] !== 'function') || typeof work !== 'function')
36
+ throw providerHostError('run needs identify, acquire, current, transport and work capabilities');
37
+ const evidence = deepFreeze(JSON.parse(canonicalizeJson(authority)));
38
+ const lifecycle = resolveLifecycle(host, providerHostError);
39
+ const controller = new AbortController();
40
+ const signal = AbortSignal.any([controller.signal, ...(options.signal ? [options.signal] : [])]);
41
+ let live = true;
42
+ let fault = false;
43
+ const observe = () => { fault = true; };
44
+ const refused = (reason) => ({ state: 'refused', reason, evidence });
45
+ if (signal.aborted) return refused('cancelled');
46
+ const identity = await identify(lifecycle, /** @type {any} */ ({ carrier: 'provider', trace: evidence.runId, signal, evidence }));
47
+ if (identity.kind !== 'lease') return refused('host-fault');
48
+ const releaseIdentity = once(identity.lease.release, observe);
49
+ let releaseAcquired = async () => true;
50
+ let executor;
51
+ let enterPromise;
52
+ let privileged;
53
+ let owns = false;
54
+ /** @type {Set<Promise<any>>} */
55
+ const operations = new Set();
56
+ const track = (promise) => {
57
+ operations.add(promise);
58
+ promise.then(() => operations.delete(promise), () => operations.delete(promise));
59
+ return promise;
60
+ };
61
+ try {
62
+ if (signal.aborted) return refused('cancelled');
63
+ const entered = await acquire(lifecycle, evidence, { host: identity.lease.host }, (lease) => {
64
+ releaseAcquired = once(lease.release, observe);
65
+ enterPromise = (async () => {
66
+ if (!live || signal.aborted) return refused('cancelled');
67
+ privileged = lease.host;
68
+ if (privileged === null || typeof privileged !== 'object') return refused('host-fault');
69
+ if (leased.has(privileged)) {
70
+ releaseAcquired = async () => true;
71
+ return refused('resource-in-use');
72
+ }
73
+ leased.add(privileged);
74
+ owns = true;
75
+ const check = async (operation) => {
76
+ if (!live || signal.aborted) return false;
77
+ let current;
78
+ try { current = await host.current(evidence, privileged, operation); }
79
+ catch { current = null; }
80
+ const allowed = current && FIELDS.every((key) => current[key] === evidence[key]);
81
+ if (!allowed) controller.abort();
82
+ return Boolean(allowed && live && !signal.aborted);
83
+ };
84
+ executor = createProviderExecutor({ ...options,
85
+ transport: (request, context) => host.transport(request, { ...context, host: privileged }),
86
+ });
87
+ const run = Object.freeze(Object.defineProperties({ evidence }, {
88
+ signal: { value: signal },
89
+ execute: { value: (request, context = {}) => track(executor.execute(request, {
90
+ ...context, signal: AbortSignal.any([signal, ...(context.signal ? [context.signal] : [])]),
91
+ beforeDispatch: () => check({ phase: 'dispatch', url: request.url, method: request.method }),
92
+ })) },
93
+ check: { value: () => track(check({ phase: 'publish' })) },
94
+ publish: { value: (value, commit) => track((async () => {
95
+ if (!await check({ phase: 'publish' })) return refused('authority-changed');
96
+ return commit(JSON.parse(canonicalizeJson(value)), evidence);
97
+ })()) },
98
+ }));
99
+ if (!await check({ phase: 'start' })) return refused('authority-changed');
100
+ try {
101
+ const value = await work(run);
102
+ if (signal.aborted || !live) return refused('cancelled');
103
+ return { state: 'complete', evidence, value: JSON.parse(canonicalizeJson(value)) };
104
+ }
105
+ catch { return refused('run-failed'); }
106
+ finally {
107
+ live = false;
108
+ controller.abort();
109
+ await executor.close();
110
+ await Promise.allSettled([...operations]);
111
+ }
112
+ })();
113
+ return enterPromise;
114
+ });
115
+ if (entered.kind !== 'entered' || entered.afterFault) {
116
+ live = false;
117
+ controller.abort();
118
+ await executor?.close();
119
+ await enterPromise;
120
+ return refused('host-fault');
121
+ }
122
+ return entered.result;
123
+ }
124
+ finally { await cleanup(); }
125
+
126
+ async function cleanup() {
127
+ live = false;
128
+ controller.abort();
129
+ await executor?.close();
130
+ await Promise.allSettled([...operations]);
131
+ // The host may restore a switched account in release. Every old request,
132
+ // callback and publication has settled before either release begins.
133
+ await releaseAcquired();
134
+ if (owns) leased.delete(privileged);
135
+ await releaseIdentity();
136
+ if (fault) throw providerHostError('provider resource release failed');
137
+ }
138
+ }