@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/compat.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
3
|
* @file Version compatibility — the one implementation of the
|
|
4
|
-
* negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13
|
|
4
|
+
* negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13): two ends
|
|
5
5
|
* speak when they declare the same `version`, or when either end's
|
|
6
6
|
* `compat` list names the other's `version`. The client's `negotiate()`
|
|
7
7
|
* and any server that wants to refuse an incompatible peer both call
|
package/src/errors.js
CHANGED
|
@@ -66,6 +66,7 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
66
66
|
JC1008: 'openHttpClient, openPortClient, client.url, createContractEffect, createContractSubscription or a projection (publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools): an argument or option is malformed (not a compiled contract, fetch/keys/sleep/createTaskEffect/projectError not a function, storage without read/write, a non-object input to url, an ops entry naming no or an opaque operation, a tool name outside ^[a-zA-Z0-9_-]{1,64}$ or shared by two operations)',
|
|
67
67
|
JC1009: 'encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id',
|
|
68
68
|
JC1010: 'client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)',
|
|
69
|
+
JC1011: 'a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out',
|
|
69
70
|
// ——— http request-time (ContractRuntimeError, mapped onto the wire) ———
|
|
70
71
|
JC2001: 'no operation matches the request method and path (404)',
|
|
71
72
|
JC2002: 'the path shape is served under other methods (405, Allow lists them)',
|
|
@@ -105,6 +106,8 @@ export const CONTRACT_CODES = Object.freeze({
|
|
|
105
106
|
JC2093: 'the stream ended with a server error event whose code the operation does not declare (kind contract; a declared code is a failure outcome under its own code)',
|
|
106
107
|
JC2094: 'the stream went silent for twice policy.stream.heartbeatMs (kind network, client-side)',
|
|
107
108
|
JC2095: 'a requested resume was refused — informational, carried as resumed:false in the fresh snapshot\'s event data, never an outcome',
|
|
109
|
+
JC2096: 'the stream\'s bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)',
|
|
110
|
+
JC2097: 'the client\'s reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)',
|
|
108
111
|
});
|
|
109
112
|
|
|
110
113
|
/**
|
package/src/host.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The host lifecycle (docs/CONTRACT-FORMAT.md §7.7): one
|
|
4
|
+
* carrier-neutral coordinator every server binding runs a request
|
|
5
|
+
* through. A host names two hooks at construction — `identify(meta)`,
|
|
6
|
+
* before anything of the request is parsed, and `acquire(input,
|
|
7
|
+
* identity, enter)`, after the input validated and (on HTTP) the
|
|
8
|
+
* idempotency claim answered `new` — each answering a LEASE `{ host,
|
|
9
|
+
* release? }` the handler sees as `ctx.host`, or a declared failure the
|
|
10
|
+
* binding renders like the handler's own. `acquire` hands its lease to
|
|
11
|
+
* `enter`, the binding's continuation that runs the handler, validates
|
|
12
|
+
* the output, serializes the response and — when the lease carries
|
|
13
|
+
* `settlement: { ledger, required: true }` — settles the idempotency
|
|
14
|
+
* claim through that ledger BEFORE `enter` resolves, so a host that
|
|
15
|
+
* opened a transaction around `enter` commits the domain write and the
|
|
16
|
+
* receipt together or not at all. Releases run once each, acquired
|
|
17
|
+
* before identity, at the boundary the binding declares (the response
|
|
18
|
+
* exposed, the opaque body settled, the stream done).
|
|
19
|
+
*
|
|
20
|
+
* The defaults are exactly `identify → { host: null }` and `acquire →
|
|
21
|
+
* enter({ host: identity.host })`: a host that names only `identify`
|
|
22
|
+
* sees its host in `scope(ctx)` and in the handler; one that names
|
|
23
|
+
* `acquire` owns the handler's host. Every returned lease is validated
|
|
24
|
+
* by shape (an object with an own `host`); a hook that throws, answers
|
|
25
|
+
* a malformed lease, never calls `enter` or calls it twice is the host's
|
|
26
|
+
* fault — observed through the binding's observer and rendered as the
|
|
27
|
+
* binding's host-fault code (`JC2008` on HTTP, `JC2070` on port and
|
|
28
|
+
* local); a declared failure is recognized by the `ContractFailure`
|
|
29
|
+
* brand, never by shape, and validated against the operation like a
|
|
30
|
+
* handler's. No policy data of the hooks reaches a wire.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { isThenable, toPromise } from '@jarenjs/core/function';
|
|
34
|
+
|
|
35
|
+
import { ContractFailure, isContractFailure } from './errors.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What `identify` sees: the matched operation, the trace, the request
|
|
39
|
+
* signal, the carrier, and the transport facts the carrier has — never
|
|
40
|
+
* a parsed input, never an auth vocabulary of the binding's own.
|
|
41
|
+
* @typedef {Object} IdentifyMeta
|
|
42
|
+
* @property {import('./compile.js').CompiledOperation} op
|
|
43
|
+
* @property {string} trace
|
|
44
|
+
* @property {AbortSignal | null} signal
|
|
45
|
+
* @property {'http' | 'port' | 'local'} carrier
|
|
46
|
+
* @property {string | null} method - the request line on HTTP; `null` elsewhere
|
|
47
|
+
* @property {string | null} path
|
|
48
|
+
* @property {Readonly<Record<string, string | readonly string[]>> | null} headers - the raw request headers on HTTP; `null` elsewhere
|
|
49
|
+
* @property {typeof ContractFailure} fail - the declared-failure factory
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A lease: the host value the handler sees as `ctx.host`, an optional
|
|
54
|
+
* release, and — from `acquire` only — an optional required settlement.
|
|
55
|
+
* @typedef {Object} Lease
|
|
56
|
+
* @property {unknown} host
|
|
57
|
+
* @property {(() => unknown) | undefined} release
|
|
58
|
+
* @property {{ ledger: import('./ledger.js').Ledger, required: true } | null} settlement
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The hooks as a binding validated them at construction.
|
|
63
|
+
* @typedef {Object} Lifecycle
|
|
64
|
+
* @property {(meta: IdentifyMeta) => unknown} identify
|
|
65
|
+
* @property {(input: unknown, identity: { host: unknown }, enter: (lease: unknown) => unknown) => unknown} acquire
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** The default identity: no host. */
|
|
69
|
+
const DEFAULT_IDENTIFY = () => ({ host: null });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The default acquisition: the identity's host, as is.
|
|
73
|
+
* @param {unknown} input
|
|
74
|
+
* @param {{ host: unknown }} identity
|
|
75
|
+
* @param {(lease: unknown) => unknown} enter
|
|
76
|
+
*/
|
|
77
|
+
const DEFAULT_ACQUIRE = (input, identity, enter) => enter({ host: identity.host });
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A host fault of the lifecycle: what the binding observes and renders
|
|
81
|
+
* as its host-fault code.
|
|
82
|
+
*/
|
|
83
|
+
export class HostLifecycleError extends Error {
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} message
|
|
86
|
+
* @param {unknown} [cause]
|
|
87
|
+
*/
|
|
88
|
+
constructor(message, cause = undefined) {
|
|
89
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
90
|
+
this.name = 'HostLifecycleError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate the hooks a binding was given; each defaults. Rejections are
|
|
96
|
+
* the binding's `JC1001` through `host`.
|
|
97
|
+
* @param {{ identify?: unknown, acquire?: unknown }} options
|
|
98
|
+
* @param {(reason: string) => Error} host - the binding's construction refusal
|
|
99
|
+
* @returns {Lifecycle}
|
|
100
|
+
*/
|
|
101
|
+
export function resolveLifecycle(options, host) {
|
|
102
|
+
for (const name of /** @type {const} */ (['identify', 'acquire'])) {
|
|
103
|
+
const value = options[name];
|
|
104
|
+
if (value !== undefined && typeof value !== 'function') throw host(`options.${name} must be a function`);
|
|
105
|
+
}
|
|
106
|
+
const identify = options.identify === undefined ? DEFAULT_IDENTIFY : /** @type {Lifecycle['identify']} */ (options.identify);
|
|
107
|
+
const acquire = options.acquire === undefined ? DEFAULT_ACQUIRE : /** @type {Lifecycle['acquire']} */ (options.acquire);
|
|
108
|
+
return Object.freeze({ identify, acquire });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read a hook's answer as a lease: an object with an own `host`, an
|
|
113
|
+
* optional function `release`, and (when `acquired`) an optional
|
|
114
|
+
* `settlement` whose `ledger` has `commit`/`fail` and whose `required`
|
|
115
|
+
* is `true`. Anything else is the host's fault.
|
|
116
|
+
* @param {unknown} value
|
|
117
|
+
* @param {'identify' | 'acquire'} hook
|
|
118
|
+
* @returns {Lease}
|
|
119
|
+
* @throws {HostLifecycleError}
|
|
120
|
+
*/
|
|
121
|
+
export function leaseOf(value, hook) {
|
|
122
|
+
const v = /** @type {any} */ (value);
|
|
123
|
+
if (v === null || typeof v !== 'object' || !Object.hasOwn(v, 'host')) {
|
|
124
|
+
throw new HostLifecycleError(`${hook} must answer a lease { host, release? } — an object with an own host member`);
|
|
125
|
+
}
|
|
126
|
+
const release = v.release;
|
|
127
|
+
if (release !== undefined && typeof release !== 'function') {
|
|
128
|
+
throw new HostLifecycleError(`${hook}: a lease's release must be a function`);
|
|
129
|
+
}
|
|
130
|
+
let settlement = null;
|
|
131
|
+
if (hook === 'acquire' && v.settlement !== undefined && v.settlement !== null) {
|
|
132
|
+
const s = v.settlement;
|
|
133
|
+
if (typeof s !== 'object' || s.required !== true || s.ledger === null || typeof s.ledger !== 'object'
|
|
134
|
+
|| typeof s.ledger.commit !== 'function' || typeof s.ledger.fail !== 'function') {
|
|
135
|
+
throw new HostLifecycleError('acquire: a lease\'s settlement must be { ledger, required: true } with a ledger that commits and fails');
|
|
136
|
+
}
|
|
137
|
+
settlement = { ledger: s.ledger, required: true };
|
|
138
|
+
}
|
|
139
|
+
else if (hook === 'identify' && v.settlement !== undefined) {
|
|
140
|
+
throw new HostLifecycleError('identify: a settlement belongs to the acquired lease, not the identity');
|
|
141
|
+
}
|
|
142
|
+
return { host: v.host, release, settlement };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A release that runs at most once, whatever the number of exits that
|
|
147
|
+
* reach it; its own failure is handed to `observed` and swallowed, and
|
|
148
|
+
* the answer says whether the release was clean (`true`) — a later
|
|
149
|
+
* call answers `true` without running anything again.
|
|
150
|
+
* @param {(() => unknown) | undefined} release
|
|
151
|
+
* @param {(error: unknown) => void} observed
|
|
152
|
+
* @returns {() => Promise<boolean>}
|
|
153
|
+
*/
|
|
154
|
+
export function once(release, observed) {
|
|
155
|
+
let done = false;
|
|
156
|
+
return () => {
|
|
157
|
+
if (done || release === undefined) return Promise.resolve(true);
|
|
158
|
+
done = true;
|
|
159
|
+
let out;
|
|
160
|
+
try {
|
|
161
|
+
out = release();
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
observed(err);
|
|
165
|
+
return Promise.resolve(false);
|
|
166
|
+
}
|
|
167
|
+
return isThenable(out) ? toPromise(out).then(() => true, (err) => { observed(err); return false; }) : Promise.resolve(true);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Classify a hook's settled answer: a lease, a declared failure, or a
|
|
173
|
+
* host fault. Total for hostile values.
|
|
174
|
+
* @param {unknown} value
|
|
175
|
+
* @param {'identify' | 'acquire'} hook
|
|
176
|
+
* @returns {{ kind: 'lease', lease: Lease } | { kind: 'failure', failure: import('./errors.js').ContractFailureValue } | { kind: 'fault', cause: unknown }}
|
|
177
|
+
*/
|
|
178
|
+
export function classifyAnswer(value, hook) {
|
|
179
|
+
if (isContractFailure(value)) return { kind: 'failure', failure: value };
|
|
180
|
+
try {
|
|
181
|
+
return { kind: 'lease', lease: leaseOf(value, hook) };
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return { kind: 'fault', cause: err };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Run `identify`: the hook's answer (sync or async) classified.
|
|
190
|
+
* @param {Lifecycle} lifecycle
|
|
191
|
+
* @param {IdentifyMeta} meta
|
|
192
|
+
* @returns {Promise<ReturnType<typeof classifyAnswer>> | ReturnType<typeof classifyAnswer>}
|
|
193
|
+
*/
|
|
194
|
+
export function identify(lifecycle, meta) {
|
|
195
|
+
let answer;
|
|
196
|
+
try {
|
|
197
|
+
answer = lifecycle.identify(meta);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
return { kind: 'fault', cause: err };
|
|
201
|
+
}
|
|
202
|
+
return isThenable(answer)
|
|
203
|
+
? toPromise(answer).then((value) => classifyAnswer(value, 'identify'), (err) => ({ kind: 'fault', cause: err }))
|
|
204
|
+
: classifyAnswer(answer, 'identify');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The private carrier a required settlement's failure travels in: the
|
|
209
|
+
* wire fault the dispatcher intends is preserved while the host's
|
|
210
|
+
* transaction around `enter` rolls back on the rejection.
|
|
211
|
+
*/
|
|
212
|
+
export class RollbackCarrier extends Error {
|
|
213
|
+
/**
|
|
214
|
+
* @param {unknown} response - the response the binding will answer
|
|
215
|
+
* @param {unknown} cause - what failed inside `enter`
|
|
216
|
+
*/
|
|
217
|
+
constructor(response, cause) {
|
|
218
|
+
super('the required settlement did not commit; the host transaction rolls back', { cause });
|
|
219
|
+
this.name = 'RollbackCarrier';
|
|
220
|
+
this.response = response;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run `acquire` around `enter`. `enter` receives the validated lease and
|
|
226
|
+
* answers the binding's result (a response, an outcome) or a promise of
|
|
227
|
+
* it; what the hook itself answers is the value of `enter` — a hook
|
|
228
|
+
* that resolves before `enter` settled, or to something else, is a
|
|
229
|
+
* fault. The result: `{ kind: 'entered', lease, result }` when `enter`
|
|
230
|
+
* ran and settled (its rejection, when it is not a RollbackCarrier of
|
|
231
|
+
* ours, is a fault of the binding's continuation); `{ kind: 'failure' }`
|
|
232
|
+
* for a declared failure answered instead of entering; `{ kind:
|
|
233
|
+
* 'fault' }` for everything else — `enter` never called, called twice,
|
|
234
|
+
* a malformed lease, a throw.
|
|
235
|
+
* @param {Lifecycle} lifecycle
|
|
236
|
+
* @param {unknown} input
|
|
237
|
+
* @param {{ host: unknown }} identity
|
|
238
|
+
* @param {(lease: Lease) => unknown} enter
|
|
239
|
+
* @returns {Promise<{ kind: 'entered', lease: Lease, result: unknown, rolledBack: RollbackCarrier | null, afterFault?: unknown }
|
|
240
|
+
* | { kind: 'failure', failure: import('./errors.js').ContractFailureValue }
|
|
241
|
+
* | { kind: 'fault', cause: unknown }>}
|
|
242
|
+
*/
|
|
243
|
+
export function acquire(lifecycle, input, identity, enter) {
|
|
244
|
+
/** @type {Lease | null} */
|
|
245
|
+
let lease = null;
|
|
246
|
+
/** @type {{ settled: boolean, value: unknown } | null} */
|
|
247
|
+
let entered = null;
|
|
248
|
+
let calls = 0;
|
|
249
|
+
/** @type {RollbackCarrier | null} */
|
|
250
|
+
let rolledBack = null;
|
|
251
|
+
const gate = (/** @type {unknown} */ candidate) => {
|
|
252
|
+
calls += 1;
|
|
253
|
+
if (calls > 1) throw new HostLifecycleError('acquire called enter more than once');
|
|
254
|
+
lease = leaseOf(candidate, 'acquire');
|
|
255
|
+
let out;
|
|
256
|
+
try {
|
|
257
|
+
out = enter(lease);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
out = Promise.reject(err);
|
|
261
|
+
}
|
|
262
|
+
const settled = toPromise(out).then(
|
|
263
|
+
(value) => {
|
|
264
|
+
entered = { settled: true, value };
|
|
265
|
+
return value;
|
|
266
|
+
},
|
|
267
|
+
(err) => {
|
|
268
|
+
if (err instanceof RollbackCarrier) {
|
|
269
|
+
rolledBack = err;
|
|
270
|
+
entered = { settled: true, value: err.response };
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
});
|
|
274
|
+
return settled;
|
|
275
|
+
};
|
|
276
|
+
let answer;
|
|
277
|
+
try {
|
|
278
|
+
answer = lifecycle.acquire(input, identity, gate);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
return Promise.resolve(finish(err, true));
|
|
282
|
+
}
|
|
283
|
+
return toPromise(answer).then((value) => finish(value, false), (err) => finish(err, true));
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {unknown} value - the hook's settled value or rejection
|
|
287
|
+
* @param {boolean} rejected
|
|
288
|
+
*/
|
|
289
|
+
function finish(value, rejected) {
|
|
290
|
+
if (rejected) {
|
|
291
|
+
// our own refusal — enter called twice, a malformed lease — is the
|
|
292
|
+
// host's fault whatever else settled
|
|
293
|
+
if (value instanceof HostLifecycleError) return { kind: 'fault', cause: value };
|
|
294
|
+
if (value instanceof RollbackCarrier && entered !== null && lease !== null) {
|
|
295
|
+
// the host transaction rolled back on our carrier: the intended fault is the answer
|
|
296
|
+
return { kind: 'entered', lease, result: value.response, rolledBack: value };
|
|
297
|
+
}
|
|
298
|
+
if (rolledBack !== null && lease !== null) {
|
|
299
|
+
// the host wrapped our carrier in a rejection of its own (a transaction that rethrows): the intent stands
|
|
300
|
+
return { kind: 'entered', lease, result: rolledBack.response, rolledBack };
|
|
301
|
+
}
|
|
302
|
+
if (entered !== null && lease !== null) {
|
|
303
|
+
// the hook rejected after enter settled: the settled result stands, the rejection is the host's fault to observe
|
|
304
|
+
return { kind: 'entered', lease, result: entered.value, rolledBack: null, afterFault: value };
|
|
305
|
+
}
|
|
306
|
+
return { kind: 'fault', cause: value };
|
|
307
|
+
}
|
|
308
|
+
if (calls === 0) {
|
|
309
|
+
if (isContractFailure(value)) return { kind: 'failure', failure: value };
|
|
310
|
+
return { kind: 'fault', cause: new HostLifecycleError('acquire answered without calling enter and without a declared failure') };
|
|
311
|
+
}
|
|
312
|
+
if (entered === null || lease === null) {
|
|
313
|
+
return { kind: 'fault', cause: new HostLifecycleError('acquire resolved before enter settled') };
|
|
314
|
+
}
|
|
315
|
+
return { kind: 'entered', lease, result: entered.value, rolledBack };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { ContractFailure };
|
package/src/http/body.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Byte bodies of the HTTP binding, carrier-neutral: the one
|
|
4
|
+
* normalizer that turns whatever an adapter or a handler hands over (a
|
|
5
|
+
* string, bytes, an async iterable of chunks, a Web `ReadableStream`)
|
|
6
|
+
* into the shape the pipeline reads; the bounded collector a JSON
|
|
7
|
+
* operation drains its source through (a JSON body must be parsed and
|
|
8
|
+
* validated whole, so it materializes — under the operation's limit
|
|
9
|
+
* and never past it); and the counting source an opaque handler
|
|
10
|
+
* receives, which never yields a byte past the limit and cancels its
|
|
11
|
+
* upstream exactly once. Nothing here keeps a chunk it has handed on:
|
|
12
|
+
* the opaque path holds one chunk at a time, and the JSON path holds
|
|
13
|
+
* at most `maxBodyBytes`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A body as the pipeline reads it: text, bytes, a pull source of byte
|
|
18
|
+
* chunks, or none.
|
|
19
|
+
* @typedef {string | Uint8Array | AsyncIterable<Uint8Array> | null} Body
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The limit crossing a counting source raises to whoever is pulling
|
|
24
|
+
* it: recognized by class, so a handler that lets it propagate answers
|
|
25
|
+
* `JC2003` rather than a host fault, and a handler that catches it
|
|
26
|
+
* decides for itself.
|
|
27
|
+
*/
|
|
28
|
+
export class BodyLimitError extends Error {
|
|
29
|
+
/** @param {number} limit */
|
|
30
|
+
constructor(limit) {
|
|
31
|
+
super(`the request body exceeds its ${limit}-byte limit`);
|
|
32
|
+
this.name = 'BodyLimitError';
|
|
33
|
+
/** @type {number} */
|
|
34
|
+
this.limit = limit;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a value is an async iterable — the pull shape of a byte source.
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
* @returns {value is AsyncIterable<Uint8Array>}
|
|
42
|
+
*/
|
|
43
|
+
export function isAsyncByteSource(value) {
|
|
44
|
+
return value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value))[Symbol.asyncIterator] === 'function';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a value is a Web `ReadableStream` (by its reader, the one
|
|
49
|
+
* member every platform's stream has).
|
|
50
|
+
* @param {unknown} value
|
|
51
|
+
* @returns {value is ReadableStream<Uint8Array>}
|
|
52
|
+
*/
|
|
53
|
+
export function isReadableStream(value) {
|
|
54
|
+
return value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value)).getReader === 'function';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An async iterable over a Web stream's reader. `return()` cancels the
|
|
59
|
+
* stream once; a completed read releases the lock.
|
|
60
|
+
* @param {ReadableStream<Uint8Array>} stream
|
|
61
|
+
* @returns {AsyncIterable<Uint8Array>}
|
|
62
|
+
*/
|
|
63
|
+
function readerSource(stream) {
|
|
64
|
+
return {
|
|
65
|
+
[Symbol.asyncIterator]() {
|
|
66
|
+
const reader = stream.getReader();
|
|
67
|
+
let done = false;
|
|
68
|
+
return {
|
|
69
|
+
async next() {
|
|
70
|
+
if (done) return { done: true, value: undefined };
|
|
71
|
+
let r;
|
|
72
|
+
try {
|
|
73
|
+
r = await reader.read();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
done = true;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
if (r.done) {
|
|
80
|
+
done = true;
|
|
81
|
+
reader.releaseLock();
|
|
82
|
+
return { done: true, value: undefined };
|
|
83
|
+
}
|
|
84
|
+
return { done: false, value: r.value };
|
|
85
|
+
},
|
|
86
|
+
async return(value) {
|
|
87
|
+
if (!done) {
|
|
88
|
+
done = true;
|
|
89
|
+
try {
|
|
90
|
+
await reader.cancel();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// a stream that refuses the cancel is already gone
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { done: true, value };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Normalize a body: `undefined`/`null` → `null`; a string or bytes
|
|
105
|
+
* pass; a Web stream becomes an async iterable over its reader; an
|
|
106
|
+
* async iterable passes. Anything else answers `undefined` — not a
|
|
107
|
+
* body, for the caller to refuse.
|
|
108
|
+
* @param {unknown} body
|
|
109
|
+
* @returns {Body | undefined}
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeBody(body) {
|
|
112
|
+
if (body === undefined || body === null) return null;
|
|
113
|
+
if (typeof body === 'string' || body instanceof Uint8Array) return body;
|
|
114
|
+
if (isReadableStream(body)) return readerSource(body);
|
|
115
|
+
if (isAsyncByteSource(body)) return body;
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Concatenate collected chunks into one Uint8Array.
|
|
121
|
+
* @param {Uint8Array[]} chunks
|
|
122
|
+
* @param {number} total
|
|
123
|
+
* @returns {Uint8Array}
|
|
124
|
+
*/
|
|
125
|
+
function concat(chunks, total) {
|
|
126
|
+
if (chunks.length === 1) return chunks[0];
|
|
127
|
+
const out = new Uint8Array(total);
|
|
128
|
+
let offset = 0;
|
|
129
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
130
|
+
out.set(chunks[i], offset);
|
|
131
|
+
offset += chunks[i].byteLength;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cancel an iterator once, swallowing what the cancel throws.
|
|
138
|
+
* @param {AsyncIterator<Uint8Array>} iterator
|
|
139
|
+
*/
|
|
140
|
+
async function cancelIterator(iterator) {
|
|
141
|
+
if (typeof iterator.return !== 'function') return;
|
|
142
|
+
try {
|
|
143
|
+
await iterator.return();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// an upstream that refuses its cancel is already gone
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What the collector answers: the whole body, or why it stopped —
|
|
152
|
+
* `limit` (the read crossed `limit`; the crossing chunk was never
|
|
153
|
+
* retained), `aborted` (the signal fired between pulls), `error` (the
|
|
154
|
+
* source threw, or yielded a non-byte chunk). In every failed case the
|
|
155
|
+
* upstream iterator's `return()` ran exactly once.
|
|
156
|
+
* @typedef {{ ok: true, bytes: Uint8Array }
|
|
157
|
+
* | { ok: false, kind: 'limit' | 'aborted' }
|
|
158
|
+
* | { ok: false, kind: 'error', cause: unknown }} Collected
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drain a byte source into one `Uint8Array` under a limit: at most
|
|
163
|
+
* `limit` bytes are ever held; the chunk that would cross it is not
|
|
164
|
+
* retained, the upstream is cancelled once, and the crossing is
|
|
165
|
+
* reported.
|
|
166
|
+
* @param {AsyncIterable<Uint8Array>} source
|
|
167
|
+
* @param {number} limit - inclusive
|
|
168
|
+
* @param {AbortSignal | null} signal - checked between pulls
|
|
169
|
+
* @returns {Promise<Collected>}
|
|
170
|
+
*/
|
|
171
|
+
export async function collectBytes(source, limit, signal) {
|
|
172
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
173
|
+
/** @type {Uint8Array[]} */
|
|
174
|
+
const chunks = [];
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (signal !== null && signal.aborted) {
|
|
178
|
+
await cancelIterator(iterator);
|
|
179
|
+
return { ok: false, kind: 'aborted' };
|
|
180
|
+
}
|
|
181
|
+
let r;
|
|
182
|
+
try {
|
|
183
|
+
r = await iterator.next();
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
await cancelIterator(iterator);
|
|
187
|
+
return { ok: false, kind: 'error', cause: err };
|
|
188
|
+
}
|
|
189
|
+
if (r.done) break;
|
|
190
|
+
const chunk = r.value;
|
|
191
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
192
|
+
await cancelIterator(iterator);
|
|
193
|
+
return { ok: false, kind: 'error', cause: new TypeError('a body source must yield Uint8Array chunks') };
|
|
194
|
+
}
|
|
195
|
+
total += chunk.byteLength;
|
|
196
|
+
if (total > limit) {
|
|
197
|
+
await cancelIterator(iterator);
|
|
198
|
+
return { ok: false, kind: 'limit' };
|
|
199
|
+
}
|
|
200
|
+
chunks.push(chunk);
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, bytes: total === 0 ? new Uint8Array(0) : concat(chunks, total) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The state of a counting source, readable by the binding that made it.
|
|
207
|
+
* @typedef {Object} SourceState
|
|
208
|
+
* @property {boolean} started - a chunk was pulled
|
|
209
|
+
* @property {boolean} finished - EOF was reached, or the source was cancelled
|
|
210
|
+
* @property {boolean} cancelled - `return()` ran (a crossing, a consumer's return, the binding's cancel)
|
|
211
|
+
* @property {boolean} crossed - the limit was crossed
|
|
212
|
+
* @property {number} bytes - the bytes yielded so far
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A counting source over an upstream: what an opaque handler receives.
|
|
217
|
+
* @typedef {AsyncIterable<Uint8Array> & { cancel: () => Promise<void>, state: SourceState }} CountingSource
|
|
218
|
+
*/
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Wrap an upstream source so it never yields a byte past `limit`: the
|
|
222
|
+
* chunk that would cross it is not yielded — the upstream is cancelled
|
|
223
|
+
* once and a `BodyLimitError` is thrown to the puller. `return()` (a
|
|
224
|
+
* consumer that stops early) and `cancel()` (the binding, when a
|
|
225
|
+
* response goes out with the request unread) both cancel the upstream
|
|
226
|
+
* exactly once; `state` says what happened.
|
|
227
|
+
* @param {AsyncIterable<Uint8Array>} upstream
|
|
228
|
+
* @param {number} limit
|
|
229
|
+
* @returns {CountingSource}
|
|
230
|
+
*/
|
|
231
|
+
export function countingSource(upstream, limit) {
|
|
232
|
+
/** @type {SourceState} */
|
|
233
|
+
const state = { started: false, finished: false, cancelled: false, crossed: false, bytes: 0 };
|
|
234
|
+
/** @type {AsyncIterator<Uint8Array> | null} */
|
|
235
|
+
let iterator = null;
|
|
236
|
+
const cancel = async () => {
|
|
237
|
+
if (state.finished) return;
|
|
238
|
+
state.finished = true;
|
|
239
|
+
state.cancelled = true;
|
|
240
|
+
if (iterator === null) iterator = upstream[Symbol.asyncIterator]();
|
|
241
|
+
await cancelIterator(iterator);
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
[Symbol.asyncIterator]() {
|
|
245
|
+
return {
|
|
246
|
+
async next() {
|
|
247
|
+
if (state.finished) return { done: true, value: undefined };
|
|
248
|
+
state.started = true;
|
|
249
|
+
if (iterator === null) iterator = upstream[Symbol.asyncIterator]();
|
|
250
|
+
let r;
|
|
251
|
+
try {
|
|
252
|
+
r = await iterator.next();
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
state.finished = true;
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
if (state.finished) return { done: true, value: undefined };
|
|
259
|
+
if (r.done) {
|
|
260
|
+
state.finished = true;
|
|
261
|
+
return { done: true, value: undefined };
|
|
262
|
+
}
|
|
263
|
+
const chunk = r.value;
|
|
264
|
+
if (!(chunk instanceof Uint8Array)) {
|
|
265
|
+
await cancel();
|
|
266
|
+
throw new TypeError('a body source must yield Uint8Array chunks');
|
|
267
|
+
}
|
|
268
|
+
state.bytes += chunk.byteLength;
|
|
269
|
+
if (state.bytes > limit) {
|
|
270
|
+
state.crossed = true;
|
|
271
|
+
await cancel();
|
|
272
|
+
throw new BodyLimitError(limit);
|
|
273
|
+
}
|
|
274
|
+
return { done: false, value: chunk };
|
|
275
|
+
},
|
|
276
|
+
async return(value) {
|
|
277
|
+
await cancel();
|
|
278
|
+
return { done: true, value };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
cancel,
|
|
283
|
+
state,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run `after` once when a response source completes, throws or is
|
|
289
|
+
* cancelled by its consumer — the hook a binding uses to release what
|
|
290
|
+
* the response held (an unread request source). A throw from the
|
|
291
|
+
* source reaches the consumer after the hook; the hook's own answer
|
|
292
|
+
* is awaited but never replaces the source's outcome.
|
|
293
|
+
* @param {AsyncIterable<Uint8Array>} source
|
|
294
|
+
* @param {(cause: unknown) => unknown} after - `cause` is the throw, or `undefined` on EOF/return
|
|
295
|
+
* @returns {AsyncIterable<Uint8Array>}
|
|
296
|
+
*/
|
|
297
|
+
export function onSettled(source, after) {
|
|
298
|
+
let settled = false;
|
|
299
|
+
/** @param {unknown} cause */
|
|
300
|
+
const settle = async (cause) => {
|
|
301
|
+
if (settled) return;
|
|
302
|
+
settled = true;
|
|
303
|
+
try {
|
|
304
|
+
await after(cause);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// the hook's failure is not the stream's
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
return {
|
|
311
|
+
[Symbol.asyncIterator]() {
|
|
312
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
313
|
+
return {
|
|
314
|
+
async next() {
|
|
315
|
+
if (settled) return { done: true, value: undefined };
|
|
316
|
+
let r;
|
|
317
|
+
try {
|
|
318
|
+
r = await iterator.next();
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
await settle(err);
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
if (r.done) await settle(undefined);
|
|
325
|
+
return r;
|
|
326
|
+
},
|
|
327
|
+
async return(value) {
|
|
328
|
+
if (!settled) {
|
|
329
|
+
await cancelIterator(iterator);
|
|
330
|
+
await settle(undefined);
|
|
331
|
+
}
|
|
332
|
+
return { done: true, value };
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|