@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,301 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `serveHttp(contract, handlers, options)`: the HTTP server binding
|
|
4
|
+
* of a compiled contract — a driver in the `@jarenjs/db` sense: a
|
|
5
|
+
* `name`, a frozen `capabilities` table that says what this binding
|
|
6
|
+
* cannot carry (never a silent downgrade), and the one method a host
|
|
7
|
+
* calls per request, `dispatch(request) → Promise<response>` over plain
|
|
8
|
+
* request/response objects (docs/CONTRACT-FORMAT.md §7). The adapters
|
|
9
|
+
* (`@jarenjs/contract/fetch`, `/node`) put that function behind the
|
|
10
|
+
* platform's request and response types.
|
|
11
|
+
*
|
|
12
|
+
* Construction refuses host mistakes with thrown `ContractHostError`s
|
|
13
|
+
* (`JC1001–JC1003`): a handler table that names no operation, a missing
|
|
14
|
+
* handler on a non-partial server, a declared idempotency policy with no
|
|
15
|
+
* ledger to carry it. Everything the pipeline reads per request is
|
|
16
|
+
* decided here, once, into a `Route` per operation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
20
|
+
|
|
21
|
+
import { ContractHostError } from '../errors.js';
|
|
22
|
+
import { dispatch } from './dispatch.js';
|
|
23
|
+
import { HTTP_ERRORS, WELL_KNOWN_PATH } from './wire.js';
|
|
24
|
+
|
|
25
|
+
export { HTTP_ERRORS, WELL_KNOWN_PATH };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {import('./wire.js').HttpRequest} HttpRequest
|
|
29
|
+
* @typedef {import('./wire.js').HttpResponse} HttpResponse
|
|
30
|
+
* @typedef {import('./wire.js').WireErrorBody} WireErrorBody
|
|
31
|
+
* @typedef {import('./dispatch.js').RequestContext} RequestContext
|
|
32
|
+
* @typedef {import('./dispatch.js').Handler} Handler
|
|
33
|
+
* @typedef {import('./dispatch.js').RawResponse} RawResponse
|
|
34
|
+
* @typedef {import('./dispatch.js').Route} Route
|
|
35
|
+
* @typedef {import('./dispatch.js').Server} Server
|
|
36
|
+
* @typedef {import('../ledger.js').Ledger} Ledger
|
|
37
|
+
* @typedef {import('../compile.js').Contract} Contract
|
|
38
|
+
* @typedef {import('../compile.js').CompiledOperation} CompiledOperation
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The options of `serveHttp`; every one has a default.
|
|
43
|
+
* @typedef {Object} ServeHttpOptions
|
|
44
|
+
* @property {() => string} [trace] - the server trace generator; default `crypto.randomUUID`
|
|
45
|
+
* @property {Ledger | null} [ledger] - the idempotency ledger; `null` refuses
|
|
46
|
+
* (`JC1003`) any operation whose `policy.idempotency` is not `none`
|
|
47
|
+
* @property {(ctx: RequestContext) => string} [scope] - the idempotency scope
|
|
48
|
+
* of a request (an installation, a principal — never a rotating token);
|
|
49
|
+
* default `''`; called before `ctx.idempotency` is set
|
|
50
|
+
* @property {boolean} [partial] - allow missing handlers; a missing one answers 501 `JC2013`
|
|
51
|
+
* @property {boolean} [head] - answer HEAD for GET operations by running the handler and dropping the body; default true
|
|
52
|
+
* @property {'always' | 'never'} [validateOutput] - `'never'` is a declared downgrade, reported in `capabilities.validatedOutput`
|
|
53
|
+
* @property {string | false} [wellKnown] - the path answering `describe()`; default `/.well-known/jaren-contract`; `false` disables
|
|
54
|
+
* @property {(wire: WireErrorBody & { status: number }, ctx: RequestContext | null) => unknown} [errorBody]
|
|
55
|
+
* - projects the wire error record into the response body (a legacy
|
|
56
|
+
* shape, an extra `error` string); a throw or a non-JSON result falls
|
|
57
|
+
* back to the D7 shape
|
|
58
|
+
* @property {(error: unknown, ctx: RequestContext | null) => void} [onError]
|
|
59
|
+
* - observes `JC2008`/`JC2010` causes and ledger faults; the response never carries them
|
|
60
|
+
* @property {Record<string, string | ((params: object) => string)>} [catalog]
|
|
61
|
+
* - a message catalog (templates or compiled renderers) consulted before the English one
|
|
62
|
+
* @property {() => number} [now] - the clock stamped into ledger claims; default `Date.now`
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The frozen capabilities table of the http binding.
|
|
67
|
+
* @typedef {Object} HttpCapabilities
|
|
68
|
+
* @property {'http'} name
|
|
69
|
+
* @property {true} status - statuses are carried
|
|
70
|
+
* @property {true} headers - headers are carried
|
|
71
|
+
* @property {true} media - non-JSON media is carried (opaque operations)
|
|
72
|
+
* @property {boolean} head - HEAD is answered for GET operations
|
|
73
|
+
* @property {true} etag - entity tags and conditionals are honored
|
|
74
|
+
* @property {boolean} idempotency - a ledger is present
|
|
75
|
+
* @property {boolean} validatedOutput - the output validator runs
|
|
76
|
+
* @property {true} stream - subscribe operations stream as SSE (docs/CONTRACT-FORMAT.md §18.1)
|
|
77
|
+
* @property {'signal'} cancel - cancellation reaches the handler as `ctx.signal`
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The server binding.
|
|
82
|
+
* @typedef {Object} HttpDispatcher
|
|
83
|
+
* @property {(request: HttpRequest) => Promise<HttpResponse>} dispatch
|
|
84
|
+
* @property {HttpCapabilities} capabilities
|
|
85
|
+
* @property {Contract} contract
|
|
86
|
+
* @property {() => any} describe
|
|
87
|
+
* @property {() => void} close - ends every live SSE stream with an `end`
|
|
88
|
+
* event (`server-shutdown`) and releases its subscription; requests in
|
|
89
|
+
* flight are unaffected
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} code
|
|
94
|
+
* @param {string} reason
|
|
95
|
+
* @returns {ContractHostError}
|
|
96
|
+
*/
|
|
97
|
+
function host(code, reason) {
|
|
98
|
+
return new ContractHostError(code, `serveHttp: ${reason}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The header name of a header-located member: its name, lowercased.
|
|
103
|
+
* @param {string} member
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function headerNameOf(member) {
|
|
107
|
+
return member.toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Prepare one operation for the pipeline.
|
|
112
|
+
* @param {CompiledOperation} op
|
|
113
|
+
* @param {Handler | null} handler
|
|
114
|
+
* @returns {Route}
|
|
115
|
+
*/
|
|
116
|
+
function prepare(op, handler) {
|
|
117
|
+
const http = op.http;
|
|
118
|
+
const input = op.input;
|
|
119
|
+
const transport = input === null ? null : input.transport;
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
const pathMembers = [];
|
|
122
|
+
/** @type {Set<string>} */
|
|
123
|
+
const queryMembers = new Set();
|
|
124
|
+
/** @type {Set<string>} */
|
|
125
|
+
const repeated = new Set();
|
|
126
|
+
/** @type {string[]} */
|
|
127
|
+
const headerMembers = [];
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const headerNames = [];
|
|
130
|
+
/** @type {boolean[]} */
|
|
131
|
+
const headerArray = [];
|
|
132
|
+
/** @type {Set<string>} */
|
|
133
|
+
const nonBody = new Set();
|
|
134
|
+
let hasBody = http.body !== null;
|
|
135
|
+
if (transport !== null) {
|
|
136
|
+
for (let i = 0; i < transport.members.path.length; i++) pathMembers.push(transport.members.path[i]);
|
|
137
|
+
for (let i = 0; i < transport.members.query.length; i++) queryMembers.add(transport.members.query[i]);
|
|
138
|
+
for (let i = 0; i < transport.members.header.length; i++) {
|
|
139
|
+
const m = transport.members.header[i];
|
|
140
|
+
headerMembers.push(m);
|
|
141
|
+
headerNames.push(headerNameOf(m));
|
|
142
|
+
headerArray.push(transport.members.repeated.includes(m));
|
|
143
|
+
}
|
|
144
|
+
for (let i = 0; i < transport.members.repeated.length; i++) {
|
|
145
|
+
const m = transport.members.repeated[i];
|
|
146
|
+
if (queryMembers.has(m)) repeated.add(m);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const members = Object.keys(http.in);
|
|
150
|
+
for (let i = 0; i < members.length; i++) {
|
|
151
|
+
const m = members[i];
|
|
152
|
+
if (http.in[m] === 'body') hasBody = true;
|
|
153
|
+
else nonBody.add(m);
|
|
154
|
+
}
|
|
155
|
+
const retryOn = new Set(op.policy.retry === null ? [] : op.policy.retry.on);
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
op,
|
|
158
|
+
handler,
|
|
159
|
+
raw: http.opaque,
|
|
160
|
+
stream: op.kind === 'subscribe',
|
|
161
|
+
maxBody: op.policy.limits.maxBodyBytes,
|
|
162
|
+
media: http.media,
|
|
163
|
+
hasBody,
|
|
164
|
+
wholeBody: http.body,
|
|
165
|
+
nonBody,
|
|
166
|
+
pathMembers,
|
|
167
|
+
queryMembers,
|
|
168
|
+
repeated,
|
|
169
|
+
headerMembers,
|
|
170
|
+
headerNames,
|
|
171
|
+
headerArray,
|
|
172
|
+
normalize: transport === null ? null : transport.normalize,
|
|
173
|
+
validateInput: input === null ? null : input.validate,
|
|
174
|
+
validateOutput: op.output.validate,
|
|
175
|
+
details: op.policy.errors.details,
|
|
176
|
+
idempotency: op.policy.idempotency,
|
|
177
|
+
retryOn,
|
|
178
|
+
errors: op.errors,
|
|
179
|
+
status: http.status,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Serve a compiled contract over HTTP: a dispatcher whose `dispatch`
|
|
185
|
+
* routes, decodes, normalizes, validates, calls the handler, validates
|
|
186
|
+
* the output, applies idempotency and entity-tag policy and answers with
|
|
187
|
+
* the declared statuses and the D7 error body — a pure function over
|
|
188
|
+
* plain request/response objects. Refuses host mistakes at construction
|
|
189
|
+
* (`JC1001` handler table, `JC1002` missing handler, `JC1003` idempotency
|
|
190
|
+
* without a ledger).
|
|
191
|
+
*
|
|
192
|
+
* @param {Contract} contract
|
|
193
|
+
* @param {Record<string, Handler>} handlers - operation id → handler
|
|
194
|
+
* @param {ServeHttpOptions} [options]
|
|
195
|
+
* @returns {HttpDispatcher}
|
|
196
|
+
* @example
|
|
197
|
+
* const server = serveHttp(contract, {
|
|
198
|
+
* 'catalog.load': async (input, ctx) => { ctx.etag('r42'); return catalog; },
|
|
199
|
+
* 'product.save': (input, ctx) => saved ? product : ctx.fail('conflict', {}, { current }),
|
|
200
|
+
* }, { ledger: createMemoryLedger() });
|
|
201
|
+
* const response = await server.dispatch({ method: 'GET', url: '/api/catalog', headers: {}, body: null });
|
|
202
|
+
*/
|
|
203
|
+
export function serveHttp(contract, handlers, options = {}) {
|
|
204
|
+
if (contract === null || typeof contract !== 'object' || typeof contract.match !== 'function'
|
|
205
|
+
|| contract.operations === null || typeof contract.operations !== 'object') {
|
|
206
|
+
throw host('JC1001', 'the first argument must be a compiled contract (compileContract)');
|
|
207
|
+
}
|
|
208
|
+
if (handlers === null || typeof handlers !== 'object' || Array.isArray(handlers)) {
|
|
209
|
+
throw host('JC1001', 'handlers must be an object of operation id → function');
|
|
210
|
+
}
|
|
211
|
+
const partial = options.partial === true;
|
|
212
|
+
const ledger = options.ledger === undefined ? null : options.ledger;
|
|
213
|
+
if (ledger !== null && (typeof ledger !== 'object' || typeof ledger.claim !== 'function'
|
|
214
|
+
|| typeof ledger.commit !== 'function' || typeof ledger.fail !== 'function' || typeof ledger.lookup !== 'function')) {
|
|
215
|
+
throw host('JC1001', 'options.ledger must implement { claim, commit, fail, lookup }');
|
|
216
|
+
}
|
|
217
|
+
const names = Object.keys(handlers);
|
|
218
|
+
for (let i = 0; i < names.length; i++) {
|
|
219
|
+
const id = names[i];
|
|
220
|
+
if (!Object.hasOwn(contract.operations, id)) {
|
|
221
|
+
throw host('JC1001', `handlers names '${id}', which is not an operation of the contract`);
|
|
222
|
+
}
|
|
223
|
+
if (typeof handlers[id] !== 'function') {
|
|
224
|
+
throw host('JC1001', `the handler of '${id}' must be a function, got ${typeof handlers[id]}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** @type {Map<string, Route>} */
|
|
228
|
+
const routes = new Map();
|
|
229
|
+
for (let i = 0; i < contract.ids.length; i++) {
|
|
230
|
+
const id = contract.ids[i];
|
|
231
|
+
const op = contract.operations[id];
|
|
232
|
+
const handler = Object.hasOwn(handlers, id) ? handlers[id] : null;
|
|
233
|
+
if (handler === null && !partial) {
|
|
234
|
+
throw host('JC1002', `operation '${id}' has no handler (pass { partial: true } to answer 501 for it)`);
|
|
235
|
+
}
|
|
236
|
+
if (op.policy.idempotency !== 'none' && ledger === null) {
|
|
237
|
+
throw host('JC1003', `operation '${id}' declares policy.idempotency '${op.policy.idempotency}' and no ledger was given — this binding cannot carry idempotency without one`);
|
|
238
|
+
}
|
|
239
|
+
routes.set(id, prepare(op, handler));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const validateOutput = options.validateOutput === undefined ? 'always' : options.validateOutput;
|
|
243
|
+
if (validateOutput !== 'always' && validateOutput !== 'never') {
|
|
244
|
+
throw host('JC1001', "options.validateOutput must be 'always' or 'never'");
|
|
245
|
+
}
|
|
246
|
+
const head = options.head === undefined ? true : options.head === true;
|
|
247
|
+
const wellKnown = options.wellKnown === undefined ? WELL_KNOWN_PATH : options.wellKnown;
|
|
248
|
+
if (wellKnown !== false && (typeof wellKnown !== 'string' || wellKnown.charCodeAt(0) !== 0x2F)) {
|
|
249
|
+
throw host('JC1001', 'options.wellKnown must be an absolute path or false');
|
|
250
|
+
}
|
|
251
|
+
for (const [name, value] of [['trace', options.trace], ['scope', options.scope], ['errorBody', options.errorBody],
|
|
252
|
+
['onError', options.onError], ['now', options.now]]) {
|
|
253
|
+
if (value !== undefined && typeof value !== 'function') throw host('JC1001', `options.${name} must be a function`);
|
|
254
|
+
}
|
|
255
|
+
if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
|
|
256
|
+
throw host('JC1001', 'options.catalog must be a message catalog object');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @type {Server} */
|
|
260
|
+
const server = {
|
|
261
|
+
contract,
|
|
262
|
+
routes,
|
|
263
|
+
trace: options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace,
|
|
264
|
+
ledger,
|
|
265
|
+
scope: options.scope === undefined ? () => '' : options.scope,
|
|
266
|
+
head,
|
|
267
|
+
validateOutput: validateOutput === 'always',
|
|
268
|
+
wellKnown,
|
|
269
|
+
errorBody: options.errorBody === undefined ? null : options.errorBody,
|
|
270
|
+
onError: options.onError === undefined ? null : options.onError,
|
|
271
|
+
catalog: options.catalog === undefined ? null : compileMessageCatalog(options.catalog),
|
|
272
|
+
now: options.now === undefined ? Date.now : options.now,
|
|
273
|
+
described: { text: null },
|
|
274
|
+
streams: new Set(),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** @type {HttpCapabilities} */
|
|
278
|
+
const capabilities = Object.freeze({
|
|
279
|
+
name: 'http',
|
|
280
|
+
status: true,
|
|
281
|
+
headers: true,
|
|
282
|
+
media: true,
|
|
283
|
+
head,
|
|
284
|
+
etag: true,
|
|
285
|
+
idempotency: ledger !== null,
|
|
286
|
+
validatedOutput: server.validateOutput,
|
|
287
|
+
stream: true,
|
|
288
|
+
cancel: 'signal',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return Object.freeze({
|
|
292
|
+
dispatch: (request) => dispatch(server, request),
|
|
293
|
+
capabilities,
|
|
294
|
+
contract,
|
|
295
|
+
describe: () => contract.describe(),
|
|
296
|
+
close: () => {
|
|
297
|
+
// each stopper removes itself from the set as it ends
|
|
298
|
+
for (const stop of [...server.streams]) stop('server-shutdown');
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|