@jarenjs/contract 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
@@ -0,0 +1,1054 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The request pipeline of the HTTP server binding: one plain
4
+ * request object in, one plain response object out — route, decode,
5
+ * assemble, normalize, validate, claim idempotency, call the handler
6
+ * through one uniform promise boundary, validate the output, apply the
7
+ * entity-tag conditionals, serialize, commit. Every failure a request
8
+ * can cause is a coded response (docs/CONTRACT-FORMAT.md §7); the
9
+ * function rejects only for a malformed request OBJECT (`JC1004`, an
10
+ * adapter author's mistake) — never for request content and never for
11
+ * what a handler returns or throws (the `tasks.js` posture: a hostile
12
+ * value settles into `JC2008`/`JC2010`, it does not escape).
13
+ *
14
+ * The trust boundary: the contract, the handlers and the ledger are the
15
+ * host's; the request is hostile. Only declared header members and the
16
+ * protocol headers the binding itself needs are read; the input object
17
+ * is assembled through `setObjectMember` from declared names only; a
18
+ * body member that names a path/query/header member is ignored; nothing
19
+ * a request sent is echoed into a message.
20
+ */
21
+
22
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
23
+ import { toPromise, isThenable } from '@jarenjs/core/function';
24
+ import { canonicalSha256, JsonCanonicalizeError } from '@jarenjs/json/canonical';
25
+
26
+ import { ContractHostError, ContractFailure } from '../errors.js';
27
+ import { validateOperationInput, settleOperation, safeTrace } from '../pipeline.js';
28
+ import {
29
+ isSubscriptionLike, runSubscription, STREAM_ERRORS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent,
30
+ } from '../stream/server.js';
31
+ import {
32
+ HTTP_ERRORS, JSON_CONTENT_TYPE, JSON_MEDIA,
33
+ renderMessage, declaredMessage, headerValue, contentLength, mediaMatches, exceedsBytes,
34
+ entityTagMatches, formatEntityTag, decodeQuery, projectValidationDetails, errorResponse, verdict,
35
+ } from './wire.js';
36
+
37
+ /**
38
+ * @typedef {import('./wire.js').HttpRequest} HttpRequest
39
+ * @typedef {import('./wire.js').HttpResponse} HttpResponse
40
+ * @typedef {import('./wire.js').Catalog} Catalog
41
+ * @typedef {import('../compile.js').CompiledOperation} CompiledOperation
42
+ * @typedef {import('../compile.js').CompiledErrorDecl} CompiledErrorDecl
43
+ * @typedef {import('../errors.js').ContractFailureValue} ContractFailureValue
44
+ * @typedef {import('../pipeline.js').OperationResult} OperationResult
45
+ * @typedef {import('../ledger.js').Ledger} Ledger
46
+ */
47
+
48
+ /**
49
+ * The per-request context a handler receives — frozen. `params` are the
50
+ * raw decoded path strings; `headers` carries the declared header members
51
+ * (by header name) plus `if-match`/`if-none-match` when present; `body`
52
+ * is the raw request body for an OPAQUE operation and `null` for a JSON
53
+ * one (whose body was decoded into the input). `fail` makes a declared
54
+ * failure by code; `etag` arms the entity-tag path; `status` overrides
55
+ * the success status (2xx only — `JC1006` otherwise, a host error the
56
+ * handler boundary settles into `JC2008` and reports through `onError`).
57
+ * @typedef {Object} RequestContext
58
+ * @property {CompiledOperation} op
59
+ * @property {string} trace
60
+ * @property {string} method
61
+ * @property {string} path
62
+ * @property {Readonly<Record<string, string>>} params
63
+ * @property {Readonly<Record<string, string>>} headers
64
+ * @property {string | Uint8Array | null} body
65
+ * @property {AbortSignal | null} signal
66
+ * @property {Readonly<{ key: string, scope: string }> | null} idempotency
67
+ * @property {(code: string, params?: Record<string, unknown>, details?: unknown, options?: { retryable?: boolean }) => ContractFailureValue} fail
68
+ * @property {(tag: string, options?: { strong?: boolean }) => void} etag
69
+ * @property {(status: number) => void} status
70
+ */
71
+
72
+ /**
73
+ * A handler of a JSON operation: the reassembled, validated input (or
74
+ * `null` when the operation declares none) and the context; returns the
75
+ * output value, a promise of it, or a declared failure. An opaque
76
+ * operation's handler is raw: it returns `{ status, headers?, body? }`
77
+ * and reads the bytes from `ctx.body`; its `input` is the decoded,
78
+ * validated transport members — they are always its whole input, since
79
+ * the compiler refuses a body-located member on an opaque operation
80
+ * (`JC0017`).
81
+ * @typedef {(input: any, ctx: RequestContext) => unknown} Handler
82
+ */
83
+
84
+ /**
85
+ * The raw response of an opaque operation's handler.
86
+ * @typedef {{ status: number, headers?: Record<string, string>, body?: string | Uint8Array | null }} RawResponse
87
+ */
88
+
89
+ /**
90
+ * One operation as `serveHttp` prepared it: everything the pipeline
91
+ * reads per request, decided once.
92
+ * @typedef {Object} Route
93
+ * @property {CompiledOperation} op
94
+ * @property {Handler | null} handler - `null` on a partial server
95
+ * @property {boolean} raw - opaque: the handler is raw
96
+ * @property {boolean} stream - a subscribe operation: the handler answers a subscription
97
+ * @property {number} maxBody
98
+ * @property {string} media
99
+ * @property {boolean} hasBody - any body-located member, or a whole-body member
100
+ * @property {string | null} wholeBody
101
+ * @property {ReadonlySet<string>} nonBody - path/query/header member names, never taken from the body
102
+ * @property {readonly string[]} pathMembers
103
+ * @property {ReadonlySet<string>} queryMembers
104
+ * @property {ReadonlySet<string>} repeated - array-typed query members
105
+ * @property {readonly string[]} headerMembers - member names
106
+ * @property {readonly string[]} headerNames - the lowercase header of each
107
+ * @property {readonly boolean[]} headerArray - array-typed, per header member
108
+ * @property {((value: any) => any) | null} normalize
109
+ * @property {((value: unknown) => any) | null} validateInput
110
+ * @property {(value: unknown) => any} validateOutput
111
+ * @property {'none' | 'paths' | 'full'} details
112
+ * @property {'none' | 'optional' | 'required'} idempotency
113
+ * @property {ReadonlySet<string>} retryOn
114
+ * @property {Readonly<Record<string, CompiledErrorDecl>>} errors
115
+ * @property {number} status
116
+ */
117
+
118
+ /**
119
+ * The server as `serveHttp` built it — the pipeline's configuration.
120
+ * @typedef {Object} Server
121
+ * @property {import('../compile.js').Contract} contract
122
+ * @property {ReadonlyMap<string, Route>} routes
123
+ * @property {() => string} trace
124
+ * @property {Ledger | null} ledger
125
+ * @property {(ctx: RequestContext) => string} scope
126
+ * @property {boolean} head
127
+ * @property {boolean} validateOutput
128
+ * @property {string | false} wellKnown
129
+ * @property {((wire: any, ctx: RequestContext | null) => unknown) | null} errorBody
130
+ * @property {((error: unknown, ctx: RequestContext | null) => void) | null} onError
131
+ * @property {Catalog | null} catalog
132
+ * @property {() => number} now
133
+ * @property {{ text: string | null }} described - the memoized well-known body
134
+ * @property {Set<(reason: string | null) => void>} streams - the live SSE
135
+ * streams' stoppers; the dispatcher's `close()` ends them all
136
+ */
137
+
138
+ /** The strict decoder of a JSON body given as bytes. */
139
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
140
+
141
+ /** The frozen empty header table of a request without declared headers. */
142
+ const NO_HEADERS = Object.freeze({});
143
+
144
+ /** The valid shape of a request object — `JC1004` otherwise. */
145
+ const REQUEST_SHAPE = 'a request is { method: string, url: string, headers: object, body: string | Uint8Array | null }';
146
+
147
+ //#region the boundary
148
+
149
+ /**
150
+ * The one entry point. Validates the request object (a malformed one is
151
+ * `JC1004`, rejected — the adapter's mistake), then runs the pipeline and
152
+ * lifts its result into a promise. Total for request content: every
153
+ * request-caused failure resolves to a response.
154
+ * @param {Server} server
155
+ * @param {HttpRequest} request
156
+ * @returns {Promise<HttpResponse>}
157
+ */
158
+ export function dispatch(server, request) {
159
+ const shapeError = requestShapeError(request);
160
+ if (shapeError !== null) return Promise.reject(shapeError);
161
+ let result;
162
+ try {
163
+ result = run(server, request);
164
+ }
165
+ catch (err) {
166
+ return Promise.resolve(lastResort(server, err));
167
+ }
168
+ // every request-caused failure has already become a response; what
169
+ // reaches this catch is a defect of the binding itself, and even that
170
+ // must not surface as a rejection on a server
171
+ return toPromise(result).then(undefined, (err) => lastResort(server, err));
172
+ }
173
+
174
+ /**
175
+ * The binding's own defect as a `JC2008` with a fresh trace — reported to
176
+ * the host observer, never a rejection.
177
+ * @param {Server} server
178
+ * @param {unknown} err
179
+ * @returns {HttpResponse}
180
+ */
181
+ function lastResort(server, err) {
182
+ observe(server, err, null);
183
+ return refuse(server, 'JC2008', makeTrace(server), { op: '' }, undefined, null, null);
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} request
188
+ * @returns {ContractHostError | null}
189
+ */
190
+ function requestShapeError(request) {
191
+ if (request === null || typeof request !== 'object') {
192
+ return new ContractHostError('JC1004', `dispatch: ${REQUEST_SHAPE}; got ${request === null ? 'null' : typeof request}`);
193
+ }
194
+ const r = /** @type {any} */ (request);
195
+ if (typeof r.method !== 'string') return new ContractHostError('JC1004', `dispatch: request.method must be a string; ${REQUEST_SHAPE}`);
196
+ if (typeof r.url !== 'string') return new ContractHostError('JC1004', `dispatch: request.url must be a string; ${REQUEST_SHAPE}`);
197
+ if (r.headers === null || typeof r.headers !== 'object') {
198
+ return new ContractHostError('JC1004', `dispatch: request.headers must be an object of lowercase names; ${REQUEST_SHAPE}`);
199
+ }
200
+ if (!(r.body === null || r.body === undefined || typeof r.body === 'string' || r.body instanceof Uint8Array)) {
201
+ return new ContractHostError('JC1004', `dispatch: request.body must be a string, a Uint8Array or null; ${REQUEST_SHAPE}`);
202
+ }
203
+ return null;
204
+ }
205
+
206
+ //#endregion
207
+
208
+ //#region helpers
209
+
210
+ /**
211
+ * A server trace id.
212
+ * @param {Server} server
213
+ * @returns {string}
214
+ */
215
+ function makeTrace(server) {
216
+ return safeTrace(server.trace);
217
+ }
218
+
219
+ /**
220
+ * Report a server-side fault to the host observer. TOTAL.
221
+ * @param {Server} server
222
+ * @param {unknown} error
223
+ * @param {RequestContext | null} ctx
224
+ */
225
+ function observe(server, error, ctx) {
226
+ if (server.onError === null) return;
227
+ try {
228
+ server.onError(error, ctx);
229
+ }
230
+ catch {
231
+ // an observer that throws never reaches the response
232
+ }
233
+ }
234
+
235
+ /**
236
+ * A taxonomy response: status/msgid/retryable from the row, message from
237
+ * the catalog. `params` are trusted (op ids, limits, method lists).
238
+ * @param {Server} server
239
+ * @param {keyof typeof HTTP_ERRORS} code
240
+ * @param {string} trace
241
+ * @param {Record<string, unknown>} params
242
+ * @param {unknown} details
243
+ * @param {Readonly<Record<string, string>> | null} extraHeaders
244
+ * @param {RequestContext | null} ctx
245
+ * @param {boolean} [retryable] - overrides the row
246
+ * @returns {HttpResponse}
247
+ */
248
+ function refuse(server, code, trace, params, details, extraHeaders, ctx, retryable) {
249
+ const row = HTTP_ERRORS[code];
250
+ return errorResponse(row.status, code, renderMessage(server.catalog, row.msgid, params), trace,
251
+ details, retryable === undefined ? row.retryable : retryable, extraHeaders, server.errorBody, ctx);
252
+ }
253
+
254
+ /**
255
+ * Whether a path decodes at all — the miss classifier: a `null` match
256
+ * with an undecodable path is `JC2011`, not a 404.
257
+ * @param {string} path
258
+ * @returns {boolean}
259
+ */
260
+ function decodable(path) {
261
+ try {
262
+ decodeURIComponent(path);
263
+ return true;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Whether the request carried a non-empty body.
272
+ * @param {string | Uint8Array | null} body
273
+ * @returns {boolean}
274
+ */
275
+ function hasContent(body) {
276
+ return body !== null && (typeof body === 'string' ? body.length > 0 : body.byteLength > 0);
277
+ }
278
+
279
+ /**
280
+ * The AbortSignal of a request, when the adapter supplied one.
281
+ * @param {HttpRequest} request
282
+ * @returns {AbortSignal | null}
283
+ */
284
+ function signalOf(request) {
285
+ const s = /** @type {any} */ (request).signal;
286
+ return s !== null && typeof s === 'object' && typeof s.aborted === 'boolean' && typeof s.addEventListener === 'function'
287
+ ? /** @type {AbortSignal} */ (s)
288
+ : null;
289
+ }
290
+
291
+ //#endregion
292
+
293
+ //#region the pipeline
294
+
295
+ /**
296
+ * Per-request mutable state: what the context's `etag`/`status` armed,
297
+ * and how the settlement classified — `outcome` 0 success, 1 declared
298
+ * failure (with `retryable`), 2 server fault — which is what the ledger
299
+ * needs to commit, record or release the claim.
300
+ * @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean }} Armed
301
+ */
302
+
303
+ /**
304
+ * The pipeline up to the handler: synchronous; returns a response for
305
+ * every early refusal, otherwise the promise the handler boundary opens.
306
+ * @param {Server} server
307
+ * @param {HttpRequest} request
308
+ * @returns {HttpResponse | Promise<HttpResponse>}
309
+ */
310
+ function run(server, request) {
311
+ const trace = makeTrace(server);
312
+ const method = request.method;
313
+ const url = request.url;
314
+ const q = url.indexOf('?');
315
+ const path = q === -1 ? url : url.slice(0, q);
316
+ const query = q === -1 ? '' : url.slice(q + 1);
317
+ const headers = request.headers;
318
+ const body = request.body === undefined ? null : request.body;
319
+
320
+ // ——— 2. route ———
321
+ let hit = server.contract.match(method, path);
322
+ let isHead = false;
323
+ if (hit === null && method === 'HEAD' && server.head) {
324
+ hit = server.contract.match('GET', path);
325
+ isHead = hit !== null;
326
+ }
327
+ if (hit === null) {
328
+ if (path.indexOf('%') !== -1 && !decodable(path)) return refuse(server, 'JC2011', trace, {}, undefined, null, null);
329
+ if (server.wellKnown !== false && path === server.wellKnown) return wellKnown(server, method, trace);
330
+ const allowed = server.contract.allowed(path);
331
+ if (allowed.length > 0) {
332
+ if (server.head && allowed.includes('GET') && !allowed.includes('HEAD')) {
333
+ allowed.push('HEAD');
334
+ allowed.sort();
335
+ }
336
+ const allow = allowed.join(', ');
337
+ return refuse(server, 'JC2002', trace, { allow }, undefined, { allow }, null);
338
+ }
339
+ return refuse(server, 'JC2001', trace, {}, undefined, null, null);
340
+ }
341
+ const route = /** @type {Route} */ (server.routes.get(hit.op.id));
342
+ const op = route.op;
343
+ if (route.handler === null) return refuse(server, 'JC2013', trace, { op: op.id }, undefined, null, null);
344
+
345
+ // ——— 4. the body limit: by declaration before the read, by length after ———
346
+ const declared = contentLength(headers);
347
+ if (declared > route.maxBody) return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
348
+ const content = hasContent(body);
349
+ if (content && exceedsBytes(/** @type {string | Uint8Array} */ (body), route.maxBody)) {
350
+ return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
351
+ }
352
+
353
+ // ——— 7a. the transport members: path, query, header ———
354
+ const params = Object.freeze(hit.params);
355
+ /** @type {Record<string, unknown>} */
356
+ const input = {};
357
+ for (let i = 0; i < route.pathMembers.length; i++) {
358
+ const m = route.pathMembers[i];
359
+ setObjectMember(input, m, params[m]);
360
+ }
361
+ if (!decodeQuery(query, route.queryMembers, route.repeated, input)) {
362
+ return refuse(server, 'JC2012', trace, {}, undefined, null, null);
363
+ }
364
+ /** @type {Record<string, string>} */
365
+ let ctxHeaders = NO_HEADERS;
366
+ if (route.headerMembers.length > 0) {
367
+ ctxHeaders = {};
368
+ for (let i = 0; i < route.headerMembers.length; i++) {
369
+ const name = route.headerNames[i];
370
+ const raw = headers[name];
371
+ if (raw === undefined) continue;
372
+ const m = route.headerMembers[i];
373
+ if (route.headerArray[i]) {
374
+ /** @type {string[]} */
375
+ const list = [];
376
+ if (typeof raw === 'string') {
377
+ const parts = raw.split(',');
378
+ for (let j = 0; j < parts.length; j++) {
379
+ const part = parts[j].trim();
380
+ if (part.length > 0) list.push(part);
381
+ }
382
+ }
383
+ else if (Array.isArray(raw)) {
384
+ for (let j = 0; j < raw.length; j++) {
385
+ if (typeof raw[j] !== 'string') return refuse(server, 'JC2015', trace, { op: op.id, header: m }, undefined, null, null);
386
+ list.push(raw[j]);
387
+ }
388
+ }
389
+ else return refuse(server, 'JC2015', trace, { op: op.id, header: m }, undefined, null, null);
390
+ setObjectMember(input, m, list);
391
+ setObjectMember(ctxHeaders, name, list.join(', '));
392
+ }
393
+ else {
394
+ let value;
395
+ if (typeof raw === 'string') value = raw;
396
+ else if (Array.isArray(raw) && raw.length === 1 && typeof raw[0] === 'string') value = raw[0];
397
+ else return refuse(server, 'JC2015', trace, { op: op.id, header: m }, undefined, null, null);
398
+ setObjectMember(input, m, value);
399
+ setObjectMember(ctxHeaders, name, value);
400
+ }
401
+ }
402
+ }
403
+ const ifMatch = headerValue(headers, 'if-match');
404
+ const ifNoneMatch = headerValue(headers, 'if-none-match');
405
+ if (ifMatch !== undefined || ifNoneMatch !== undefined) {
406
+ if (ctxHeaders === NO_HEADERS) ctxHeaders = {};
407
+ if (ifMatch !== undefined) ctxHeaders['if-match'] = ifMatch;
408
+ if (ifNoneMatch !== undefined) ctxHeaders['if-none-match'] = ifNoneMatch;
409
+ }
410
+ Object.freeze(ctxHeaders);
411
+ const transported = route.normalize === null ? input : route.normalize(input);
412
+
413
+ /** @type {Armed} */
414
+ const armed = { etag: null, strong: false, status: 0, outcome: 0, retryable: false };
415
+ /** @type {RequestContext} */
416
+ const ctx = {
417
+ op, trace, method, path, params, headers: ctxHeaders,
418
+ body: route.raw ? body : null,
419
+ signal: signalOf(request),
420
+ idempotency: null,
421
+ fail: ContractFailure,
422
+ etag: (tag, options) => {
423
+ if (typeof tag !== 'string' || tag.length === 0 || tag.indexOf('"') !== -1) {
424
+ throw new TypeError('ctx.etag: the tag must be a non-empty string without double quotes');
425
+ }
426
+ armed.etag = tag;
427
+ armed.strong = options !== undefined && options !== null && options.strong === true;
428
+ },
429
+ status: (status) => {
430
+ if (!Number.isInteger(status) || status < 200 || status > 299) {
431
+ throw new ContractHostError('JC1006', `ctx.status: the success status must be an integer in 200–299, got ${String(status)}`);
432
+ }
433
+ armed.status = status;
434
+ },
435
+ };
436
+
437
+ // ——— 3. opaque: the raw handler. The transport members ARE the whole
438
+ // input (an opaque operation has no body-located member — JC0017), so
439
+ // they are validated like any other input; the bytes go to the handler
440
+ // untouched in ctx.body ———
441
+ if (route.raw) {
442
+ const invalid = validateOperationInput(route, transported);
443
+ if (invalid !== null && invalid.kind === 'contract') {
444
+ if (invalid.cause !== undefined) observe(server, invalid.cause, null);
445
+ return refuse(server, 'JC2006', trace, { op: op.id }, invalid.details, null, null);
446
+ }
447
+ Object.freeze(ctx);
448
+ return boundary(server, route, ctx, op.input === null ? null : transported, trace, armed, isHead, ifMatch, ifNoneMatch, true);
449
+ }
450
+
451
+ // ——— 5. media, 6. parse ———
452
+ let parsed;
453
+ if (route.hasBody && content) {
454
+ if (!mediaMatches(headerValue(headers, 'content-type'), route.media)) {
455
+ return refuse(server, 'JC2004', trace, { op: op.id, media: route.media }, undefined, null, null);
456
+ }
457
+ let text;
458
+ if (typeof body === 'string') text = body;
459
+ else {
460
+ try {
461
+ text = utf8.decode(/** @type {Uint8Array} */ (body));
462
+ }
463
+ catch {
464
+ return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
465
+ }
466
+ }
467
+ try {
468
+ parsed = JSON.parse(text);
469
+ }
470
+ catch {
471
+ return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
472
+ }
473
+ }
474
+
475
+ // ——— 7b. the body members, never coerced ———
476
+ /** @type {any} */
477
+ let assembled = op.input === null ? null : transported;
478
+ if (parsed !== undefined) {
479
+ if (route.wholeBody !== null) setObjectMember(assembled, route.wholeBody, parsed);
480
+ else if (!isJsonObject(parsed)) {
481
+ return refuse(server, 'JC2006', trace, { op: op.id },
482
+ projectValidationDetails(route.details, [{ instancePath: '', keyword: 'type' }]), null, null);
483
+ }
484
+ else {
485
+ const names = Object.keys(parsed);
486
+ for (let i = 0; i < names.length; i++) {
487
+ const name = names[i];
488
+ if (route.nonBody.has(name)) continue;
489
+ setObjectMember(assembled, name, parsed[name]);
490
+ }
491
+ }
492
+ }
493
+
494
+ // ——— 8. validate (the pipeline's half) ———
495
+ const invalid = validateOperationInput(route, assembled);
496
+ if (invalid !== null && invalid.kind === 'contract') {
497
+ if (invalid.cause !== undefined) observe(server, invalid.cause, null);
498
+ return refuse(server, 'JC2006', trace, { op: op.id }, invalid.details, null, null);
499
+ }
500
+
501
+ // ——— subscribe: the stream branch (§17–§18), or the one-shot read ———
502
+ if (route.stream) {
503
+ Object.freeze(ctx);
504
+ return subscribeBranch(server, route, ctx, assembled, trace, headers, armed, isHead, ifMatch, ifNoneMatch);
505
+ }
506
+
507
+ // ——— 9. idempotency ———
508
+ if (route.idempotency !== 'none') {
509
+ const key = headerValue(headers, 'idempotency-key');
510
+ if (key === undefined || key.length === 0) {
511
+ if (route.idempotency === 'required') return refuse(server, 'JC2007', trace, { op: op.id }, undefined, null, null);
512
+ }
513
+ else return idempotent(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, key);
514
+ }
515
+
516
+ Object.freeze(ctx);
517
+ return boundary(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, false);
518
+ }
519
+
520
+ /**
521
+ * The well-known negotiation document: `describe()` (with `revision` and
522
+ * `compat`) under GET/HEAD, memoized as text. The revision is computed
523
+ * lazily on the FIRST well-known request — construction stays synchronous
524
+ * and a server nobody negotiates with never pays the digest. A projection
525
+ * that cannot be canonicalized (`JC0061`) is reported to `onError` once
526
+ * and the document honestly answers `revision: null`.
527
+ * @param {Server} server
528
+ * @param {string} method
529
+ * @param {string} trace
530
+ * @returns {HttpResponse | Promise<HttpResponse>}
531
+ */
532
+ function wellKnown(server, method, trace) {
533
+ if (method !== 'GET' && method !== 'HEAD') {
534
+ return refuse(server, 'JC2002', trace, { allow: 'GET, HEAD' }, undefined, { allow: 'GET, HEAD' }, null);
535
+ }
536
+ const respond = () => {
537
+ if (server.described.text === null) server.described.text = JSON.stringify(server.contract.describe());
538
+ return {
539
+ status: 200,
540
+ headers: { 'content-type': JSON_CONTENT_TYPE, 'x-jaren-trace': trace },
541
+ body: method === 'HEAD' ? null : server.described.text,
542
+ };
543
+ };
544
+ if (server.described.text !== null) return respond();
545
+ return server.contract.revision().then(respond, (err) => {
546
+ observe(server, err, null);
547
+ return respond();
548
+ });
549
+ }
550
+
551
+ //#endregion
552
+
553
+ //#region the stream branch
554
+
555
+ /**
556
+ * A subscribe operation, settled: the handler answers the duck-typed
557
+ * subscription (§17.1); a declared failure or a host fault is an
558
+ * ordinary §7.3 response BEFORE any stream starts. With `accept:
559
+ * text/event-stream` the response streams SSE; without it (or under
560
+ * HEAD) the snapshot answers as plain JSON — the one-shot read.
561
+ * @param {Server} server
562
+ * @param {Route} route
563
+ * @param {RequestContext} ctx - frozen
564
+ * @param {any} input
565
+ * @param {string} trace
566
+ * @param {Readonly<Record<string, string | readonly string[]>>} headers
567
+ * @param {Armed} armed
568
+ * @param {boolean} isHead
569
+ * @param {string | undefined} ifMatch
570
+ * @param {string | undefined} ifNoneMatch
571
+ * @returns {Promise<HttpResponse>}
572
+ */
573
+ function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHead, ifMatch, ifNoneMatch) {
574
+ const accept = headerValue(headers, 'accept');
575
+ const wantsStream = !isHead && accept !== undefined && accept.toLowerCase().indexOf(STREAM_MEDIA) !== -1;
576
+ return settleOperation(route, input, ctx, false).then((result) => {
577
+ if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
578
+ if (result.kind === 'contract') {
579
+ if (result.cause !== undefined) observe(server, result.cause, ctx);
580
+ armed.outcome = 2;
581
+ return refuse(server, result.code, trace, { op: route.op.id }, result.details, null, ctx);
582
+ }
583
+ const sub = result.value;
584
+ if (!isSubscriptionLike(sub)) {
585
+ observe(server, new TypeError(`the handler of subscribe operation '${route.op.id}' did not answer a subscription ({ result | snapshot(), subscribe, close })`), ctx);
586
+ armed.outcome = 2;
587
+ return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
588
+ }
589
+ if (!wantsStream) return oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch, ifNoneMatch);
590
+ return sseResponse(server, route, ctx, sub, trace, headers);
591
+ });
592
+ }
593
+
594
+ /**
595
+ * The one-shot read of a subscribe operation: the snapshot, validated,
596
+ * released, answered through the ordinary §7 serializer.
597
+ * @param {Server} server
598
+ * @param {Route} route
599
+ * @param {RequestContext} ctx
600
+ * @param {import('../stream/server.js').SubscriptionLike} sub
601
+ * @param {string} trace
602
+ * @param {Armed} armed
603
+ * @param {boolean} isHead
604
+ * @param {string | undefined} ifMatch
605
+ * @param {string | undefined} ifNoneMatch
606
+ * @returns {HttpResponse}
607
+ */
608
+ function oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch, ifNoneMatch) {
609
+ let value;
610
+ let thrown;
611
+ try {
612
+ value = typeof sub.snapshot === 'function' ? sub.snapshot() : sub.result;
613
+ }
614
+ catch (err) {
615
+ thrown = err === undefined ? new Error('the snapshot accessor threw') : err;
616
+ }
617
+ try {
618
+ sub.close();
619
+ }
620
+ catch (err) {
621
+ observe(server, err, ctx);
622
+ }
623
+ if (thrown !== undefined) {
624
+ observe(server, thrown, ctx);
625
+ armed.outcome = 2;
626
+ return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
627
+ }
628
+ if (server.validateOutput) {
629
+ const v = verdict(route.validateOutput, value);
630
+ if (!v.valid) {
631
+ observe(server, v.thrown !== undefined ? v.thrown : v.errors, ctx);
632
+ armed.outcome = 2;
633
+ return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
634
+ }
635
+ }
636
+ // the one-shot answer is plain JSON — the operation's media names the
637
+ // stream envelope, which this response is not
638
+ return finishValue(server, { ...route, media: JSON_MEDIA }, ctx, value, trace, armed, isHead, ifMatch, ifNoneMatch);
639
+ }
640
+
641
+ /**
642
+ * The SSE response of a live subscription: `200 text/event-stream` with
643
+ * the pump behind `stream`. The pump runs the carrier-neutral
644
+ * subscription runner — snapshot/patch/error/end land as SSE events, a
645
+ * heartbeat comment line goes out every `policy.stream.heartbeatMs`,
646
+ * the peer's abort (`ctx.signal`) stops silently, and the dispatcher's
647
+ * `close()` ends with `server-shutdown`.
648
+ * @param {Server} server
649
+ * @param {Route} route
650
+ * @param {RequestContext} ctx
651
+ * @param {import('../stream/server.js').SubscriptionLike} sub
652
+ * @param {string} trace
653
+ * @param {Readonly<Record<string, string | readonly string[]>>} headers
654
+ * @returns {HttpResponse}
655
+ */
656
+ function sseResponse(server, route, ctx, sub, trace, headers) {
657
+ const lastRaw = headerValue(headers, 'last-event-id');
658
+ let lastSeq = null;
659
+ if (lastRaw !== undefined) {
660
+ const n = Number.parseInt(lastRaw, 10);
661
+ if (Number.isFinite(n) && n >= 0) lastSeq = n;
662
+ }
663
+ const streamPolicy = route.op.policy.stream;
664
+ const heartbeatMs = streamPolicy === null ? 15000 : streamPolicy.heartbeatMs;
665
+
666
+ /** @type {NonNullable<HttpResponse['stream']>} */
667
+ const stream = (sink) => {
668
+ const timer = setInterval(() => {
669
+ try {
670
+ sink.write(HEARTBEAT_LINE);
671
+ }
672
+ catch {
673
+ // a dead sink is ended by the abort path
674
+ }
675
+ }, heartbeatMs);
676
+ if (timer !== null && typeof (/** @type {any} */ (timer)).unref === 'function') /** @type {any} */ (timer).unref();
677
+
678
+ /** @param {string} event @param {number | null} seq @param {unknown} data */
679
+ const write = (event, seq, data) => {
680
+ try {
681
+ sink.write(encodeStreamEvent(event, seq, data));
682
+ }
683
+ catch (err) {
684
+ observe(server, err, ctx);
685
+ }
686
+ };
687
+ // registered BEFORE the runner starts, so a stream that fails during
688
+ // construction removes itself and never lingers in the set
689
+ /** @type {{ stop: (reason: string | null) => void } | null} */
690
+ let runner = null;
691
+ /** @type {(reason: string | null) => void} */
692
+ const stopper = (reason) => {
693
+ if (runner !== null) runner.stop(reason);
694
+ };
695
+ server.streams.add(stopper);
696
+ runner = runSubscription(route, sub, {
697
+ snapshot: (seq, value, resumed) => write('snapshot', seq, { value, resumed }),
698
+ patch: (seq, emission) => write('patch', seq, emission),
699
+ error: (intent, cause) => {
700
+ observe(server, cause, ctx);
701
+ const code = intent === 'invalid-snapshot' ? 'JC2091' : 'JC2008';
702
+ const msgid = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091.msgid : HTTP_ERRORS.JC2008.msgid;
703
+ write('error', null, { code, message: renderMessage(server.catalog, msgid, { op: route.op.id }), requestId: trace, retryable: false });
704
+ },
705
+ end: (reason) => write('end', null, { reason }),
706
+ done: () => {
707
+ clearInterval(timer);
708
+ server.streams.delete(stopper);
709
+ try {
710
+ sink.end();
711
+ }
712
+ catch {
713
+ // the sink may already be gone
714
+ }
715
+ },
716
+ }, { lastSeq, validate: server.validateOutput });
717
+ if (ctx.signal !== null) {
718
+ if (ctx.signal.aborted) stopper(null);
719
+ else ctx.signal.addEventListener('abort', () => stopper(null), { once: true });
720
+ }
721
+ return () => stopper(null);
722
+ };
723
+
724
+ return {
725
+ status: 200,
726
+ headers: { 'content-type': STREAM_MEDIA, 'cache-control': 'no-store', 'x-jaren-trace': trace },
727
+ body: null,
728
+ stream,
729
+ };
730
+ }
731
+
732
+ //#endregion
733
+
734
+ //#region the handler boundary
735
+
736
+ /**
737
+ * Call the handler through the pipeline's uniform promise boundary and
738
+ * project the classified result onto the HTTP wire.
739
+ * @param {Server} server
740
+ * @param {Route} route
741
+ * @param {RequestContext} ctx - frozen
742
+ * @param {any} input
743
+ * @param {string} trace
744
+ * @param {Armed} armed
745
+ * @param {boolean} isHead
746
+ * @param {string | undefined} ifMatch
747
+ * @param {string | undefined} ifNoneMatch
748
+ * @param {boolean} raw
749
+ * @returns {Promise<HttpResponse>}
750
+ */
751
+ function boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, raw) {
752
+ return settleOperation(route, input, ctx, server.validateOutput).then(
753
+ (result) => project(server, route, ctx, result, trace, armed, isHead, ifMatch, ifNoneMatch, raw));
754
+ }
755
+
756
+ /**
757
+ * One classified settlement onto the wire: a declared failure renders
758
+ * its message and answers the declared status; a contract result
759
+ * (`JC2008`/`JC2010`) is a server fault — its cause goes to `onError`,
760
+ * never onto the wire; a value is serialized under the entity-tag and
761
+ * HEAD/204 rules (or passed through verbatim for a raw handler).
762
+ * @param {Server} server
763
+ * @param {Route} route
764
+ * @param {RequestContext} ctx
765
+ * @param {OperationResult} result
766
+ * @param {string} trace
767
+ * @param {Armed} armed
768
+ * @param {boolean} isHead
769
+ * @param {string | undefined} ifMatch
770
+ * @param {string | undefined} ifNoneMatch
771
+ * @param {boolean} raw
772
+ * @returns {HttpResponse}
773
+ */
774
+ function project(server, route, ctx, result, trace, armed, isHead, ifMatch, ifNoneMatch, raw) {
775
+ if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
776
+ if (result.kind === 'contract') {
777
+ if (result.cause !== undefined) observe(server, result.cause, ctx);
778
+ armed.outcome = 2;
779
+ return refuse(server, result.code, trace, { op: route.op.id }, result.details, null, ctx);
780
+ }
781
+ return raw
782
+ ? finishRaw(server, route, ctx, result.value, trace, armed, isHead)
783
+ : finishValue(server, route, ctx, result.value, trace, armed, isHead, ifMatch, ifNoneMatch);
784
+ }
785
+
786
+ /**
787
+ * The validated value of a JSON handler: serialized (`JC2010` when JSON
788
+ * cannot carry it), then the entity-tag conditionals and the HEAD/204
789
+ * body rules.
790
+ * @param {Server} server
791
+ * @param {Route} route
792
+ * @param {RequestContext} ctx
793
+ * @param {unknown} value
794
+ * @param {string} trace
795
+ * @param {Armed} armed
796
+ * @param {boolean} isHead
797
+ * @param {string | undefined} ifMatch
798
+ * @param {string | undefined} ifNoneMatch
799
+ * @returns {HttpResponse}
800
+ */
801
+ function finishValue(server, route, ctx, value, trace, armed, isHead, ifMatch, ifNoneMatch) {
802
+ let text;
803
+ try {
804
+ text = JSON.stringify(value);
805
+ }
806
+ catch (err) {
807
+ observe(server, err, ctx);
808
+ armed.outcome = 2;
809
+ return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
810
+ }
811
+ const status = armed.status !== 0 ? armed.status : route.status;
812
+ /** @type {Record<string, string>} */
813
+ const headers = { 'x-jaren-trace': trace };
814
+ if (armed.etag !== null) {
815
+ // If-Match first (RFC 9110 §13.2.2), strong comparison; then
816
+ // If-None-Match, weak comparison: 304 for GET/HEAD, 412 otherwise
817
+ if (ifMatch !== undefined && !entityTagMatches(ifMatch, armed.etag, armed.strong, true)) {
818
+ armed.outcome = 2;
819
+ return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, null, ctx);
820
+ }
821
+ const etag = formatEntityTag(armed.etag, armed.strong);
822
+ if (ifNoneMatch !== undefined && entityTagMatches(ifNoneMatch, armed.etag, armed.strong, false)) {
823
+ if (ctx.method === 'GET' || ctx.method === 'HEAD') {
824
+ headers.etag = etag;
825
+ return { status: 304, headers, body: null };
826
+ }
827
+ armed.outcome = 2;
828
+ return refuse(server, 'JC2014', trace, { op: route.op.id }, undefined, { etag }, ctx);
829
+ }
830
+ headers.etag = etag;
831
+ }
832
+ if (text === undefined || status === 204) return { status, headers, body: null };
833
+ headers['content-type'] = route.media.indexOf(';') === -1 ? `${route.media}; charset=utf-8` : route.media;
834
+ if (isHead) {
835
+ headers['content-length'] = String(new TextEncoder().encode(text).byteLength);
836
+ return { status, headers, body: null };
837
+ }
838
+ return { status, headers, body: text };
839
+ }
840
+
841
+ /**
842
+ * The value of a raw (opaque) handler: passed through verbatim plus the
843
+ * trace header; anything that is not `{ status, headers?, body? }` is
844
+ * `JC2010`.
845
+ * @param {Server} server
846
+ * @param {Route} route
847
+ * @param {RequestContext} ctx
848
+ * @param {unknown} value
849
+ * @param {string} trace
850
+ * @param {Armed} armed
851
+ * @param {boolean} isHead
852
+ * @returns {HttpResponse}
853
+ */
854
+ function finishRaw(server, route, ctx, value, trace, armed, isHead) {
855
+ let status;
856
+ let body;
857
+ /** @type {Record<string, string>} */
858
+ const headers = {};
859
+ try {
860
+ const r = /** @type {any} */ (value);
861
+ if (r === null || typeof r !== 'object') throw new TypeError('a raw handler must return { status, headers?, body? }');
862
+ status = r.status;
863
+ const rawHeaders = r.headers;
864
+ body = r.body;
865
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw new TypeError('raw status');
866
+ if (rawHeaders !== undefined && (rawHeaders === null || typeof rawHeaders !== 'object')) throw new TypeError('raw headers');
867
+ if (!(body === undefined || body === null || typeof body === 'string' || body instanceof Uint8Array)) throw new TypeError('raw body');
868
+ if (rawHeaders !== undefined) {
869
+ const names = Object.keys(rawHeaders);
870
+ for (let i = 0; i < names.length; i++) {
871
+ const v = rawHeaders[names[i]];
872
+ if (typeof v === 'string') headers[names[i].toLowerCase()] = v;
873
+ }
874
+ }
875
+ }
876
+ catch (err) {
877
+ observe(server, err, ctx);
878
+ armed.outcome = 2;
879
+ return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
880
+ }
881
+ headers['x-jaren-trace'] = trace;
882
+ return { status, headers, body: isHead || body === undefined ? null : body };
883
+ }
884
+
885
+ /**
886
+ * A declared operation error, already validated by the pipeline: the
887
+ * declared status and code on the wire, the message from
888
+ * `contract/error/<code>` in the host catalog when it has one, else the
889
+ * generic `contract/handler-error`.
890
+ * @param {Server} server
891
+ * @param {Route} route
892
+ * @param {RequestContext} ctx
893
+ * @param {Extract<OperationResult, { kind: 'failure' }>} result
894
+ * @param {string} trace
895
+ * @param {Armed} armed
896
+ * @returns {HttpResponse}
897
+ */
898
+ function declaredFailure(server, route, ctx, result, trace, armed) {
899
+ const message = declaredMessage(server.catalog, route.op.id, result.code, result.params);
900
+ armed.outcome = 1;
901
+ armed.retryable = result.retryable;
902
+ return errorResponse(result.status, result.code, message, trace, result.details, result.retryable, null, server.errorBody, ctx);
903
+ }
904
+
905
+ //#endregion
906
+
907
+ //#region idempotency
908
+
909
+ /**
910
+ * The idempotency step: hash the validated input, claim the key, then
911
+ * replay / refuse / run — and settle the claim with the response.
912
+ * @param {Server} server
913
+ * @param {Route} route
914
+ * @param {RequestContext} ctx - not yet frozen: `idempotency` is set here
915
+ * @param {any} input
916
+ * @param {string} trace
917
+ * @param {Armed} armed
918
+ * @param {boolean} isHead
919
+ * @param {string | undefined} ifMatch
920
+ * @param {string | undefined} ifNoneMatch
921
+ * @param {string} key
922
+ * @returns {Promise<HttpResponse>}
923
+ */
924
+ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, key) {
925
+ const ledger = /** @type {Ledger} */ (server.ledger);
926
+ let scope;
927
+ try {
928
+ scope = server.scope(ctx);
929
+ if (typeof scope !== 'string') throw new TypeError(`serveHttp: the scope function must return a string, got ${typeof scope}`);
930
+ }
931
+ catch (err) {
932
+ observe(server, err, ctx);
933
+ Object.freeze(ctx);
934
+ return Promise.resolve(refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx));
935
+ }
936
+ /** @type {any} */ (ctx).idempotency = Object.freeze({ key, scope });
937
+ Object.freeze(ctx);
938
+ const op = route.op.id;
939
+ /** @param {unknown} err */
940
+ const fault = (err) => {
941
+ observe(server, err, ctx);
942
+ return refuse(server, 'JC2008', trace, { op }, undefined, null, ctx);
943
+ };
944
+ /** @param {unknown} claimed */
945
+ const onClaim = (claimed) => {
946
+ let state;
947
+ let ref;
948
+ let stored;
949
+ try {
950
+ const c = /** @type {any} */ (claimed);
951
+ state = c === null || typeof c !== 'object' ? undefined : c.state;
952
+ ref = state === 'new' ? c.ref : undefined;
953
+ stored = state === 'replay' ? c.response : undefined;
954
+ }
955
+ catch (err) {
956
+ return fault(err);
957
+ }
958
+ if (state === 'new') {
959
+ return boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false)
960
+ .then((response) => settleClaim(server, ledger, ref, response, ctx, armed));
961
+ }
962
+ if (state === 'replay') return replay(server, route, stored, trace, ctx);
963
+ if (state === 'in-progress') {
964
+ return refuse(server, 'JC2009', trace, { op, kind: 'in-progress' }, undefined, { 'retry-after': '1' }, ctx, true);
965
+ }
966
+ if (state === 'mismatch') {
967
+ return refuse(server, 'JC2009', trace, { op, kind: 'mismatch' }, [{ kind: 'mismatch' }], null, ctx, false);
968
+ }
969
+ return fault(new TypeError(`serveHttp: the ledger answered an unknown claim state ${JSON.stringify(state)}`));
970
+ };
971
+ /** @param {string} hash */
972
+ const afterHash = (hash) => {
973
+ let claimed;
974
+ try {
975
+ claimed = ledger.claim({ op, scope, key, hash, now: server.now() });
976
+ }
977
+ catch (err) {
978
+ return fault(err);
979
+ }
980
+ return isThenable(claimed) ? /** @type {Promise<any>} */ (claimed).then(onClaim, fault) : onClaim(claimed);
981
+ };
982
+ return canonicalSha256(input).then(afterHash, (err) => {
983
+ if (err instanceof JsonCanonicalizeError) {
984
+ return refuse(server, 'JC2006', trace, { op },
985
+ projectValidationDetails(route.details, [{ instancePath: err.dataPath, keyword: 'canonical' }]), null, ctx);
986
+ }
987
+ return fault(err);
988
+ });
989
+ }
990
+
991
+ /**
992
+ * Settle a `new` claim with the response the handler produced: a
993
+ * success commits (replayed verbatim later); a declared failure is
994
+ * recorded as failed with its response and retryability; a server fault
995
+ * (`JC2008`/`JC2010`/`JC2014`) releases the key as retryable. TOTAL: a
996
+ * ledger that throws or rejects is reported, and the response still
997
+ * goes out.
998
+ * @param {Server} server
999
+ * @param {Ledger} ledger
1000
+ * @param {unknown} ref
1001
+ * @param {HttpResponse} response
1002
+ * @param {RequestContext} ctx
1003
+ * @param {Armed} armed
1004
+ * @returns {HttpResponse | Promise<HttpResponse>}
1005
+ */
1006
+ function settleClaim(server, ledger, ref, response, ctx, armed) {
1007
+ let settlement;
1008
+ try {
1009
+ if (armed.outcome === 0) settlement = ledger.commit(ref, response);
1010
+ else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response);
1011
+ else settlement = ledger.fail(ref, true, undefined);
1012
+ }
1013
+ catch (err) {
1014
+ observe(server, err, ctx);
1015
+ return response;
1016
+ }
1017
+ if (isThenable(settlement)) {
1018
+ return /** @type {Promise<void>} */ (settlement).then(() => response, (/** @type {unknown} */ err) => {
1019
+ observe(server, err, ctx);
1020
+ return response;
1021
+ });
1022
+ }
1023
+ return response;
1024
+ }
1025
+
1026
+ /**
1027
+ * A replayed response: the stored status, headers and body verbatim,
1028
+ * with a fresh trace and `idempotent-replayed: true`.
1029
+ * @param {Server} server
1030
+ * @param {Route} route
1031
+ * @param {unknown} stored
1032
+ * @param {string} trace
1033
+ * @param {RequestContext} ctx
1034
+ * @returns {HttpResponse}
1035
+ */
1036
+ function replay(server, route, stored, trace, ctx) {
1037
+ try {
1038
+ const s = /** @type {any} */ (stored);
1039
+ if (s === null || typeof s !== 'object' || !Number.isInteger(s.status) || s.headers === null || typeof s.headers !== 'object') {
1040
+ throw new TypeError('serveHttp: the ledger replayed a value that is not a response');
1041
+ }
1042
+ return {
1043
+ status: s.status,
1044
+ headers: { ...s.headers, 'x-jaren-trace': trace, 'idempotent-replayed': 'true' },
1045
+ body: s.body === undefined ? null : s.body,
1046
+ };
1047
+ }
1048
+ catch (err) {
1049
+ observe(server, err, ctx);
1050
+ return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
1051
+ }
1052
+ }
1053
+
1054
+ //#endregion