@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.
- package/README.md +191 -30
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/project/tools.d.ts +1 -1
- package/dist/types/project/typescript.d.ts +11 -0
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +617 -145
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/tools.js +9 -2
- package/src/project/typescript.js +91 -1
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- package/src/stream/sse.js +2 -0
package/src/client/http.js
CHANGED
|
@@ -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). `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
/** @type {
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
796
|
-
* `
|
|
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 {
|
|
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
|
|
1115
|
+
const base = signal === null
|
|
826
1116
|
? AbortSignal.any([closer.signal, controller.signal])
|
|
827
1117
|
: AbortSignal.any([closer.signal, controller.signal, signal]);
|
|
828
|
-
|
|
829
|
-
let
|
|
830
|
-
/** @type {ReturnType<typeof
|
|
831
|
-
let
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
/**
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
1135
|
+
if (body !== null && body !== undefined && typeof body.cancel === 'function') {
|
|
1136
|
+
Promise.resolve(body.cancel()).catch(() => {});
|
|
1137
|
+
}
|
|
864
1138
|
}
|
|
865
1139
|
catch {
|
|
866
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
1275
|
+
requestUrl = baseUrl + pathOf(route, value);
|
|
949
1276
|
}
|
|
950
1277
|
catch {
|
|
951
|
-
|
|
1278
|
+
consumer.fail(invalidInput(route, meta, [{ path: '', keyword: 'encoding' }]));
|
|
1279
|
+
return null;
|
|
952
1280
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
consumer.fail(lost(err));
|
|
1313
|
+
return null;
|
|
969
1314
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
if (
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
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,
|