@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
package/src/ledger.js ADDED
@@ -0,0 +1,225 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Idempotency as data: the ledger INTERFACE the http server
4
+ * binding calls when an operation declares `policy.idempotency`, a
5
+ * `createMemoryLedger` reference implementation for tests and
6
+ * single-process hosts, and the two documents a durable host opens with
7
+ * the rest of the suite — `idempotencyLedgerModel` (a `$model` 0.1
8
+ * document for `@jarenjs/db`) and `commandLifecycleFsm` (a `$fsm` 0.1
9
+ * document for `@jarenjs/flow`). Both are JSON only: this package never
10
+ * imports db or flow (docs/CONTRACT-FORMAT.md §8).
11
+ *
12
+ * The three identities stay apart here as everywhere: the idempotency
13
+ * KEY is the caller's (sent as `Idempotency-Key`, scoped by the host's
14
+ * `scope`), the request HASH is the binding's (SHA-256 over the RFC 8785
15
+ * canonical input), and the TRACE is never stored — a replay carries a
16
+ * fresh one.
17
+ */
18
+
19
+ /**
20
+ * The record a ledger keeps per `(op, scope, key)`; the schema of
21
+ * `idempotencyLedgerModel`'s collection.
22
+ * @typedef {Object} LedgerRecord
23
+ * @property {string} id - `"<op>|<scope>|<key>"`
24
+ * @property {string} op
25
+ * @property {string} scope
26
+ * @property {string} key
27
+ * @property {string} hash - lowercase hex SHA-256 over the canonical input
28
+ * @property {'started' | 'committed' | 'failed'} status
29
+ * @property {any} response - the stored `{ status, headers, body }`, or null
30
+ * @property {boolean | null} retryable - of a failed record; null otherwise
31
+ * @property {number} createdAt - epoch ms
32
+ * @property {number} updatedAt - epoch ms
33
+ * @property {number} expiresAt - epoch ms
34
+ */
35
+
36
+ /**
37
+ * The claim result: `new` hands back a `ref` to commit or fail; `replay`
38
+ * carries the stored response; `in-progress` and `mismatch` are the two
39
+ * 409 answers.
40
+ * @typedef {{ state: 'new', ref: unknown }
41
+ * | { state: 'replay', response: any }
42
+ * | { state: 'in-progress' }
43
+ * | { state: 'mismatch' }} ClaimResult
44
+ */
45
+
46
+ /**
47
+ * The ledger interface the http binding calls. Every method may return
48
+ * its value or a promise of it — the binding composes with `chain`, so a
49
+ * synchronous ledger costs no promise. Semantics the binding relies on:
50
+ * same key + same hash → `replay` of the stored response; same key +
51
+ * different hash → `mismatch`; `started` and unexpired → `in-progress`;
52
+ * `failed` with `retryable: true` → `new` (the key may be retried);
53
+ * `failed` and not retryable → `replay` of the stored failure. `now` on
54
+ * a claim is the binding's clock (epoch ms) a ledger may prefer to its
55
+ * own.
56
+ * @typedef {Object} Ledger
57
+ * @property {(claim: { op: string, scope: string, key: string, hash: string, now?: number }) => ClaimResult | Promise<ClaimResult>} claim
58
+ * @property {(ref: unknown, response: any) => void | Promise<void>} commit
59
+ * @property {(ref: unknown, retryable: boolean, response?: any) => void | Promise<void>} fail
60
+ * @property {(key: { op: string, scope: string, key: string }) => LedgerRecord | null | Promise<LedgerRecord | null>} lookup
61
+ */
62
+
63
+ /** One day, the default retention of a key. */
64
+ const DEFAULT_TTL_MS = 86_400_000;
65
+
66
+ /**
67
+ * @param {string} op
68
+ * @param {string} scope
69
+ * @param {string} key
70
+ * @returns {string}
71
+ */
72
+ function idOf(op, scope, key) {
73
+ return `${op}|${scope}|${key}`;
74
+ }
75
+
76
+ /**
77
+ * The reference ledger over a `Map`: synchronous, single-process,
78
+ * expiring on `claim` (a record past `expiresAt` is dropped and the key
79
+ * is `new` again). `sweep()` drops every expired record — a host may
80
+ * call it on a timer.
81
+ * @param {{ ttlMs?: number, now?: () => number }} [options]
82
+ * @returns {Ledger & { sweep(): number, size: number }}
83
+ */
84
+ export function createMemoryLedger(options = {}) {
85
+ const ttlMs = options.ttlMs === undefined ? DEFAULT_TTL_MS : options.ttlMs;
86
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('createMemoryLedger: ttlMs must be a positive number');
87
+ const clock = options.now === undefined ? Date.now : options.now;
88
+ if (typeof clock !== 'function') throw new TypeError('createMemoryLedger: now must be a function');
89
+ /** @type {Map<string, LedgerRecord>} */
90
+ const records = new Map();
91
+
92
+ return {
93
+ claim({ op, scope, key, hash, now }) {
94
+ const at = typeof now === 'number' ? now : clock();
95
+ const id = idOf(op, scope, key);
96
+ const existing = records.get(id);
97
+ if (existing !== undefined) {
98
+ if (existing.expiresAt <= at) records.delete(id);
99
+ else if (existing.hash !== hash) return { state: 'mismatch' };
100
+ else if (existing.status === 'started') return { state: 'in-progress' };
101
+ else if (existing.status === 'committed') return { state: 'replay', response: existing.response };
102
+ else if (existing.retryable !== true && existing.response !== null) return { state: 'replay', response: existing.response };
103
+ else records.delete(id);
104
+ }
105
+ /** @type {LedgerRecord} */
106
+ const record = {
107
+ id, op, scope, key, hash, status: 'started', response: null, retryable: null,
108
+ createdAt: at, updatedAt: at, expiresAt: at + ttlMs,
109
+ };
110
+ records.set(id, record);
111
+ return { state: 'new', ref: record };
112
+ },
113
+ commit(ref, response) {
114
+ const record = /** @type {LedgerRecord} */ (ref);
115
+ if (records.get(record.id) !== record) return;
116
+ record.status = 'committed';
117
+ record.response = response;
118
+ record.retryable = null;
119
+ record.updatedAt = clock();
120
+ },
121
+ fail(ref, retryable, response) {
122
+ const record = /** @type {LedgerRecord} */ (ref);
123
+ if (records.get(record.id) !== record) return;
124
+ record.status = 'failed';
125
+ record.retryable = retryable === true;
126
+ record.response = response === undefined ? null : response;
127
+ record.updatedAt = clock();
128
+ },
129
+ lookup({ op, scope, key }) {
130
+ const record = records.get(idOf(op, scope, key));
131
+ if (record === undefined) return null;
132
+ if (record.expiresAt <= clock()) {
133
+ records.delete(record.id);
134
+ return null;
135
+ }
136
+ return record;
137
+ },
138
+ sweep() {
139
+ const at = clock();
140
+ let dropped = 0;
141
+ for (const [id, record] of records) {
142
+ if (record.expiresAt <= at) {
143
+ records.delete(id);
144
+ dropped++;
145
+ }
146
+ }
147
+ return dropped;
148
+ },
149
+ get size() {
150
+ return records.size;
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The `$model` 0.1 document of a durable ledger: one collection,
157
+ * `ledger`, keyed by `/id` (`"<op>|<scope>|<key>"`), indexed on
158
+ * `expiresAt` (the sweep) and `status` (the in-flight scan). A host
159
+ * opens it with `@jarenjs/db`'s `openStore` and implements the `Ledger`
160
+ * interface over the collection; the record shape is exactly what
161
+ * `createMemoryLedger` keeps.
162
+ */
163
+ export const idempotencyLedgerModel = Object.freeze({
164
+ $model: '0.1',
165
+ collections: {
166
+ ledger: {
167
+ schema: {
168
+ type: 'object',
169
+ required: ['id', 'op', 'scope', 'key', 'hash', 'status', 'response', 'retryable', 'createdAt', 'updatedAt', 'expiresAt'],
170
+ properties: {
171
+ id: { type: 'string', minLength: 1 },
172
+ op: { type: 'string', minLength: 1 },
173
+ scope: { type: 'string' },
174
+ key: { type: 'string', minLength: 1 },
175
+ hash: { type: 'string', pattern: '^[0-9a-f]{64}$' },
176
+ status: { type: 'string', enum: ['started', 'committed', 'failed'] },
177
+ response: {
178
+ oneOf: [
179
+ { type: 'null' },
180
+ {
181
+ type: 'object',
182
+ required: ['status', 'headers', 'body'],
183
+ properties: {
184
+ status: { type: 'integer', minimum: 100, maximum: 599 },
185
+ headers: { type: 'object', additionalProperties: { type: 'string' } },
186
+ body: { type: ['string', 'null'] },
187
+ },
188
+ },
189
+ ],
190
+ },
191
+ retryable: { type: ['boolean', 'null'] },
192
+ createdAt: { type: 'integer' },
193
+ updatedAt: { type: 'integer' },
194
+ expiresAt: { type: 'integer' },
195
+ },
196
+ additionalProperties: false,
197
+ },
198
+ key: '/id',
199
+ indexes: [
200
+ { name: 'by_expires', path: '$.expiresAt' },
201
+ { name: 'by_status', path: '$.status' },
202
+ ],
203
+ },
204
+ },
205
+ });
206
+
207
+ /**
208
+ * The `$fsm` 0.1 document of one command's lifecycle under an
209
+ * idempotency key: `idle → started` on `claim`, `started → committed` on
210
+ * `commit`, `started → failed` on `fail`, and `failed → started` on
211
+ * `claim` only when the failure was retryable (`$.context.retryable`).
212
+ * A host compiles it with `@jarenjs/flow` to drive or audit a durable
213
+ * ledger; the memory ledger walks exactly these transitions.
214
+ */
215
+ export const commandLifecycleFsm = Object.freeze({
216
+ $fsm: '0.1',
217
+ initial: 'idle',
218
+ states: ['idle', 'started', { id: 'committed', final: true }, 'failed'],
219
+ transitions: [
220
+ { from: 'idle', event: 'claim', to: 'started' },
221
+ { from: 'started', event: 'commit', to: 'committed' },
222
+ { from: 'started', event: 'fail', to: 'failed' },
223
+ { from: 'failed', event: 'claim', guard: '$.context.retryable', to: 'started' },
224
+ ],
225
+ });
@@ -0,0 +1,363 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `openLocalClient(contract, handlers, options)`: the in-process
4
+ * binding of a compiled contract (docs/CONTRACT-FORMAT.md §15) — the
5
+ * same operation pipeline the HTTP server runs, with no wire: the test
6
+ * seam, SSR, a CLI. The client and the server are one object;
7
+ * `serveLocal` is the same factory under the serve name, for symmetry
8
+ * with the other bindings.
9
+ *
10
+ * `invoke(op, input, ctx)` validates the input with the operation's
11
+ * compiled validator (a refusal is the `JC2050` outcome — nothing ran),
12
+ * runs the neutral pipeline against the handler with a frozen context
13
+ * `{ op, trace, signal, params: null, headers: {}, fail, idempotency:
14
+ * null }`, and resolves a D6 outcome: a declared failure is `kind:
15
+ * "failure"` with `status: null` (the member present, never omitted —
16
+ * this binding carries no statuses and `capabilities` says so); a
17
+ * handler fault of any class — a throw, an undeclared code, a broken
18
+ * output or error-details schema — is `kind: "contract"` `JC2070`, its
19
+ * cause reported to `onError`, so a host bug is never mistaken for a
20
+ * declared failure; an aborted `ctx.signal` (or `close()`) is `kind:
21
+ * "cancelled"`, and a handler still running then settles into nothing.
22
+ *
23
+ * What this binding cannot carry, it refuses or ignores loudly:
24
+ * statuses, headers, entity tags and non-JSON media do not exist here
25
+ * (`invoke` of an opaque operation throws `JC1005`; a handler given for
26
+ * one is accepted so an HTTP handler table can be reused verbatim, and
27
+ * never called); a declared `policy.idempotency` is ALLOWED and ignored
28
+ * — the same contract must serve over http and locally, and re-running
29
+ * a command in one process is the caller's own hand — with
30
+ * `capabilities.idempotency: false` saying so.
31
+ */
32
+
33
+ import { compileMessageCatalog } from '@jarenjs/core/message';
34
+
35
+ import { ContractHostError, ContractFailure } from '../errors.js';
36
+ import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS } from '../pipeline.js';
37
+ import { renderMessage, declaredMessage } from '../http/wire.js';
38
+ import {
39
+ prepareOutcomeRoute, assembleOutcome, makeMeta, failedOutcome, outcomeError, clientError,
40
+ } from '../client/outcome.js';
41
+
42
+ export { PORT_LOCAL_ERRORS };
43
+
44
+ /**
45
+ * @typedef {import('../compile.js').Contract} Contract
46
+ * @typedef {import('../compile.js').CompiledOperation} CompiledOperation
47
+ * @typedef {import('../http/wire.js').Catalog} Catalog
48
+ * @typedef {import('../http/dispatch.js').Handler} Handler
49
+ * @typedef {import('../client/outcome.js').Outcome} Outcome
50
+ * @typedef {import('../client/outcome.js').OutcomeMeta} OutcomeMeta
51
+ * @typedef {import('../pipeline.js').PipelineRoute} PipelineRoute
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} LocalOptions
56
+ * @property {() => string} [trace] - the trace generator; default `crypto.randomUUID`
57
+ * @property {'always' | 'never'} [validateOutput] - `'never'` is a declared
58
+ * downgrade, reported in `capabilities.validatedOutput`
59
+ * @property {Record<string, string | ((params: object) => string)>} [catalog]
60
+ * - a message catalog consulted before the English one
61
+ * @property {(error: unknown, ctx: { op: string, trace: string } | null) => void} [onError]
62
+ * - observes the cause behind every `JC2070` outcome and validator throw
63
+ */
64
+
65
+ /**
66
+ * Per-call context of `invoke` — the client half's, not the handler's.
67
+ * @typedef {Object} LocalInvokeContext
68
+ * @property {AbortSignal} [signal] - resolves the outcome `cancelled`
69
+ * @property {unknown} [attempt] - the caller's attempt id, echoed in `meta.attempt`
70
+ */
71
+
72
+ /**
73
+ * The frozen capabilities table of the local binding: no wire, so no
74
+ * statuses, headers, media, entity tags or idempotency carriage — a
75
+ * contract declaring them still serves (the declarations describe its
76
+ * HTTP life), and this table is how a consumer knows they are inert here.
77
+ * @typedef {Object} LocalCapabilities
78
+ * @property {'local'} name
79
+ * @property {false} status
80
+ * @property {false} headers
81
+ * @property {false} media
82
+ * @property {false} etag
83
+ * @property {false} idempotency
84
+ * @property {boolean} validatedOutput - the output validator runs
85
+ * @property {false} stream
86
+ * @property {'signal'} cancel
87
+ */
88
+
89
+ /**
90
+ * The local client — client and server in one object.
91
+ * @typedef {Object} LocalClient
92
+ * @property {(op: string, input?: unknown, ctx?: LocalInvokeContext) => Promise<Outcome>} invoke
93
+ * @property {LocalCapabilities} capabilities
94
+ * @property {Contract} contract
95
+ * @property {() => any} describe
96
+ * @property {() => void} close - later invokes resolve `cancelled`; running handlers see their signal abort
97
+ */
98
+
99
+ /** The frozen empty header table every local handler context carries. */
100
+ const NO_HEADERS = Object.freeze({});
101
+
102
+ /** The sentinel `race` resolves when the signal wins. */
103
+ const ABORTED = Symbol('aborted');
104
+
105
+ /**
106
+ * @param {string} code
107
+ * @param {string} reason
108
+ * @returns {ContractHostError}
109
+ */
110
+ function host(code, reason) {
111
+ return new ContractHostError(code, `serveLocal: ${reason}`);
112
+ }
113
+
114
+ /**
115
+ * One operation as this binding prepared it: the pipeline's subset plus
116
+ * the outcome assembly. The outcome route's output validator is a
117
+ * pass-through — the pipeline already validated (or the host declared
118
+ * `validateOutput: 'never'`), and one validation per invoke is the point
119
+ * of having no wire.
120
+ * @typedef {PipelineRoute & { hasInput: boolean, outcome: import('../client/outcome.js').OutcomeRoute }} LocalRoute
121
+ */
122
+
123
+ /**
124
+ * @param {CompiledOperation} op
125
+ * @param {Handler | null} handler
126
+ * @returns {LocalRoute}
127
+ */
128
+ function prepare(op, handler) {
129
+ return Object.freeze({
130
+ op,
131
+ handler,
132
+ raw: op.http.opaque,
133
+ validateInput: op.input === null ? null : op.input.validate,
134
+ validateOutput: op.output.validate,
135
+ details: op.policy.errors.details,
136
+ errors: op.errors,
137
+ retryOn: new Set(op.policy.retry === null ? [] : op.policy.retry.on),
138
+ hasInput: op.input !== null,
139
+ outcome: Object.freeze({ ...prepareOutcomeRoute(op), validateOutput: () => true }),
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Race the pipeline's settlement against the abort signal: the first
145
+ * one wins, and a handler that settles after the abort settles into
146
+ * nothing (the state-side id guard is the caller's guarantee; this is
147
+ * the honest local reading of "the request was cancelled").
148
+ * @param {Promise<import('../pipeline.js').OperationResult>} settled
149
+ * @param {AbortSignal} signal
150
+ * @returns {Promise<import('../pipeline.js').OperationResult | typeof ABORTED>}
151
+ */
152
+ function race(settled, signal) {
153
+ return new Promise((resolve) => {
154
+ const onAbort = () => resolve(ABORTED);
155
+ signal.addEventListener('abort', onAbort, { once: true });
156
+ settled.then((result) => {
157
+ signal.removeEventListener('abort', onAbort);
158
+ resolve(result);
159
+ });
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Serve a compiled contract in-process and call it through the same
165
+ * object: the `open(contract) → Client` half and the `serve(contract,
166
+ * handlers)` half of the driver pair are one here. Construction refuses
167
+ * host mistakes (`JC1001` handler table or option, `JC1002` missing
168
+ * handler — an opaque operation is exempt: this binding cannot invoke
169
+ * it, and an HTTP handler table that carries one may be reused verbatim).
170
+ *
171
+ * @param {Contract} contract
172
+ * @param {Record<string, Handler>} handlers - operation id → handler
173
+ * @param {LocalOptions} [options]
174
+ * @returns {LocalClient}
175
+ * @throws {ContractHostError}
176
+ * @example
177
+ * const client = openLocalClient(contract, {
178
+ * 'catalog.load': () => catalog,
179
+ * 'product.save': (input, ctx) => saved ? product : ctx.fail('conflict', {}, { current }),
180
+ * });
181
+ * const outcome = await client.invoke('catalog.load', { since: '2026-01-01T00:00:00Z' });
182
+ * if (!outcome.ok && outcome.kind === 'failure') show(outcome.error.code); // status is null here
183
+ */
184
+ export function openLocalClient(contract, handlers, options = {}) {
185
+ if (contract === null || typeof contract !== 'object' || typeof contract.match !== 'function'
186
+ || contract.operations === null || typeof contract.operations !== 'object' || !Array.isArray(contract.ids)) {
187
+ throw host('JC1001', 'the first argument must be a compiled contract (compileContract)');
188
+ }
189
+ if (handlers === null || typeof handlers !== 'object' || Array.isArray(handlers)) {
190
+ throw host('JC1001', 'handlers must be an object of operation id → function');
191
+ }
192
+ const names = Object.keys(handlers);
193
+ for (let i = 0; i < names.length; i++) {
194
+ const id = names[i];
195
+ if (!Object.hasOwn(contract.operations, id)) {
196
+ throw host('JC1001', `handlers names '${id}', which is not an operation of the contract`);
197
+ }
198
+ if (typeof handlers[id] !== 'function') {
199
+ throw host('JC1001', `the handler of '${id}' must be a function, got ${typeof handlers[id]}`);
200
+ }
201
+ }
202
+ if (options === null || typeof options !== 'object') throw host('JC1001', 'options must be an object');
203
+ const validateOutput = options.validateOutput === undefined ? 'always' : options.validateOutput;
204
+ if (validateOutput !== 'always' && validateOutput !== 'never') {
205
+ throw host('JC1001', "options.validateOutput must be 'always' or 'never'");
206
+ }
207
+ for (const [name, value] of [['trace', options.trace], ['onError', options.onError]]) {
208
+ if (value !== undefined && typeof value !== 'function') throw host('JC1001', `options.${name} must be a function`);
209
+ }
210
+ if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
211
+ throw host('JC1001', 'options.catalog must be a message catalog object');
212
+ }
213
+ const trace = options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace;
214
+ const onError = options.onError === undefined ? null : options.onError;
215
+ /** @type {Catalog | null} */
216
+ const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
217
+ const validate = validateOutput === 'always';
218
+
219
+ /** @type {Map<string, LocalRoute>} */
220
+ const routes = new Map();
221
+ for (let i = 0; i < contract.ids.length; i++) {
222
+ const id = contract.ids[i];
223
+ const op = contract.operations[id];
224
+ const handler = Object.hasOwn(handlers, id) ? handlers[id] : null;
225
+ // an opaque operation cannot be invoked here, and a subscribe
226
+ // operation cannot be streamed here (capabilities.stream is false) —
227
+ // neither demands a handler, so an HTTP handler table reuses verbatim
228
+ if (handler === null && !op.http.opaque && op.kind !== 'subscribe') {
229
+ throw host('JC1002', `operation '${id}' has no handler`);
230
+ }
231
+ routes.set(id, prepare(op, handler));
232
+ }
233
+
234
+ const closer = new AbortController();
235
+ let closed = false;
236
+
237
+ /**
238
+ * Report a fault to the host observer. TOTAL.
239
+ * @param {unknown} error
240
+ * @param {{ op: string, trace: string } | null} ctx
241
+ */
242
+ function observe(error, ctx) {
243
+ if (onError === null) return;
244
+ try {
245
+ onError(error, ctx);
246
+ }
247
+ catch {
248
+ // an observer that throws never reaches the outcome
249
+ }
250
+ }
251
+
252
+ /**
253
+ * @param {LocalRoute} route
254
+ * @param {OutcomeMeta} meta
255
+ * @returns {Outcome}
256
+ */
257
+ function cancelled(route, meta) {
258
+ return failedOutcome('cancelled', clientError(catalog, 'JC2052', { op: route.outcome.id }, null, undefined), meta);
259
+ }
260
+
261
+ /**
262
+ * @param {string} op
263
+ * @param {unknown} [input]
264
+ * @param {LocalInvokeContext} [ctx]
265
+ * @returns {Promise<Outcome>}
266
+ */
267
+ async function invoke(op, input, ctx = {}) {
268
+ const route = routes.get(op);
269
+ if (route === undefined) {
270
+ throw new ContractHostError('JC1005', `client: '${String(op)}' is not an operation of the contract`);
271
+ }
272
+ if (route.raw) {
273
+ throw new ContractHostError('JC1005', `client: '${route.outcome.id}' is an opaque operation (media ${route.op.http.media}); the local binding carries JSON only (capabilities.media is false)`);
274
+ }
275
+ if (route.op.kind === 'subscribe') {
276
+ throw new ContractHostError('JC1005', `client: '${route.outcome.id}' is a subscribe operation; the local binding cannot carry a stream (capabilities.stream is false)`);
277
+ }
278
+ if (ctx === null || typeof ctx !== 'object') throw host('JC1001', 'ctx must be an object');
279
+ const meta = makeMeta(route.outcome.id, ctx.attempt, null);
280
+ const caller = ctx.signal === undefined || ctx.signal === null ? null : ctx.signal;
281
+ if ((caller !== null && caller.aborted) || closed) return cancelled(route, meta);
282
+
283
+ // 1. validate — the same verdict the pipeline would reach, once
284
+ let value;
285
+ if (!route.hasInput) {
286
+ if (input !== undefined && input !== null) {
287
+ return failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.outcome.id }, null,
288
+ [{ path: '', keyword: 'input' }]), meta);
289
+ }
290
+ value = null;
291
+ }
292
+ else {
293
+ value = input === undefined || input === null ? {} : input;
294
+ const invalid = validateOperationInput(route, value);
295
+ if (invalid !== null && invalid.kind === 'contract') {
296
+ if (invalid.cause !== undefined) observe(invalid.cause, null);
297
+ return failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.outcome.id }, null, invalid.details), meta);
298
+ }
299
+ }
300
+
301
+ // 2. run the neutral pipeline under the composed signal
302
+ const signal = caller === null ? closer.signal : AbortSignal.any([closer.signal, caller]);
303
+ const id = safeTrace(trace);
304
+ meta.trace = id;
305
+ const handlerCtx = Object.freeze({
306
+ op: route.op, trace: id, signal, params: null, headers: NO_HEADERS,
307
+ fail: ContractFailure, idempotency: null,
308
+ });
309
+ const result = await race(settleOperation(route, value, handlerCtx, validate), signal);
310
+ if (result === ABORTED) return cancelled(route, meta);
311
+
312
+ // 3. assemble the D6 outcome
313
+ if (result.kind === 'contract') {
314
+ if (result.cause !== undefined) observe(result.cause, { op: route.outcome.id, trace: id });
315
+ return failedOutcome('contract', outcomeError('JC2070',
316
+ renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: route.outcome.id }), null, null, false), meta);
317
+ }
318
+ if (result.kind === 'failure') {
319
+ return assembleOutcome(route.outcome, {
320
+ status: null,
321
+ headers: null,
322
+ error: {
323
+ code: result.code,
324
+ message: declaredMessage(catalog, route.outcome.id, result.code, result.params),
325
+ details: result.details,
326
+ retryable: result.retryable,
327
+ },
328
+ }, meta, catalog);
329
+ }
330
+ return assembleOutcome(route.outcome, { status: null, headers: null, value: result.value }, meta, catalog);
331
+ }
332
+
333
+ /** @type {LocalCapabilities} */
334
+ const capabilities = Object.freeze({
335
+ name: 'local',
336
+ status: false,
337
+ headers: false,
338
+ media: false,
339
+ etag: false,
340
+ idempotency: false,
341
+ validatedOutput: validate,
342
+ stream: false,
343
+ cancel: 'signal',
344
+ });
345
+
346
+ return Object.freeze({
347
+ invoke,
348
+ capabilities,
349
+ contract,
350
+ describe: () => contract.describe(),
351
+ close: () => {
352
+ closed = true;
353
+ closer.abort();
354
+ },
355
+ });
356
+ }
357
+
358
+ /**
359
+ * The serve name of the same factory: the server half IS the client half
360
+ * here — one object, both names, for symmetry with `serveHttp`/
361
+ * `openHttpClient` and `servePort`/`openPortClient`.
362
+ */
363
+ export const serveLocal = openLocalClient;
@@ -0,0 +1,68 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The English message catalog: one template per `contract/*` msgid
4
+ * a binding can answer with — the HTTP taxonomy, the port/local codes,
5
+ * `contract/handler-error` (the generic text for a declared operation
6
+ * error that has no message of its own) — and one per client-originated
7
+ * outcome (the `JC205x` codes a client resolves without a server
8
+ * message). Compiled ONCE at module scope
9
+ * with `@jarenjs/core`'s `compileMessageCatalog` (the two-stage house
10
+ * rule applied to messages) and exported in plain form for the locale
11
+ * packs to mirror key for key.
12
+ *
13
+ * A message never interpolates a request value: the parameters are the
14
+ * operation id, a declared limit, a media type, a method list, a
15
+ * declared header member name, a declared error code, a status, a
16
+ * platform error's NAME or a contract version — trusted artifacts or
17
+ * protocol facts, never something a peer sent as content (the
18
+ * trust-boundary rule of docs/CONTRACT-FORMAT.md §7).
19
+ */
20
+
21
+ import { compileMessageCatalog } from '@jarenjs/core/message';
22
+
23
+ /**
24
+ * The plain English catalog: msgid → template (`{param}` placeholders).
25
+ * The keys are exactly the msgids of the CONTRACT-FORMAT.md §7 taxonomy,
26
+ * `contract/handler-error`, and the §10 client table; a test holds them
27
+ * equal.
28
+ */
29
+ export const contractMessagesEn = Object.freeze({
30
+ 'contract/not-found': 'no operation matches the request method and path',
31
+ 'contract/method-not-allowed': 'the path is served under other methods: {allow}',
32
+ 'contract/body-too-large': 'the request body of operation {op} exceeds its {limit}-byte limit',
33
+ 'contract/unsupported-media': 'operation {op} accepts {media} bodies only',
34
+ 'contract/malformed-json': 'the request body of operation {op} is not valid JSON',
35
+ 'contract/invalid-input': 'the input of operation {op} is invalid',
36
+ 'contract/idempotency-key-required': 'operation {op} requires an Idempotency-Key header',
37
+ 'contract/handler-failed': 'operation {op} failed',
38
+ 'contract/idempotency-conflict': 'the Idempotency-Key of operation {op} conflicts with an earlier request ({kind})',
39
+ 'contract/invalid-output': 'operation {op} produced a response that violates its contract',
40
+ 'contract/malformed-path': 'the request path carries a malformed percent-escape',
41
+ 'contract/malformed-query': 'the query string is not decodable',
42
+ 'contract/not-implemented': 'operation {op} is not implemented on this server',
43
+ 'contract/precondition-failed': 'the If-Match precondition of operation {op} failed',
44
+ 'contract/invalid-header': 'the {header} header of operation {op} is invalid',
45
+ 'contract/handler-error': 'operation {op} failed with {code}',
46
+ 'contract/client-invalid-input': 'the input of operation {op} is invalid; nothing was sent',
47
+ 'contract/network': 'the request of {op} did not complete ({name})',
48
+ 'contract/cancelled': 'the request of operation {op} was cancelled',
49
+ 'contract/invalid-response': 'the response of operation {op} violates its contract',
50
+ 'contract/key-storage-failed': 'the idempotency key of operation {op} could not be stored; nothing was sent',
51
+ 'contract/undeclared-response': 'operation {op} answered an undeclared response (status {status})',
52
+ 'contract/not-a-contract': 'the server does not describe contract {id} at its well-known path',
53
+ 'contract/incompatible': 'the server speaks version {server} of contract {id}; this client speaks {client} and neither end declares the other compatible',
54
+ 'contract/host-failed': 'operation {op} failed in the host before an outcome was produced',
55
+ 'contract/local-handler-failed': 'operation {op} failed in the serving host',
56
+ 'contract/unknown-operation': 'the request names no operation served on this channel',
57
+ 'contract/port-timeout': 'operation {op} got no answer on the channel within {ms}ms',
58
+ 'contract/malformed-frame': 'the response frame of operation {op} is malformed',
59
+ 'contract/channel-closed': 'the channel of operation {op} is closed',
60
+ 'contract/not-a-stream': 'the server answered the subscription of operation {op} with a non-stream response',
61
+ 'contract/invalid-snapshot': 'operation {op} produced a snapshot that violates its contract',
62
+ 'contract/seq-regression': 'the stream of operation {op} violated its seq order',
63
+ 'contract/stream-error': 'the stream of operation {op} ended with a server error ({code})',
64
+ 'contract/heartbeat-missed': 'the stream of operation {op} went silent for {ms}ms',
65
+ });
66
+
67
+ /** The compiled English catalog (module-level singleton). */
68
+ export const contractCatalogEn = compileMessageCatalog(contractMessagesEn);