@jarenjs/contract 0.49.2 → 0.66.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 (52) hide show
  1. package/README.md +191 -30
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. package/src/stream/sse.js +2 -0
@@ -16,10 +16,14 @@
16
16
  *
17
17
  * `invoke` NEVER rejects for anything a server or a network can do; it
18
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`.
19
+ * opaque operation). `bytes(op, input, ctx)` is the opaque twin: one
20
+ * request whose success value carries the status, the headers, the
21
+ * media and the LIVE response body as a `ReadableStream` — never
22
+ * `text()`, never collected and whose `ctx.body` streams an upload.
23
+ * `url(op, input)` builds the URL of any operation — what an `<img src>`
24
+ * uses for an opaque one. `negotiate()` asks the server's well-known
25
+ * description whether the two ends speak compatible versions.
26
+ * Everything per operation is decided once at `open`.
23
27
  */
24
28
 
25
29
  import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
@@ -30,12 +34,14 @@ import { JarenValidator } from '@jarenjs/validate';
30
34
  import { createSseEventDecoder } from '@jarenjs/core/text/sse';
31
35
 
32
36
  import { ContractHostError } from '../errors.js';
37
+ import { resolveHostRuntime } from '../runtime.js';
38
+ import { isReadableStream, isAsyncByteSource } from '../http/body.js';
33
39
  import { compatReason } from '../compat.js';
34
40
  import { WELL_KNOWN_PATH, verdict, projectValidationDetails, renderMessage } from '../http/wire.js';
35
41
  import { createStreamConsumer, STREAM_ERRORS } from '../stream/client.js';
36
42
  import { STREAM_MEDIA } from '../stream/sse.js';
37
43
  import {
38
- CLIENT_ERRORS, prepareOutcomeRoute, assembleOutcome, makeMeta, failedOutcome, clientError, outcomeError,
44
+ CLIENT_ERRORS, prepareOutcomeRoute, assembleOutcome, makeMeta, okOutcome, failedOutcome, clientError, outcomeError,
39
45
  } from './outcome.js';
40
46
 
41
47
  export { CLIENT_ERRORS };
@@ -65,14 +71,20 @@ export { CLIENT_ERRORS };
65
71
  * @property {(url: string, init: RequestInit) => Promise<any>} [fetch] - default `globalThis.fetch`
66
72
  * @property {string} [baseUrl] - prefixed to every path; default `''` (relative URLs)
67
73
  * @property {Record<string, string>} [headers] - static headers, merged under per-call ones
68
- * @property {() => string} [keys] - the idempotency key generator; default `crypto.randomUUID`
74
+ * @property {() => string} [keys] - the idempotency key generator; default
75
+ * the runtime record's `uuid`, itself `crypto.randomUUID` by default
69
76
  * @property {KeyStorage | null} [storage] - durable key records; default `null`
70
77
  * @property {number} [timeoutMs] - per request; `0` (default) means none; composed with `ctx.signal`
71
78
  * @property {(ms: number, signal?: AbortSignal) => Promise<void>} [sleep] - the retry backoff sleeper (injectable for tests)
72
79
  * @property {Record<string, string | ((params: object) => string)>} [catalog] - a message catalog consulted before the English one
73
80
  * @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`
81
+ * @property {() => number} [now] - the clock stamped into key records; default
82
+ * the runtime record's `now`, itself `Date.now` by default
75
83
  * @property {JarenValidator<any>} [validator] - the validator `url()` compiles its path/query check with
84
+ * @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime]
85
+ * - the host's runtime record: its `uuid` generates idempotency keys
86
+ * and its `now` stamps key records, each only where `keys` / `now` is
87
+ * absent, and its `random` draws the retry backoff jitter
76
88
  */
77
89
 
78
90
  /**
@@ -86,6 +98,30 @@ export { CLIENT_ERRORS };
86
98
  * @property {string} [ifMatch] - sent as `If-Match`
87
99
  */
88
100
 
