@jarenjs/contract 0.43.1

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.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
@@ -0,0 +1,1150 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `openHttpClient(contract, options)`: the HTTP client binding of a
4
+ * compiled contract (docs/CONTRACT-FORMAT.md §10) — the `open(contract,
5
+ * options) → Client` half of the driver pair whose server half is
6
+ * `serveHttp`. `invoke(op, input, ctx)` validates the input with the
7
+ * SAME compiled validator the server will run, splits it by the declared
8
+ * locations (path, query, header, body), sends it through an injectable
9
+ * `fetch`, and resolves a D6 outcome for every possible result — success,
10
+ * declared failure, network failure, contract violation, cancellation —
11
+ * keeping the three identities apart: the caller's `attempt` (carried
12
+ * in `meta`, never sent), the server's `trace` (read from
13
+ * `x-jaren-trace`, never generated here) and the idempotency `key`
14
+ * (generated here per command, sent as `Idempotency-Key`, optionally
15
+ * recorded in a durable `storage` WITHOUT the input).
16
+ *
17
+ * `invoke` NEVER rejects for anything a server or a network can do; it
18
+ * throws only for the host's own mistakes (`JC1005`: an unknown or
19
+ * opaque operation). `url(op, input)` builds the URL of any operation —
20
+ * what an `<img src>` uses for an opaque one. `negotiate()` asks the
21
+ * server's well-known description whether the two ends speak compatible
22
+ * versions. Everything per operation is decided once at `open`.
23
+ */
24
+
25
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
26
+ import { compileMessageCatalog } from '@jarenjs/core/message';
27
+ import { canonicalSha256 } from '@jarenjs/json/canonical';
28
+ import { JarenValidator } from '@jarenjs/validate';
29
+
30
+ import { createSseEventDecoder } from '@jarenjs/core/text/sse';
31
+
32
+ import { ContractHostError } from '../errors.js';
33
+ import { compatReason } from '../compat.js';
34
+ import { WELL_KNOWN_PATH, verdict, projectValidationDetails, renderMessage } from '../http/wire.js';
35
+ import { createStreamConsumer, STREAM_ERRORS } from '../stream/client.js';
36
+ import { STREAM_MEDIA } from '../stream/sse.js';
37
+ import {
38
+ CLIENT_ERRORS, prepareOutcomeRoute, assembleOutcome, makeMeta, failedOutcome, clientError, outcomeError,
39
+ } from './outcome.js';
40
+
41
+ export { CLIENT_ERRORS };
42
+
43
+ /**
44
+ * @typedef {import('../compile.js').Contract} Contract
45
+ * @typedef {import('../compile.js').CompiledOperation} CompiledOperation
46
+ * @typedef {import('../http/wire.js').Catalog} Catalog
47
+ * @typedef {import('./outcome.js').Outcome} Outcome
48
+ * @typedef {import('./outcome.js').OutcomeMeta} OutcomeMeta
49
+ * @typedef {import('./outcome.js').OutcomeError} OutcomeError
50
+ * @typedef {import('./outcome.js').OutcomeRoute} OutcomeRoute
51
+ */
52
+
53
+ /**
54
+ * The durable idempotency-key storage: the `createDocStore` adapter shape
55
+ * of `@jarenjs/app` — `read()` returns the whole stored value (or
56
+ * `undefined`), `write(value)` replaces it. Either may return a promise.
57
+ * The client keeps its records under the member `jaren-contract`, keyed
58
+ * by contract id, operation and key; a record is `{ op, key, hash, at }`
59
+ * and never carries the input.
60
+ * @typedef {{ read: () => any, write: (value: any) => any }} KeyStorage
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} HttpClientOptions
65
+ * @property {(url: string, init: RequestInit) => Promise<any>} [fetch] - default `globalThis.fetch`
66
+ * @property {string} [baseUrl] - prefixed to every path; default `''` (relative URLs)
67
+ * @property {Record<string, string>} [headers] - static headers, merged under per-call ones
68
+ * @property {() => string} [keys] - the idempotency key generator; default `crypto.randomUUID`
69
+ * @property {KeyStorage | null} [storage] - durable key records; default `null`
70
+ * @property {number} [timeoutMs] - per request; `0` (default) means none; composed with `ctx.signal`
71
+ * @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep] - the retry backoff sleeper (injectable for tests)
72
+ * @property {Record<string, string | ((params: object) => string)>} [catalog] - a message catalog consulted before the English one
73
+ * @property {string} [wellKnown] - the server's description path; default `/.well-known/jaren-contract`
74
+ * @property {() => number} [now] - the clock stamped into key records; default `Date.now`
75
+ * @property {JarenValidator<any>} [validator] - the validator `url()` compiles its path/query check with
76
+ */
77
+
78
+ /**
79
+ * Per-call context of `invoke`.
80
+ * @typedef {Object} InvokeContext
81
+ * @property {AbortSignal} [signal] - cancels the request (`kind: "cancelled"`)
82
+ * @property {unknown} [attempt] - the caller's attempt id, echoed in `meta.attempt`, never sent
83
+ * @property {string} [idempotencyKey] - the key to send instead of a generated one
84
+ * @property {Record<string, string>} [headers] - per-call headers (over the static ones)
85
+ * @property {string} [ifNoneMatch] - sent as `If-None-Match`
86
+ * @property {string} [ifMatch] - sent as `If-Match`
87
+ */
88
+
89
+ /**
90
+ * The negotiation result.
91
+ * @typedef {Object} Negotiation
92
+ * @property {boolean} compatible
93
+ * @property {'same-version' | 'server-accepts' | 'client-accepts' | 'version-mismatch' | 'unreachable' | 'not-a-contract'} reason
94
+ * @property {{ id: string | null, version: string | null, compat: string[], revision: string | null } | null} server
95
+ * @property {{ code: string, message: string } | null} error - `null` when compatible
96
+ */
97
+
98
+ /**
99
+ * The frozen capabilities table of the http client.
100
+ * @typedef {Object} HttpClientCapabilities
101
+ * @property {'http'} name
102
+ * @property {true} status
103
+ * @property {true} headers
104
+ * @property {true} media
105
+ * @property {true} etag
106
+ * @property {true} idempotency
107
+ * @property {boolean} durableKeys - a `storage` was given
108
+ * @property {true} stream - `subscribe` carries SSE streams (docs/CONTRACT-FORMAT.md §19)
109
+ * @property {'signal'} cancel
110
+ */
111
+
112
+ /**
113
+ * The options of one `subscribe` call (docs/CONTRACT-FORMAT.md §19).
114
+ * @typedef {Object} SubscribeOptions
115
+ * @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
116
+ * @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
117
+ * @property {(outcome: Outcome) => void} [onError]
118
+ * @property {(info: { reason: string }) => void} [onEnd]
119
+ * @property {AbortSignal} [signal] - stops the subscription silently
120
+ * @property {number} [lastSeq] - the resume seq (what a reconnect passes)
121
+ */
122
+
123
+ /**
124
+ * The client — the binding-agnostic shape every client binding exposes.
125
+ * @typedef {Object} HttpClient
126
+ * @property {(op: string, input?: unknown, ctx?: InvokeContext) => Promise<Outcome>} invoke
127
+ * @property {(op: string, input?: unknown, options?: SubscribeOptions) => { stop: () => void }} subscribe
128
+ * @property {(op: string, input?: unknown) => string} url
129
+ * @property {(options?: { signal?: AbortSignal }) => Promise<Negotiation>} negotiate
130
+ * @property {() => Promise<{ op: string, key: string }[]>} pending - the key records a restart must reconcile
131
+ * @property {HttpClientCapabilities} capabilities
132
+ * @property {Contract} contract
133
+ * @property {() => any} describe
134
+ * @property {() => void} close - aborts every in-flight request; later invokes resolve `cancelled`
135
+ */
136
+
137
+ /** The storage member every record lives under. */
138
+ const STORAGE_MEMBER = 'jaren-contract';
139
+
140
+ /** The backoff ceiling of a retry, in ms. */
141
+ const BACKOFF_MAX = 8000;
142
+
143
+ /** The jitter added to a backoff, in ms (upper bound, exclusive). */
144
+ const BACKOFF_JITTER = 250;
145
+
146
+ /**
147
+ * @param {string} code
148
+ * @param {string} reason
149
+ * @returns {ContractHostError}
150
+ */
151
+ function host(code, reason) {
152
+ return new ContractHostError(code, `openHttpClient: ${reason}`);
153
+ }
154
+
155
+ /**
156
+ * An `AbortError`-named error, the platform's when a signal carries one.
157
+ * @param {AbortSignal | null} signal
158
+ * @returns {unknown}
159
+ */
160
+ function abortReason(signal) {
161
+ if (signal !== null && signal.reason !== undefined) return signal.reason;
162
+ const err = new Error('The operation was aborted.');
163
+ err.name = 'AbortError';
164
+ return err;
165
+ }
166
+
167
+ /**
168
+ * Abortable delay; rejects with the abort reason.
169
+ * @param {number} ms
170
+ * @param {AbortSignal} [signal]
171
+ * @returns {Promise<void>}
172
+ */
173
+ function defaultSleep(ms, signal) {
174
+ return new Promise((resolve, reject) => {
175
+ if (signal !== undefined && signal.aborted) {
176
+ reject(abortReason(signal));
177
+ return;
178
+ }
179
+ const onAbort = () => {
180
+ clearTimeout(timer);
181
+ reject(abortReason(signal ?? null));
182
+ };
183
+ const timer = setTimeout(() => {
184
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort);
185
+ resolve();
186
+ }, ms);
187
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * A rejection value's `name`, read guardedly; `null` when it has none.
193
+ * @param {unknown} err
194
+ * @returns {string | null}
195
+ */
196
+ function safeName(err) {
197
+ if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null;
198
+ try {
199
+ const name = /** @type {any} */ (err).name;
200
+ return typeof name === 'string' && name.length > 0 ? name : null;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * The transport string of a value: scalars verbatim, everything else as
209
+ * JSON (a shape the server's normalizer cannot decode, but deterministic).
210
+ * @param {unknown} v
211
+ * @returns {string}
212
+ */
213
+ function transportString(v) {
214
+ return typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
215
+ }
216
+
217
+ /**
218
+ * The cached JSON-schema check of an operation's path/query members, for
219
+ * `url()`: the input schema restricted to those members, rooted on the
220
+ * contract document so `$ref`s resolve as they do for the validator.
221
+ * @typedef {{ validate: ((value: unknown) => any) | null }} TransportCheck
222
+ */
223
+
224
+ /**
225
+ * One operation as the client prepared it: everything `invoke`/`url`
226
+ * read per call, decided once.
227
+ * @typedef {Object} ClientRoute
228
+ * @property {CompiledOperation} op
229
+ * @property {string} id
230
+ * @property {string} method
231
+ * @property {readonly import('../path.js').PathSegment[]} segments
232
+ * @property {readonly string[]} queryMembers
233
+ * @property {ReadonlySet<string>} queryRepeated
234
+ * @property {readonly string[]} headerMembers
235
+ * @property {readonly string[]} headerNames
236
+ * @property {readonly string[]} bodyMembers - body-located members when the body is their object
237
+ * @property {string | null} wholeBody - the member whose value IS the body
238
+ * @property {boolean} hasBody
239
+ * @property {string} media
240
+ * @property {boolean} opaque
241
+ * @property {boolean} hasInput
242
+ * @property {((value: unknown) => any) | null} validateInput
243
+ * @property {'none' | 'paths' | 'full'} details
244
+ * @property {'none' | 'optional' | 'required'} idempotency
245
+ * @property {{ max: number, on: readonly string[] } | null} retry
246
+ * @property {ReadonlySet<string>} retryOn
247
+ * @property {OutcomeRoute} outcome
248
+ * @property {TransportCheck} transport - lazily compiled for `url()`
249
+ */
250
+
251
+ /**
252
+ * @param {CompiledOperation} op
253
+ * @returns {ClientRoute}
254
+ */
255
+ function prepare(op) {
256
+ const http = op.http;
257
+ /** @type {string[]} */
258
+ const queryMembers = [];
259
+ /** @type {Set<string>} */
260
+ const queryRepeated = new Set();
261
+ /** @type {string[]} */
262
+ const headerMembers = [];
263
+ /** @type {string[]} */
264
+ const headerNames = [];
265
+ /** @type {string[]} */
266
+ const bodyMembers = [];
267
+ const transport = op.input === null ? null : op.input.transport;
268
+ const repeated = new Set(transport === null ? [] : transport.members.repeated);
269
+ const members = Object.keys(http.in);
270
+ for (let i = 0; i < members.length; i++) {
271
+ const m = members[i];
272
+ const loc = http.in[m];
273
+ if (loc === 'query') {
274
+ queryMembers.push(m);
275
+ if (repeated.has(m)) queryRepeated.add(m);
276
+ }
277
+ else if (loc === 'header') {
278
+ headerMembers.push(m);
279
+ headerNames.push(m.toLowerCase());
280
+ }
281
+ else if (loc === 'body' && http.body === null) bodyMembers.push(m);
282
+ }
283
+ return {
284
+ op,
285
+ id: op.id,
286
+ method: http.method,
287
+ segments: http.template.segments,
288
+ queryMembers,
289
+ queryRepeated,
290
+ headerMembers,
291
+ headerNames,
292
+ bodyMembers,
293
+ wholeBody: http.body,
294
+ hasBody: http.body !== null || bodyMembers.length > 0,
295
+ media: http.media,
296
+ opaque: http.opaque,
297
+ hasInput: op.input !== null,
298
+ validateInput: op.input === null ? null : op.input.validate,
299
+ details: op.policy.errors.details,
300
+ idempotency: op.policy.idempotency,
301
+ retry: op.policy.retry,
302
+ retryOn: new Set(op.policy.retry === null ? [] : op.policy.retry.on),
303
+ outcome: prepareOutcomeRoute(op),
304
+ transport: { validate: null },
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Open an HTTP client over a compiled contract.
310
+ *
311
+ * @param {Contract} contract
312
+ * @param {HttpClientOptions} [options]
313
+ * @returns {HttpClient}
314
+ * @throws {ContractHostError} `JC1008` for a malformed argument or option
315
+ * @example
316
+ * const client = openHttpClient(contract, { baseUrl: 'https://api.example', timeoutMs: 5000 });
317
+ * const outcome = await client.invoke('catalog.load', { since: '2026-01-01T00:00:00Z' });
318
+ * if (outcome.ok) render(outcome.value);
319
+ * else if (outcome.kind === 'failure') show(outcome.error.code); // a declared error, e.g. 'stale'
320
+ * else if (outcome.kind === 'network') retryLater();
321
+ */
322
+ export function openHttpClient(contract, options = {}) {
323
+ if (contract === null || typeof contract !== 'object' || typeof contract.match !== 'function'
324
+ || contract.operations === null || typeof contract.operations !== 'object' || !Array.isArray(contract.ids)) {
325
+ throw host('JC1008', 'the first argument must be a compiled contract (compileContract)');
326
+ }
327
+ if (options === null || typeof options !== 'object') throw host('JC1008', 'options must be an object');
328
+ const fetchFn = options.fetch === undefined
329
+ ? (/** @type {string} */ url, /** @type {RequestInit} */ init) => globalThis.fetch(url, init)
330
+ : options.fetch;
331
+ for (const [name, value] of [['fetch', fetchFn], ['keys', options.keys], ['sleep', options.sleep], ['now', options.now]]) {
332
+ if (value !== undefined && typeof value !== 'function') throw host('JC1008', `options.${name} must be a function`);
333
+ }
334
+ const baseUrl = options.baseUrl === undefined ? '' : options.baseUrl;
335
+ if (typeof baseUrl !== 'string') throw host('JC1008', 'options.baseUrl must be a string');
336
+ const wellKnown = options.wellKnown === undefined ? WELL_KNOWN_PATH : options.wellKnown;
337
+ if (typeof wellKnown !== 'string' || wellKnown.charCodeAt(0) !== 0x2F) throw host('JC1008', 'options.wellKnown must be an absolute path');
338
+ const timeoutMs = options.timeoutMs === undefined ? 0 : options.timeoutMs;
339
+ if (typeof timeoutMs !== 'number' || !(timeoutMs >= 0) || !Number.isFinite(timeoutMs)) {
340
+ throw host('JC1008', 'options.timeoutMs must be a non-negative finite number');
341
+ }
342
+ const storage = options.storage === undefined ? null : options.storage;
343
+ if (storage !== null && (typeof storage !== 'object' || typeof storage.read !== 'function' || typeof storage.write !== 'function')) {
344
+ throw host('JC1008', 'options.storage must be { read, write } or null');
345
+ }
346
+ if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
347
+ throw host('JC1008', 'options.catalog must be a message catalog object');
348
+ }
349
+ /** @type {Record<string, string>} */
350
+ const staticHeaders = {};
351
+ if (options.headers !== undefined) {
352
+ if (options.headers === null || typeof options.headers !== 'object') throw host('JC1008', 'options.headers must be an object of strings');
353
+ const names = Object.keys(options.headers);
354
+ for (let i = 0; i < names.length; i++) {
355
+ const v = options.headers[names[i]];
356
+ if (typeof v !== 'string') throw host('JC1008', `options.headers['${names[i]}'] must be a string`);
357
+ setObjectMember(staticHeaders, names[i].toLowerCase(), v);
358
+ }
359
+ }
360
+ Object.freeze(staticHeaders);
361
+ const keys = options.keys === undefined ? () => globalThis.crypto.randomUUID() : options.keys;
362
+ const sleep = options.sleep === undefined ? defaultSleep : options.sleep;
363
+ const now = options.now === undefined ? Date.now : options.now;
364
+ /** @type {Catalog | null} */
365
+ const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
366
+ const contractId = contract.id === null ? '' : contract.id;
367
+
368
+ /** @type {Map<string, ClientRoute>} */
369
+ const routes = new Map();
370
+ for (let i = 0; i < contract.ids.length; i++) {
371
+ const id = contract.ids[i];
372
+ routes.set(id, prepare(contract.operations[id]));
373
+ }
374
+
375
+ /** @type {JarenValidator<any> | null} */
376
+ let validator = options.validator === undefined ? null : options.validator;
377
+ const closer = new AbortController();
378
+ let closed = false;
379
+ /**
380
+ * The server's contract revision, learned from the well-known document
381
+ * by `negotiate()` (docs/CONTRACT-FORMAT.md §14) and carried in
382
+ * `meta.revision` of every subsequent outcome; `null` until negotiated.
383
+ * @type {string | null}
384
+ */
385
+ let serverRevision = null;
386
+
387
+ /**
388
+ * A meta in its fixed member order, with the negotiated revision.
389
+ * @param {string} op
390
+ * @param {unknown} attempt
391
+ * @returns {OutcomeMeta}
392
+ */
393
+ function newMeta(op, attempt) {
394
+ const meta = makeMeta(op, attempt, null);
395
+ meta.revision = serverRevision;
396
+ return meta;
397
+ }
398
+
399
+ //#region helpers
400
+
401
+ /**
402
+ * @param {string} op
403
+ * @returns {ClientRoute}
404
+ */
405
+ function routeOf(op) {
406
+ const route = routes.get(op);
407
+ if (route === undefined) {
408
+ throw new ContractHostError('JC1005', `client: '${String(op)}' is not an operation of the contract`);
409
+ }
410
+ return route;
411
+ }
412
+
413
+ /**
414
+ * Compose the caller's signal, the per-request timeout and the
415
+ * client's closer into the signal `fetch` receives.
416
+ * @param {AbortSignal | null} signal
417
+ * @returns {AbortSignal}
418
+ */
419
+ function composeSignal(signal) {
420
+ if (signal === null && timeoutMs === 0) return closer.signal;
421
+ /** @type {AbortSignal[]} */
422
+ const list = [closer.signal];
423
+ if (signal !== null) list.push(signal);
424
+ if (timeoutMs > 0) list.push(AbortSignal.timeout(timeoutMs));
425
+ return AbortSignal.any(list);
426
+ }
427
+
428
+ /**
429
+ * The path + query of an operation for a value whose transport members
430
+ * are valid. Throws `URIError` for a lone surrogate — the caller maps it.
431
+ * @param {ClientRoute} route
432
+ * @param {any} value
433
+ * @returns {string}
434
+ */
435
+ function pathOf(route, value) {
436
+ let path = '';
437
+ const segments = route.segments;
438
+ for (let i = 0; i < segments.length; i++) {
439
+ const s = segments[i];
440
+ path += '/' + (s.variable ? encodeURIComponent(transportString(value[s.text])) : s.text);
441
+ }
442
+ if (segments.length === 0) path = '/';
443
+ if (route.queryMembers.length === 0) return path;
444
+ const params = new URLSearchParams();
445
+ for (let i = 0; i < route.queryMembers.length; i++) {
446
+ const m = route.queryMembers[i];
447
+ const v = value[m];
448
+ if (v === undefined || v === null) continue;
449
+ if (route.queryRepeated.has(m) && Array.isArray(v)) {
450
+ for (let j = 0; j < v.length; j++) {
451
+ if (v[j] !== undefined && v[j] !== null) params.append(m, transportString(v[j]));
452
+ }
453
+ }
454
+ else params.append(m, transportString(v));
455
+ }
456
+ const query = params.toString();
457
+ return query.length === 0 ? path : path + '?' + query;
458
+ }
459
+
460
+ /**
461
+ * The input as the validator sees it: `null`/`undefined` is `{}` for an
462
+ * operation with input; a non-null input for an input-less operation
463
+ * is refused by the caller.
464
+ * @param {unknown} input
465
+ * @returns {any}
466
+ */
467
+ function inputValue(input) {
468
+ return input === undefined || input === null ? {} : input;
469
+ }
470
+
471
+ /**
472
+ * The request headers of an invoke: static, per-call, declared header
473
+ * members, then the protocol headers the client owns.
474
+ * @param {ClientRoute} route
475
+ * @param {any} value
476
+ * @param {InvokeContext} ctx
477
+ * @returns {Record<string, string>}
478
+ */
479
+ function headersOf(route, value, ctx) {
480
+ /** @type {Record<string, string>} */
481
+ const headers = { ...staticHeaders };
482
+ if (ctx.headers !== undefined && ctx.headers !== null && typeof ctx.headers === 'object') {
483
+ const names = Object.keys(ctx.headers);
484
+ for (let i = 0; i < names.length; i++) setObjectMember(headers, names[i].toLowerCase(), String(ctx.headers[names[i]]));
485
+ }
486
+ for (let i = 0; i < route.headerMembers.length; i++) {
487
+ const v = value[route.headerMembers[i]];
488
+ if (v === undefined || v === null) continue;
489
+ setObjectMember(headers, route.headerNames[i], Array.isArray(v) ? v.map(transportString).join(', ') : transportString(v));
490
+ }
491
+ if (typeof ctx.ifNoneMatch === 'string') headers['if-none-match'] = ctx.ifNoneMatch;
492
+ if (typeof ctx.ifMatch === 'string') headers['if-match'] = ctx.ifMatch;
493
+ return headers;
494
+ }
495
+
496
+ /**
497
+ * The JSON body of an invoke, or `null` when the operation carries none.
498
+ * Throws when JSON cannot carry the value — the caller maps it.
499
+ * @param {ClientRoute} route
500
+ * @param {any} value
501
+ * @returns {string | null}
502
+ */
503
+ function bodyOf(route, value) {
504
+ if (!route.hasBody) return null;
505
+ if (route.wholeBody !== null) {
506
+ const v = value[route.wholeBody];
507
+ return v === undefined ? null : JSON.stringify(v);
508
+ }
509
+ /** @type {Record<string, unknown>} */
510
+ const body = {};
511
+ for (let i = 0; i < route.bodyMembers.length; i++) {
512
+ const m = route.bodyMembers[i];
513
+ if (value[m] !== undefined) setObjectMember(body, m, value[m]);
514
+ }
515
+ return JSON.stringify(body);
516
+ }
517
+
518
+ /**
519
+ * A pre-send refusal: `JC2050` with validation details by policy.
520
+ * @param {ClientRoute} route
521
+ * @param {OutcomeMeta} meta
522
+ * @param {unknown} details
523
+ * @returns {Outcome}
524
+ */
525
+ function invalidInput(route, meta, details) {
526
+ return failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.id }, null, details), meta);
527
+ }
528
+
529
+ /**
530
+ * @param {ClientRoute} route
531
+ * @param {OutcomeMeta} meta
532
+ * @returns {Outcome}
533
+ */
534
+ function cancelled(route, meta) {
535
+ return failedOutcome('cancelled', clientError(catalog, 'JC2052', { op: route.id }, null, undefined), meta);
536
+ }
537
+
538
+ /**
539
+ * Classify a transport rejection: the caller's (or the client's) abort
540
+ * is `cancelled`; anything else — including the per-request timeout —
541
+ * is `network`, with the error's NAME only (its text may embed the URL
542
+ * and credentials).
543
+ * @param {ClientRoute} route
544
+ * @param {unknown} err
545
+ * @param {AbortSignal | null} signal
546
+ * @param {OutcomeMeta} meta
547
+ * @returns {Outcome}
548
+ */
549
+ function rejected(route, err, signal, meta) {
550
+ if ((signal !== null && signal.aborted) || closer.signal.aborted || safeName(err) === 'AbortError') return cancelled(route, meta);
551
+ return failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta);
552
+ }
553
+
554
+ /**
555
+ * One request/response cycle → one outcome. Never throws.
556
+ * @param {ClientRoute} route
557
+ * @param {string} url
558
+ * @param {Record<string, string>} headers
559
+ * @param {string | null} body
560
+ * @param {InvokeContext} ctx
561
+ * @param {AbortSignal | null} signal
562
+ * @returns {Promise<Outcome>}
563
+ */
564
+ async function send(route, url, headers, body, ctx, signal) {
565
+ const meta = newMeta(route.id, ctx.attempt);
566
+ if ((signal !== null && signal.aborted) || closed) return cancelled(route, meta);
567
+ /** @type {RequestInit} */
568
+ const init = { method: route.method, headers, signal: composeSignal(signal) };
569
+ if (body !== null) init.body = body;
570
+ let response;
571
+ try {
572
+ response = await fetchFn(url, init);
573
+ }
574
+ catch (err) {
575
+ return rejected(route, err, signal, meta);
576
+ }
577
+ let status;
578
+ let trace = null;
579
+ let etag = null;
580
+ let text;
581
+ try {
582
+ status = response.status;
583
+ const h = response.headers;
584
+ if (h !== null && typeof h === 'object' && typeof h.get === 'function') {
585
+ const t = h.get('x-jaren-trace');
586
+ if (typeof t === 'string' && t.length > 0) trace = t;
587
+ const e = h.get('etag');
588
+ if (typeof e === 'string' && e.length > 0) etag = e;
589
+ }
590
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
591
+ return failedOutcome('contract', clientError(catalog, 'JC2053', { op: route.id }, null, [{ path: '', keyword: 'status' }]), meta);
592
+ }
593
+ text = status === 204 || status === 304 ? '' : await response.text();
594
+ }
595
+ catch (err) {
596
+ return rejected(route, err, signal, meta);
597
+ }
598
+ meta.trace = trace;
599
+ return assembleOutcome(route.outcome, { status, headers: etag === null ? null : { etag }, text }, meta, catalog);
600
+ }
601
+
602
+ /**
603
+ * Whether an outcome is worth another attempt under the route's policy.
604
+ * @param {ClientRoute} route
605
+ * @param {Outcome} outcome
606
+ * @returns {boolean}
607
+ */
608
+ function retryable(route, outcome) {
609
+ if (outcome.ok) return false;
610
+ if (outcome.kind === 'network') return true;
611
+ return outcome.kind === 'failure' && route.retryOn.has(outcome.error.code);
612
+ }
613
+
614
+ /**
615
+ * Whether a durable key record may be dropped: the peer gave a
616
+ * definite answer (or the client refused for good).
617
+ * @param {Outcome} outcome
618
+ * @returns {boolean}
619
+ */
620
+ function terminal(outcome) {
621
+ return outcome.ok || outcome.kind === 'failure' || (outcome.kind === 'contract' && !outcome.error.retryable);
622
+ }
623
+
624
+ //#endregion
625
+
626
+ //#region durable keys
627
+
628
+ /**
629
+ * The record table of this contract inside the stored value, as a
630
+ * copy-on-write path: nothing the adapter handed out is mutated, so a
631
+ * `write` that throws leaves the stored value exactly as it was
632
+ * whether the adapter returns live references or fresh parses.
633
+ * @param {(table: Record<string, any>) => boolean} update - mutates the
634
+ * copied table; returns false when nothing changed (no write then)
635
+ * @returns {Promise<void>}
636
+ */
637
+ async function updateTable(update) {
638
+ const raw = await storage?.read();
639
+ /** @type {Record<string, any>} */
640
+ const store = isJsonObject(raw) ? { ...raw } : {};
641
+ /** @type {Record<string, any>} */
642
+ const root = isJsonObject(store[STORAGE_MEMBER]) ? { ...store[STORAGE_MEMBER] } : {};
643
+ /** @type {Record<string, any>} */
644
+ const table = isJsonObject(root[contractId]) ? { ...root[contractId] } : {};
645
+ if (!update(table)) return;
646
+ setObjectMember(root, contractId, table);
647
+ setObjectMember(store, STORAGE_MEMBER, root);
648
+ await storage?.write(store);
649
+ }
650
+
651
+ /**
652
+ * @param {string} op
653
+ * @param {string} key
654
+ * @param {string} hash
655
+ */
656
+ function recordKey(op, key, hash) {
657
+ return updateTable((table) => {
658
+ const records = isJsonObject(table[op]) ? { ...table[op] } : {};
659
+ setObjectMember(records, key, { op, key, hash, at: now() });
660
+ setObjectMember(table, op, records);
661
+ return true;
662
+ });
663
+ }
664
+
665
+ /**
666
+ * @param {string} op
667
+ * @param {string} key
668
+ */
669
+ function releaseKey(op, key) {
670
+ return updateTable((table) => {
671
+ if (!isJsonObject(table[op]) || !Object.hasOwn(table[op], key)) return false;
672
+ const records = { ...table[op] };
673
+ delete records[key];
674
+ if (Object.keys(records).length === 0) delete table[op];
675
+ else setObjectMember(table, op, records);
676
+ return true;
677
+ });
678
+ }
679
+
680
+ /**
681
+ * The records of this contract, read-only.
682
+ * @returns {Promise<Record<string, any>>}
683
+ */
684
+ async function readTable() {
685
+ const raw = await storage?.read();
686
+ const root = isJsonObject(raw) ? raw[STORAGE_MEMBER] : undefined;
687
+ const table = isJsonObject(root) ? root[contractId] : undefined;
688
+ return isJsonObject(table) ? table : {};
689
+ }
690
+
691
+ //#endregion
692
+
693
+ //#region the client
694
+
695
+ /**
696
+ * @param {string} op
697
+ * @param {unknown} [input]
698
+ * @param {InvokeContext} [ctx]
699
+ * @returns {Promise<Outcome>}
700
+ */
701
+ async function invoke(op, input, ctx = {}) {
702
+ const route = routeOf(op);
703
+ if (route.opaque) {
704
+ throw new ContractHostError('JC1005', `client: '${route.id}' is an opaque operation (media ${route.media}); invoke carries JSON only — use client.url(op, input) and fetch the bytes yourself`);
705
+ }
706
+ if (ctx === null || typeof ctx !== 'object') throw host('JC1008', 'ctx must be an object');
707
+ const meta = newMeta(route.id, ctx.attempt);
708
+ const signal = ctx.signal === undefined || ctx.signal === null ? null : ctx.signal;
709
+
710
+ // 1. validate — nothing leaves before the same verdict the server would reach
711
+ let value;
712
+ if (!route.hasInput) {
713
+ if (input !== undefined && input !== null) return invalidInput(route, meta, [{ path: '', keyword: 'input' }]);
714
+ value = null;
715
+ }
716
+ else {
717
+ value = inputValue(input);
718
+ const v = verdict(/** @type {(value: unknown) => any} */ (route.validateInput), value);
719
+ if (!v.valid) return invalidInput(route, meta, projectValidationDetails(route.details, v.errors));
720
+ }
721
+
722
+ // 2. split by location
723
+ let url;
724
+ let body;
725
+ let headers;
726
+ try {
727
+ url = baseUrl + pathOf(route, value);
728
+ body = bodyOf(route, value);
729
+ headers = headersOf(route, value, ctx);
730
+ }
731
+ catch {
732
+ return invalidInput(route, meta, [{ path: '', keyword: 'encoding' }]);
733
+ }
734
+ if (body !== null) headers['content-type'] = route.media;
735
+
736
+ // 3. the idempotency key — generated here, never by the server
737
+ let key = null;
738
+ if (route.idempotency !== 'none') {
739
+ key = typeof ctx.idempotencyKey === 'string' && ctx.idempotencyKey.length > 0 ? ctx.idempotencyKey : String(keys());
740
+ headers['idempotency-key'] = key;
741
+ if (storage !== null) {
742
+ let hash;
743
+ try {
744
+ hash = await canonicalSha256(value);
745
+ }
746
+ catch (err) {
747
+ return invalidInput(route, meta, [{ path: /** @type {any} */ (err)?.dataPath ?? '', keyword: 'canonical' }]);
748
+ }
749
+ try {
750
+ await recordKey(route.id, key, hash);
751
+ }
752
+ catch {
753
+ return failedOutcome('contract', clientError(catalog, 'JC2054', { op: route.id }, null, undefined), meta);
754
+ }
755
+ }
756
+ }
757
+
758
+ // 4. send, retrying under the declared policy only
759
+ let outcome;
760
+ let n = 0;
761
+ for (;;) {
762
+ outcome = await send(route, url, headers, body, ctx, signal);
763
+ if (route.retry === null || n >= route.retry.max || !retryable(route, outcome)) break;
764
+ const delay = Math.min(1000 * 2 ** n, BACKOFF_MAX) + Math.floor(Math.random() * BACKOFF_JITTER);
765
+ n++;
766
+ try {
767
+ await sleep(delay, signal === null ? undefined : signal);
768
+ }
769
+ catch {
770
+ outcome = cancelled(route, newMeta(route.id, ctx.attempt));
771
+ break;
772
+ }
773
+ }
774
+
775
+ // 5. settle the durable record: a definite answer drops it; a
776
+ // network/cancelled outcome leaves it for `pending()`
777
+ if (key !== null && storage !== null && terminal(outcome)) {
778
+ try {
779
+ await releaseKey(route.id, key);
780
+ }
781
+ catch {
782
+ // a record that cannot be dropped stays pending — the
783
+ // conservative side; the outcome itself is unaffected
784
+ }
785
+ }
786
+ return outcome;
787
+ }
788
+
789
+ /**
790
+ * Subscribe to a subscribe operation's stream (docs/CONTRACT-FORMAT.md
791
+ * §19): one `GET` with `accept: text/event-stream`, the SSE events
792
+ * decoded and delivered through the callbacks; snapshots validated
793
+ * against the output schema, `seq` strictly increasing (`JC2092`),
794
+ * silence beyond `2 × heartbeatMs` a `JC2094` network outcome.
795
+ * Reconnection is the caller's: pass the last delivered seq as
796
+ * `lastSeq`.
797
+ * @param {string} op
798
+ * @param {unknown} [input]
799
+ * @param {SubscribeOptions} [options]
800
+ * @returns {{ stop: () => void }}
801
+ * @throws {ContractHostError} `JC1010` for a non-subscribe operation, `JC1008` for a malformed option
802
+ */
803
+ function subscribe(op, input, options = {}) {
804
+ const route = routeOf(op);
805
+ if (route.op.kind !== 'subscribe') {
806
+ throw new ContractHostError('JC1010', `client: '${route.id}' is a ${route.op.kind} operation — subscribe carries streams; use invoke`);
807
+ }
808
+ if (options === null || typeof options !== 'object') throw host('JC1008', 'subscribe options must be an object');
809
+ for (const name of ['onSnapshot', 'onPatch', 'onError', 'onEnd']) {
810
+ const cb = /** @type {any} */ (options)[name];
811
+ if (cb !== undefined && typeof cb !== 'function') throw host('JC1008', `options.${name} must be a function`);
812
+ }
813
+ /** @type {number | null} */
814
+ let lastSeq = null;
815
+ if (options.lastSeq !== undefined && options.lastSeq !== null) {
816
+ if (!Number.isInteger(options.lastSeq) || options.lastSeq < 0) throw host('JC1008', 'options.lastSeq must be a non-negative integer');
817
+ lastSeq = options.lastSeq;
818
+ }
819
+ const signal = options.signal === undefined || options.signal === null ? null : options.signal;
820
+ const meta = newMeta(route.id, null);
821
+ const streamPolicy = route.op.policy.stream;
822
+ const heartbeatMs = streamPolicy === null ? 15000 : streamPolicy.heartbeatMs;
823
+
824
+ const controller = new AbortController();
825
+ const composed = signal === null
826
+ ? AbortSignal.any([closer.signal, controller.signal])
827
+ : AbortSignal.any([closer.signal, controller.signal, signal]);
828
+ /** @type {ReadableStreamDefaultReader<Uint8Array> | null} */
829
+ let reader = null;
830
+ /** @type {ReturnType<typeof setTimeout> | 0} */
831
+ let watchdog = 0;
832
+
833
+ const consumer = createStreamConsumer({
834
+ route: route.outcome,
835
+ catalog,
836
+ meta,
837
+ callbacks: { onSnapshot: options.onSnapshot, onPatch: options.onPatch, onError: options.onError, onEnd: options.onEnd },
838
+ finish: () => {
839
+ if (watchdog !== 0) clearTimeout(watchdog);
840
+ watchdog = 0;
841
+ if (reader !== null) reader.cancel().catch(() => {});
842
+ },
843
+ lastSeq,
844
+ });
845
+
846
+ /** Push the silence watchdog forward: any bytes count as life. */
847
+ function resetWatchdog() {
848
+ if (watchdog !== 0) clearTimeout(watchdog);
849
+ watchdog = setTimeout(() => {
850
+ consumer.fail(failedOutcome('network',
851
+ outcomeError('JC2094', renderMessage(catalog, STREAM_ERRORS.JC2094.msgid, { op: route.id, ms: 2 * heartbeatMs }), null, null, true), meta));
852
+ controller.abort();
853
+ }, 2 * heartbeatMs);
854
+ /** @type {any} */ (watchdog).unref?.();
855
+ }
856
+
857
+ /** @param {import('@jarenjs/core/text/sse').SseEvent} ev */
858
+ function deliver(ev) {
859
+ const parsed = ev.id === null ? NaN : Number.parseInt(ev.id, 10);
860
+ const seq = Number.isFinite(parsed) ? parsed : null;
861
+ let data;
862
+ try {
863
+ data = JSON.parse(ev.data);
864
+ }
865
+ catch {
866
+ data = undefined;
867
+ }
868
+ switch (ev.event) {
869
+ case 'snapshot':
870
+ consumer.snapshot(seq, data);
871
+ break;
872
+ case 'patch':
873
+ consumer.patch(seq, data);
874
+ break;
875
+ case 'error':
876
+ consumer.error(data);
877
+ break;
878
+ case 'end':
879
+ consumer.end(data);
880
+ break;
881
+ // an unknown event name is ignored — SSE's forward compatibility
882
+ }
883
+ }
884
+
885
+ (async () => {
886
+ if (closed || (signal !== null && signal.aborted)) {
887
+ consumer.cancel();
888
+ return;
889
+ }
890
+ // 1. validate before anything is sent — the shared pre-send refusal
891
+ let value;
892
+ if (!route.hasInput) {
893
+ if (input !== undefined && input !== null) {
894
+ consumer.fail(invalidInput(route, meta, [{ path: '', keyword: 'input' }]));
895
+ return;
896
+ }
897
+ value = {};
898
+ }
899
+ else {
900
+ value = inputValue(input);
901
+ const v = verdict(/** @type {(value: unknown) => any} */ (route.validateInput), value);
902
+ if (!v.valid) {
903
+ consumer.fail(invalidInput(route, meta, projectValidationDetails(route.details, v.errors)));
904
+ return;
905
+ }
906
+ }
907
+ // 2. the request
908
+ let requestUrl;
909
+ try {
910
+ requestUrl = baseUrl + pathOf(route, value);
911
+ }
912
+ catch {
913
+ consumer.fail(invalidInput(route, meta, [{ path: '', keyword: 'encoding' }]));
914
+ return;
915
+ }
916
+ /** @type {Record<string, string>} */
917
+ const requestHeaders = { ...staticHeaders, accept: STREAM_MEDIA };
918
+ if (lastSeq !== null) requestHeaders['last-event-id'] = String(lastSeq);
919
+ let response;
920
+ try {
921
+ response = await fetchFn(requestUrl, { method: 'GET', headers: requestHeaders, signal: composed });
922
+ }
923
+ catch (err) {
924
+ if (consumer.finished()) return;
925
+ if (composed.aborted || safeName(err) === 'AbortError') consumer.cancel();
926
+ else consumer.fail(failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta));
927
+ return;
928
+ }
929
+ // 3. the answer must be a 200 event stream — anything else classifies
930
+ let status;
931
+ let contentType = null;
932
+ try {
933
+ status = response.status;
934
+ const h = response.headers;
935
+ if (h !== null && typeof h === 'object' && typeof h.get === 'function') {
936
+ contentType = h.get('content-type');
937
+ const t = h.get('x-jaren-trace');
938
+ if (typeof t === 'string' && t.length > 0) meta.trace = t;
939
+ }
940
+ }
941
+ catch (err) {
942
+ if (!consumer.finished()) consumer.fail(failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta));
943
+ return;
944
+ }
945
+ if (!Number.isInteger(status) || status < 200 || status > 299) {
946
+ let text = null;
947
+ try {
948
+ text = await response.text();
949
+ }
950
+ catch {
951
+ text = null;
952
+ }
953
+ consumer.fail(assembleOutcome(route.outcome, { status, headers: null, text }, meta, catalog));
954
+ return;
955
+ }
956
+ const body = /** @type {any} */ (response).body;
957
+ if (typeof contentType !== 'string' || contentType.toLowerCase().indexOf(STREAM_MEDIA) === -1
958
+ || body === null || body === undefined || typeof body.getReader !== 'function') {
959
+ consumer.fail(failedOutcome('contract',
960
+ outcomeError('JC2090', renderMessage(catalog, STREAM_ERRORS.JC2090.msgid, { op: route.id }), status, null, false), meta));
961
+ return;
962
+ }
963
+ // 4. the event loop under the silence watchdog
964
+ reader = body.getReader();
965
+ const textDecoder = new TextDecoder();
966
+ const sse = createSseEventDecoder();
967
+ resetWatchdog();
968
+ try {
969
+ for (;;) {
970
+ const { done, value: chunk } = await reader.read();
971
+ if (done) break;
972
+ if (consumer.finished()) return;
973
+ resetWatchdog();
974
+ for (const ev of sse.feed(textDecoder.decode(chunk, { stream: true }))) {
975
+ deliver(ev);
976
+ if (consumer.finished()) return;
977
+ }
978
+ }
979
+ for (const ev of sse.end()) {
980
+ deliver(ev);
981
+ if (consumer.finished()) return;
982
+ }
983
+ // a stream that ends without an end event is reported as closed
984
+ consumer.end(undefined);
985
+ }
986
+ catch (err) {
987
+ if (consumer.finished()) return;
988
+ if (composed.aborted || safeName(err) === 'AbortError') consumer.cancel();
989
+ else consumer.fail(failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta));
990
+ }
991
+ finally {
992
+ if (watchdog !== 0) clearTimeout(watchdog);
993
+ watchdog = 0;
994
+ }
995
+ })();
996
+
997
+ return {
998
+ stop: () => {
999
+ consumer.cancel();
1000
+ controller.abort();
1001
+ },
1002
+ };
1003
+ }
1004
+
1005
+ /**
1006
+ * @param {string} op
1007
+ * @param {unknown} [input]
1008
+ * @returns {string}
1009
+ */
1010
+ function url(op, input) {
1011
+ const route = routeOf(op);
1012
+ const value = inputValue(input);
1013
+ if (!isJsonObject(value)) throw host('JC1008', 'url(): input must be an object (or null)');
1014
+ if (route.transport.validate === null && route.hasInput) {
1015
+ const transport = /** @type {import('../compile.js').CompiledInput} */ (route.op.input).transport;
1016
+ if (transport !== null && transport.members.path.length + transport.members.query.length > 0) {
1017
+ // the path/query members only, rooted on the contract document so
1018
+ // their `$ref`s resolve as they do for the validator; compiled on
1019
+ // first use because only a URL builder needs it
1020
+ const names = [...transport.members.path, ...transport.members.query];
1021
+ /** @type {Record<string, unknown>} */
1022
+ const pick = {};
1023
+ for (let i = 0; i < names.length; i++) setObjectMember(pick, names[i], transport.schemas[names[i]]);
1024
+ if (validator === null) validator = new JarenValidator({ collectErrors: true, skipErrors: false });
1025
+ route.transport.validate = validator.compile({
1026
+ ...contract.doc, type: 'object', properties: pick, required: transport.required.filter((r) => names.includes(r)),
1027
+ });
1028
+ }
1029
+ }
1030
+ if (route.transport.validate !== null) {
1031
+ const v = verdict(route.transport.validate, value);
1032
+ if (!v.valid) throw host('JC1008', `url(): the path/query members of operation '${route.id}' fail their schema`);
1033
+ }
1034
+ try {
1035
+ return baseUrl + pathOf(route, value);
1036
+ }
1037
+ catch {
1038
+ throw host('JC1008', `url(): a path/query member of operation '${route.id}' cannot be encoded`);
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * @param {{ signal?: AbortSignal }} [options]
1044
+ * @returns {Promise<Negotiation>}
1045
+ */
1046
+ async function negotiate(options = {}) {
1047
+ const signal = options.signal === undefined || options.signal === null ? null : options.signal;
1048
+ /**
1049
+ * @param {Negotiation['reason']} reason
1050
+ * @param {Negotiation['server']} server
1051
+ * @param {string | null} code
1052
+ * @param {Record<string, unknown>} params
1053
+ * @returns {Negotiation}
1054
+ */
1055
+ const result = (reason, server, code, params) => ({
1056
+ compatible: code === null,
1057
+ reason,
1058
+ server,
1059
+ error: code === null ? null : { code, message: renderMessage(catalog, CLIENT_ERRORS[/** @type {keyof typeof CLIENT_ERRORS} */ (code)].msgid, params) },
1060
+ });
1061
+ let response;
1062
+ try {
1063
+ response = await fetchFn(baseUrl + wellKnown, { method: 'GET', headers: { ...staticHeaders }, signal: composeSignal(signal) });
1064
+ }
1065
+ catch (err) {
1066
+ return result('unreachable', null, 'JC2051', { op: 'the negotiation', name: safeName(err) ?? typeof err });
1067
+ }
1068
+ let doc;
1069
+ try {
1070
+ if (response.status !== 200) throw new Error('status');
1071
+ doc = JSON.parse(await response.text());
1072
+ }
1073
+ catch {
1074
+ return result('not-a-contract', null, 'JC2056', { id: contractId });
1075
+ }
1076
+ if (!isJsonObject(doc) || doc.$contract !== '0.1' || !Array.isArray(doc.operations)) {
1077
+ return result('not-a-contract', null, 'JC2056', { id: contractId });
1078
+ }
1079
+ const server = {
1080
+ id: typeof doc.id === 'string' ? doc.id : null,
1081
+ version: typeof doc.version === 'string' ? doc.version : null,
1082
+ compat: Array.isArray(doc.compat) ? doc.compat.filter((/** @type {unknown} */ v) => typeof v === 'string') : [],
1083
+ revision: typeof doc.revision === 'string' ? doc.revision : null,
1084
+ };
1085
+ if (server.id !== null && contract.id !== null && server.id !== contract.id) {
1086
+ return result('not-a-contract', server, 'JC2056', { id: contractId });
1087
+ }
1088
+ // the same contract on the other end: its revision rides in
1089
+ // meta.revision of every subsequent outcome, compatible or not —
1090
+ // correlation data, never the compatibility decision (that is the
1091
+ // version rule below)
1092
+ serverRevision = server.revision;
1093
+ const reason = compatReason(contract, server);
1094
+ if (reason !== null) return result(reason, server, null, {});
1095
+ return result('version-mismatch', server, 'JC2057', { id: contractId, server: server.version, client: contract.version });
1096
+ }
1097
+
1098
+ /**
1099
+ * @returns {Promise<{ op: string, key: string }[]>}
1100
+ */
1101
+ async function pending() {
1102
+ if (storage === null) return [];
1103
+ const table = await readTable();
1104
+ /** @type {{ op: string, key: string }[]} */
1105
+ const out = [];
1106
+ const ops = Object.keys(table);
1107
+ for (let i = 0; i < ops.length; i++) {
1108
+ const records = table[ops[i]];
1109
+ if (!isJsonObject(records)) continue;
1110
+ const ks = Object.keys(records);
1111
+ for (let j = 0; j < ks.length; j++) out.push({ op: ops[i], key: ks[j] });
1112
+ }
1113
+ return out;
1114
+ }
1115
+
1116
+ /** @type {HttpClientCapabilities} */
1117
+ const capabilities = Object.freeze({
1118
+ name: 'http',
1119
+ status: true,
1120
+ headers: true,
1121
+ media: true,
1122
+ etag: true,
1123
+ idempotency: true,
1124
+ durableKeys: storage !== null,
1125
+ stream: true,
1126
+ cancel: 'signal',
1127
+ });
1128
+
1129
+ return Object.freeze({
1130
+ invoke,
1131
+ subscribe,
1132
+ url,
1133
+ negotiate,
1134
+ pending,
1135
+ capabilities,
1136
+ contract,
1137
+ describe: () => contract.describe(),
1138
+ close: () => {
1139
+ closed = true;
1140
+ closer.abort();
1141
+ },
1142
+ });
1143
+
1144
+ //#endregion
1145
+ }
1146
+
1147
+ export {
1148
+ okOutcome, failedOutcome, makeMeta, outcomeError, isOutcome, assembleOutcome, prepareOutcomeRoute, hostFailureOutcome,
1149
+ OUTCOME_ERROR_MEMBERS, OUTCOME_META_MEMBERS,
1150
+ } from './outcome.js';