@jarenjs/contract 0.56.0 → 0.67.0
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 +139 -25
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +580 -128
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- package/src/stream/sse.js +2 -0
package/src/http/dispatch.js
CHANGED
|
@@ -23,10 +23,16 @@
|
|
|
23
23
|
|
|
24
24
|
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
25
25
|
import { toPromise, isThenable } from '@jarenjs/core/function';
|
|
26
|
+
import { createAwaitedSink } from '@jarenjs/core/async';
|
|
27
|
+
import { utf8ByteLength } from '@jarenjs/core/string';
|
|
26
28
|
import { canonicalSha256, JsonCanonicalizeError } from '@jarenjs/json/canonical';
|
|
27
29
|
|
|
28
30
|
import { ContractHostError, ContractFailure } from '../errors.js';
|
|
29
|
-
import { validateOperationInput, settleOperation, safeTrace } from '../pipeline.js';
|
|
31
|
+
import { validateOperationInput, settleOperation, safeTrace, classifyDeclared } from '../pipeline.js';
|
|
32
|
+
import { identify as identifyHost, acquire as acquireHost, once, RollbackCarrier } from '../host.js';
|
|
33
|
+
import {
|
|
34
|
+
BodyLimitError, isAsyncByteSource, normalizeBody, collectBytes, countingSource, onSettled,
|
|
35
|
+
} from './body.js';
|
|
30
36
|
import {
|
|
31
37
|
isSubscriptionLike, runSubscription, STREAM_ERRORS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent,
|
|
32
38
|
} from '../stream/server.js';
|
|
@@ -51,19 +57,25 @@ import {
|
|
|
51
57
|
* The per-request context a handler receives — frozen. `params` are the
|
|
52
58
|
* raw decoded path strings; `headers` carries the declared header members
|
|
53
59
|
* (by header name) plus `if-match`/`if-none-match` when present; `body`
|
|
54
|
-
* is the raw request body for an OPAQUE operation
|
|
55
|
-
*
|
|
60
|
+
* is the raw request body for an OPAQUE operation — text, bytes, or a
|
|
61
|
+
* pull source of chunks that never yields a byte past
|
|
62
|
+
* `policy.limits.maxBodyBytes` (the chunk that would cross it throws a
|
|
63
|
+
* `BodyLimitError` to the puller instead; let it propagate and the
|
|
64
|
+
* response is `JC2003`) — and `null` for a JSON one (whose body was
|
|
65
|
+
* decoded into the input). `fail` makes a declared
|
|
56
66
|
* failure by code; `etag` arms the entity-tag path; `status` overrides
|
|
57
67
|
* the success status (2xx only — `JC1006` otherwise, a host error the
|
|
58
68
|
* handler boundary settles into `JC2008` and reports through `onError`).
|
|
59
69
|
* @typedef {Object} RequestContext
|
|
60
70
|
* @property {CompiledOperation} op
|
|
61
71
|
* @property {string} trace
|
|
72
|
+
* @property {'http'} carrier - the binding this context comes from
|
|
73
|
+
* @property {any} host - the identity's host until `acquire` entered; the acquired host in the handler's context (§7.7) — `any`, so a table typed by the projection's `HandlerContext<Host>` is a `Handler`
|
|
62
74
|
* @property {string} method
|
|
63
75
|
* @property {string} path
|
|
64
76
|
* @property {Readonly<Record<string, string>>} params
|
|
65
77
|
* @property {Readonly<Record<string, string>>} headers
|
|
66
|
-
* @property {string | Uint8Array | null} body
|
|
78
|
+
* @property {string | Uint8Array | AsyncIterable<Uint8Array> | null} body
|
|
67
79
|
* @property {AbortSignal | null} signal
|
|
68
80
|
* @property {Readonly<{ key: string, scope: string }> | null} idempotency
|
|
69
81
|
* @property {(code: string, params?: Record<string, unknown>, details?: unknown, options?: { retryable?: boolean }) => ContractFailureValue} fail
|
|
@@ -84,8 +96,10 @@ import {
|
|
|
84
96
|
*/
|
|
85
97
|
|
|
86
98
|
/**
|
|
87
|
-
* The raw response of an opaque operation's handler
|
|
88
|
-
*
|
|
99
|
+
* The raw response of an opaque operation's handler: `body` may be text,
|
|
100
|
+
* bytes, a pull source of chunks (an async iterable, or a Web
|
|
101
|
+
* `ReadableStream` — normalized, never collected), or none.
|
|
102
|
+
* @typedef {{ status: number, headers?: Record<string, string>, body?: string | Uint8Array | AsyncIterable<Uint8Array> | ReadableStream<Uint8Array> | null }} RawResponse
|
|
89
103
|
*/
|
|
90
104
|
|
|
91
105
|
/**
|
|
@@ -151,6 +165,17 @@ import {
|
|
|
151
165
|
* @property {{ text: string | null }} described - the memoized well-known body
|
|
152
166
|
* @property {Set<(reason: string | null) => void>} streams - the live SSE
|
|
153
167
|
* streams' stoppers; the dispatcher's `close()` ends them all
|
|
168
|
+
* @property {import('../stream/server.js').StreamLimits} streamLimits - the bounds of every SSE stream
|
|
169
|
+
* @property {import('../host.js').Lifecycle} lifecycle - the host lifecycle hooks (§7.7)
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The lifetime of one request's leases: the identity's release, the
|
|
174
|
+
* acquired lease's release once entered, and whether the releases were
|
|
175
|
+
* handed to a body or a stream (`deferred`) rather than run before the
|
|
176
|
+
* response is exposed. Each release runs at most once, acquired before
|
|
177
|
+
* identity.
|
|
178
|
+
* @typedef {{ op: string, trace: string, identity: () => Promise<boolean>, acquired: (() => Promise<boolean>) | null, deferred: boolean }} Life
|
|
154
179
|
*/
|
|
155
180
|
|
|
156
181
|
/** The strict decoder of a JSON body given as bytes. */
|
|
@@ -160,7 +185,7 @@ const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
|
160
185
|
const NO_HEADERS = Object.freeze({});
|
|
161
186
|
|
|
162
187
|
/** The valid shape of a request object — `JC1004` otherwise. */
|
|
163
|
-
const REQUEST_SHAPE = 'a request is { method: string, url: string, headers: object, body: string | Uint8Array | null }';
|
|
188
|
+
const REQUEST_SHAPE = 'a request is { method: string, url: string, headers: object, body: string | Uint8Array | AsyncIterable<Uint8Array> | ReadableStream | null }';
|
|
164
189
|
|
|
165
190
|
//#region the boundary
|
|
166
191
|
|
|
@@ -215,8 +240,8 @@ function requestShapeError(request) {
|
|
|
215
240
|
if (r.headers === null || typeof r.headers !== 'object') {
|
|
216
241
|
return new ContractHostError('JC1004', `dispatch: request.headers must be an object of lowercase names; ${REQUEST_SHAPE}`);
|
|
217
242
|
}
|
|
218
|
-
if (
|
|
219
|
-
return new ContractHostError('JC1004', `dispatch: request.body must be a string, a Uint8Array or null; ${REQUEST_SHAPE}`);
|
|
243
|
+
if (normalizeBody(r.body) === undefined) {
|
|
244
|
+
return new ContractHostError('JC1004', `dispatch: request.body must be a string, a Uint8Array, an async iterable of Uint8Array chunks, a ReadableStream or null; ${REQUEST_SHAPE}`);
|
|
220
245
|
}
|
|
221
246
|
return null;
|
|
222
247
|
}
|
|
@@ -286,12 +311,33 @@ function decodable(path) {
|
|
|
286
311
|
}
|
|
287
312
|
|
|
288
313
|
/**
|
|
289
|
-
* Whether the request carried a non-empty body.
|
|
290
|
-
*
|
|
314
|
+
* Whether the request carried a non-empty body. A pull source counts as
|
|
315
|
+
* content: whether it yields anything is known only by pulling it.
|
|
316
|
+
* @param {string | Uint8Array | AsyncIterable<Uint8Array> | null} body
|
|
291
317
|
* @returns {boolean}
|
|
292
318
|
*/
|
|
293
319
|
function hasContent(body) {
|
|
294
|
-
|
|
320
|
+
if (body === null) return false;
|
|
321
|
+
if (typeof body === 'string') return body.length > 0;
|
|
322
|
+
if (body instanceof Uint8Array) return body.byteLength > 0;
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Cancel a pull source exactly once, for a body nobody will read (a
|
|
328
|
+
* HEAD's returned stream, a request source a response left unread).
|
|
329
|
+
* @param {AsyncIterable<Uint8Array>} source
|
|
330
|
+
* @returns {Promise<void>}
|
|
331
|
+
*/
|
|
332
|
+
async function discard(source) {
|
|
333
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
334
|
+
if (typeof iterator.return !== 'function') return;
|
|
335
|
+
try {
|
|
336
|
+
await iterator.return();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// a source that refuses its cancel is already gone
|
|
340
|
+
}
|
|
295
341
|
}
|
|
296
342
|
|
|
297
343
|
/**
|
|
@@ -321,7 +367,9 @@ function signalOf(request) {
|
|
|
321
367
|
* claim. `decided` is set by a `preconditions` resolver that already
|
|
322
368
|
* evaluated the conditionals; the post-handler comparison then stands
|
|
323
369
|
* down.
|
|
324
|
-
*
|
|
370
|
+
* `settled` is set by a required settlement (§7.7) that recorded the
|
|
371
|
+
* claim inside `enter`; the root ledger then stands down.
|
|
372
|
+
* @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean, decided: boolean, settled: boolean }} Armed
|
|
325
373
|
*/
|
|
326
374
|
|
|
327
375
|
/**
|
|
@@ -339,7 +387,7 @@ function run(server, request) {
|
|
|
339
387
|
const path = q === -1 ? url : url.slice(0, q);
|
|
340
388
|
const query = q === -1 ? '' : url.slice(q + 1);
|
|
341
389
|
const headers = request.headers;
|
|
342
|
-
const body =
|
|
390
|
+
const body = /** @type {import('./body.js').Body} */ (normalizeBody(request.body));
|
|
343
391
|
|
|
344
392
|
// ——— 2. route ———
|
|
345
393
|
let hit = server.contract.match(method, path);
|
|
@@ -366,11 +414,178 @@ function run(server, request) {
|
|
|
366
414
|
const op = route.op;
|
|
367
415
|
if (route.handler === null) return refuse(server, 'JC2013', trace, { op: op.id }, undefined, null, null);
|
|
368
416
|
|
|
417
|
+
// ——— 3. identify: the host's first look at the request, before any
|
|
418
|
+
// byte of the body is read — the operation, the trace, the signal and
|
|
419
|
+
// the transport facts; never a parsed input (§7.7) ———
|
|
420
|
+
const meta = Object.freeze({ op, trace, signal: signalOf(request), carrier: /** @type {const} */ ('http'), method, path, headers, fail: ContractFailure });
|
|
421
|
+
const identified = identifyHost(server.lifecycle, meta);
|
|
422
|
+
/** @param {ReturnType<typeof identifyHost> extends Promise<infer A> ? A : never} answer */
|
|
423
|
+
const identifiedAs = (answer) => {
|
|
424
|
+
if (answer.kind === 'fault') {
|
|
425
|
+
observe(server, answer.cause, null);
|
|
426
|
+
return refuse(server, 'JC2008', trace, { op: op.id }, undefined, null, null);
|
|
427
|
+
}
|
|
428
|
+
if (answer.kind === 'failure') return hookFailure(server, route, null, answer.failure, trace, freshArmed());
|
|
429
|
+
/** @type {Life} */
|
|
430
|
+
const life = { op: op.id, trace, identity: once(answer.lease.release, (err) => observe(server, err, null)), acquired: null, deferred: false };
|
|
431
|
+
return exposeWith(server, life, () => afterIdentity(server, request, route, trace, hit, isHead, method, path, query, headers, body, life, answer.lease.host));
|
|
432
|
+
};
|
|
433
|
+
return isThenable(identified) ? /** @type {Promise<any>} */ (identified).then(identifiedAs) : identifiedAs(/** @type {any} */ (identified));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A fresh per-request state record. @returns {Armed} */
|
|
437
|
+
function freshArmed() {
|
|
438
|
+
return { etag: null, strong: false, status: 0, outcome: 0, retryable: false, decided: false, settled: false };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run the pipeline after identity and expose its response: the leases
|
|
443
|
+
* are released (acquired, then identity) before the response is
|
|
444
|
+
* exposed — unless the response handed them to its body or stream — and
|
|
445
|
+
* a release that fails before exposure is the host's fault (`JC2008`,
|
|
446
|
+
* the cause observed); one that fails after exposure is observed only.
|
|
447
|
+
* A defect of the pipeline itself still releases before it reaches the
|
|
448
|
+
* last resort.
|
|
449
|
+
* @param {Server} server
|
|
450
|
+
* @param {Life} life
|
|
451
|
+
* @param {() => HttpResponse | Promise<HttpResponse>} produce
|
|
452
|
+
* @returns {Promise<HttpResponse>}
|
|
453
|
+
*/
|
|
454
|
+
function exposeWith(server, life, produce) {
|
|
455
|
+
let out;
|
|
456
|
+
try {
|
|
457
|
+
out = produce();
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
out = Promise.reject(err);
|
|
461
|
+
}
|
|
462
|
+
return toPromise(out).then(
|
|
463
|
+
(response) => (life.deferred ? response : releaseLife(life).then((clean) =>
|
|
464
|
+
(clean ? response : refuse(server, 'JC2008', life.trace, { op: life.op }, undefined, null, null)))),
|
|
465
|
+
(err) => releaseLife(life).then(() => { throw err; }));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Release a request's leases in order — acquired, then identity — each
|
|
470
|
+
* at most once; answers whether every release was clean.
|
|
471
|
+
* @param {Life} life
|
|
472
|
+
* @returns {Promise<boolean>}
|
|
473
|
+
*/
|
|
474
|
+
function releaseLife(life) {
|
|
475
|
+
const acquired = life.acquired === null ? Promise.resolve(true) : life.acquired();
|
|
476
|
+
return acquired.then((a) => life.identity().then((b) => a && b));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Hand the releases to a pull-source body: they run once when the body
|
|
481
|
+
* reaches EOF, throws, or is cancelled by the consumer (a HEAD's
|
|
482
|
+
* discard included) — after the headers went out, so a failing release
|
|
483
|
+
* is observed, never answered.
|
|
484
|
+
* @param {Server} server
|
|
485
|
+
* @param {Life} life
|
|
486
|
+
* @param {HttpResponse} response
|
|
487
|
+
* @returns {HttpResponse}
|
|
488
|
+
*/
|
|
489
|
+
function deferToBody(server, life, response) {
|
|
490
|
+
if (life.deferred) return response;
|
|
491
|
+
const body = response.body;
|
|
492
|
+
if (!isAsyncByteSource(body)) return response;
|
|
493
|
+
life.deferred = true;
|
|
494
|
+
return { ...response, body: onSettled(body, () => releaseLife(life).then(() => undefined)) };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A declared failure a host hook answered: classified against the
|
|
499
|
+
* operation like a handler's (an undeclared code or bad details is the
|
|
500
|
+
* host's fault), rendered as the declared response.
|
|
501
|
+
* @param {Server} server
|
|
502
|
+
* @param {Route} route
|
|
503
|
+
* @param {RequestContext | null} ctx
|
|
504
|
+
* @param {import('../errors.js').ContractFailureValue} failure
|
|
505
|
+
* @param {string} trace
|
|
506
|
+
* @param {Armed} armed
|
|
507
|
+
* @returns {HttpResponse}
|
|
508
|
+
*/
|
|
509
|
+
function hookFailure(server, route, ctx, failure, trace, armed) {
|
|
510
|
+
const result = classifyDeclared(route, failure);
|
|
511
|
+
if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
|
|
512
|
+
if (result.cause !== undefined) observe(server, result.cause, ctx);
|
|
513
|
+
armed.outcome = 2;
|
|
514
|
+
return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The handler's context: the identity context with the acquired host,
|
|
519
|
+
* frozen. The host value itself is the host's and is not deep-frozen.
|
|
520
|
+
* @param {RequestContext} ctx
|
|
521
|
+
* @param {unknown} host
|
|
522
|
+
* @returns {RequestContext}
|
|
523
|
+
*/
|
|
524
|
+
function handlerContext(ctx, host) {
|
|
525
|
+
return Object.freeze({ ...ctx, host });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Run `acquire` around a continuation and settle its answer: a lease
|
|
530
|
+
* entered is the continuation's response (a rejection of the hook after
|
|
531
|
+
* the continuation settled is observed, the response stands); a declared
|
|
532
|
+
* failure is rendered; a fault is the host's (`JC2008`, observed). The
|
|
533
|
+
* acquired release is registered on the lifetime as the lease enters.
|
|
534
|
+
* @param {Server} server
|
|
535
|
+
* @param {Route} route
|
|
536
|
+
* @param {RequestContext} ctx - the identity context (frozen)
|
|
537
|
+
* @param {unknown} input
|
|
538
|
+
* @param {string} trace
|
|
539
|
+
* @param {Armed} armed
|
|
540
|
+
* @param {Life} life
|
|
541
|
+
* @param {(lease: import('../host.js').Lease, hctx: RequestContext) => HttpResponse | Promise<HttpResponse>} enter
|
|
542
|
+
* @returns {Promise<HttpResponse>}
|
|
543
|
+
*/
|
|
544
|
+
function acquireAround(server, route, ctx, input, trace, armed, life, enter) {
|
|
545
|
+
return acquireHost(server.lifecycle, input, ctx, (lease) => {
|
|
546
|
+
const hctx = handlerContext(ctx, lease.host);
|
|
547
|
+
life.acquired = once(lease.release, (err) => observe(server, err, hctx));
|
|
548
|
+
return enter(lease, hctx);
|
|
549
|
+
}).then((out) => {
|
|
550
|
+
if (out.kind === 'fault') {
|
|
551
|
+
observe(server, out.cause, ctx);
|
|
552
|
+
armed.outcome = 2;
|
|
553
|
+
return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
|
|
554
|
+
}
|
|
555
|
+
if (out.kind === 'failure') return hookFailure(server, route, ctx, out.failure, trace, armed);
|
|
556
|
+
if (out.afterFault !== undefined) observe(server, out.afterFault, ctx);
|
|
557
|
+
return /** @type {HttpResponse} */ (out.result);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The pipeline after identity: the body limit, the transport members,
|
|
563
|
+
* the context, then the opaque branch or the parse. Every early refusal
|
|
564
|
+
* returns a response; the lifetime's releases run at exposure.
|
|
565
|
+
* @param {Server} server
|
|
566
|
+
* @param {HttpRequest} request
|
|
567
|
+
* @param {Route} route
|
|
568
|
+
* @param {string} trace
|
|
569
|
+
* @param {{ params: Record<string, string> }} hit
|
|
570
|
+
* @param {boolean} isHead
|
|
571
|
+
* @param {string} method
|
|
572
|
+
* @param {string} path
|
|
573
|
+
* @param {string} query
|
|
574
|
+
* @param {Readonly<Record<string, string | readonly string[]>>} headers
|
|
575
|
+
* @param {import('./body.js').Body} body
|
|
576
|
+
* @param {Life} life
|
|
577
|
+
* @param {unknown} identityHost
|
|
578
|
+
* @returns {HttpResponse | Promise<HttpResponse>}
|
|
579
|
+
*/
|
|
580
|
+
function afterIdentity(server, request, route, trace, hit, isHead, method, path, query, headers, body, life, identityHost) {
|
|
581
|
+
const op = route.op;
|
|
582
|
+
|
|
369
583
|
// ——— 4. the body limit: by declaration before the read, by length after ———
|
|
370
584
|
const declared = contentLength(headers);
|
|
371
585
|
if (declared > route.maxBody) return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
|
|
372
586
|
const content = hasContent(body);
|
|
373
|
-
|
|
587
|
+
const sourced = isAsyncByteSource(body);
|
|
588
|
+
if (content && !sourced && exceedsBytes(/** @type {string | Uint8Array} */ (body), route.maxBody)) {
|
|
374
589
|
return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
|
|
375
590
|
}
|
|
376
591
|
|
|
@@ -434,12 +649,15 @@ function run(server, request) {
|
|
|
434
649
|
Object.freeze(ctxHeaders);
|
|
435
650
|
const transported = route.normalize === null ? input : route.normalize(input);
|
|
436
651
|
|
|
437
|
-
|
|
438
|
-
|
|
652
|
+
const armed = freshArmed();
|
|
653
|
+
// an opaque handler pulls its bytes through a counting source that
|
|
654
|
+
// never yields past the limit; text and bytes were already measured
|
|
655
|
+
/** @type {import('./body.js').CountingSource | null} */
|
|
656
|
+
const requestSource = route.raw && sourced ? countingSource(/** @type {AsyncIterable<Uint8Array>} */ (body), route.maxBody) : null;
|
|
439
657
|
/** @type {RequestContext} */
|
|
440
658
|
const ctx = {
|
|
441
|
-
op, trace, method, path, params, headers: ctxHeaders,
|
|
442
|
-
body: route.raw ? body : null,
|
|
659
|
+
op, trace, carrier: 'http', host: identityHost, method, path, params, headers: ctxHeaders,
|
|
660
|
+
body: route.raw ? (requestSource !== null ? requestSource : body) : null,
|
|
443
661
|
signal: signalOf(request),
|
|
444
662
|
idempotency: null,
|
|
445
663
|
fail: ContractFailure,
|
|
@@ -466,23 +684,74 @@ function run(server, request) {
|
|
|
466
684
|
const invalid = validateOperationInput(route, transported);
|
|
467
685
|
if (invalid !== null && invalid.kind === 'contract') {
|
|
468
686
|
if (invalid.cause !== undefined) observe(server, invalid.cause, null);
|
|
687
|
+
if (requestSource !== null) void requestSource.cancel();
|
|
469
688
|
return refuse(server, 'JC2006', trace, { op: op.id }, invalid.details, null, null);
|
|
470
689
|
}
|
|
471
690
|
Object.freeze(ctx);
|
|
472
|
-
|
|
691
|
+
const rawInput = op.input === null ? null : transported;
|
|
692
|
+
return acquireAround(server, route, ctx, rawInput, trace, armed, life, (lease, hctx) => {
|
|
693
|
+
const ran = boundary(server, route, hctx, rawInput, trace, armed, isHead, ifMatch, ifNoneMatch, true);
|
|
694
|
+
return (requestSource === null ? ran : ran.then((response) => settleUpload(server, hctx, response, requestSource)))
|
|
695
|
+
.then((response) => deferToBody(server, life, response));
|
|
696
|
+
});
|
|
473
697
|
}
|
|
474
698
|
|
|
475
699
|
// ——— 5. media, 6. parse ———
|
|
476
|
-
let parsed;
|
|
477
700
|
if (route.hasBody && content) {
|
|
478
701
|
if (!mediaMatches(headerValue(headers, 'content-type'), route.media)) {
|
|
702
|
+
if (sourced) void discard(/** @type {AsyncIterable<Uint8Array>} */ (body));
|
|
479
703
|
return refuse(server, 'JC2004', trace, { op: op.id, media: route.media }, undefined, null, null);
|
|
480
704
|
}
|
|
705
|
+
if (sourced) {
|
|
706
|
+
// a JSON body must be parsed and validated whole: drain the
|
|
707
|
+
// source under the limit — never past it — then parse
|
|
708
|
+
return collectBytes(/** @type {AsyncIterable<Uint8Array>} */ (body), route.maxBody, signalOf(request)).then((collected) => {
|
|
709
|
+
if (!collected.ok) {
|
|
710
|
+
if (collected.kind === 'limit') return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
|
|
711
|
+
// the body never arrived whole (the source failed, or the
|
|
712
|
+
// request was aborted between pulls): not a valid document
|
|
713
|
+
return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
|
|
714
|
+
}
|
|
715
|
+
return parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers, collected.bytes.byteLength === 0 ? null : collected.bytes, life);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else if (sourced) {
|
|
720
|
+
// a body-less operation ignores the body; a source is released, never read
|
|
721
|
+
void discard(/** @type {AsyncIterable<Uint8Array>} */ (body));
|
|
722
|
+
}
|
|
723
|
+
return parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers,
|
|
724
|
+
route.hasBody && content && !sourced ? /** @type {string | Uint8Array} */ (body) : null, life);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The pipeline from the parse on: the materialized JSON body (or none),
|
|
729
|
+
* the body members, validation, then the subscribe / idempotency /
|
|
730
|
+
* precondition / handler branches.
|
|
731
|
+
* @param {Server} server
|
|
732
|
+
* @param {Route} route
|
|
733
|
+
* @param {RequestContext} ctx - not yet frozen
|
|
734
|
+
* @param {HttpRequest} request
|
|
735
|
+
* @param {string} trace
|
|
736
|
+
* @param {Armed} armed
|
|
737
|
+
* @param {boolean} isHead
|
|
738
|
+
* @param {string | undefined} ifMatch
|
|
739
|
+
* @param {string | undefined} ifNoneMatch
|
|
740
|
+
* @param {any} transported
|
|
741
|
+
* @param {Readonly<Record<string, string | readonly string[]>>} headers
|
|
742
|
+
* @param {string | Uint8Array | null} body - the whole JSON body, or none
|
|
743
|
+
* @param {Life} life
|
|
744
|
+
* @returns {HttpResponse | Promise<HttpResponse>}
|
|
745
|
+
*/
|
|
746
|
+
function parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers, body, life) {
|
|
747
|
+
const op = route.op;
|
|
748
|
+
let parsed;
|
|
749
|
+
if (body !== null) {
|
|
481
750
|
let text;
|
|
482
751
|
if (typeof body === 'string') text = body;
|
|
483
752
|
else {
|
|
484
753
|
try {
|
|
485
|
-
text = utf8.decode(
|
|
754
|
+
text = utf8.decode(body);
|
|
486
755
|
}
|
|
487
756
|
catch {
|
|
488
757
|
return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
|
|
@@ -525,7 +794,8 @@ function run(server, request) {
|
|
|
525
794
|
// ——— subscribe: the stream branch (§17–§18), or the one-shot read ———
|
|
526
795
|
if (route.stream) {
|
|
527
796
|
Object.freeze(ctx);
|
|
528
|
-
return
|
|
797
|
+
return acquireAround(server, route, ctx, assembled, trace, armed, life, (lease, hctx) =>
|
|
798
|
+
subscribeBranch(server, route, hctx, assembled, trace, headers, armed, isHead, ifMatch, ifNoneMatch, life));
|
|
529
799
|
}
|
|
530
800
|
|
|
531
801
|
// ——— 9. idempotency ———
|
|
@@ -534,12 +804,85 @@ function run(server, request) {
|
|
|
534
804
|
if (key === undefined || key.length === 0) {
|
|
535
805
|
if (route.idempotency === 'required') return refuse(server, 'JC2007', trace, { op: op.id }, undefined, null, null);
|
|
536
806
|
}
|
|
537
|
-
else return idempotent(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, key);
|
|
807
|
+
else return idempotent(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, key, life);
|
|
538
808
|
}
|
|
539
809
|
|
|
810
|
+
// ——— 10. acquire, then the handler inside enter ———
|
|
540
811
|
Object.freeze(ctx);
|
|
541
|
-
|
|
542
|
-
|
|
812
|
+
return acquireAround(server, route, ctx, assembled, trace, armed, life, (lease, hctx) =>
|
|
813
|
+
enterHandler(server, route, hctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, lease, undefined));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The continuation inside `enter` for a JSON operation: the
|
|
818
|
+
* precondition, the handler, the output validation and the response
|
|
819
|
+
* serialization; then, for a lease that requires settlement of a new
|
|
820
|
+
* claim, the settlement through the lease's ledger — inside `enter`, so
|
|
821
|
+
* a host transaction around it commits the domain write and the receipt
|
|
822
|
+
* together. A host fault or a pre-handler refusal (outcome 2) rejects
|
|
823
|
+
* `enter` with the rollback carrier: the host transaction rolls back
|
|
824
|
+
* and the intended fault is still the answer.
|
|
825
|
+
* @param {Server} server
|
|
826
|
+
* @param {Route} route
|
|
827
|
+
* @param {RequestContext} hctx - the handler's context (frozen)
|
|
828
|
+
* @param {any} input
|
|
829
|
+
* @param {string} trace
|
|
830
|
+
* @param {Armed} armed
|
|
831
|
+
* @param {boolean} isHead
|
|
832
|
+
* @param {string | undefined} ifMatch
|
|
833
|
+
* @param {string | undefined} ifNoneMatch
|
|
834
|
+
* @param {import('../host.js').Lease} lease
|
|
835
|
+
* @param {unknown} ref - the new claim's ref, `undefined` without one
|
|
836
|
+
* @returns {Promise<HttpResponse>}
|
|
837
|
+
*/
|
|
838
|
+
function enterHandler(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, lease, ref) {
|
|
839
|
+
const ran = route.tag !== null
|
|
840
|
+
? preconditionedBoundary(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch)
|
|
841
|
+
: boundary(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
|
|
842
|
+
return toPromise(ran).then((response) => {
|
|
843
|
+
if (armed.outcome === 2) throw new RollbackCarrier(response, undefined);
|
|
844
|
+
if (lease.settlement === null || ref === undefined) return response;
|
|
845
|
+
return requiredSettlement(server, route, lease.settlement.ledger, ref, response, hctx, armed, trace);
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The required settlement (§7.7): the same receipt `settleClaim` would
|
|
851
|
+
* record, through the lease's ledger, inside `enter`. A settlement that
|
|
852
|
+
* throws or rejects is the host's fault: observed, and `enter` rejects
|
|
853
|
+
* with the carrier of a `JC2008` so the host transaction rolls back and
|
|
854
|
+
* the root claim is released retryable outside it.
|
|
855
|
+
* @param {Server} server
|
|
856
|
+
* @param {Route} route
|
|
857
|
+
* @param {Ledger} ledger
|
|
858
|
+
* @param {unknown} ref
|
|
859
|
+
* @param {HttpResponse} response
|
|
860
|
+
* @param {RequestContext} hctx
|
|
861
|
+
* @param {Armed} armed
|
|
862
|
+
* @param {string} trace
|
|
863
|
+
* @returns {Promise<HttpResponse>}
|
|
864
|
+
*/
|
|
865
|
+
function requiredSettlement(server, route, ledger, ref, response, hctx, armed, trace) {
|
|
866
|
+
/** @param {unknown} err */
|
|
867
|
+
const carrier = (err) => {
|
|
868
|
+
observe(server, err, hctx);
|
|
869
|
+
armed.outcome = 2;
|
|
870
|
+
return new RollbackCarrier(refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, hctx), err);
|
|
871
|
+
};
|
|
872
|
+
let settlement;
|
|
873
|
+
try {
|
|
874
|
+
const now = server.now();
|
|
875
|
+
if (armed.outcome === 0) settlement = ledger.commit(ref, response, now);
|
|
876
|
+
else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response, now);
|
|
877
|
+
else settlement = ledger.fail(ref, false, response, now);
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
return Promise.reject(carrier(err));
|
|
881
|
+
}
|
|
882
|
+
return toPromise(settlement).then(() => {
|
|
883
|
+
armed.settled = true;
|
|
884
|
+
return response;
|
|
885
|
+
}, (err) => { throw carrier(err); });
|
|
543
886
|
}
|
|
544
887
|
|
|
545
888
|
/**
|
|
@@ -593,9 +936,10 @@ function wellKnown(server, method, trace) {
|
|
|
593
936
|
* @param {boolean} isHead
|
|
594
937
|
* @param {string | undefined} ifMatch
|
|
595
938
|
* @param {string | undefined} ifNoneMatch
|
|
939
|
+
* @param {Life} life
|
|
596
940
|
* @returns {Promise<HttpResponse>}
|
|
597
941
|
*/
|
|
598
|
-
function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHead, ifMatch, ifNoneMatch) {
|
|
942
|
+
function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHead, ifMatch, ifNoneMatch, life) {
|
|
599
943
|
const accept = headerValue(headers, 'accept');
|
|
600
944
|
const wantsStream = !isHead && accept !== undefined && accept.toLowerCase().indexOf(STREAM_MEDIA) !== -1;
|
|
601
945
|
return settleOperation(route, input, ctx, false).then((result) => {
|
|
@@ -612,7 +956,10 @@ function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHea
|
|
|
612
956
|
return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
|
|
613
957
|
}
|
|
614
958
|
if (!wantsStream) return oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch, ifNoneMatch);
|
|
615
|
-
|
|
959
|
+
// the stream owns the leases from here: they are released after the
|
|
960
|
+
// runner's stop/close/done sequence, once the sink has ended
|
|
961
|
+
life.deferred = true;
|
|
962
|
+
return sseResponse(server, route, ctx, sub, trace, headers, life);
|
|
616
963
|
});
|
|
617
964
|
}
|
|
618
965
|
|
|
@@ -666,19 +1013,29 @@ function oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch,
|
|
|
666
1013
|
/**
|
|
667
1014
|
* The SSE response of a live subscription: `200 text/event-stream` with
|
|
668
1015
|
* the pump behind `stream`. The pump runs the carrier-neutral
|
|
669
|
-
* subscription runner
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* `
|
|
1016
|
+
* subscription runner over the adapter's sink, serialized through the
|
|
1017
|
+
* suite's awaited-sink primitive: snapshot/patch/error/end land as SSE
|
|
1018
|
+
* events, each written only after the previous write settled (a Node
|
|
1019
|
+
* response that answered `false` waits for `drain`; a Web stream
|
|
1020
|
+
* bridge waits for the consumer's pull), a heartbeat comment line goes
|
|
1021
|
+
* out every `policy.stream.heartbeatMs` unless the previous heartbeat
|
|
1022
|
+
* is still waiting on the sink (a stalled consumer never queues a
|
|
1023
|
+
* second), the peer's abort (`ctx.signal`) stops silently, and the
|
|
1024
|
+
* dispatcher's `close()` ends with `server-shutdown`. A sink write that
|
|
1025
|
+
* rejects is the peer being gone: the runner releases the subscription
|
|
1026
|
+
* silently and nothing is observed. The pump answers the runner's
|
|
1027
|
+
* stopper and its completion signal, which settles only after the
|
|
1028
|
+
* subscription's `stop()`/`close()` and the sink's `end()` have.
|
|
673
1029
|
* @param {Server} server
|
|
674
1030
|
* @param {Route} route
|
|
675
1031
|
* @param {RequestContext} ctx
|
|
676
1032
|
* @param {import('../stream/server.js').SubscriptionLike} sub
|
|
677
1033
|
* @param {string} trace
|
|
678
1034
|
* @param {Readonly<Record<string, string | readonly string[]>>} headers
|
|
1035
|
+
* @param {Life} life
|
|
679
1036
|
* @returns {HttpResponse}
|
|
680
1037
|
*/
|
|
681
|
-
function sseResponse(server, route, ctx, sub, trace, headers) {
|
|
1038
|
+
function sseResponse(server, route, ctx, sub, trace, headers, life) {
|
|
682
1039
|
const lastRaw = headerValue(headers, 'last-event-id');
|
|
683
1040
|
let lastSeq = null;
|
|
684
1041
|
if (lastRaw !== undefined) {
|
|
@@ -689,29 +1046,41 @@ function sseResponse(server, route, ctx, sub, trace, headers) {
|
|
|
689
1046
|
const heartbeatMs = streamPolicy === null ? 15000 : streamPolicy.heartbeatMs;
|
|
690
1047
|
|
|
691
1048
|
/** @type {NonNullable<HttpResponse['stream']>} */
|
|
692
|
-
const stream = (
|
|
1049
|
+
const stream = (raw) => {
|
|
1050
|
+
const sink = createAwaitedSink(raw);
|
|
1051
|
+
let heartbeatPending = false;
|
|
1052
|
+
const heartbeatSettled = () => { heartbeatPending = false; };
|
|
693
1053
|
const timer = setInterval(() => {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
// a
|
|
1054
|
+
if (heartbeatPending) return;
|
|
1055
|
+
const answer = sink.write(HEARTBEAT_LINE);
|
|
1056
|
+
if (answer !== undefined) {
|
|
1057
|
+
heartbeatPending = true;
|
|
1058
|
+
// a rejected heartbeat is the sink being gone; the runner's own
|
|
1059
|
+
// next write meets the same rejection and releases the stream
|
|
1060
|
+
answer.then(heartbeatSettled, heartbeatSettled);
|
|
699
1061
|
}
|
|
700
1062
|
}, heartbeatMs);
|
|
701
1063
|
if (timer !== null && typeof (/** @type {any} */ (timer)).unref === 'function') /** @type {any} */ (timer).unref();
|
|
702
1064
|
|
|
703
|
-
/**
|
|
704
|
-
|
|
1065
|
+
/**
|
|
1066
|
+
* Encode one event as its SSE text — the frame the runner measures,
|
|
1067
|
+
* queues and writes. An event the frame cannot carry (`JC1009`, a
|
|
1068
|
+
* host error) is observed and skipped; the stream goes on.
|
|
1069
|
+
* @param {string} event @param {number | null} seq @param {unknown} data
|
|
1070
|
+
* @returns {string | null}
|
|
1071
|
+
*/
|
|
1072
|
+
const frame = (event, seq, data) => {
|
|
705
1073
|
try {
|
|
706
|
-
|
|
1074
|
+
return encodeStreamEvent(event, seq, data);
|
|
707
1075
|
}
|
|
708
1076
|
catch (err) {
|
|
709
1077
|
observe(server, err, ctx);
|
|
1078
|
+
return null;
|
|
710
1079
|
}
|
|
711
1080
|
};
|
|
712
1081
|
// registered BEFORE the runner starts, so a stream that fails during
|
|
713
1082
|
// construction removes itself and never lingers in the set
|
|
714
|
-
/** @type {
|
|
1083
|
+
/** @type {import('../stream/server.js').StreamRunner | null} */
|
|
715
1084
|
let runner = null;
|
|
716
1085
|
/** @type {(reason: string | null) => void} */
|
|
717
1086
|
const stopper = (reason) => {
|
|
@@ -719,31 +1088,46 @@ function sseResponse(server, route, ctx, sub, trace, headers) {
|
|
|
719
1088
|
};
|
|
720
1089
|
server.streams.add(stopper);
|
|
721
1090
|
runner = runSubscription(route, sub, {
|
|
722
|
-
snapshot: (seq,
|
|
723
|
-
patch: (seq, emission) =>
|
|
724
|
-
error: (intent, cause) => {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1091
|
+
snapshot: (seq, data) => frame('snapshot', seq, data),
|
|
1092
|
+
patch: (seq, emission) => frame('patch', seq, emission),
|
|
1093
|
+
error: (intent, cause, seq, declared) => {
|
|
1094
|
+
// a slow consumer is the peer's doing, not the host's: not observed
|
|
1095
|
+
if (intent !== 'slow-consumer') observe(server, cause, ctx);
|
|
1096
|
+
if (intent === 'declared' && declared !== null) {
|
|
1097
|
+
// the operation's own declared failure, its message rendered
|
|
1098
|
+
// from the catalog — never the error's text
|
|
1099
|
+
const message = declaredMessage(server.catalog, route.op.id, declared.code, {});
|
|
1100
|
+
return frame('error', null, declared.details === undefined
|
|
1101
|
+
? { code: declared.code, message, requestId: trace, retryable: declared.retryable }
|
|
1102
|
+
: { code: declared.code, message, requestId: trace, details: declared.details, retryable: declared.retryable });
|
|
1103
|
+
}
|
|
1104
|
+
const code = intent === 'invalid-snapshot' ? 'JC2091' : intent === 'slow-consumer' ? 'JC2096' : 'JC2008';
|
|
1105
|
+
const row = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091
|
|
1106
|
+
: intent === 'slow-consumer' ? STREAM_ERRORS.JC2096 : HTTP_ERRORS.JC2008;
|
|
1107
|
+
return frame('error', null, { code, message: renderMessage(server.catalog, row.msgid, { op: route.op.id }), requestId: trace, retryable: row.retryable });
|
|
729
1108
|
},
|
|
730
|
-
end: (reason) =>
|
|
731
|
-
|
|
1109
|
+
end: (reason) => frame('end', null, { reason }),
|
|
1110
|
+
size: (text) => utf8ByteLength(text),
|
|
1111
|
+
write: (text) => sink.write(text),
|
|
1112
|
+
done: (reason) => {
|
|
732
1113
|
clearInterval(timer);
|
|
733
1114
|
server.streams.delete(stopper);
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1115
|
+
// a consumer that fell behind is not waited for: the sink is
|
|
1116
|
+
// torn down (the node adapter destroys the socket, the fetch
|
|
1117
|
+
// bridge errors the stream); otherwise the sink ends, and one
|
|
1118
|
+
// that cannot end cleanly (the peer dropped it) is the fact a
|
|
1119
|
+
// silent release expects, not a fault
|
|
1120
|
+
const ended = reason === 'slow-consumer'
|
|
1121
|
+
? sink.abort(new Error('the consumer fell behind the stream\'s bounded queue')).catch(() => undefined)
|
|
1122
|
+
: sink.end().catch(() => undefined);
|
|
1123
|
+
return ended.then(() => releaseLife(life)).then(() => undefined);
|
|
740
1124
|
},
|
|
741
|
-
}, { lastSeq, validate: server.validateOutput });
|
|
1125
|
+
}, { lastSeq, validate: server.validateOutput, limits: server.streamLimits });
|
|
742
1126
|
if (ctx.signal !== null) {
|
|
743
1127
|
if (ctx.signal.aborted) stopper(null);
|
|
744
1128
|
else ctx.signal.addEventListener('abort', () => stopper(null), { once: true });
|
|
745
1129
|
}
|
|
746
|
-
return () => stopper(null);
|
|
1130
|
+
return { stop: () => stopper(null), done: runner.done };
|
|
747
1131
|
};
|
|
748
1132
|
|
|
749
1133
|
return {
|
|
@@ -876,6 +1260,12 @@ function boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNo
|
|
|
876
1260
|
function project(server, route, ctx, result, trace, armed, isHead, ifMatch, ifNoneMatch, raw) {
|
|
877
1261
|
if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
|
|
878
1262
|
if (result.kind === 'contract') {
|
|
1263
|
+
if (raw && result.code === 'JC2008' && result.cause instanceof BodyLimitError) {
|
|
1264
|
+
// the raw handler let the upload's limit crossing propagate: the
|
|
1265
|
+
// request is too large, and the handler is not at fault
|
|
1266
|
+
armed.outcome = 2;
|
|
1267
|
+
return refuse(server, 'JC2003', trace, { op: route.op.id, limit: result.cause.limit }, undefined, null, ctx);
|
|
1268
|
+
}
|
|
879
1269
|
if (result.cause !== undefined) observe(server, result.cause, ctx);
|
|
880
1270
|
armed.outcome = 2;
|
|
881
1271
|
return refuse(server, result.code, trace, { op: route.op.id }, result.details, null, ctx);
|
|
@@ -951,7 +1341,9 @@ function finishValue(server, route, ctx, value, trace, armed, isHead, ifMatch, i
|
|
|
951
1341
|
/**
|
|
952
1342
|
* The value of a raw (opaque) handler: passed through verbatim plus the
|
|
953
1343
|
* trace header; anything that is not `{ status, headers?, body? }` is
|
|
954
|
-
* `JC2010`.
|
|
1344
|
+
* `JC2010`. A body that is a pull source (an async iterable, or a Web
|
|
1345
|
+
* stream, normalized) is passed through as one — the adapter writes it
|
|
1346
|
+
* chunk by chunk — and under HEAD it is cancelled, never drained.
|
|
955
1347
|
* @param {Server} server
|
|
956
1348
|
* @param {Route} route
|
|
957
1349
|
* @param {RequestContext} ctx
|
|
@@ -974,7 +1366,8 @@ function finishRaw(server, route, ctx, value, trace, armed, isHead) {
|
|
|
974
1366
|
body = r.body;
|
|
975
1367
|
if (!Number.isInteger(status) || status < 100 || status > 599) throw new TypeError('raw status');
|
|
976
1368
|
if (rawHeaders !== undefined && (rawHeaders === null || typeof rawHeaders !== 'object')) throw new TypeError('raw headers');
|
|
977
|
-
|
|
1369
|
+
body = normalizeBody(body);
|
|
1370
|
+
if (body === undefined) throw new TypeError('raw body');
|
|
978
1371
|
if (rawHeaders !== undefined) {
|
|
979
1372
|
const names = Object.keys(rawHeaders);
|
|
980
1373
|
for (let i = 0; i < names.length; i++) {
|
|
@@ -989,7 +1382,43 @@ function finishRaw(server, route, ctx, value, trace, armed, isHead) {
|
|
|
989
1382
|
return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
|
|
990
1383
|
}
|
|
991
1384
|
headers['x-jaren-trace'] = trace;
|
|
992
|
-
|
|
1385
|
+
if (isHead && isAsyncByteSource(body)) {
|
|
1386
|
+
// a HEAD drops the body: a source nobody will read is released, never pulled
|
|
1387
|
+
void discard(body);
|
|
1388
|
+
body = null;
|
|
1389
|
+
}
|
|
1390
|
+
return { status, headers, body: isHead ? null : body };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Settle an opaque upload after its handler answered: a response whose
|
|
1395
|
+
* body is not a stream goes out only after an unread request source
|
|
1396
|
+
* was cancelled once (the connection cannot be reused with an upload
|
|
1397
|
+
* still arriving, and nothing will read it); a streamed response keeps
|
|
1398
|
+
* the request source alive — the handler may be transforming it — and
|
|
1399
|
+
* releases it once when the response body reaches EOF, throws, or is
|
|
1400
|
+
* cancelled by the consumer. A throw from the response body after the
|
|
1401
|
+
* headers are out (a limit crossing met mid-transform among them) cuts
|
|
1402
|
+
* the body and is observed: the status cannot be rewritten by then.
|
|
1403
|
+
* @param {Server} server
|
|
1404
|
+
* @param {RequestContext} ctx
|
|
1405
|
+
* @param {HttpResponse} response
|
|
1406
|
+
* @param {import('./body.js').CountingSource} source
|
|
1407
|
+
* @returns {HttpResponse | Promise<HttpResponse>}
|
|
1408
|
+
*/
|
|
1409
|
+
function settleUpload(server, ctx, response, source) {
|
|
1410
|
+
const body = response.body;
|
|
1411
|
+
if (isAsyncByteSource(body)) {
|
|
1412
|
+
return {
|
|
1413
|
+
...response,
|
|
1414
|
+
body: onSettled(body, (cause) => {
|
|
1415
|
+
if (cause !== undefined) observe(server, cause, ctx);
|
|
1416
|
+
return source.state.finished ? undefined : source.cancel();
|
|
1417
|
+
}),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
if (source.state.finished) return response;
|
|
1421
|
+
return source.cancel().then(() => response);
|
|
993
1422
|
}
|
|
994
1423
|
|
|
995
1424
|
/**
|
|
@@ -1029,9 +1458,10 @@ function declaredFailure(server, route, ctx, result, trace, armed) {
|
|
|
1029
1458
|
* @param {string | undefined} ifMatch
|
|
1030
1459
|
* @param {string | undefined} ifNoneMatch
|
|
1031
1460
|
* @param {string} key
|
|
1461
|
+
* @param {Life} life
|
|
1032
1462
|
* @returns {Promise<HttpResponse>}
|
|
1033
1463
|
*/
|
|
1034
|
-
function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, key) {
|
|
1464
|
+
function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, key, life) {
|
|
1035
1465
|
const ledger = /** @type {Ledger} */ (server.ledger);
|
|
1036
1466
|
let scope;
|
|
1037
1467
|
try {
|
|
@@ -1066,13 +1496,16 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
|
|
|
1066
1496
|
return fault(err);
|
|
1067
1497
|
}
|
|
1068
1498
|
if (state === 'new') {
|
|
1069
|
-
// claim first, precondition
|
|
1070
|
-
// stored response before
|
|
1071
|
-
//
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1499
|
+
// claim first, acquire second, precondition third: a committed key
|
|
1500
|
+
// replays its stored response before any host resource is taken
|
|
1501
|
+
// and before the resolver runs (a retried command that already
|
|
1502
|
+
// succeeded must not answer 412). A lease that requires settlement
|
|
1503
|
+
// records the claim inside `enter`; otherwise, or when the host
|
|
1504
|
+
// transaction rolled back, the root ledger settles it here — a
|
|
1505
|
+
// rollback releases the key retryable (outcome 2)
|
|
1506
|
+
return acquireAround(server, route, ctx, input, trace, armed, life, (lease, hctx) =>
|
|
1507
|
+
enterHandler(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, lease, ref))
|
|
1508
|
+
.then((response) => (armed.settled ? response : settleClaim(server, ledger, ref, response, ctx, armed)));
|
|
1076
1509
|
}
|
|
1077
1510
|
if (state === 'replay') return replay(server, route, stored, trace, ctx);
|
|
1078
1511
|
if (state === 'in-progress') {
|
|
@@ -1123,11 +1556,14 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
|
|
|
1123
1556
|
*/
|
|
1124
1557
|
function settleClaim(server, ledger, ref, response, ctx, armed) {
|
|
1125
1558
|
let settlement;
|
|
1559
|
+
// the settlement carries the binding's clock, as the claim did: one
|
|
1560
|
+
// clock judges the record from claim to expiry
|
|
1126
1561
|
try {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
else if (armed.outcome ===
|
|
1130
|
-
else settlement = ledger.fail(ref,
|
|
1562
|
+
const now = server.now();
|
|
1563
|
+
if (armed.outcome === 0) settlement = ledger.commit(ref, response, now);
|
|
1564
|
+
else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response, now);
|
|
1565
|
+
else if (armed.outcome === 3) settlement = ledger.fail(ref, false, response, now);
|
|
1566
|
+
else settlement = ledger.fail(ref, true, undefined, now);
|
|
1131
1567
|
}
|
|
1132
1568
|
catch (err) {
|
|
1133
1569
|
observe(server, err, ctx);
|