101
+ /**
102
+ * Per-call context of `bytes` (docs/CONTRACT-FORMAT.md §10.6): the
103
+ * members of `InvokeContext` that apply to an opaque call — no
104
+ * idempotency key, an opaque operation carries none — plus `body`, the
105
+ * request body to send: text, bytes, a Web `ReadableStream`, an async
106
+ * iterable of `Uint8Array` chunks (pulled one chunk per demand), or
107
+ * none.
108
+ * @typedef {Object} ByteContext
109
+ * @property {AbortSignal} [signal] - cancels the request (`kind: "cancelled"`)
110
+ * @property {unknown} [attempt] - the caller's attempt id, echoed in `meta.attempt`, never sent
111
+ * @property {Record<string, string>} [headers] - per-call headers (over the static ones)
112
+ * @property {string} [ifNoneMatch] - sent as `If-None-Match`
113
+ * @property {string} [ifMatch] - sent as `If-Match`
114
+ * @property {string | Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> | null} [body] - the request body
115
+ */
116
+
117
+ /**
118
+ * The success value of `bytes`: the status, the response headers
119
+ * (lowercase names), the response media (`content-type`, or `null`) and
120
+ * the LIVE body — a `ReadableStream` the caller reads, never collected
121
+ * by the client; `null` when the response carries none (a 304, a 204).
122
+ * @typedef {{ status: number, headers: Record<string, string>, media: string | null, body: ReadableStream<Uint8Array> | null }} ByteResponse
123
+ */
124
+
89
125
  /**
90
126
  * The negotiation result.
91
127
  * @typedef {Object} Negotiation
@@ -112,19 +148,36 @@ export { CLIENT_ERRORS };
112
148
  /**
113
149
  * The options of one `subscribe` call (docs/CONTRACT-FORMAT.md §19).
114
150
  * @typedef {Object} SubscribeOptions
115
- * @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
151
+ * @property {(value: unknown, info: { seq: number, resumed: boolean, reset: boolean, earliestAvailable: number | null, highWatermark: number | null }) => void} [onSnapshot]
116
152
  * @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
117
153
  * @property {(outcome: Outcome) => void} [onError]
118
154
  * @property {(info: { reason: string }) => void} [onEnd]
119
155
  * @property {AbortSignal} [signal] - stops the subscription silently
120
- * @property {number} [lastSeq] - the resume seq (what a reconnect passes)
156
+ * @property {number} [lastSeq] - the resume seq (what a re-entered subscribe passes)
157
+ * @property {{ max: number }} [reconnect] - opt into re-establishing the
158
+ * stream after a network loss: at most `max` further attempts, each
159
+ * after the retry backoff and from the last delivered seq; the budget
160
+ * spent, `onError` gets one `JC2097`. Absent (or `max: 0`), a network
161
+ * outcome is delivered as is
162
+ */
163
+
164
+ /**
165
+ * A live subscription (docs/CONTRACT-FORMAT.md §19): `stop()` releases
166
+ * it silently; `lastSeq` is the last delivered seq — `null` before the
167
+ * first, the passed `lastSeq` until an event moves it — what a
168
+ * re-entered `subscribe` passes.
169
+ * @typedef {Object} Subscription
170
+ * @property {() => void} stop
171
+ * @property {number | null} lastSeq - read-only
121
172
  */
122
173
 
123
174
  /**
124
175
  * The client — the binding-agnostic shape every client binding exposes.
125
176
  * @typedef {Object} HttpClient
126
177
  * @property {(op: string, input?: unknown, ctx?: InvokeContext) => Promise<Outcome>} invoke
127
- * @property {(op: string, input?: unknown, options?: SubscribeOptions) => { stop: () => void }} subscribe
178
+ * @property {(op: string, input?: unknown, ctx?: ByteContext) => Promise<Outcome>} bytes - an
179
+ * opaque operation: the outcome's value is a {@link ByteResponse} (§10.6)
180
+ * @property {(op: string, input?: unknown, options?: SubscribeOptions) => Subscription} subscribe
128
181
  * @property {(op: string, input?: unknown) => string} url
129
182
  * @property {(options?: { signal?: AbortSignal }) => Promise<Negotiation>} negotiate
130
183
  * @property {() => Promise<{ op: string, key: string }[]>} pending - the key records a restart must reconcile
@@ -137,6 +190,12 @@ export { CLIENT_ERRORS };
137
190
  /** The storage member every record lives under. */
138
191
  const STORAGE_MEMBER = 'jaren-contract';
139
192
 
193
+ /** Read-copy-write mutations share one queue per adapter, even across
194
+ * clients and contract ids. Other processes or adapter wrappers must
195
+ * coordinate through their storage implementation.
196
+ * @type {WeakMap<KeyStorage, Promise<void>>} */
197
+ const storageUpdates = new WeakMap();
198
+
140
199
  /** The backoff ceiling of a retry, in ms. */
141
200
  const BACKOFF_MAX = 8000;
142
201
 
@@ -358,9 +417,40 @@ export function openHttpClient(contract, options = {}) {
358
417
  }
359
418
  }
360
419
  Object.freeze(staticHeaders);
361
- const keys = options.keys === undefined ? () => globalThis.crypto.randomUUID() : options.keys;
420
+ const runtime = resolveHostRuntime(options.runtime, host, 'JC1008');
421
+ /**
422
+ * A host fact drawn from an injected generator (`keys`, `now`, the
423
+ * record's `random`). `invoke` is total over everything a server or a
424
+ * network can do; a generator the HOST supplied that throws is the
425
+ * host's own mistake, and rejects as one (`JC1008`) rather than being
426
+ * dressed as a storage or network outcome.
427
+ * @template T
428
+ * @param {string} name @param {() => T} draw @returns {T}
429
+ */
430
+ const hostFact = (name, draw) => {
431
+ try {
432
+ return draw();
433
+ }
434
+ catch (error) {
435
+ throw host('JC1008', `options.${name}${name === 'random' ? ' (the runtime record)' : ''} threw (${
436
+ error instanceof Error ? error.message : String(error)})`);
437
+ }
438
+ };
439
+ const keys = options.keys === undefined ? runtime.uuid : options.keys;
362
440
  const sleep = options.sleep === undefined ? defaultSleep : options.sleep;
