@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
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;
|
package/src/messages.js
ADDED
|
@@ -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);
|