@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,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
|