363
- const now = options.now === undefined ? Date.now : options.now;
441
+
442
+ /**
443
+ * The backoff before further attempt `n` (0-based): 1,000 × 2^n ms
444
+ * capped at `BACKOFF_MAX`, plus 0–249 ms of jitter drawn from the
445
+ * runtime's `random` — the one formula of `invoke`'s retry and
446
+ * `subscribe`'s reconnect.
447
+ * @param {number} n
448
+ * @returns {number}
449
+ */
450
+ function backoffDelay(n) {
451
+ return Math.min(1000 * 2 ** n, BACKOFF_MAX) + Math.floor(hostFact('random', runtime.random) * BACKOFF_JITTER);
452
+ }
453
+ const now = options.now === undefined ? runtime.now : options.now;
364
454
  /** @type {Catalog | null} */
365
455
  const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
366
456
  const contractId = contract.id === null ? '' : contract.id;
@@ -634,18 +724,26 @@ export function openHttpClient(contract, options = {}) {
634
724
  * copied table; returns false when nothing changed (no write then)
635
725
  * @returns {Promise<void>}
636
726
  */
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);
727
+ function updateTable(update) {
728
+ // The invoking paths enter only when durable storage is configured.
729
+ const adapter = /** @type {KeyStorage} */ (storage);
730
+ const previous = storageUpdates.get(adapter) ?? Promise.resolve();
731
+ const next = previous.then(async () => {
732
+ const raw = await adapter.read();
733
+ /** @type {Record<string, any>} */
734
+ const store = isJsonObject(raw) ? { ...raw } : {};
735
+ /** @type {Record<string, any>} */
736
+ const root = isJsonObject(store[STORAGE_MEMBER]) ? { ...store[STORAGE_MEMBER] } : {};
737
+ /** @type {Record<string, any>} */
738
+ const table = isJsonObject(root[contractId]) ? { ...root[contractId] } : {};
739
+ if (!update(table)) return;
740
+ setObjectMember(root, contractId, table);
741
+ setObjectMember(store, STORAGE_MEMBER, root);
742
+ await adapter.write(store);
743
+ });
744
+ // The caller receives its failure; later writes still get their turn.
745
+ storageUpdates.set(adapter, next.catch(() => {}));
746
+ return next;
649
747
  }
650
748
 
651
749
  /**
@@ -653,10 +751,10 @@ export function openHttpClient(contract, options = {}) {
653
751
  * @param {string} key
654
752
  * @param {string} hash
655
753
  */
