@jarenjs/contract 0.49.2 → 0.66.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 +191 -30
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/project/tools.d.ts +1 -1
- package/dist/types/project/typescript.d.ts +11 -0
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +617 -145
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/tools.js +9 -2
- package/src/project/typescript.js +91 -1
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- package/src/stream/sse.js +2 -0
package/README.md
CHANGED
|
@@ -51,8 +51,9 @@ published as JSON Schema in
|
|
|
51
51
|
"$contract": "0.1",
|
|
52
52
|
"id": "shop",
|
|
53
53
|
"$defs": {
|
|
54
|
-
"Product": { "type": "object",
|
|
55
|
-
"properties": { "id": { "type": "integer" }, "name": { "type": "string" } }
|
|
54
|
+
"Product": { "type": "object",
|
|
55
|
+
"properties": { "id": { "type": "integer" }, "name": { "type": "string" } },
|
|
56
|
+
"required": ["id", "name"] }
|
|
56
57
|
},
|
|
57
58
|
"operations": {
|
|
58
59
|
"catalog.load": {
|
|
@@ -64,9 +65,10 @@ published as JSON Schema in
|
|
|
64
65
|
},
|
|
65
66
|
"product.save": {
|
|
66
67
|
"kind": "command",
|
|
67
|
-
"input": { "type": "object", "
|
|
68
|
+
"input": { "type": "object", "properties": {
|
|
68
69
|
"id": { "type": "integer" }, "revision": { "type": "integer" },
|
|
69
|
-
"product": { "$ref": "#/$defs/Product" } }
|
|
70
|
+
"product": { "$ref": "#/$defs/Product" } },
|
|
71
|
+
"required": ["id", "revision", "product"] },
|
|
70
72
|
"output": { "$ref": "#/$defs/Product" },
|
|
71
73
|
"errors": { "conflict": { "status": 409 }, "not-found": { "status": 404 } },
|
|
72
74
|
"policy": { "idempotency": "required", "revision": "input:/revision" },
|
|
@@ -74,7 +76,7 @@ published as JSON Schema in
|
|
|
74
76
|
},
|
|
75
77
|
"image.bytes": {
|
|
76
78
|
"kind": "read",
|
|
77
|
-
"input": { "type": "object", "
|
|
79
|
+
"input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
|
|
78
80
|
"output": true,
|
|
79
81
|
"http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
|
|
80
82
|
}
|
|
@@ -88,7 +90,54 @@ a command by default, or an explicit `in` map). Path and query strings
|
|
|
88
90
|
are decoded by a normalizer compiled over exactly those members; body
|
|
89
91
|
members are never coerced. An operation without `http` is bound to the
|
|
90
92
|
canonical `POST /<op-id>`. A non-JSON `media` marks an operation
|
|
91
|
-
*opaque*: routed and matched, never validated as JSON
|
|
93
|
+
*opaque*: routed and matched, never validated as JSON, its bytes
|
|
94
|
+
streamed both ways — the handler pulls the upload chunk by chunk and
|
|
95
|
+
may answer a stream, and the HTTP client reaches it through `bytes()`.
|
|
96
|
+
|
|
97
|
+
## The same document, by code
|
|
98
|
+
|
|
99
|
+
`@jarenjs/linq/contract` is the pen that writes this format. The
|
|
100
|
+
builders are the schema pen's, every `named()` schema is hoisted into
|
|
101
|
+
the contract's `$defs`, the members land in the order §12.1 fixes, and
|
|
102
|
+
no default is written — so the document below is byte for byte the one
|
|
103
|
+
above:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import * as s from '@jarenjs/linq/schema';
|
|
107
|
+
import { command, defineContract, error, http, read } from '@jarenjs/linq/contract';
|
|
108
|
+
|
|
109
|
+
const Product = s.named('Product', s.object({ id: s.integer(), name: s.string() }).open());
|
|
110
|
+
|
|
111
|
+
export const shop = defineContract({ id: 'shop' }, {
|
|
112
|
+
'catalog.load': read({
|
|
113
|
+
input: s.object({ since: s.string().format('date-time').optional() }).open(),
|
|
114
|
+
output: s.array(Product),
|
|
115
|
+
policy: { task: 'switch', cache: 'revision' },
|
|
116
|
+
http: http({ method: 'GET', path: '/api/catalog' }),
|
|
117
|
+
}),
|
|
118
|
+
'product.save': command({
|
|
119
|
+
input: s.object({ id: s.integer(), revision: s.integer(), product: Product }).open(),
|
|
120
|
+
output: Product,
|
|
121
|
+
errors: { conflict: error({ status: 409 }), 'not-found': error({ status: 404 }) },
|
|
122
|
+
policy: { idempotency: 'required', revision: 'input:/revision' },
|
|
123
|
+
http: http({ method: 'PUT', path: '/api/products/{id}/master' }),
|
|
124
|
+
}),
|
|
125
|
+
'image.bytes': read({
|
|
126
|
+
input: s.object({ id: s.integer() }).open(),
|
|
127
|
+
output: true,
|
|
128
|
+
http: http({ method: 'GET', path: '/api/images/{id}', media: 'application/octet-stream' }),
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
compileContract(shop.document); // the same compile, the same errors
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The types come with it, without the `types` projection: `ContractOf<typeof
|
|
136
|
+
shop>` is the operation map, and `typedClient`, `typedHandlers` and
|
|
137
|
+
`typedTools` carry it onto a client, a handler table and an AI toolbox.
|
|
138
|
+
The pen's document is
|
|
139
|
+
[CONTRACT-PEN.md](../linq/docs/CONTRACT-PEN.md); it imports nothing of
|
|
140
|
+
this package.
|
|
92
141
|
|
|
93
142
|
## Compile once, use everywhere
|
|
94
143
|
|
|
@@ -150,6 +199,20 @@ response.headers['x-jaren-trace']; // the server trace
|
|
|
150
199
|
JSON.parse(response.body); // the catalog
|
|
151
200
|
```
|
|
152
201
|
|
|
202
|
+
Every option has a default. The host facts among them come from a
|
|
203
|
+
`runtime` record (`@jarenjs/core/runtime`) when one is given — and every
|
|
204
|
+
binding takes one: `serveHttp`, `servePort` and `openLocalClient` mint
|
|
205
|
+
their trace from its `uuid`, `openPortClient` its client id, `openHttpClient`
|
|
206
|
+
its idempotency keys (its `now` stamps the key records and its `random`
|
|
207
|
+
draws the retry jitter), and `createMemoryLedger` stamps claims with its
|
|
208
|
+
`now`. So a server, its ledger, a client, a store and a job queue share
|
|
209
|
+
one record and a deterministic run is configured once; an explicit `trace`,
|
|
210
|
+
`keys` or `now` still wins over the record's member, and with no record
|
|
211
|
+
every binding reads the platform as it always did. Give the server and its
|
|
212
|
+
ledger the SAME record: a ledger built without a clock follows the
|
|
213
|
+
binding's instants, and one built with its own clock must not disagree
|
|
214
|
+
with the server that stamps its claims.
|
|
215
|
+
|
|
153
216
|
The pipeline routes (404/405 with `Allow`), enforces the body limit
|
|
154
217
|
(413) before reading, checks the media (415), parses (400), assembles the
|
|
155
218
|
input from path, query and headers through a prototype-safe setter,
|
|
@@ -167,6 +230,34 @@ is refused at construction. `GET /.well-known/jaren-contract` answers
|
|
|
167
230
|
`describe()`. The normative pipeline, taxonomy and ledger interface are
|
|
168
231
|
[CONTRACT-FORMAT.md §7–§9](docs/CONTRACT-FORMAT.md#7-the-http-server-binding).
|
|
169
232
|
|
|
233
|
+
**The host lifecycle.** Every server binding takes the same two hooks
|
|
234
|
+
([§7.7](docs/CONTRACT-FORMAT.md#77-the-host-lifecycle-identify-acquire-release-settle)):
|
|
235
|
+
`identify(meta)` runs after the route resolved and before a byte of the
|
|
236
|
+
body is read, and answers `{ host, release? }` — the host `scope(ctx)`
|
|
237
|
+
sees; `acquire(input, identity, enter)` runs after the input validated
|
|
238
|
+
and after a new idempotency claim, and calls `enter({ host, release?,
|
|
239
|
+
settlement? })` once — the host the handler sees as `ctx.host`, frozen
|
|
240
|
+
beside it. A host that opens a transaction around `enter` commits it
|
|
241
|
+
when `enter` resolves and rolls it back when it rejects, and a lease
|
|
242
|
+
whose `settlement` is `{ ledger: createDbLedger(tx), required: true }`
|
|
243
|
+
has the claim recorded inside `enter`, so the domain write and the
|
|
244
|
+
receipt commit together or not at all. Releases run once each, acquired
|
|
245
|
+
before identity, before the response is exposed — or when an opaque
|
|
246
|
+
body or an SSE stream is done. A hook fault is the host's (`JC2008`,
|
|
247
|
+
observed), a hook's `meta.fail(code)` a declared failure. The generated
|
|
248
|
+
`HandlerContext<Host, Carrier>` carries `ctx.host` and `ctx.carrier`;
|
|
249
|
+
port and local contexts spell the HTTP-only members as `null`.
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
const server = serveHttp(contract, handlers, {
|
|
253
|
+
ledger: createDbLedger(db),
|
|
254
|
+
identify: (meta) => ({ host: { tenant: meta.headers['x-tenant'] ?? null } }),
|
|
255
|
+
acquire: (input, identity, enter) => db.transaction(
|
|
256
|
+
(tx) => enter({ host: { db: tx }, settlement: { ledger: createDbLedger(tx), required: true } }),
|
|
257
|
+
{ mode: 'immediate' }),
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
170
261
|
Without a declared resolver, `If-Match`/`If-None-Match` are applied
|
|
171
262
|
**after** the handler and only when it armed a tag — a cache device,
|
|
172
263
|
**never a write guard**. The `preconditions` option is the write guard:
|
|
@@ -205,8 +296,10 @@ beat the wildcard, and Fastify's own `bodyLimit` never answers — the
|
|
|
205
296
|
operation's `policy.limits.maxBodyBytes` is the single body ceiling,
|
|
206
297
|
refusing as the contract's coded `JC2003` instead of Fastify's
|
|
207
298
|
`FST_ERR_CTP_BODY_TOO_LARGE`. Subscribe operations stream (the adapter
|
|
208
|
-
calls `response.stream
|
|
209
|
-
|
|
299
|
+
calls `response.stream`, writing each event only after the previous one
|
|
300
|
+
drained — a slow reader parks the source instead of growing a buffer)
|
|
301
|
+
and a dropped peer reaches the handler as `ctx.signal` — the earlier
|
|
302
|
+
buffer-parser recipe carried neither. To
|
|
210
303
|
confine the contract, register the same route in an encapsulated plugin
|
|
211
304
|
with `{ prefix }`; the prefix must then prefix the contract's declared
|
|
212
305
|
paths (canonical bindings and the well-known path included). One
|
|
@@ -352,10 +445,10 @@ its frozen `capabilities`:
|
|
|
352
445
|
|---|---|---|---|
|
|
353
446
|
| `status` | yes | no (`error.status: null`) | no (`error.status: null`) |
|
|
354
447
|
| `headers` | yes | no | no |
|
|
355
|
-
| `media` (opaque operations) | yes | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
|
|
448
|
+
| `media` (opaque operations) | yes — streamed both ways; `client.bytes()` | no (`JC1005` at invoke) | no (`JC1005`; `JC2071` to a foreign asker) |
|
|
356
449
|
| `etag` | yes | no | no |
|
|
357
450
|
| `idempotency` | with a `ledger` / always sent | no — declared policy inert, stated | no — `key` reserved in the frame grammar |
|
|
358
|
-
| `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption | no (`JC1005` at invoke) | yes — push frames, per-client streams |
|
|
451
|
+
| `stream` (`subscribe`) | yes — SSE, `Last-Event-ID` resumption (paged replay, bounded queue) | no (`JC1005` at invoke) | yes — push frames, per-client streams (the same replay and bounds) |
|
|
359
452
|
| `cancel` | `'signal'` | `'signal'` | `'message'` |
|
|
360
453
|
|
|
361
454
|
The normative bindings are [CONTRACT-FORMAT.md §15–§16](docs/CONTRACT-FORMAT.md#15-the-local-binding),
|
|
@@ -426,12 +519,27 @@ And on the command line, the drift gate:
|
|
|
426
519
|
|
|
427
520
|
```sh
|
|
428
521
|
jaren-contract openapi --contract shop.json --out api/ --info-title Shop
|
|
429
|
-
jaren-contract types --contract shop.
|
|
522
|
+
jaren-contract types --contract shop.js --out src/shop.d.ts --check # exit 1 when stale; the module IS the source
|
|
430
523
|
jaren-contract docs --contract shop.json --out docs/
|
|
524
|
+
jaren-contract diff --from api/v1.json --to shop.js --fail-on breaking
|
|
431
525
|
```
|
|
432
526
|
|
|
527
|
+
Every document flag — `--contract`, `--from`, `--to` — takes a `.json`
|
|
528
|
+
file or a pure module (`.js`, `.mjs`, `.cjs`, and `.ts`/`.mts`/`.cts`
|
|
529
|
+
where Node strips types) whose `default` or `contract` export is the
|
|
530
|
+
document or a `@jarenjs/linq/contract` pen (its `toJSON()` is the
|
|
531
|
+
emission); the module is evaluated twice and refused (exit 2) when the
|
|
532
|
+
two emissions differ, so a clock or randomness in a contract module
|
|
533
|
+
never projects two different declarations. The loader is
|
|
534
|
+
`@jarenjs/json/node`, the Node-only subpath `jaren-db` shares — this
|
|
535
|
+
package imports neither `linq` nor `db`. `types --out … --check` against
|
|
536
|
+
the module is the types twin CI runs: exit 1 when the declaration is
|
|
537
|
+
missing or stale, 0 when current, and an ordinary run rewrites nothing
|
|
538
|
+
that did not change.
|
|
539
|
+
|
|
433
540
|
`describe` and `public` print JSON; exit 0 current/written, 1 drift
|
|
434
|
-
under `--check`, 2 on a compile refusal printed as `code docPath reason
|
|
541
|
+
under `--check`, 2 on a compile refusal printed as `code docPath reason`
|
|
542
|
+
or an unreadable, impure or undocumented input.
|
|
435
543
|
The normative projection rules — the public projection's member order
|
|
436
544
|
(the revision hashes those bytes), the OpenAPI mapping and keyword
|
|
437
545
|
policy, the tool naming — are
|
|
@@ -477,6 +585,9 @@ isCompatible(clientContract, serverContract);
|
|
|
477
585
|
jaren-contract diff --from api/v1.json --to api/v2.json --fail-on breaking # exit 1 on a breaking change
|
|
478
586
|
```
|
|
479
587
|
|
|
588
|
+
`--fail-on` requires at least one change class; a missing or empty value is a
|
|
589
|
+
usage error (exit 2), so an unset CI variable cannot silently disable the gate.
|
|
590
|
+
|
|
480
591
|
The revision answers "is this byte-for-byte the contract I compiled
|
|
481
592
|
against?"; `version`/`compat` answer "do the authors claim we speak?";
|
|
482
593
|
the diff answers "what exactly moved, and does it break me?". They are
|
|
@@ -492,19 +603,19 @@ benchmark-figure gate so no number here is typed by hand:
|
|
|
492
603
|
|
|
493
604
|
- **Route match**: the compiled matcher resolves the probe mix —
|
|
494
605
|
static hot paths, variables, the static-beats-variable case, a miss —
|
|
495
|
-
at <!--
|
|
496
|
-
hono's TrieRouter is <!--
|
|
606
|
+
at <!--fact:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 185 ns<!--/fact-->;
|
|
607
|
+
hono's TrieRouter is <!--fact:contract.match.vs-hono-->1.9x<!--/fact--> behind, and its RegExpRouter refuses this
|
|
497
608
|
route table outright (a static path registered after a param sibling).
|
|
498
609
|
- **Dispatch, in-process**: the whole pipeline (route, decode, validate
|
|
499
610
|
input, handler, validate output,
|
|
500
|
-
serialize) is <!--
|
|
611
|
+
serialize) is <!--fact:contract.dispatch.vs-fastify-->2.8–11.9x<!--/fact-->
|
|
501
612
|
faster than Fastify driven through its own `inject` — a number that
|
|
502
613
|
includes Fastify's mock-stream harness, which is why the next row
|
|
503
614
|
exists.
|
|
504
615
|
- **The honest loss**: the bare pieces Fastify composes — find-my-way +
|
|
505
616
|
Ajv + fast-json-stringify, called directly with no harness and no
|
|
506
617
|
response validation
|
|
507
|
-
— are <!--
|
|
618
|
+
— are <!--fact:contract.dispatch.losses-->2.5–11.4x<!--/fact--> faster than
|
|
508
619
|
this pipeline. The wide end of that band is the bare `{ok:true}`
|
|
509
620
|
route, where the rival's compiled serializer answers in ~200 ns and
|
|
510
621
|
there is almost no work to amortize the pipeline against; on the
|
|
@@ -513,8 +624,16 @@ benchmark-figure gate so no number here is typed by hand:
|
|
|
513
624
|
hostile input settles into a coded response) that also proves the
|
|
514
625
|
server kept its own contract before a byte leaves. Over a real
|
|
515
626
|
loopback socket the two stacks are level: the socket dominates both.
|
|
627
|
+
- **The optimization trigger**: on the heaviest in-process row (the
|
|
628
|
+
5×4-body PUT), response serialization is <!--fact:contract.serialization.share-->11.5%<!--/fact-->
|
|
629
|
+
of the request and output validation is <!--fact:contract.validateOutput.share-->29%<!--/fact-->.
|
|
630
|
+
A schema-driven serializer stays unscheduled while serialization is below
|
|
631
|
+
25%: even making that stage free would move the whole request by only about a
|
|
632
|
+
tenth. The suite republishes both shares on every measured run; for a large
|
|
633
|
+
cached representation, validate on rebuild and serve by revision instead of
|
|
634
|
+
paying validation on every request.
|
|
516
635
|
- **Revision**: computing it
|
|
517
|
-
costs <!--
|
|
636
|
+
costs <!--fact:contract.revision.ms-->2.8 ms<!--/fact--> for the 123-operation
|
|
518
637
|
contract, once per process.
|
|
519
638
|
|
|
520
639
|
## What it is not
|
|
@@ -538,19 +657,31 @@ deliberate decision, not a gap:
|
|
|
538
657
|
a nonce scheme and does not authenticate the sender.
|
|
539
658
|
- **Bytes are not JSON.** A non-JSON `media` marks an operation opaque:
|
|
540
659
|
routed and matched, path and query still decoded and validated, the
|
|
541
|
-
body
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
660
|
+
body streamed to the handler as a pull source that never yields past
|
|
661
|
+
the declared limit, and never modeled. `client.bytes()` is its typed
|
|
662
|
+
door — a live response stream, never a JSON value — and `invoke`
|
|
663
|
+
refuses it. Images and OAuth redirects are host paths, not JSON
|
|
664
|
+
operations.
|
|
665
|
+
- **No replication, and no storage of its own.** The ledger and the
|
|
666
|
+
command lifecycle ship as JSON documents (`$model`, `$fsm`); the
|
|
667
|
+
in-memory ledger is for tests and single-process hosts. A durable
|
|
668
|
+
ledger is one import away and adds no dependency here:
|
|
669
|
+
`createDbLedger` in `@jarenjs/linq/db` implements the ledger over a
|
|
670
|
+
`@jarenjs/db` store (immediate claims, a persisted generation fence,
|
|
671
|
+
settlement inside the host's transaction), and
|
|
672
|
+
[CONTRACT-FORMAT.md §8.1](docs/CONTRACT-FORMAT.md#81-a-durable-ledger-over-nodesqlite--an-example-not-an-export)
|
|
548
673
|
is a complete, tested ~60-line ledger over `node:sqlite` (built into
|
|
549
|
-
Node ≥ 24)
|
|
550
|
-
- **
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
674
|
+
Node ≥ 24) for a host without the store — an example, not an export.
|
|
675
|
+
- **Reconnect is opt-in, HTTP-only, and network-only.**
|
|
676
|
+
`subscribe(op, input, { reconnect: { max } })` re-establishes a
|
|
677
|
+
stream after a network loss — a rejected request, a missed heartbeat,
|
|
678
|
+
the server's `JC2096`, a body that ends before `end` — from the last
|
|
679
|
+
delivered seq under the retry backoff, and ends with one `JC2097`
|
|
680
|
+
when the budget is spent; a declared failure, a contract outcome or
|
|
681
|
+
the server's `end` never reconnects, and the port client has no
|
|
682
|
+
network loss to reconnect from. Absent, a `network` outcome is
|
|
683
|
+
delivered as is and the host re-enters `subscribe` with the
|
|
684
|
+
subscription's `lastSeq`.
|
|
554
685
|
|
|
555
686
|
## What is here
|
|
556
687
|
|
|
@@ -560,7 +691,10 @@ Here: the document and its grammar, `compileContract`, `contract.match`,
|
|
|
560
691
|
`fetch` and `node` adapters, the ledger interface with `createMemoryLedger`
|
|
561
692
|
and the `idempotencyLedgerModel`/`commandLifecycleFsm` documents); the HTTP
|
|
562
693
|
client (`openHttpClient`, the D6 outcomes with the `JC2050–JC2058` client
|
|
563
|
-
codes, the client half of idempotency, retry, `negotiate
|
|
694
|
+
codes, the client half of idempotency, retry, `negotiate`, and `bytes`
|
|
695
|
+
for the opaque operations — a live response stream and a streamed
|
|
696
|
+
upload, typed as `HttpClient`/`ByteOperations` by the projection and
|
|
697
|
+
`typedHttpClient`/`OpaqueOf` by the pen); the app
|
|
564
698
|
binding (`contractAppBinding`, `createContractEffect`); the projections
|
|
565
699
|
(`publicProjection`, `toOpenApi` with `JC0060`, `toTypeScript`,
|
|
566
700
|
`toMarkdown`, `contractTools`); `contract.revision()` with `JC0061`,
|
|
@@ -574,3 +708,30 @@ patches, `JC2090–JC2095`, the generated app subscription with
|
|
|
574
708
|
`createContractSubscription`, and the one SSE codec of the suite in
|
|
575
709
|
`@jarenjs/core/text/sse`); and the `contract/*` locale packs in all
|
|
576
710
|
eleven `@jarenjs/locales` languages, key parity enforced by test.
|
|
711
|
+
|
|
712
|
+
## Exports
|
|
713
|
+
|
|
714
|
+
Every subpath a consumer can import, derived from the manifest by
|
|
715
|
+
`npm run docs:derive` (`npm run docs:check` fails when the two drift):
|
|
716
|
+
|
|
717
|
+
<!--fact:exports.contract-->
|
|
718
|
+
| Import | Kind | Declarations |
|
|
719
|
+
|---|---|---|
|
|
720
|
+
| `@jarenjs/contract` | JavaScript | declared |
|
|
721
|
+
| `@jarenjs/contract/http` | JavaScript | declared |
|
|
722
|
+
| `@jarenjs/contract/fetch` | JavaScript | declared |
|
|
723
|
+
| `@jarenjs/contract/node` | JavaScript | declared |
|
|
724
|
+
| `@jarenjs/contract/ledger` | JavaScript | declared |
|
|
725
|
+
| `@jarenjs/contract/diff` | JavaScript | declared |
|
|
726
|
+
| `@jarenjs/contract/client` | JavaScript | declared |
|
|
727
|
+
| `@jarenjs/contract/local` | JavaScript | declared |
|
|
728
|
+
| `@jarenjs/contract/port` | JavaScript | declared |
|
|
729
|
+
| `@jarenjs/contract/stream` | JavaScript | declared |
|
|
730
|
+
| `@jarenjs/contract/app` | JavaScript | declared |
|
|
731
|
+
| `@jarenjs/contract/project` | JavaScript | declared |
|
|
732
|
+
| `@jarenjs/contract/schemas/jaren-contract-port.draft-07.schema.json` | schema | — |
|
|
733
|
+
| `@jarenjs/contract/schemas/jaren-contract-port.schema.json` | schema | — |
|
|
734
|
+
| `@jarenjs/contract/schemas/jaren-contract.draft-07.schema.json` | schema | — |
|
|
735
|
+
| `@jarenjs/contract/schemas/jaren-contract.schema.json` | schema | — |
|
|
736
|
+
| `@jarenjs/contract/package.json` | metadata | — |
|
|
737
|
+
<!--/fact-->
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* Dependency-free and structurally typed: it needs only the platform's
|
|
6
6
|
* `Request`, `Response` and `Headers`.
|
|
7
7
|
*
|
|
8
|
-
* The adapter
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
8
|
+
* The adapter hands the request's body stream to the dispatcher as a
|
|
9
|
+
* pull source only when the matched operation can carry one — a JSON
|
|
10
|
+
* operation drains it under its limit there (the strict UTF-8 decode
|
|
11
|
+
* decides `JC2005`, as through the node adapter), an opaque handler
|
|
12
|
+
* pulls it chunk by chunk — refuses a declared `content-length` above
|
|
13
|
+
* the operation's limit BEFORE reading (the dispatcher answers the 413
|
|
14
|
+
* from the header), never reads an unmatched request's body, and hands
|
|
15
|
+
* everything else to `dispatch`. A streamed response body becomes a
|
|
16
|
+
* `ReadableStream` that pulls one chunk per demand. `Headers` combines
|
|
17
|
+
* repeated field lines with `, `, so a repeated scalar header member is
|
|
18
|
+
* invisible here (the node adapter sees distinct lines).
|
|
18
19
|
*/
|
|
19
20
|
export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
|
|
20
21
|
/**
|
|
@@ -6,19 +6,27 @@
|
|
|
6
6
|
* `headersDistinct` (or `headers`) and a readable-stream event surface,
|
|
7
7
|
* the response anything with `writeHead`/`end`.
|
|
8
8
|
*
|
|
9
|
-
* The body
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The body reaches the dispatcher as a PULL SOURCE over the request's
|
|
10
|
+
* own chunks — nothing is collected here: a JSON operation drains it
|
|
11
|
+
* under `policy.limits.maxBodyBytes` in the dispatcher (its strict
|
|
12
|
+
* UTF-8 decode decides `JC2005`), an opaque handler pulls it one chunk
|
|
13
|
+
* at a time through a source that never yields past the limit. When an
|
|
14
|
+
* upload was pulled and left unread (a limit crossing, a response
|
|
15
|
+
* before EOF) the answer carries `connection: close`, and once it has
|
|
16
|
+
* flushed the socket lingers — draining and discarding the rest of the
|
|
17
|
+
* upload (bounded by a grace timer) before it is destroyed, so the
|
|
18
|
+
* close is a FIN the peer can read the 413 through, not an RST that
|
|
19
|
+
* discards it. A declared `content-length` above the limit is never
|
|
20
|
+
* read at all; an unmatched request's body is never read (the
|
|
16
21
|
* dispatcher answers 404/405 without it and the platform discards the
|
|
17
|
-
* rest).
|
|
18
|
-
*
|
|
22
|
+
* rest). A streamed response body is written chunk by chunk behind the
|
|
23
|
+
* socket's `drain`. Repeated
|
|
19
24
|
* header lines reach the dispatcher as arrays (`headersDistinct`), which
|
|
20
25
|
* is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
|
|
21
|
-
* aborts when the client goes away before the response finished.
|
|
26
|
+
* aborts when the client goes away before the response finished. A
|
|
27
|
+
* streaming (SSE) response writes each event only after the previous
|
|
28
|
+
* one drained: a `res.write()` that answers `false` parks the pump
|
|
29
|
+
* until `drain`, so a slow reader never grows the process's buffers.
|
|
22
30
|
*/
|
|
23
31
|
export type HttpDispatcher = import('../http/serve.js').HttpDispatcher;
|
|
24
32
|
export type NodeRequestLike = {
|
|
@@ -37,9 +45,15 @@ export type NodeResponseLike = {
|
|
|
37
45
|
writeHead: (status: number, headers?: Record<string, string>) => unknown;
|
|
38
46
|
end: (body?: string | Uint8Array, callback?: () => void) => unknown;
|
|
39
47
|
on: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
48
|
+
once?: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
49
|
+
removeListener?: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
50
|
+
off?: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
40
51
|
write?: (chunk: string | Uint8Array) => unknown;
|
|
41
52
|
flushHeaders?: () => unknown;
|
|
53
|
+
destroy?: (error?: Error) => unknown;
|
|
42
54
|
writableFinished?: boolean;
|
|
55
|
+
writableEnded?: boolean;
|
|
56
|
+
destroyed?: boolean;
|
|
43
57
|
headersSent?: boolean;
|
|
44
58
|
};
|
|
45
59
|
/**
|
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* `invoke` NEVER rejects for anything a server or a network can do; it
|
|
17
17
|
* throws only for the host's own mistakes (`JC1005`: an unknown or
|
|
18
|
-
* opaque operation). `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* opaque operation). `bytes(op, input, ctx)` is the opaque twin: one
|
|
19
|
+
* request whose success value carries the status, the headers, the
|
|
20
|
+
* media and the LIVE response body as a `ReadableStream` — never
|
|
21
|
+
* `text()`, never collected — and whose `ctx.body` streams an upload.
|
|
22
|
+
* `url(op, input)` builds the URL of any operation — what an `<img src>`
|
|
23
|
+
* uses for an opaque one. `negotiate()` asks the server's well-known
|
|
24
|
+
* description whether the two ends speak compatible versions.
|
|
25
|
+
* Everything per operation is decided once at `open`.
|
|
22
26
|
*/
|
|
23
27
|
import { JarenValidator } from '@jarenjs/validate';
|
|
24
28
|
import { CLIENT_ERRORS } from './outcome.js';
|
|
@@ -48,7 +52,8 @@ export type HttpClientOptions = {
|
|
|
48
52
|
*/
|
|
49
53
|
headers?: Record<string, string>;
|
|
50
54
|
/**
|
|
51
|
-
* - the idempotency key generator; default
|
|
55
|
+
* - the idempotency key generator; default
|
|
56
|
+
* the runtime record's `uuid`, itself `crypto.randomUUID` by default
|
|
52
57
|
*/
|
|
53
58
|
keys?: () => string;
|
|
54
59
|
/**
|
|
@@ -72,13 +77,20 @@ export type HttpClientOptions = {
|
|
|
72
77
|
*/
|
|
73
78
|
wellKnown?: string;
|
|
74
79
|
/**
|
|
75
|
-
* - the clock stamped into key records; default
|
|
80
|
+
* - the clock stamped into key records; default
|
|
81
|
+
* the runtime record's `now`, itself `Date.now` by default
|
|
76
82
|
*/
|
|
77
83
|
now?: () => number;
|
|
78
84
|
/**
|
|
79
85
|
* - the validator `url()` compiles its path/query check with
|
|
80
86
|
*/
|
|
81
87
|
validator?: JarenValidator<any>;
|
|
88
|
+
/**
|
|
89
|
+
* - the host's runtime record: its `uuid` generates idempotency keys
|
|
90
|
+
* and its `now` stamps key records, each only where `keys` / `now` is
|
|
91
|
+
* absent, and its `random` draws the retry backoff jitter
|
|
92
|
+
*/
|
|
93
|
+
runtime?: Partial<import('@jarenjs/core/runtime').Runtime>;
|
|
82
94
|
};
|
|
83
95
|
export type InvokeContext = {
|
|
84
96
|
/**
|
|
@@ -106,6 +118,38 @@ export type InvokeContext = {
|
|
|
106
118
|
*/
|
|
107
119
|
ifMatch?: string;
|
|
108
120
|
};
|
|
121
|
+
export type ByteContext = {
|
|
122
|
+
/**
|
|
123
|
+
* - cancels the request (`kind: "cancelled"`)
|
|
124
|
+
*/
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
/**
|
|
127
|
+
* - the caller's attempt id, echoed in `meta.attempt`, never sent
|
|
128
|
+
*/
|
|
129
|
+
attempt?: unknown;
|
|
130
|
+
/**
|
|
131
|
+
* - per-call headers (over the static ones)
|
|
132
|
+
*/
|
|
133
|
+
headers?: Record<string, string>;
|
|
134
|
+
/**
|
|
135
|
+
* - sent as `If-None-Match`
|
|
136
|
+
*/
|
|
137
|
+
ifNoneMatch?: string;
|
|
138
|
+
/**
|
|
139
|
+
* - sent as `If-Match`
|
|
140
|
+
*/
|
|
141
|
+
ifMatch?: string;
|
|
142
|
+
/**
|
|
143
|
+
* - the request body
|
|
144
|
+
*/
|
|
145
|
+
body?: string | Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> | null;
|
|
146
|
+
};
|
|
147
|
+
export type ByteResponse = {
|
|
148
|
+
status: number;
|
|
149
|
+
headers: Record<string, string>;
|
|
150
|
+
media: string | null;
|
|
151
|
+
body: ReadableStream<Uint8Array> | null;
|
|
152
|
+
};
|
|
109
153
|
export type Negotiation = {
|
|
110
154
|
compatible: boolean;
|
|
111
155
|
reason: 'same-version' | 'server-accepts' | 'client-accepts' | 'version-mismatch' | 'unreachable' | 'not-a-contract';
|
|
@@ -144,6 +188,9 @@ export type SubscribeOptions = {
|
|
|
144
188
|
onSnapshot?: (value: unknown, info: {
|
|
145
189
|
seq: number;
|
|
146
190
|
resumed: boolean;
|
|
191
|
+
reset: boolean;
|
|
192
|
+
earliestAvailable: number | null;
|
|
193
|
+
highWatermark: number | null;
|
|
147
194
|
}) => void;
|
|
148
195
|
onPatch?: (emission: {
|
|
149
196
|
patch: unknown[];
|
|
@@ -158,15 +205,35 @@ export type SubscribeOptions = {
|
|
|
158
205
|
*/
|
|
159
206
|
signal?: AbortSignal;
|
|
160
207
|
/**
|
|
161
|
-
* - the resume seq (what a
|
|
208
|
+
* - the resume seq (what a re-entered subscribe passes)
|
|
162
209
|
*/
|
|
163
210
|
lastSeq?: number;
|
|
211
|
+
/**
|
|
212
|
+
* - opt into re-establishing the
|
|
213
|
+
* stream after a network loss: at most `max` further attempts, each
|
|
214
|
+
* after the retry backoff and from the last delivered seq; the budget
|
|
215
|
+
* spent, `onError` gets one `JC2097`. Absent (or `max: 0`), a network
|
|
216
|
+
* outcome is delivered as is
|
|
217
|
+
*/
|
|
218
|
+
reconnect?: {
|
|
219
|
+
max: number;
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
export type Subscription = {
|
|
223
|
+
stop: () => void;
|
|
224
|
+
/**
|
|
225
|
+
* - read-only
|
|
226
|
+
*/
|
|
227
|
+
lastSeq: number | null;
|
|
164
228
|
};
|
|
165
229
|
export type HttpClient = {
|
|
166
230
|
invoke: (op: string, input?: unknown, ctx?: InvokeContext) => Promise<Outcome>;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
231
|
+
/**
|
|
232
|
+
* - an
|
|
233
|
+
* opaque operation: the outcome's value is a {@link ByteResponse} (§10.6)
|
|
234
|
+
*/
|
|
235
|
+
bytes: (op: string, input?: unknown, ctx?: ByteContext) => Promise<Outcome>;
|
|
236
|
+
subscribe: (op: string, input?: unknown, options?: SubscribeOptions) => Subscription;
|
|
170
237
|
url: (op: string, input?: unknown) => string;
|
|
171
238
|
negotiate: (options?: {
|
|
172
239
|
signal?: AbortSignal;
|
package/dist/types/compat.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Version compatibility — the one implementation of the
|
|
3
|
-
* negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13
|
|
3
|
+
* negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13): two ends
|
|
4
4
|
* speak when they declare the same `version`, or when either end's
|
|
5
5
|
* `compat` list names the other's `version`. The client's `negotiate()`
|
|
6
6
|
* and any server that wants to refuse an incompatible peer both call
|
package/dist/types/errors.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare const CONTRACT_CODES: Readonly<{
|
|
|
61
61
|
JC1008: "openHttpClient, openPortClient, client.url, createContractEffect, createContractSubscription or a projection (publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools): an argument or option is malformed (not a compiled contract, fetch/keys/sleep/createTaskEffect/projectError not a function, storage without read/write, a non-object input to url, an ops entry naming no or an opaque operation, a tool name outside ^[a-zA-Z0-9_-]{1,64}$ or shared by two operations)";
|
|
62
62
|
JC1009: "encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id";
|
|
63
63
|
JC1010: "client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)";
|
|
64
|
+
JC1011: "a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out";
|
|
64
65
|
JC2001: "no operation matches the request method and path (404)";
|
|
65
66
|
JC2002: "the path shape is served under other methods (405, Allow lists them)";
|
|
66
67
|
JC2003: "the request body exceeds policy.limits.maxBodyBytes, by content-length or by read length (413)";
|
|
@@ -96,6 +97,8 @@ export declare const CONTRACT_CODES: Readonly<{
|
|
|
96
97
|
JC2093: "the stream ended with a server error event whose code the operation does not declare (kind contract; a declared code is a failure outcome under its own code)";
|
|
97
98
|
JC2094: "the stream went silent for twice policy.stream.heartbeatMs (kind network, client-side)";
|
|
98
99
|
JC2095: "a requested resume was refused — informational, carried as resumed:false in the fresh snapshot's event data, never an outcome";
|
|
100
|
+
JC2096: "the stream's bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)";
|
|
101
|
+
JC2097: "the client's reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)";
|
|
99
102
|
}>;
|
|
100
103
|
/**
|
|
101
104
|
* A defect in the contract document itself, raised while
|