@jarenjs/contract 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
@@ -0,0 +1,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
+ }