@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.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- 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';
|