656
- function recordKey(op, key, hash) {
754
+ function recordKey(op, key, hash, at) {
657
755
  return updateTable((table) => {
658
756
  const records = isJsonObject(table[op]) ? { ...table[op] } : {};
659
- setObjectMember(records, key, { op, key, hash, at: now() });
757
+ setObjectMember(records, key, { op, key, hash, at });
660
758
  setObjectMember(table, op, records);
661
759
  return true;
662
760
  });
@@ -736,7 +834,7 @@ export function openHttpClient(contract, options = {}) {
736
834
  // 3. the idempotency key — generated here, never by the server
737
835
  let key = null;
738
836
  if (route.idempotency !== 'none') {
739
- key = typeof ctx.idempotencyKey === 'string' && ctx.idempotencyKey.length > 0 ? ctx.idempotencyKey : String(keys());
837
+ key = typeof ctx.idempotencyKey === 'string' && ctx.idempotencyKey.length > 0 ? ctx.idempotencyKey : String(hostFact('keys', keys));
740
838
  headers['idempotency-key'] = key;
741
839
  if (storage !== null) {
742
840
  let hash;
@@ -746,8 +844,11 @@ export function openHttpClient(contract, options = {}) {
746
844
  catch (err) {
747
845
  return invalidInput(route, meta, [{ path: /** @type {any} */ (err)?.dataPath ?? '', keyword: 'canonical' }]);
748
846
  }
847
+ // the clock is the host's, read before the store is asked: a
848
+ // clock that throws is the host's mistake, not a storage failure
849
+ const at = hostFact('now', now);
749
850
  try {
750
- await recordKey(route.id, key, hash);
851
+ await recordKey(route.id, key, hash, at);
751
852
  }
752
853
  catch {
753
854
  return failedOutcome('contract', clientError(catalog, 'JC2054', { op: route.id }, null, undefined), meta);
@@ -761,7 +862,7 @@ export function openHttpClient(contract, options = {}) {
761
862
  for (;;) {
762
863
  outcome = await send(route, url, headers, body, ctx, signal);
763
864
  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);
865
+ const delay = backoffDelay(n);
765
866
  n++;
766
867
  try {
767
868
  await sleep(delay, signal === null ? undefined : signal);
@@ -786,18 +887,195 @@ export function openHttpClient(contract, options = {}) {
786
887
  return outcome;
787
888
  }
788
889
 
890
+ /**
891
+ * The lowercase header table of a platform response, read guardedly.
892
+ * @param {any} response
893
+ * @returns {Record<string, string>}
894
+ */
895
+ function responseHeaders(response) {
896
+ /** @type {Record<string, string>} */
897
+ const out = {};
898
+ const h = response.headers;
899
+ if (h !== null && typeof h === 'object' && typeof h.forEach === 'function') {
900
+ h.forEach((/** @type {string} */ value, /** @type {string} */ name) => { setObjectMember(out, String(name).toLowerCase(), String(value)); });
901
+ }
902
+ return out;
903
+ }
904
+
905
+ /**
906
+ * The request body of a byte call as `fetch` takes it: text and bytes
907
+ * pass; a Web stream passes; an async iterable of chunks is wrapped
908
+ * in a `ReadableStream` that pulls one chunk per demand and cancels
909
+ * the iterator once. Anything else is the host's mistake.
910
+ * @param {unknown} body
911
+ * @returns {{ init: any, streamed: boolean }}
912
+ */
913
+ function uploadBody(body) {
914
+ if (body === undefined || body === null) return { init: undefined, streamed: false };
915
+ if (typeof body === 'string' || body instanceof Uint8Array) return { init: body, streamed: false };
916
+ if (isReadableStream(body)) return { init: body, streamed: true };
917
+ if (isAsyncByteSource(body)) {
918
+ /** @type {AsyncIterator<Uint8Array> | null} */
919
+ let iterator = null;
920
+ let cancelled = false;
921
+ const stream = new ReadableStream({
922
+ async pull(controller) {
923
+ if (iterator === null) iterator = body[Symbol.asyncIterator]();
924
+ let r;
925
+ try {
926
+ r = await iterator.next();
927
+ }
928
+ catch (err) {
929
+ controller.error(err);
930
+ return;
931
+ }
932
+ if (r.done) {
933
+ controller.close();
934
+ return;
935
+ }
936
+ controller.enqueue(r.value);
937
+ },
938
+ async cancel() {
939
+ if (cancelled) return;
940
+ cancelled = true;
941
+ if (iterator !== null && typeof iterator.return === 'function') {
942
+ try {
943
+ await iterator.return();
944
+ }
945
+ catch {
946
+ // the upstream refused its cancel; it is gone either way
947
+ }
948
+ }
949
+ },
950
+ }, { highWaterMark: 0 });
951
+ return { init: stream, streamed: true };
952
+ }
953
+ throw host('JC1008', 'bytes(): ctx.body must be a string, a Uint8Array, a ReadableStream, an async iterable of Uint8Array chunks, or null');
954
+ }
955
+
956
+ /**
957
+ * Call an opaque operation and expose its response as bytes
958
+ * (docs/CONTRACT-FORMAT.md §10.6): one request, no retry, and on a
959
+ * 2xx the LIVE response body — a `ReadableStream` the caller reads,
960
+ * never collected here. `ctx.body` is the request body to send.
961
+ * @param {string} op
962
+ * @param {unknown} [input]
963
+ * @param {ByteContext} [ctx]
964
+ * @returns {Promise<Outcome>}
965
+ * @throws {ContractHostError} `JC1005` for a JSON operation (use `invoke`), `JC1008` for a malformed context
966
+ */
967
+ async function bytes(op, input, ctx = {}) {
968
+ const route = routeOf(op);
969
+ if (!route.opaque) {
970
+ throw new ContractHostError('JC1005', `client: '${route.id}' is a JSON operation (media ${route.media}); bytes carries opaque operations only — use invoke`);
971
+ }
972
+ if (ctx === null || typeof ctx !== 'object') throw host('JC1008', 'ctx must be an object');
973
+ const meta = newMeta(route.id, ctx.attempt);
974
+ const signal = ctx.signal === undefined || ctx.signal === null ? null : ctx.signal;
975
+
976
+ // 1. validate — the transport members are the whole input of an opaque operation
977
+ let value;
978
+ if (!route.hasInput) {
979
+ if (input !== undefined && input !== null) return invalidInput(route, meta, [{ path: '', keyword: 'input' }]);
980
+ value = null;
981
+ }
982
+ else {
983
+ value = inputValue(input);
984
+ const v = verdict(/** @type {(value: unknown) => any} */ (route.validateInput), value);
985
+ if (!v.valid) return invalidInput(route, meta, projectValidationDetails(route.details, v.errors));
986
+ }
987
+
988
+ // 2. the request line and the headers
989
+ let url;
990
+ let headers;
991
+ try {
992
+ url = baseUrl + pathOf(route, value === null ? {} : value);
993
+ headers = headersOf(route, value === null ? {} : value, ctx);
994
+ }
995
+ catch {
996
+ return invalidInput(route, meta, [{ path: '', keyword: 'encoding' }]);
997
+ }
998
+ const upload = uploadBody(ctx.body);
999
+ if (upload.init !== undefined && headers['content-type'] === undefined) headers['content-type'] = route.media;
1000
+ if ((signal !== null && signal.aborted) || closed) return cancelled(route, meta);
1001
+
1002
+ // 3. one request; a streamed upload needs the half-duplex flag
1003
+ /** @type {any} */
1004
+ const init = { method: route.method, headers, signal: composeSignal(signal) };
1005
+ if (upload.init !== undefined) {
1006
+ init.body = upload.init;
1007
+ if (upload.streamed) init.duplex = 'half';
1008
+ }
1009
+ let response;
1010
+ try {
1011
+ response = await fetchFn(url, init);
1012
+ }
1013
+ catch (err) {
1014
+ return rejected(route, err, signal, meta);
1015
+ }
1016
+
1017
+ // 4. the answer: a 2xx exposes the live body; anything else classifies through the error envelope
1018
+ let status;
1019
+ /** @type {Record<string, string>} */
1020
+ let responseHeaderTable;
1021
+ try {
1022
+ status = response.status;
1023
+ responseHeaderTable = responseHeaders(response);
1024
+ }
1025
+ catch (err) {
1026
+ return rejected(route, err, signal, meta);
1027
+ }
1028
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
1029
+ return failedOutcome('contract', clientError(catalog, 'JC2053', { op: route.id }, null, [{ path: '', keyword: 'status' }]), meta);
1030
+ }
1031
+ const trace = responseHeaderTable['x-jaren-trace'];
1032
+ if (typeof trace === 'string' && trace.length > 0) meta.trace = trace;
1033
+ const etag = responseHeaderTable.etag;
1034
+ if (status === 304) {
1035
+ meta.etag = typeof etag === 'string' && etag.length > 0 ? etag : null;
1036
+ meta.notModified = true;
1037
+ return okOutcome({ status, headers: responseHeaderTable, media: null, body: null }, meta);
1038
+ }
1039
+ if (status >= 200 && status <= 299) {
1040
+ meta.etag = typeof etag === 'string' && etag.length > 0 ? etag : null;
1041
+ const body = /** @type {any} */ (response).body;
1042
+ const media = responseHeaderTable['content-type'];
1043
+ return okOutcome({
1044
+ status,
1045
+ headers: responseHeaderTable,
1046
+ media: typeof media === 'string' && media.length > 0 ? media : null,
1047
+ body: body === undefined || body === null ? null : body,
1048
+ }, meta);
1049
+ }
1050
+ let text = null;
1051
+ try {
1052
+ text = await response.text();
1053
+ }
1054
+ catch (err) {
1055
+ return rejected(route, err, signal, meta);
1056
+ }
1057
+ return assembleOutcome(route.outcome, { status, headers: etag === undefined ? null : { etag }, text }, meta, catalog);
1058
+ }
1059
+
789
1060
  /**
790
1061
  * Subscribe to a subscribe operation's stream (docs/CONTRACT-FORMAT.md
791
1062
  * §19): one `GET` with `accept: text/event-stream`, the SSE events
792
1063
  * decoded and delivered through the callbacks; snapshots validated
793
1064
  * against the output schema, `seq` strictly increasing (`JC2092`),
794
1065
  * silence beyond `2 × heartbeatMs` a `JC2094` network outcome.
795
- * Reconnection is the caller's: pass the last delivered seq as
796
- * `lastSeq`.
1066
+ * `reconnect: { max }` opts into re-establishing the stream after a
1067
+ * network loss — a fetch rejection (`JC2051`), a missed heartbeat
1068
+ * (`JC2094`), the server's `JC2096`, or a body that ends before an
1069
+ * `end` event — up to `max` times, each attempt after the shared
1070
+ * backoff and from the last delivered seq, no callback in between;
1071
+ * the budget spent, `onError` gets one `JC2097`. Without it a network
1072
+ * outcome is delivered as is and re-entering is the caller's (pass
1073
+ * `lastSeq`). A declared failure, a contract outcome, the server's
1074
+ * `end`, `stop()` and the signal are terminal on every setting.
797
1075
  * @param {string} op
798
1076
  * @param {unknown} [input]
799
1077
  * @param {SubscribeOptions} [options]
800
- * @returns {{ stop: () => void }}
1078
+ * @returns {Subscription}
801
1079
  * @throws {ContractHostError} `JC1010` for a non-subscribe operation, `JC1008` for a malformed option
802
1080
  */
803
1081
  function subscribe(op, input, options = {}) {
@@ -816,201 +1094,321 @@ export function openHttpClient(contract, options = {}) {
816
1094
  if (!Number.isInteger(options.lastSeq) || options.lastSeq < 0) throw host('JC1008', 'options.lastSeq must be a non-negative integer');
817
1095
  lastSeq = options.lastSeq;
818
1096
  }
1097
+ let reconnectMax = 0;
1098
+ if (options.reconnect !== undefined && options.reconnect !== null) {
1099
+ const r = /** @type {any} */ (options.reconnect);
1100
+ if (typeof r !== 'object' || !Number.isInteger(r.max) || r.max < 0) {
1101
+ throw host('JC1008', 'options.reconnect must be { max } with a non-negative integer number of further attempts');
1102
+ }
1103
+ reconnectMax = r.max;
1104
+ }
819
1105
  const signal = options.signal === undefined || options.signal === null ? null : options.signal;
820
1106
  const meta = newMeta(route.id, null);
821
1107
  const streamPolicy = route.op.policy.stream;
822
1108
  const heartbeatMs = streamPolicy === null ? 15000 : streamPolicy.heartbeatMs;
1109
+ const callbacks = { onSnapshot: options.onSnapshot, onPatch: options.onPatch, onError: options.onError, onEnd: options.onEnd };
823
1110
 
1111
+ // one subscription across every transport attempt: `controller` is
1112
+ // its stop, `attempts` the further attempts made, `current` the
1113
+ // consumer of the attempt in flight (the seq getter reads it)
824
1114
  const controller = new AbortController();
825
- const composed = signal === null
1115
+ const base = signal === null
826
1116
  ? AbortSignal.any([closer.signal, controller.signal])
827
1117
  : 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?.();
1118
+ let attempts = 0;
1119
+ let stopped = false;
1120
+ /** @type {ReturnType<typeof createStreamConsumer> | null} */
1121
+ let current = null;
1122
+
1123
+ /** Whether a network loss of the attempt in flight is answered by a further attempt. */
1124
+ function mayReconnect() {
1125
+ return attempts < reconnectMax && !stopped && !closed && !base.aborted;
855
1126
  }
856
1127
 
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;
1128
+ /**
1129
+ * Release a response body the subscription will not read, or the
1130
+ * transport keeps the connection reserved for a read that never comes.
1131
+ * @param {any} body
1132
+ */
1133
+ function discard(body) {
862
1134
  try {
863
- data = JSON.parse(ev.data);
1135
+ if (body !== null && body !== undefined && typeof body.cancel === 'function') {
1136
+ Promise.resolve(body.cancel()).catch(() => {});
1137
+ }
864
1138
  }
865
1139
  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
1140
+ // release is best-effort; the outcome is the answer
882
1141
  }
883
1142
  }
884
1143
 
885
- (async () => {
886
- if (closed || (signal !== null && signal.aborted)) {
887
- consumer.cancel();
888
- return;
1144
+ /**
1145
+ * A network failure of this attempt's request or read.
1146
+ * @param {unknown} err
1147
+ * @returns {Outcome}
1148
+ */
1149
+ function lost(err) {
1150
+ return failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta);
1151
+ }
1152
+
1153
+ /**
1154
+ * One transport attempt from `resumeSeq`: a consumer, a request, a
1155
+ * reader and a watchdog of its own — a stale attempt's settlements
1156
+ * touch nothing of a newer one. Resolves with the network code that
1157
+ * ended it when a further attempt follows, else `null`: the attempt
1158
+ * ended finally (an outcome delivered, the server's end, a cancel).
1159
+ * @param {number | null} resumeSeq
1160
+ * @returns {Promise<string | null>}
1161
+ */
1162
+ function attempt(resumeSeq) {
1163
+ /** @type {PromiseWithResolvers<string | null>} */
1164
+ const settled = Promise.withResolvers();
1165
+ const own = new AbortController();
1166
+ const composed = AbortSignal.any([base, own.signal]);
1167
+ /** @type {ReadableStreamDefaultReader<Uint8Array> | null} */
1168
+ let reader = null;
1169
+ /** @type {ReturnType<typeof setTimeout> | 0} */
1170
+ let watchdog = 0;
1171
+
1172
+ const consumer = createStreamConsumer({
1173
+ route: route.outcome,
1174
+ catalog,
1175
+ meta,
1176
+ callbacks: {
1177
+ onSnapshot: callbacks.onSnapshot,
1178
+ onPatch: callbacks.onPatch,
1179
+ onEnd: (info) => {
1180
+ settled.resolve(null);
1181
+ if (callbacks.onEnd !== undefined) callbacks.onEnd(info);
1182
+ },
1183
+ onError: (outcome) => {
1184
+ if (outcome.kind !== 'network' || reconnectMax === 0) {
1185
+ settled.resolve(null);
1186
+ if (callbacks.onError !== undefined) callbacks.onError(outcome);
1187
+ return;
1188
+ }
1189
+ if (mayReconnect()) {
1190
+ // an intermediate loss: answered by the next attempt, never delivered
1191
+ settled.resolve(outcome.error.code);
1192
+ return;
1193
+ }
1194
+ settled.resolve(null);
1195
+ const details = { attempts, lastCode: outcome.error.code };
1196
+ if (callbacks.onError !== undefined) {
1197
+ callbacks.onError(failedOutcome('network', outcomeError('JC2097',
1198
+ renderMessage(catalog, STREAM_ERRORS.JC2097.msgid, { op: route.id, ...details }), null, details, false), meta));
1199
+ }
1200
+ },
1201
+ },
1202
+ finish: () => {
1203
+ if (watchdog !== 0) clearTimeout(watchdog);
1204
+ watchdog = 0;
1205
+ if (reader !== null) reader.cancel().catch(() => {});
1206
+ },
1207
+ lastSeq: resumeSeq,
1208
+ });
1209
+ current = consumer;
1210
+
1211
+ /** Push the silence watchdog forward: any bytes count as life. */
1212
+ function resetWatchdog() {
1213
+ if (watchdog !== 0) clearTimeout(watchdog);
1214
+ watchdog = setTimeout(() => {
1215
+ consumer.fail(failedOutcome('network',
1216
+ outcomeError('JC2094', renderMessage(catalog, STREAM_ERRORS.JC2094.msgid, { op: route.id, ms: 2 * heartbeatMs }), null, null, true), meta));
1217
+ own.abort();
1218
+ }, 2 * heartbeatMs);
1219
+ /** @type {any} */ (watchdog).unref?.();
889
1220
  }
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;
1221
+
1222
+ /** @param {import('@jarenjs/core/text/sse').SseEvent} ev */
1223
+ function deliver(ev) {
1224
+ const parsed = ev.id === null ? NaN : Number.parseInt(ev.id, 10);
1225
+ const seq = Number.isFinite(parsed) ? parsed : null;
1226
+ let data;
1227
+ try {
1228
+ data = JSON.parse(ev.data);
896
1229
  }
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;
1230
+ catch {
1231
+ data = undefined;
905
1232
  }
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;
1233
+ switch (ev.event) {
1234
+ case 'snapshot':
1235
+ consumer.snapshot(seq, data);
1236
+ break;
1237
+ case 'patch':
1238
+ consumer.patch(seq, data);
1239
+ break;
1240
+ case 'error':
1241
+ consumer.error(data);
1242
+ break;
1243
+ case 'end':
1244
+ consumer.end(data);
1245
+ break;
1246
+ // an unknown event name is ignored — SSE's forward compatibility
939
1247
  }
940
1248
  }
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;
1249
+
1250
+ (async () => {
1251
+ if (closed || stopped || base.aborted) {
1252
+ consumer.cancel();
1253
+ return null;
1254
+ }
1255
+ // 1. validate before anything is sent — the shared pre-send refusal
1256
+ let value;
1257
+ if (!route.hasInput) {
1258
+ if (input !== undefined && input !== null) {
1259
+ consumer.fail(invalidInput(route, meta, [{ path: '', keyword: 'input' }]));
1260
+ return null;
1261
+ }
1262
+ value = {};
1263
+ }
1264
+ else {
1265
+ value = inputValue(input);
1266
+ const v = verdict(/** @type {(value: unknown) => any} */ (route.validateInput), value);
1267
+ if (!v.valid) {
1268
+ consumer.fail(invalidInput(route, meta, projectValidationDetails(route.details, v.errors)));
1269
+ return null;
1270
+ }
1271
+ }
1272
+ // 2. the request, from the cursor
1273
+ let requestUrl;
947
1274
  try {
948
- text = await response.text();
1275
+ requestUrl = baseUrl + pathOf(route, value);
949
1276
  }
950
1277
  catch {
951
- text = null;
1278
+ consumer.fail(invalidInput(route, meta, [{ path: '', keyword: 'encoding' }]));
1279
+ return null;
952
1280
  }
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
- // a refused stream still holds a live response body; release it,
960
- // or the transport keeps the connection reserved for a read that
961
- // will never come
1281
+ /** @type {Record<string, string>} */
1282
+ const requestHeaders = { ...staticHeaders, accept: STREAM_MEDIA };
1283
+ if (resumeSeq !== null) requestHeaders['last-event-id'] = String(resumeSeq);
1284
+ let response;
1285
+ try {
1286
+ response = await fetchFn(requestUrl, { method: 'GET', headers: requestHeaders, signal: composed });
1287
+ }
1288
+ catch (err) {
1289
+ if (consumer.finished()) return null;
1290
+ if (composed.aborted || safeName(err) === 'AbortError') consumer.cancel();
1291
+ else consumer.fail(lost(err));
1292
+ return null;
1293
+ }
1294
+ if (consumer.finished()) {
1295
+ // stopped while the request was in flight: the answer is nobody's
1296
+ discard(/** @type {any} */ (response)?.body);
1297
+ return null;
1298
+ }
1299
+ // 3. the answer must be a 200 event stream — anything else classifies
1300
+ let status;
1301
+ let contentType = null;
962
1302
  try {
963
- if (body !== null && body !== undefined && typeof body.cancel === 'function') {
964
- Promise.resolve(body.cancel()).catch(() => {});
1303
+ status = response.status;
1304
+ const h = response.headers;
1305
+ if (h !== null && typeof h === 'object' && typeof h.get === 'function') {
1306
+ contentType = h.get('content-type');
1307
+ const t = h.get('x-jaren-trace');
1308
+ if (typeof t === 'string' && t.length > 0) meta.trace = t;
965
1309
  }
966
1310
  }
967
- catch {
968
- // cancellation is best-effort; the outcome below is the answer
1311
+ catch (err) {
1312
+ consumer.fail(lost(err));
1313
+ return null;
969
1314
  }
970
- consumer.fail(failedOutcome('contract',
971
- outcomeError('JC2090', renderMessage(catalog, STREAM_ERRORS.JC2090.msgid, { op: route.id }), status, null, false), meta));
972
- return;
973
- }
974
- // 4. the event loop under the silence watchdog
975
- reader = body.getReader();
976
- const textDecoder = new TextDecoder();
977
- const sse = createSseEventDecoder();
978
- resetWatchdog();
979
- try {
980
- for (;;) {
981
- const { done, value: chunk } = await reader.read();
982
- if (done) break;
983
- if (consumer.finished()) return;
984
- resetWatchdog();
985
- for (const ev of sse.feed(textDecoder.decode(chunk, { stream: true }))) {
1315
+ if (!Number.isInteger(status) || status < 200 || status > 299) {
1316
+ let text = null;
1317
+ try {
1318
+ text = await response.text();
1319
+ }
1320
+ catch {
1321
+ text = null;
1322
+ }
1323
+ consumer.fail(assembleOutcome(route.outcome, { status, headers: null, text }, meta, catalog));
1324
+ return null;
1325
+ }
1326
+ const body = /** @type {any} */ (response).body;
1327
+ if (typeof contentType !== 'string' || contentType.toLowerCase().indexOf(STREAM_MEDIA) === -1
1328
+ || body === null || body === undefined || typeof body.getReader !== 'function') {
1329
+ // a refused stream still holds a live response body
1330
+ discard(body);
1331
+ consumer.fail(failedOutcome('contract',
1332
+ outcomeError('JC2090', renderMessage(catalog, STREAM_ERRORS.JC2090.msgid, { op: route.id }), status, null, false), meta));
1333
+ return null;
1334
+ }
1335
+ // 4. the event loop under the silence watchdog
1336
+ reader = body.getReader();
1337
+ const textDecoder = new TextDecoder();
1338
+ const sse = createSseEventDecoder();
1339
+ resetWatchdog();
1340
+ try {
1341
+ for (;;) {
1342
+ const { done, value: chunk } = await reader.read();
1343
+ if (done) break;
1344
+ if (consumer.finished()) return null;
1345
+ resetWatchdog();
1346
+ for (const ev of sse.feed(textDecoder.decode(chunk, { stream: true }))) {
1347
+ deliver(ev);
1348
+ if (consumer.finished()) return null;
1349
+ }
1350
+ }
1351
+ for (const ev of sse.end()) {
986
1352
  deliver(ev);
987
- if (consumer.finished()) return;
1353
+ if (consumer.finished()) return null;
988
1354
  }
1355
+ // a body that ends without an end event: a network loss under a
1356
+ // reconnect policy, otherwise reported as closed
1357
+ if (reconnectMax > 0) consumer.fail(lost({ name: 'EOF' }));
1358
+ else consumer.end(undefined);
989
1359
  }
990
- for (const ev of sse.end()) {
991
- deliver(ev);
992
- if (consumer.finished()) return;
1360
+ catch (err) {
1361
+ if (consumer.finished()) return null;
1362
+ if (composed.aborted || safeName(err) === 'AbortError') consumer.cancel();
1363
+ else consumer.fail(lost(err));
993
1364
  }
994
- // a stream that ends without an end event is reported as closed
995
- consumer.end(undefined);
996
- }
997
- catch (err) {
998
- if (consumer.finished()) return;
999
- if (composed.aborted || safeName(err) === 'AbortError') consumer.cancel();
1000
- else consumer.fail(failedOutcome('network', clientError(catalog, 'JC2051', { op: route.id, name: safeName(err) ?? typeof err }, null, undefined), meta));
1001
- }
1002
- finally {
1003
- if (watchdog !== 0) clearTimeout(watchdog);
1004
- watchdog = 0;
1365
+ finally {
1366
+ if (watchdog !== 0) clearTimeout(watchdog);
1367
+ watchdog = 0;
1368
+ }
1369
+ return null;
1370
+ })().catch(() => {}).finally(() => {
1371
+ // a network loss resolved the code from inside the consumer; every
1372
+ // other way out of this attempt is final
1373
+ settled.resolve(null);
1374
+ });
1375
+ return settled.promise;
1376
+ }
1377
+
1378
+ /**
1379
+ * Drive the attempts: a further one after each network loss the
1380
+ * budget admits, from the last delivered seq, after the backoff.
1381
+ */
1382
+ async function drive() {
1383
+ let resumeSeq = lastSeq;
1384
+ for (;;) {
1385
+ // attempt() resolves its own promise; the returned value is the
1386
+ // network code the consumer answered with, `null` when final
1387
+ const code = await attempt(resumeSeq);
1388
+ if (code === null) return;
1389
+ attempts += 1;
1390
+ try {
1391
+ await sleep(backoffDelay(attempts - 1), base);
1392
+ }
1393
+ catch {
1394
+ return; // stopped, closed or aborted during the backoff: silent, the caller asked
1395
+ }
1396
+ if (stopped || closed || base.aborted) return;
1397
+ resumeSeq = /** @type {ReturnType<typeof createStreamConsumer>} */ (current).lastSeq();
1005
1398
  }
1006
- })();
1399
+ }
1400
+ drive().catch(() => {});
1007
1401
 
1008
- return {
1402
+ return Object.freeze({
1009
1403
  stop: () => {
1010
- consumer.cancel();
1404
+ stopped = true;
1405
+ if (current !== null) current.cancel();
1011
1406
  controller.abort();
1012
1407
  },
1013
- };
1408
+ get lastSeq() {
1409
+ return current === null ? lastSeq : current.lastSeq();
1410
+ },
1411
+ });
1014
1412
  }
1015
1413
 
1016
1414
  /**
@@ -1139,6 +1537,7 @@ export function openHttpClient(contract, options = {}) {
1139
1537
 
1140
1538
  return Object.freeze({
1141
1539
  invoke,
1540
+ bytes,
1142
1541
  subscribe,
1143
1542
  url,
1144
1543
  negotiate,