@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/README.md
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# @jarenjs/contract
|
|
2
|
+
|
|
3
|
+
Operation contracts for the Jaren suite. A **`$contract` document** — the
|
|
4
|
+
sibling of `$model`, `$fsm` and `jaren-app` — declares the operations two
|
|
5
|
+
Jaren ends may exchange: JSON in, JSON out, each with a *kind* (`read` or
|
|
6
|
+
`command`), an input object schema, an output schema, declared errors, a
|
|
7
|
+
behavior *policy* and an HTTP *binding*. `compileContract` compiles it
|
|
8
|
+
**once** into per-operation validators, transport normalizers and a path
|
|
9
|
+
matcher whose static segments beat variables regardless of registration
|
|
10
|
+
order; `serveHttp` puts it behind HTTP as a **total** dispatch pipeline —
|
|
11
|
+
plain request in, plain response out, every request-caused failure a
|
|
12
|
+
coded response — with a `fetch` and a `node` adapter and idempotency
|
|
13
|
+
through a ledger interface; `openHttpClient` calls it from the other end
|
|
14
|
+
with the same validator and resolves a JSON **outcome** for everything a
|
|
15
|
+
server or a network can do; `contractAppBinding` + `createContractEffect`
|
|
16
|
+
let a `@jarenjs/app` document call every operation through one generated
|
|
17
|
+
task slot per operation and one registered effect; and the
|
|
18
|
+
**projections** turn the same compiled contract into every artifact a
|
|
19
|
+
consumer wants beside the runtime — a browser-safe public subset that is
|
|
20
|
+
itself a `$contract` document, a valid OpenAPI 3.1 document, TypeScript
|
|
21
|
+
declarations with a typed operation map, Markdown reference docs and
|
|
22
|
+
`@jarenjs/ai` tool definitions — with a `jaren-contract` CLI whose
|
|
23
|
+
`--check` fails CI the moment an artifact drifts. The contract knows its
|
|
24
|
+
own identity: `contract.revision()` is the SHA-256 of the canonical
|
|
25
|
+
public projection, served at the well-known path and carried in every
|
|
26
|
+
outcome's `meta.revision`, and `diffContracts(a, b)` classifies what
|
|
27
|
+
changed between two versions — breaking, additive, neutral or honestly
|
|
28
|
+
**unknown** — by a published rule table, with `jaren-contract diff
|
|
29
|
+
--fail-on breaking` as the CI gate. A `subscribe` operation streams a
|
|
30
|
+
`@jarenjs/db` `live()`-shaped subscription — the snapshot, then
|
|
31
|
+
LIVE-FORMAT `{ patch, seq }` emissions — as Server-Sent Events over
|
|
32
|
+
http and as push frames over port, resumable by seq. Every wire error
|
|
33
|
+
speaks twelve languages: the `contract/*` message catalog ships English
|
|
34
|
+
in-package and all eleven `@jarenjs/locales` packs carry it, key for
|
|
35
|
+
key, enforced by the repository's parity tests.
|
|
36
|
+
|
|
37
|
+
Zero dependencies outside the suite: `@jarenjs/core`, `@jarenjs/json`,
|
|
38
|
+
`@jarenjs/validate`, and — reached only from the `./project` subpath, so
|
|
39
|
+
a bundle that never projects never carries it — `@jarenjs/emit`. No
|
|
40
|
+
`eval`, CSP-safe; the adapters need only the
|
|
41
|
+
platform's `Request`/`Response` or Node's `(req, res)`. The normative contract is
|
|
42
|
+
[docs/CONTRACT-FORMAT.md](docs/CONTRACT-FORMAT.md); the grammar is
|
|
43
|
+
published as JSON Schema in
|
|
44
|
+
[`schemas/jaren-contract.schema.json`](schemas/jaren-contract.schema.json)
|
|
45
|
+
(with a mechanically derived draft-07 twin).
|
|
46
|
+
|
|
47
|
+
## The document in one glance
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"$contract": "0.1",
|
|
52
|
+
"id": "shop",
|
|
53
|
+
"$defs": {
|
|
54
|
+
"Product": { "type": "object", "required": ["id", "name"],
|
|
55
|
+
"properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }
|
|
56
|
+
},
|
|
57
|
+
"operations": {
|
|
58
|
+
"catalog.load": {
|
|
59
|
+
"kind": "read",
|
|
60
|
+
"input": { "type": "object", "properties": { "since": { "type": "string", "format": "date-time" } } },
|
|
61
|
+
"output": { "type": "array", "items": { "$ref": "#/$defs/Product" } },
|
|
62
|
+
"policy": { "task": "switch", "cache": "revision" },
|
|
63
|
+
"http": { "method": "GET", "path": "/api/catalog" }
|
|
64
|
+
},
|
|
65
|
+
"product.save": {
|
|
66
|
+
"kind": "command",
|
|
67
|
+
"input": { "type": "object", "required": ["id", "revision", "product"], "properties": {
|
|
68
|
+
"id": { "type": "integer" }, "revision": { "type": "integer" },
|
|
69
|
+
"product": { "$ref": "#/$defs/Product" } } },
|
|
70
|
+
"output": { "$ref": "#/$defs/Product" },
|
|
71
|
+
"errors": { "conflict": { "status": 409 }, "not-found": { "status": 404 } },
|
|
72
|
+
"policy": { "idempotency": "required", "revision": "input:/revision" },
|
|
73
|
+
"http": { "method": "PUT", "path": "/api/products/{id}/master" }
|
|
74
|
+
},
|
|
75
|
+
"image.bytes": {
|
|
76
|
+
"kind": "read",
|
|
77
|
+
"input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
|
|
78
|
+
"output": true,
|
|
79
|
+
"http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
One input schema per operation; the binding only says **where** its
|
|
86
|
+
members travel (`path` variables, then `query` for a read and `body` for
|
|
87
|
+
a command by default, or an explicit `in` map). Path and query strings
|
|
88
|
+
are decoded by a normalizer compiled over exactly those members; body
|
|
89
|
+
members are never coerced. An operation without `http` is bound to the
|
|
90
|
+
canonical `POST /<op-id>`. A non-JSON `media` marks an operation
|
|
91
|
+
*opaque*: routed and matched, never validated as JSON.
|
|
92
|
+
|
|
93
|
+
## Compile once, use everywhere
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { compileContract } from '@jarenjs/contract';
|
|
97
|
+
|
|
98
|
+
const contract = compileContract(doc); // ContractCompileError (JC00xx, with docPath) on a bad document
|
|
99
|
+
|
|
100
|
+
const hit = contract.match('PUT', '/api/products/12/master');
|
|
101
|
+
hit.op.id; // 'product.save'
|
|
102
|
+
hit.params; // { id: '12' } — decoded strings
|
|
103
|
+
|
|
104
|
+
const op = contract.operations['product.save'];
|
|
105
|
+
op.http.in; // { id: 'path', revision: 'body', product: 'body' }
|
|
106
|
+
op.input.transport.normalize({ id: '12' }); // { id: 12 } — path/query strings → declared types
|
|
107
|
+
op.input.validate({ id: 12, revision: 3, product: { id: 12, name: 'x' } }).valid; // true
|
|
108
|
+
op.output.validate({ id: 12 }).valid; // false — the validator's collect-errors contract
|
|
109
|
+
op.policy; // every default materialized
|
|
110
|
+
op.errors.conflict.status; // 409
|
|
111
|
+
|
|
112
|
+
contract.describe(); // pure JSON: resolved bindings + policy, defaults marked `inferred`
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The compile is synchronous and total for a hostile document: an
|
|
116
|
+
unresolved `$ref`, an unknown member (the vocabulary is closed), a
|
|
117
|
+
`GET` with a body, two operations sharing a route shape, a reserved
|
|
118
|
+
template form — each is a `ContractCompileError` with a stable code and
|
|
119
|
+
the JSON Pointer of the member at fault, at compile, never at request
|
|
120
|
+
time.
|
|
121
|
+
|
|
122
|
+
## Serve it over HTTP
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
import { compileContract } from '@jarenjs/contract';
|
|
126
|
+
import { serveHttp } from '@jarenjs/contract/http';
|
|
127
|
+
import { toNodeHandler } from '@jarenjs/contract/node'; // or toFetchHandler from '@jarenjs/contract/fetch'
|
|
128
|
+
import { createMemoryLedger } from '@jarenjs/contract/ledger';
|
|
129
|
+
import http from 'node:http';
|
|
130
|
+
|
|
131
|
+
const server = serveHttp(compileContract(doc), {
|
|
132
|
+
'catalog.load': async (input, ctx) => { // input = { since? } — query strings already coerced
|
|
133
|
+
const catalog = await loadCatalog(input.since);
|
|
134
|
+
ctx.etag(String(catalog.revision)); // 304 on a matching If-None-Match, etag: W/"…" otherwise
|
|
135
|
+
return catalog; // validated against the output schema before it leaves
|
|
136
|
+
},
|
|
137
|
+
'product.save': async (input, ctx) => { // input = { id, revision, product } — path + body assembled
|
|
138
|
+
const saved = await save(input);
|
|
139
|
+
return saved ?? ctx.fail('conflict', {}, { current: await current(input.id) }); // 409, the declared code on the wire
|
|
140
|
+
},
|
|
141
|
+
'image.bytes': (input, ctx) => ({ status: 200, headers: { 'content-type': 'image/png' }, body: bytes(input.id) }), // opaque: raw
|
|
142
|
+
}, { ledger: createMemoryLedger() }); // required: product.save declares idempotency
|
|
143
|
+
|
|
144
|
+
http.createServer(toNodeHandler(server)).listen(8080);
|
|
145
|
+
|
|
146
|
+
// or drive it directly — a pure function over plain objects, no socket needed
|
|
147
|
+
const response = await server.dispatch({ method: 'GET', url: '/api/catalog?since=2026-01-01T00:00:00Z', headers: {}, body: null });
|
|
148
|
+
response.status; // 200
|
|
149
|
+
response.headers['x-jaren-trace']; // the server trace of this request
|
|
150
|
+
JSON.parse(response.body); // the catalog
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The pipeline routes (404/405 with `Allow`), enforces the body limit
|
|
154
|
+
(413) before reading, checks the media (415), parses (400), assembles the
|
|
155
|
+
input from path, query and headers through a prototype-safe setter,
|
|
156
|
+
normalizes the transport strings, validates (400 with `details` by
|
|
157
|
+
`policy.errors.details`), claims the idempotency key, calls the handler
|
|
158
|
+
through one promise boundary, validates the output (500 — the server
|
|
159
|
+
broke the contract), applies `If-Match`/`If-None-Match`, serializes.
|
|
160
|
+
Every non-2xx body is `{ code, message, requestId, details?, retryable }`
|
|
161
|
+
with `x-jaren-trace` on the response; a handler's thrown error never
|
|
162
|
+
reaches the wire (`onError` sees it). `server.capabilities` says what
|
|
163
|
+
the binding carries — `head`, `etag`, `idempotency`, `validatedOutput` —
|
|
164
|
+
and never degrades silently: an idempotent operation without a `ledger`
|
|
165
|
+
is refused at construction. `GET /.well-known/jaren-contract` answers
|
|
166
|
+
`describe()`. The normative pipeline, taxonomy and ledger interface are
|
|
167
|
+
[CONTRACT-FORMAT.md §7–§9](docs/CONTRACT-FORMAT.md#7-the-http-server-binding).
|
|
168
|
+
|
|
169
|
+
### Recipes: Fastify, Hono, Express
|
|
170
|
+
|
|
171
|
+
None of these is a dependency; each recipe is executed by a test that
|
|
172
|
+
imports the framework from the benchmark workspace.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
// Fastify — a catch-all route, the raw body handed to dispatch
|
|
176
|
+
const app = fastify();
|
|
177
|
+
app.removeAllContentTypeParsers();
|
|
178
|
+
app.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => done(null, body));
|
|
179
|
+
app.all('/*', async (req, reply) => {
|
|
180
|
+
const r = await server.dispatch({ method: req.method, url: req.url, headers: req.headers, body: req.body ?? null });
|
|
181
|
+
reply.code(r.status).headers(r.headers);
|
|
182
|
+
return r.body === null ? reply.send() : reply.send(r.body);
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
// Hono — the fetch handler is the whole app (Bun.serve, Deno, workers alike)
|
|
188
|
+
const app = new Hono();
|
|
189
|
+
app.all('*', (c) => toFetchHandler(server)(c.req.raw));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
// Express — the node handler is middleware
|
|
194
|
+
const app = express();
|
|
195
|
+
app.use(toNodeHandler(server));
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Call it from the other end
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
import { openHttpClient } from '@jarenjs/contract/client';
|
|
202
|
+
|
|
203
|
+
const client = openHttpClient(contract, { baseUrl: 'https://shop.example', timeoutMs: 5000 });
|
|
204
|
+
|
|
205
|
+
const loaded = await client.invoke('catalog.load', { since: '2026-01-01T00:00:00Z' }, { attempt: 1 });
|
|
206
|
+
// { ok: true, value: [...], meta: { op, attempt: 1, trace: '<x-jaren-trace>', revision, etag: 'W/"…"', notModified: false } }
|
|
207
|
+
// meta.revision is the server's contract revision once negotiate() has learned it, null before
|
|
208
|
+
|
|
209
|
+
const saved = await client.invoke('product.save', { id: 12, revision: 3, product }); // validated with the SAME validator the server runs, then PUT /api/products/12/master with the body members as JSON and a generated Idempotency-Key
|
|
210
|
+
if (!saved.ok) {
|
|
211
|
+
saved.kind; // 'failure' (a declared error or a JC2xxx the server answered) | 'network' | 'contract' | 'cancelled'
|
|
212
|
+
saved.error; // { code: 'conflict', message, status: 409, details: { current }, retryable: false } — JSON, never an Error
|
|
213
|
+
}
|
|
214
|
+
client.url('image.bytes', { id: 7 }); // 'https://shop.example/api/images/7' — an opaque operation is a URL, not an invoke
|
|
215
|
+
await client.negotiate(); // { compatible, reason: 'same-version' | 'server-accepts' | 'client-accepts' | 'version-mismatch' | 'unreachable' | 'not-a-contract', server, error }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`invoke` **never rejects** for anything a server or a network can do —
|
|
219
|
+
invalid input is refused before anything is sent (`JC2050`), a transport
|
|
220
|
+
failure is `network` (`JC2051`, the error's name only — never its text),
|
|
221
|
+
an abort is `cancelled` (`JC2052`), an invalid or undeclared response is
|
|
222
|
+
`contract` (`JC2053`/`JC2055`); it throws only for the host's own
|
|
223
|
+
mistake (`JC1005`: an unknown or opaque operation). The three identities
|
|
224
|
+
stay apart by construction: `meta.attempt` is the caller's and is never
|
|
225
|
+
read from a response, `meta.trace` is the server's `x-jaren-trace`, and
|
|
226
|
+
the idempotency key is generated here (`keys`, or `ctx.idempotencyKey`)
|
|
227
|
+
and travels only as `Idempotency-Key` — with a durable `storage` it is
|
|
228
|
+
recorded without the input and `client.pending()` lists what a restart
|
|
229
|
+
must reconcile. Retry runs only under a declared `policy.retry`. The
|
|
230
|
+
normative client is [CONTRACT-FORMAT.md §10](docs/CONTRACT-FORMAT.md#10-the-http-client-binding).
|
|
231
|
+
|
|
232
|
+
## Bindings: the same contract with no wire, or a message channel
|
|
233
|
+
|
|
234
|
+
HTTP is one of three bindings; the other two carry the SAME operations,
|
|
235
|
+
handlers and outcomes where no HTTP exists. **`local`** is the pipeline
|
|
236
|
+
in-process — the test seam, SSR, a CLI calling its own operations — with
|
|
237
|
+
the client and the server as one object:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
import { openLocalClient } from '@jarenjs/contract/local'; // serveLocal is the same factory
|
|
241
|
+
|
|
242
|
+
const client = openLocalClient(contract, handlers); // the serveHttp handler table, reused verbatim
|
|
243
|
+
const saved = await client.invoke('product.save', { id: 12, revision: 3, product });
|
|
244
|
+
// a declared failure: { ok: false, kind: 'failure', error: { code: 'conflict', …, status: null, … } }
|
|
245
|
+
// — status is null and PRESENT: this binding carries no statuses and says so, never omits the member
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The jaren website runs its whole data plane on this binding: a compiled
|
|
249
|
+
`$contract` declares the reads for its package census, build provenance,
|
|
250
|
+
benchmark artifacts and repository documents, the browser resolves every one
|
|
251
|
+
of them through `openLocalClient` with output validation on, and the build's
|
|
252
|
+
generators prove what they write against the output schema of the operation
|
|
253
|
+
the page will read it through. It is a static site — there is no server to
|
|
254
|
+
talk to — so the contract buys shape rather than transport: a drifted
|
|
255
|
+
artifact settles as a typed refusal instead of a wrong render.
|
|
256
|
+
|
|
257
|
+
**`port`** is request/response over a `MessagePort`, a `Worker`, a
|
|
258
|
+
`BroadcastChannel` or a worker's own `self` — JSON frames marked
|
|
259
|
+
`jaren: "contract/0.1"` (the grammar ships as
|
|
260
|
+
`schemas/jaren-contract-port.schema.json`), so contract traffic shares a
|
|
261
|
+
channel with anything else without touching it:
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
// inside the worker
|
|
265
|
+
import { servePort } from '@jarenjs/contract/port';
|
|
266
|
+
servePort(contract, handlers, { channel: self });
|
|
267
|
+
|
|
268
|
+
// in the page
|
|
269
|
+
import { openPortClient } from '@jarenjs/contract/port';
|
|
270
|
+
const client = openPortClient(contract, { channel: worker, timeoutMs: 15_000 });
|
|
271
|
+
const rows = await client.invoke('data.rows', { collection: 'notes' });
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Request ids are `"<clientId>:<seq>"` with a UUID per client instance,
|
|
275
|
+
and a client ignores every frame outside its own prefix — so two tabs
|
|
276
|
+
on one shared channel can never settle each other's requests, whatever
|
|
277
|
+
they fire concurrently (the repository's own data studio runs its
|
|
278
|
+
cross-tab db-owner protocol on exactly this). A handler fault answers
|
|
279
|
+
`JC2070` (kind `contract` — never dressed as a declared failure), an
|
|
280
|
+
unanswered request is `JC2072` after `timeoutMs`, cancellation crosses
|
|
281
|
+
as a `cancel` frame with the id scoping as the guarantee.
|
|
282
|
+
`createContractEffect` and `contractTools` take these clients unchanged
|
|
283
|
+
— they read `invoke` and nothing else. What each binding carries, from
|
|
284
|
+
its frozen `capabilities`:
|
|
285
|
+
|
|
286
|
+
| capability | `http` server / client | `local` | `port` |
|
|
287
|
+
|---|---|---|---|
|
|
288
|
+
| `status` | yes | no (`error.status: null`) | no (`error.status: null`) |
|
|
289
|
+
| `headers` | yes | no | no |
|
|
290
|
+
| `media` (opaque operations) | yes | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
|
|
291
|
+
| `etag` | yes | no | no |
|
|
292
|
+
| `idempotency` | with a `ledger` / always sent | no — declared policy inert, stated | no — `key` reserved in the frame grammar |
|
|
293
|
+
| `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption | no (`JC1005` at invoke) | yes — push frames, per-client streams |
|
|
294
|
+
| `cancel` | `'signal'` | `'signal'` | `'message'` |
|
|
295
|
+
|
|
296
|
+
The normative bindings are [CONTRACT-FORMAT.md §15–§16](docs/CONTRACT-FORMAT.md#15-the-local-binding),
|
|
297
|
+
the stream wire [§17–§19](docs/CONTRACT-FORMAT.md#17-subscribe-operations).
|
|
298
|
+
|
|
299
|
+
## Call it from a @jarenjs/app document
|
|
300
|
+
|
|
301
|
+
```js
|
|
302
|
+
import { createApp, createTaskEffect } from '@jarenjs/app';
|
|
303
|
+
import { contractAppBinding, createContractEffect } from '@jarenjs/contract/app';
|
|
304
|
+
|
|
305
|
+
const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
|
|
306
|
+
// slice → { 'catalog.load': { id: 0, status: 'idle', kind: null, value: null, error: null, meta: null }, … } pure JSON, mount at /contract
|
|
307
|
+
// actions → 'contract/catalog.load/start' + '/done' + '/reset' per operation — the TASKS.md id guard built in
|
|
308
|
+
// schema → the slice's JSON Schema for validateState (value = the output schema or null)
|
|
309
|
+
|
|
310
|
+
const app = createApp({ state: { contract: slice }, view, actions: { ...actions, ...own } }, {
|
|
311
|
+
effects: { contract: createContractEffect(client, { createTaskEffect }) }, // ONE effect; the task mode comes from policy.task
|
|
312
|
+
});
|
|
313
|
+
app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A `subscribe` operation becomes a **subscription** instead of a task:
|
|
317
|
+
the binding additionally returns `subs` (spread into the app document)
|
|
318
|
+
and `createContractSubscription(client)` is the one `contract-stream`
|
|
319
|
+
handler they run — `start` flips the slot live, the snapshot and every
|
|
320
|
+
patch land id- and seq-guarded, and the maintained document keeps
|
|
321
|
+
LIVE's structural sharing because the handler applies the emissions
|
|
322
|
+
with `@jarenjs/json/patch` ([CONTRACT-FORMAT.md §11.4](docs/CONTRACT-FORMAT.md#114-subscribe-operations-the-generated-subscription)).
|
|
323
|
+
|
|
324
|
+
No route strings, no hand-written wrappers, and no import of
|
|
325
|
+
`@jarenjs/app` from this package — the documents cross as JSON and the
|
|
326
|
+
task-effect factory crosses as a function the host passes in. A
|
|
327
|
+
superseded read dispatches once with the newer result, an out-of-order
|
|
328
|
+
older response is rejected by the id guard, a double-dispatched command
|
|
329
|
+
runs once and its result lands, a slot can always be released with
|
|
330
|
+
`reset`, and every failure lands in state as the same outcome shape with
|
|
331
|
+
its kind beside it.
|
|
332
|
+
The runnable walkthrough is [docs/APP-INTEGRATION.md](docs/APP-INTEGRATION.md);
|
|
333
|
+
the normative binding is [CONTRACT-FORMAT.md §11](docs/CONTRACT-FORMAT.md#11-the-app-binding).
|
|
334
|
+
|
|
335
|
+
## Project it to everything else
|
|
336
|
+
|
|
337
|
+
One compiled contract, five artifacts — every one deterministic, every
|
|
338
|
+
one checkable in CI (`@jarenjs/contract/project`):
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
import { publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools } from '@jarenjs/contract/project';
|
|
342
|
+
|
|
343
|
+
publicProjection(contract); // the browser-safe subset — ITSELF a valid $contract document
|
|
344
|
+
// (server-audience operations, limits and error detail levels stripped;
|
|
345
|
+
// operations with `policy: { audience: 'server' }` never leave the server)
|
|
346
|
+
toOpenApi(contract, { info: { title: 'Shop', version: '5' } });
|
|
347
|
+
// → { document, dropped }: OpenAPI 3.1, validated in this repo against the
|
|
348
|
+
// official meta-schema; declared errors become enum-pinned wire-error
|
|
349
|
+
// schemas; policy rides along as x-jaren-policy; every keyword the dialect
|
|
350
|
+
// cannot carry is refused (JC0060) or — under lenient — dropped and REPORTED
|
|
351
|
+
toTypeScript(contract); // one .d.ts: CatalogLoadInput/Output per operation, a typed Operations map,
|
|
352
|
+
// Outcome<T>/Meta/WireError exactly as every binding builds them, and a
|
|
353
|
+
// typed Client and Handlers — invoke('product.save', …) is fully typed
|
|
354
|
+
toMarkdown(contract); // reference docs: operations table, per-operation sections, the type tables
|
|
355
|
+
contractTools(contract, client);
|
|
356
|
+
// @jarenjs/ai ToolDefs (WebMCP for free) without importing that package:
|
|
357
|
+
// name 'product_save', a self-contained inputSchema, execute → the outcome
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
And on the command line, the drift gate:
|
|
361
|
+
|
|
362
|
+
```sh
|
|
363
|
+
jaren-contract openapi --contract shop.json --out api/ --info-title Shop
|
|
364
|
+
jaren-contract types --contract shop.json --out src/shop.d.ts --check # exit 1 when stale
|
|
365
|
+
jaren-contract docs --contract shop.json --out docs/
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`describe` and `public` print JSON; exit 0 current/written, 1 drift
|
|
369
|
+
under `--check`, 2 on a compile refusal printed as `code docPath reason`.
|
|
370
|
+
The normative projection rules — the public projection's member order
|
|
371
|
+
(the revision hashes those bytes), the OpenAPI mapping and keyword
|
|
372
|
+
policy, the tool naming — are
|
|
373
|
+
[CONTRACT-FORMAT.md §12](docs/CONTRACT-FORMAT.md#12-projections).
|
|
374
|
+
|
|
375
|
+
## Know what changed: revision and diff
|
|
376
|
+
|
|
377
|
+
```js
|
|
378
|
+
import { diffContracts, isCompatible } from '@jarenjs/contract/diff';
|
|
379
|
+
|
|
380
|
+
await contract.revision();
|
|
381
|
+
// 64 lowercase hex: the SHA-256 over the RFC 8785 canonical bytes of the
|
|
382
|
+
// public projection — memoized, so it is computed at most once per process.
|
|
383
|
+
// Two compiles of equal documents agree across machines; a change a client
|
|
384
|
+
// can observe moves it; a server-audience operation or a policy.limits
|
|
385
|
+
// value does not. GET /.well-known/jaren-contract answers it (computed
|
|
386
|
+
// lazily on the first request), negotiate() learns it, and every outcome
|
|
387
|
+
// after that carries it in meta.revision — correlation data, never the
|
|
388
|
+
// compatibility decision.
|
|
389
|
+
|
|
390
|
+
const { breaking, additive, neutral, unknown } = diffContracts(v1, v2);
|
|
391
|
+
// every change classified by the CONTRACT-FORMAT §13 rule table (R1–R15):
|
|
392
|
+
// breaking — an operation or error removed, a binding member moved, a new
|
|
393
|
+
// required input member, a narrowed input, a removed/optional-
|
|
394
|
+
// ized/narrowed output member, idempotency now required, an
|
|
395
|
+
// operation withdrawn to audience: server
|
|
396
|
+
// additive — an operation/error added, an optional input member, a widened
|
|
397
|
+
// schema, a relaxed idempotency
|
|
398
|
+
// neutral — task mode, retry, cache, policy.revision, doc
|
|
399
|
+
// unknown — what the checker does not model (anyOf/if/not, a CHANGED
|
|
400
|
+
// pattern, an external $ref, an error details schema):
|
|
401
|
+
// REPORTED, never silently classed
|
|
402
|
+
// each Change = { kind, op, docPath, from?, to?, rule } — the docPath a
|
|
403
|
+
// validator error would name, $refs resolved
|
|
404
|
+
|
|
405
|
+
isCompatible(clientContract, serverContract);
|
|
406
|
+
// the negotiation rule as a pure function (same version, or either end's
|
|
407
|
+
// compat names the other's) — the SAME implementation negotiate() runs,
|
|
408
|
+
// exported so a server can refuse an incompatible peer too
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
```sh
|
|
412
|
+
jaren-contract diff --from api/v1.json --to api/v2.json --fail-on breaking # exit 1 on a breaking change
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The revision answers "is this byte-for-byte the contract I compiled
|
|
416
|
+
against?"; `version`/`compat` answer "do the authors claim we speak?";
|
|
417
|
+
the diff answers "what exactly moved, and does it break me?". They are
|
|
418
|
+
three different questions and none is derived from another —
|
|
419
|
+
[CONTRACT-FORMAT.md §13–§14](docs/CONTRACT-FORMAT.md#13-the-breaking-change-diff).
|
|
420
|
+
|
|
421
|
+
## Benchmarks
|
|
422
|
+
|
|
423
|
+
Measured on the committed suite (`npm run benchmark:contract` — a real
|
|
424
|
+
123-route table, 47 GET; recipes, fairness decisions and the correctness
|
|
425
|
+
gates are in the file's header), published through the repository's
|
|
426
|
+
benchmark-figure gate so no number here is typed by hand:
|
|
427
|
+
|
|
428
|
+
- **Route match**: the compiled matcher resolves the probe mix —
|
|
429
|
+
static hot paths, variables, the static-beats-variable case, a miss —
|
|
430
|
+
at <!--bm:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 180 ns<!--/bm-->;
|
|
431
|
+
hono's TrieRouter is <!--bm:contract.match.vs-hono-->1.8x<!--/bm--> behind, and its RegExpRouter refuses this
|
|
432
|
+
route table outright (a static path registered after a param sibling).
|
|
433
|
+
- **Dispatch, in-process**: the whole pipeline (route, decode, validate
|
|
434
|
+
input, handler, validate output,
|
|
435
|
+
serialize) is <!--bm:contract.dispatch.vs-fastify-->2.8–15.1x<!--/bm-->
|
|
436
|
+
faster than Fastify driven through its own `inject` — a number that
|
|
437
|
+
includes Fastify's mock-stream harness, which is why the next row
|
|
438
|
+
exists.
|
|
439
|
+
- **The honest loss**: the bare pieces Fastify composes — find-my-way +
|
|
440
|
+
Ajv + fast-json-stringify, called directly with no harness and no
|
|
441
|
+
response validation
|
|
442
|
+
— are <!--bm:contract.dispatch.losses-->2.4–7.5x<!--/bm--> faster than
|
|
443
|
+
this pipeline. The wide end of that band is the bare `{ok:true}`
|
|
444
|
+
route, where the rival's compiled serializer answers in ~200 ns and
|
|
445
|
+
there is almost no work to amortize the pipeline against; on the
|
|
446
|
+
request shapes with real bodies and validation the loss sits at the
|
|
447
|
+
narrow end. That is the measured price of a total dispatch (every
|
|
448
|
+
hostile input settles into a coded response) that also proves the
|
|
449
|
+
server kept its own contract before a byte leaves. Over a real
|
|
450
|
+
loopback socket the two stacks are level: the socket dominates both.
|
|
451
|
+
- **Revision**: computing it
|
|
452
|
+
costs <!--bm:contract.revision.ms-->1.4 ms<!--/bm--> for the 123-operation
|
|
453
|
+
contract, once per process.
|
|
454
|
+
|
|
455
|
+
## What it is not
|
|
456
|
+
|
|
457
|
+
Boundaries, stated as plainly as the capabilities — each one a
|
|
458
|
+
deliberate decision, not a gap:
|
|
459
|
+
|
|
460
|
+
- **Not a server framework.** No process manager, no middleware stack,
|
|
461
|
+
no plugin system, no logger. The server binding is a pure dispatch
|
|
462
|
+
pipeline over plain request/response objects; bring `node:http`,
|
|
463
|
+
`Bun.serve`, or any framework through the ≤15-line adapter recipes
|
|
464
|
+
above.
|
|
465
|
+
- **No authentication or authorization.** A request that reaches the
|
|
466
|
+
pipeline is dispatched by route alone. Compose auth in front of the
|
|
467
|
+
handler table (the adapter seam is where a host's middleware already
|
|
468
|
+
runs) — the contract declares what may be said, not who may say it.
|
|
469
|
+
- **No transport encryption.** TLS belongs to the server or proxy that
|
|
470
|
+
terminates the socket.
|
|
471
|
+
- **No replay protection beyond idempotency keys.** `Idempotency-Key`
|
|
472
|
+
deduplicates a declared command through the host's ledger; it is not
|
|
473
|
+
a nonce scheme and does not authenticate the sender.
|
|
474
|
+
- **Bytes are not JSON.** A non-JSON `media` marks an operation opaque:
|
|
475
|
+
routed and matched, path and query still decoded and validated, the
|
|
476
|
+
body handed over raw and never modeled. Images and OAuth redirects
|
|
477
|
+
are host paths, not JSON operations.
|
|
478
|
+
- **No replication or durability.** The ledger and the command
|
|
479
|
+
lifecycle ship as JSON documents (`$model`, `$fsm`) a host may open
|
|
480
|
+
with `@jarenjs/db`; the in-memory ledger is for tests and
|
|
481
|
+
single-process hosts. Durability is the host's.
|
|
482
|
+
- **No automatic reconnect.** A stream that ends with a `network`
|
|
483
|
+
outcome is re-entered by the host calling `subscribe` again with the
|
|
484
|
+
last delivered seq (`lastSeq` is the hook); the backoff/resume/give-up
|
|
485
|
+
policy is the open decision tracked in the repository ROADMAP.
|
|
486
|
+
|
|
487
|
+
## What is here
|
|
488
|
+
|
|
489
|
+
Here: the document and its grammar, `compileContract`, `contract.match`,
|
|
490
|
+
`describe()`, the `JC0001–JC0017` compile errors; the HTTP server binding
|
|
491
|
+
(`serveHttp`, the `JC2001–JC2015` wire taxonomy with its English catalog,
|
|
492
|
+
`fetch` and `node` adapters, the ledger interface with `createMemoryLedger`
|
|
493
|
+
and the `idempotencyLedgerModel`/`commandLifecycleFsm` documents); the HTTP
|
|
494
|
+
client (`openHttpClient`, the D6 outcomes with the `JC2050–JC2058` client
|
|
495
|
+
codes, the client half of idempotency, retry, `negotiate`); the app
|
|
496
|
+
binding (`contractAppBinding`, `createContractEffect`); the projections
|
|
497
|
+
(`publicProjection`, `toOpenApi` with `JC0060`, `toTypeScript`,
|
|
498
|
+
`toMarkdown`, `contractTools`); `contract.revision()` with `JC0061`,
|
|
499
|
+
`diffContracts`/`isCompatible` and the `jaren-contract` CLI with `diff
|
|
500
|
+
--fail-on`; the `local` and `port` bindings (`openLocalClient`/`serveLocal`,
|
|
501
|
+
`servePort`/`openPortClient`, the `JC2070–JC2074` codes and the
|
|
502
|
+
`jaren-contract-port` frame grammar with collision-free client-scoped
|
|
503
|
+
request ids); the `subscribe` kind with the `stream` binding
|
|
504
|
+
(`client.subscribe` over SSE and port push frames carrying LIVE-FORMAT
|
|
505
|
+
patches, `JC2090–JC2095`, the generated app subscription with
|
|
506
|
+
`createContractSubscription`, and the one SSE codec of the suite in
|
|
507
|
+
`@jarenjs/core/text/sse`); and the `contract/*` locale packs in all
|
|
508
|
+
eleven `@jarenjs/locales` languages, key parity enforced by test.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The WHATWG adapter: `toFetchHandler(dispatcher)` puts the
|
|
3
|
+
* dispatcher behind `(Request) => Promise<Response>` — the lingua franca
|
|
4
|
+
* of Bun.serve, Deno, service workers and Cloudflare-style hosts.
|
|
5
|
+
* Dependency-free and structurally typed: it needs only the platform's
|
|
6
|
+
* `Request`, `Response` and `Headers`.
|
|
7
|
+
*
|
|
8
|
+
* The adapter reads the body only when the matched operation can carry
|
|
9
|
+
* one (`text()` for a JSON operation, `arrayBuffer()` for an opaque one),
|
|
10
|
+
* refuses a declared `content-length` above the operation's limit
|
|
11
|
+
* BEFORE reading (the dispatcher answers the 413 from the header), never
|
|
12
|
+
* reads an unmatched request's body, and hands everything else to
|
|
13
|
+
* `dispatch`. `Headers` combines repeated field lines with `, `, so a
|
|
14
|
+
* repeated scalar header member is invisible here (the node adapter sees
|
|
15
|
+
* distinct lines); `text()` decodes with replacement, so invalid UTF-8
|
|
16
|
+
* reaches the JSON parser as U+FFFD (the node adapter hands bytes over
|
|
17
|
+
* and the dispatcher's strict decode answers `JC2005`).
|
|
18
|
+
*/
|
|
19
|
+
export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
|
|
20
|
+
/**
|
|
21
|
+
* Put a dispatcher behind the WHATWG request/response pair.
|
|
22
|
+
* @param {HttpDispatcher} dispatcher
|
|
23
|
+
* @returns {(request: Request) => Promise<Response>}
|
|
24
|
+
* @example
|
|
25
|
+
* Bun.serve({ fetch: toFetchHandler(serveHttp(contract, handlers)) });
|
|
26
|
+
*/
|
|
27
|
+
export declare function toFetchHandler(dispatcher: HttpDispatcher): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The Node adapter: `toNodeHandler(dispatcher)` puts the dispatcher
|
|
3
|
+
* behind `(req, res)` — `http.createServer`'s listener, and what Express
|
|
4
|
+
* mounts with `app.use`. Dependency-free and STRUCTURALLY typed: nothing
|
|
5
|
+
* here imports `node:http`; the request is anything with `method`, `url`,
|
|
6
|
+
* `headersDistinct` (or `headers`) and a readable-stream event surface,
|
|
7
|
+
* the response anything with `writeHead`/`end`.
|
|
8
|
+
*
|
|
9
|
+
* The body is collected chunk by chunk up to the matched operation's
|
|
10
|
+
* `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
|
|
11
|
+
* answered with `connection: close` and the request is destroyed once
|
|
12
|
+
* the response has flushed. A declared `content-length` above the limit
|
|
13
|
+
* is never read at all; an unmatched request's body is never read (the
|
|
14
|
+
* dispatcher answers 404/405 without it and the platform discards the
|
|
15
|
+
* rest). Bytes are handed to the dispatcher as received — for a JSON
|
|
16
|
+
* operation too, so its strict UTF-8 decode decides `JC2005`. Repeated
|
|
17
|
+
* header lines reach the dispatcher as arrays (`headersDistinct`), which
|
|
18
|
+
* is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
|
|
19
|
+
* aborts when the client goes away before the response finished.
|
|
20
|
+
*/
|
|
21
|
+
export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
|
|
22
|
+
export type NodeRequestLike = {
|
|
23
|
+
method?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
headersDistinct?: Record<string, string[] | undefined>;
|
|
26
|
+
headers: Record<string, string | string[] | undefined>;
|
|
27
|
+
on: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
28
|
+
pause?: () => unknown;
|
|
29
|
+
destroy?: (error?: Error) => unknown;
|
|
30
|
+
};
|
|
31
|
+
export type NodeResponseLike = {
|
|
32
|
+
writeHead: (status: number, headers?: Record<string, string>) => unknown;
|
|
33
|
+
end: (body?: string | Uint8Array, callback?: () => void) => unknown;
|
|
34
|
+
on: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
35
|
+
write?: (chunk: string | Uint8Array) => unknown;
|
|
36
|
+
flushHeaders?: () => unknown;
|
|
37
|
+
writableFinished?: boolean;
|
|
38
|
+
headersSent?: boolean;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Put a dispatcher behind Node's `(req, res)` listener.
|
|
42
|
+
* @param {HttpDispatcher} dispatcher
|
|
43
|
+
* @returns {(req: NodeRequestLike, res: NodeResponseLike) => void}
|
|
44
|
+
* @example
|
|
45
|
+
* http.createServer(toNodeHandler(serveHttp(contract, handlers))).listen(8080);
|
|
46
|
+
*/
|
|
47
|
+
export declare function toNodeHandler(dispatcher: HttpDispatcher): (req: NodeRequestLike, res: NodeResponseLike) => void;
|