@jarenjs/contract 0.72.3 → 0.75.0
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 +32 -0
- package/dist/types/app/binding.d.ts +10 -0
- package/dist/types/app/subscription.d.ts +4 -1
- package/dist/types/client/http.d.ts +1 -1
- package/dist/types/compile.d.ts +5 -0
- package/dist/types/http/dispatch.d.ts +2 -2
- package/dist/types/http/wire.d.ts +6 -5
- package/docs/CONTRACT-FORMAT.md +66 -30
- package/package.json +5 -5
- package/src/app/binding.js +23 -2
- package/src/app/subscription.js +11 -3
- package/src/client/http.js +7 -12
- package/src/compile.js +17 -12
- package/src/http/dispatch.js +2 -2
- package/src/http/serve.js +1 -7
- package/src/http/wire.js +15 -10
- package/src/project/openapi.js +3 -1
package/README.md
CHANGED
|
@@ -739,3 +739,35 @@ Every subpath a consumer can import, derived from the manifest by
|
|
|
739
739
|
Author JSON template catalogs and MessageSpec references with the
|
|
740
740
|
[messages pen](../linq/docs/MESSAGES-PEN.md); existing locale render functions
|
|
741
741
|
retain their pluralization and formatting behavior.
|
|
742
|
+
|
|
743
|
+
## Structured query inputs and app reconnect
|
|
744
|
+
|
|
745
|
+
Object- and array-typed query members use one JSON-encoded parameter,
|
|
746
|
+
including empty arrays, nulls and nested values. Scalar strings stay
|
|
747
|
+
literal; parsed JSON is validated without coercion. The client, URL
|
|
748
|
+
builder, server dispatcher and OpenAPI projection share this codec.
|
|
749
|
+
Malformed or repeated JSON members are `JC2012`. Handwritten callers
|
|
750
|
+
must migrate repeated array keys (`tag=a&tag=b`) to one encoded JSON array;
|
|
751
|
+
deploy matching client/server versions and revise the contract's `version`
|
|
752
|
+
for revision negotiation. See [the wire rules](docs/CONTRACT-FORMAT.md#4-the-http-binding-and-member-locations).
|
|
753
|
+
|
|
754
|
+
Opt into HTTP subscription recovery per operation:
|
|
755
|
+
|
|
756
|
+
```javascript
|
|
757
|
+
const binding = contractAppBinding(contract, {
|
|
758
|
+
subs: { 'board.feed': { reconnect: { max: 2 } } },
|
|
759
|
+
});
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
The generated subscription forwards the option to `client.subscribe`.
|
|
763
|
+
The slot stays live with its id and last value while reconnecting; replay
|
|
764
|
+
continues from the last delivered sequence. Exhaustion surfaces `JC2097`.
|
|
765
|
+
Stop, reset and app destruction stop recovery. Without the option,
|
|
766
|
+
network loss is surfaced immediately; an explicit server end remains
|
|
767
|
+
terminal. Port and local channel lifecycles are unchanged.
|
|
768
|
+
|
|
769
|
+
The fixed-heap host-seam test measures the Node response's writable queue
|
|
770
|
+
and the producer's unwritten chunk separately from bytes already accepted
|
|
771
|
+
by TCP. The in-process Fetch leg can measure produced-minus-consumed
|
|
772
|
+
bytes directly. Complete-byte hashes, cursor pulls and cancellation
|
|
773
|
+
finalizers remain part of the same end-to-end test.
|
|
@@ -33,6 +33,16 @@ export type ContractAppBindingOptions = {
|
|
|
33
33
|
* - the operations the app uses; default every operation
|
|
34
34
|
*/
|
|
35
35
|
ops?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* - per
|
|
38
|
+
* subscribe-operation options. Reconnect is opt-in; the slot stays live
|
|
39
|
+
* while the HTTP client resumes, and reports error after exhaustion.
|
|
40
|
+
*/
|
|
41
|
+
subs?: Record<string, {
|
|
42
|
+
reconnect: {
|
|
43
|
+
max: number;
|
|
44
|
+
};
|
|
45
|
+
}>;
|
|
36
46
|
};
|
|
37
47
|
export type TaskSlot = {
|
|
38
48
|
id: number;
|
|
@@ -42,6 +42,9 @@ export type StreamProps = {
|
|
|
42
42
|
snapshot: string;
|
|
43
43
|
patch: string;
|
|
44
44
|
error: string;
|
|
45
|
+
reconnect?: {
|
|
46
|
+
max: number;
|
|
47
|
+
};
|
|
45
48
|
};
|
|
46
49
|
/**
|
|
47
50
|
* @typedef {import('../compile.js').Contract} Contract
|
|
@@ -60,7 +63,7 @@ export type StreamProps = {
|
|
|
60
63
|
/**
|
|
61
64
|
* The props of one generated subs entry: the operation, the slot id and
|
|
62
65
|
* input resolved from state, and the action names to dispatch.
|
|
63
|
-
* @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
|
|
66
|
+
* @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string, reconnect?: { max: number } }} StreamProps
|
|
64
67
|
*/
|
|
65
68
|
/**
|
|
66
69
|
* Make the `contract-stream` subscription handler of an app over a
|
|
@@ -262,7 +262,7 @@ export type ClientRoute = {
|
|
|
262
262
|
method: string;
|
|
263
263
|
segments: readonly import('../path.js').PathSegment[];
|
|
264
264
|
queryMembers: readonly string[];
|
|
265
|
-
|
|
265
|
+
queryJson: ReadonlySet<string>;
|
|
266
266
|
headerMembers: readonly string[];
|
|
267
267
|
headerNames: readonly string[];
|
|
268
268
|
/**
|
package/dist/types/compile.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export type InputTransport = {
|
|
|
38
38
|
header: readonly string[];
|
|
39
39
|
repeated: readonly string[];
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* - query members with a declared
|
|
43
|
+
* object/array type, encoded as one JSON value (including nullable unions)
|
|
44
|
+
*/
|
|
45
|
+
queryJson: readonly string[];
|
|
41
46
|
schemas: Readonly<Record<string, any>>;
|
|
42
47
|
required: readonly string[];
|
|
43
48
|
};
|
|
@@ -102,9 +102,9 @@ export type Route = {
|
|
|
102
102
|
pathMembers: readonly string[];
|
|
103
103
|
queryMembers: ReadonlySet<string>;
|
|
104
104
|
/**
|
|
105
|
-
* -
|
|
105
|
+
* - JSON-encoded query members
|
|
106
106
|
*/
|
|
107
|
-
|
|
107
|
+
queryJson: ReadonlySet<string>;
|
|
108
108
|
/**
|
|
109
109
|
* - member names
|
|
110
110
|
*/
|
|
@@ -292,18 +292,19 @@ export declare function formatEntityTag(tag: string, strong: boolean): string;
|
|
|
292
292
|
/**
|
|
293
293
|
* Decode a query string into the declared members of an input object:
|
|
294
294
|
* only declared names are set (an undeclared key is never merged, so no
|
|
295
|
-
* request can smuggle a member);
|
|
296
|
-
*
|
|
295
|
+
* request can smuggle a member); JSON members decode exactly one value,
|
|
296
|
+
* scalar members are last-wins; a `+` is a
|
|
297
297
|
* space and escapes decode as `application/x-www-form-urlencoded`
|
|
298
298
|
* (`URLSearchParams`). Returns `false` when the query is not decodable
|
|
299
|
-
* (
|
|
299
|
+
* (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
|
|
300
|
+
* — the `JC2012` case.
|
|
300
301
|
* @param {string} query - the part after `?`, possibly empty
|
|
301
302
|
* @param {ReadonlySet<string>} declared - the query member names
|
|
302
|
-
* @param {ReadonlySet<string>} repeated - the array-typed ones
|
|
303
303
|
* @param {Record<string, unknown>} out - the input object under assembly
|
|
304
|
+
* @param {ReadonlySet<string>} json - schema-directed JSON members
|
|
304
305
|
* @returns {boolean} false when not decodable
|
|
305
306
|
*/
|
|
306
|
-
export declare function decodeQuery(query: string, declared: ReadonlySet<string>,
|
|
307
|
+
export declare function decodeQuery(query: string, declared: ReadonlySet<string>, out: Record<string, unknown>, json: ReadonlySet<string>): boolean;
|
|
307
308
|
/**
|
|
308
309
|
* The verdict of a compiled validator under either contract: the
|
|
309
310
|
* default `{ valid, errors }` or a host-injected boolean validator.
|
package/docs/CONTRACT-FORMAT.md
CHANGED
|
@@ -261,25 +261,44 @@ top-level `input.properties` member gets exactly one location:
|
|
|
261
261
|
does not declare);
|
|
262
262
|
4. otherwise the default: `query` for a `read`, `body` for a `command`.
|
|
263
263
|
|
|
264
|
-
|
|
265
|
-
normalizer compiled
|
|
266
|
-
(`@jarenjs/validate/normalize`)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
`
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
264
|
+
Scalar path, query and header members arrive as strings and are decoded
|
|
265
|
+
by a normalizer compiled over those members with `coerceTypes`
|
|
266
|
+
(`@jarenjs/validate/normalize`). **JSON query members and body members are
|
|
267
|
+
never coerced**, including nested values.
|
|
268
|
+
|
|
269
|
+
A query member whose effective declared `type` is `object` or `array`
|
|
270
|
+
(or a type array including either) uses **one JSON value in one query
|
|
271
|
+
parameter**. The client applies `JSON.stringify`, then `URLSearchParams`;
|
|
272
|
+
the server percent-decodes and applies `JSON.parse` before validation.
|
|
273
|
+
This includes nullable and scalar/structured unions: their values always
|
|
274
|
+
use JSON encoding. Empty arrays, nulls, arrays of objects, numeric object
|
|
275
|
+
keys and strings inside those unions round-trip without guessing from
|
|
276
|
+
text. An absent member is omitted. Plain `string` members remain literal,
|
|
277
|
+
even when their text looks like JSON. `$ref` chains are resolved at
|
|
278
|
+
compilation; unconstrained schemas and unions expressed only with
|
|
279
|
+
`anyOf`/`oneOf` do not imply a codec — declare a top-level `type` to choose
|
|
280
|
+
one. Malformed JSON or multiple occurrences of a JSON member are `JC2012`;
|
|
281
|
+
a well-formed value of the wrong type is `JC2006`. Undeclared query keys
|
|
282
|
+
are ignored. Scalar query members remain last-wins.
|
|
283
|
+
|
|
284
|
+
The compiled operation carries
|
|
285
|
+
`input.transport = { normalize, members: { path, query, header, repeated }, queryJson, schemas, required }`
|
|
286
|
+
(`null` when nothing travels as a string), beside `input.effective`, the
|
|
287
|
+
resolved input object schema. `schemas` and `required` describe all
|
|
288
|
+
transport members for URL validation; `queryJson` lists the JSON members
|
|
289
|
+
excluded from scalar normalization. `repeated` identifies array-typed
|
|
290
|
+
query/header members; JSON query encoding takes precedence. Array headers
|
|
291
|
+
still collect repeated lines or comma-separated values (RFC 9110 §5.3)
|
|
292
|
+
before normalization; other headers require one line (§7.4). The server
|
|
293
|
+
validates the reassembled input; the client validates before splitting it.
|
|
294
|
+
|
|
295
|
+
**Wire migration:** array query members use `tag=["a","b"]` (URL-encoded),
|
|
296
|
+
replacing `tag=a&tag=b`. Update handwritten callers and deploy matching
|
|
297
|
+
client/server versions together; published contracts should change their
|
|
298
|
+
`version` so revision negotiation detects a mismatched deployment. The
|
|
299
|
+
OpenAPI projection declares these parameters with
|
|
300
|
+
`content: { "application/json": { schema: ... } }`, rather than an
|
|
301
|
+
exploded array schema.
|
|
283
302
|
|
|
284
303
|
An operation bound to `GET` or `HEAD` MUST NOT carry a body-located
|
|
285
304
|
member (`JC0016`) — including a `command` whose members default to the
|
|
@@ -601,12 +620,12 @@ is `JC2008`.
|
|
|
601
620
|
stripped by the decoder); then `JSON.parse` (a failure is `JC2005`).
|
|
602
621
|
7. **Assemble** the input object through a prototype-safe setter only, in
|
|
603
622
|
this order: path members (raw decoded strings), query members
|
|
604
|
-
(`URLSearchParams` semantics — `+` is a space;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
623
|
+
(`URLSearchParams` semantics — `+` is a space; `queryJson` members
|
|
624
|
+
decode one JSON value, scalar members are last-wins; an **undeclared
|
|
625
|
+
query key is ignored, never merged**; malformed encoding/JSON or a
|
|
626
|
+
repeated JSON member is `JC2012`), declared header members (by their
|
|
627
|
+
lowercased name; §7.4), then the scalar transport normalizer (JSON
|
|
628
|
+
members excluded), then the body,
|
|
610
629
|
**never coerced**: `http.body` names a member → the parsed value is
|
|
611
630
|
that member; otherwise the parsed value must be an object (`JC2006`
|
|
612
631
|
with `path: ""` otherwise) and each of its own members is set unless
|
|
@@ -747,7 +766,7 @@ below; `HTTP_ERRORS` (`@jarenjs/contract/http`) is the same table as data,
|
|
|
747
766
|
| `JC2009` | 409 | `contract/idempotency-conflict` | see §8 | the ledger says `in-progress` (retryable, `retry-after: 1`) or `mismatch` (not retryable, `details: [{ "kind": "mismatch" }]`) |
|
|
748
767
|
| `JC2010` | 500 | `contract/invalid-output` | no | the handler value fails the output validator, cannot be serialized, a raw response is malformed, or a declared error's details fail their schema — the server broke the contract |
|
|
749
768
|
| `JC2011` | 400 | `contract/malformed-path` | no | the path carries a malformed percent-escape |
|
|
750
|
-
| `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (
|
|
769
|
+
| `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (malformed escape/UTF-8/JSON, or a repeated JSON member) |
|
|
751
770
|
| `JC2013` | 501 | `contract/not-implemented` | no | a `partial` server has no handler for the operation |
|
|
752
771
|
| `JC2014` | 412 | `contract/precondition-failed` | no | `If-Match` does not match the armed tag (strong comparison), or `If-None-Match` matches on a non-GET/HEAD |
|
|
753
772
|
| `JC2015` | 400 | `contract/invalid-header` | no | a declared scalar header member arrived repeated, or a header value is not a string |
|
|
@@ -1341,9 +1360,9 @@ is true exactly for a 304.
|
|
|
1341
1360
|
by `policy.errors.details`; **nothing was sent**.
|
|
1342
1361
|
3. **Split by location** (`http.in`): path variables → `encodeURIComponent`
|
|
1343
1362
|
per segment into the canonical template; query members →
|
|
1344
|
-
`URLSearchParams` (
|
|
1345
|
-
|
|
1346
|
-
|
|
1363
|
+
`URLSearchParams` (`queryJson` members use one JSON value, including
|
|
1364
|
+
null and empty arrays; undefined is omitted; scalar transport members
|
|
1365
|
+
omit null/undefined and otherwise use their string); header members → the header named by the
|
|
1347
1366
|
member lowercased (an array as a `, `-joined list); the body: `http.body`
|
|
1348
1367
|
names a member → `JSON.stringify` of that member's value; otherwise
|
|
1349
1368
|
the object of the body-located members, stringified. `method` from
|
|
@@ -1712,6 +1731,23 @@ The generated actions:
|
|
|
1712
1731
|
- `<ns><op>/reset` — as for tasks: `status "idle"`, `kind`/`error`
|
|
1713
1732
|
cleared, everything else kept.
|
|
1714
1733
|
|
|
1734
|
+
Reconnect is an opt-in **binding choice**, not a new stream policy:
|
|
1735
|
+
`contractAppBinding(contract, { subs: { "data.live": { reconnect: { max: 2 } } } })`
|
|
1736
|
+
embeds the per-operation option in its generated `withQuery` props and
|
|
1737
|
+
`createContractSubscription` forwards it to `client.subscribe`. During
|
|
1738
|
+
retry/backoff the slot stays `live`, retaining its id, value and sequence;
|
|
1739
|
+
replay patches continue against the same cached document. Exhaustion
|
|
1740
|
+
surfaces `JC2097` in the slot. Explicit server end and non-network errors
|
|
1741
|
+
are terminal. Stop/reset/destroy cancel the active stream and prevent
|
|
1742
|
+
further retries. Without the option a lost stream surfaces immediately.
|
|
1743
|
+
Unknown/unselected/non-subscribe operations and malformed reconnect
|
|
1744
|
+
options are `JC1007`. Reconnection is supplied by the HTTP client; port
|
|
1745
|
+
and local clients retain their existing terminal channel lifecycle.
|
|
1746
|
+
|
|
1747
|
+
The subscription handler rejects stale sequence numbers **before**
|
|
1748
|
+
applying a patch to its cached document, and ignores callbacks after
|
|
1749
|
+
cleanup; the generated action guards provide the state-side check too.
|
|
1750
|
+
|
|
1715
1751
|
The binding additionally returns `subs` — one entry per subscribe
|
|
1716
1752
|
operation — and names its handler in `subscription`:
|
|
1717
1753
|
|
|
@@ -1807,7 +1843,7 @@ point). The mapping:
|
|
|
1807
1843
|
|---|---|
|
|
1808
1844
|
| — | `openapi: "3.1.0"`, `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"`, `info` (title/version defaulting to the contract id/version), `servers` when given |
|
|
1809
1845
|
| operation | `paths[<canonical path>][<lowercased method>]`, paths sorted by path then method; `operationId` = the id; `summary` = `doc`'s first line (`description` = the whole `doc` when it has more); `tags` = the id's first dotted segment |
|
|
1810
|
-
| `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema
|
|
1846
|
+
| `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`, or `content.application/json.schema` for JSON query members; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
|
|
1811
1847
|
| body members | `requestBody`: the object of the body-located members (their `required` intersection, the input's `additionalProperties`); the whole input schema when every member is body-located; the member's own schema under `http.body`; `content[<http.media>]` |
|
|
1812
1848
|
| `output`, `http.status` | `responses[<status>]` with the output schema; no content on `204`; opaque → `content[<media>]: { type: "string", format: "binary" }` |
|
|
1813
1849
|
| declared `errors` | one response per status: the D7 wire-error schema (`code` **enum-pinned** to the codes of that status, `details` the declared schema when present) |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/contract",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.75.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -102,9 +102,9 @@
|
|
|
102
102
|
"prepack": "npm run build:types"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
-
"@jarenjs/core": "^0.
|
|
106
|
-
"@jarenjs/json": "^0.
|
|
107
|
-
"@jarenjs/validate": "^0.
|
|
108
|
-
"@jarenjs/emit": "^0.
|
|
105
|
+
"@jarenjs/core": "^0.75.0",
|
|
106
|
+
"@jarenjs/json": "^0.75.0",
|
|
107
|
+
"@jarenjs/validate": "^0.75.0",
|
|
108
|
+
"@jarenjs/emit": "^0.75.0"
|
|
109
109
|
}
|
|
110
110
|
}
|
package/src/app/binding.js
CHANGED
|
@@ -34,6 +34,9 @@ import { ContractHostError } from '../errors.js';
|
|
|
34
34
|
* @property {string} [statePath] - where the slice lives in app state, as a
|
|
35
35
|
* chain of identifier-safe segments; default `/contract`
|
|
36
36
|
* @property {readonly string[]} [ops] - the operations the app uses; default every operation
|
|
37
|
+
* @property {Record<string, { reconnect: { max: number } }>} [subs] - per
|
|
38
|
+
* subscribe-operation options. Reconnect is opt-in; the slot stays live
|
|
39
|
+
* while the HTTP client resumes, and reports error after exhaustion.
|
|
37
40
|
*/
|
|
38
41
|
|
|
39
42
|
/**
|
|
@@ -145,8 +148,9 @@ function replace(path, value) {
|
|
|
145
148
|
* @param {Record<string, any>} actions
|
|
146
149
|
* @param {any[]} subs
|
|
147
150
|
* @param {Record<string, any>} properties
|
|
151
|
+
* @param {{ max: number } | undefined} reconnect
|
|
148
152
|
*/
|
|
149
|
-
function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties) {
|
|
153
|
+
function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties, reconnect) {
|
|
150
154
|
const idGuard = { $eq: ['$payload.id', idQuery] };
|
|
151
155
|
|
|
152
156
|
setObjectMember(actions, `${namespace}${id}/start`, {
|
|
@@ -224,6 +228,7 @@ function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuer
|
|
|
224
228
|
snapshot: `${namespace}${id}/snapshot`,
|
|
225
229
|
patch: `${namespace}${id}/patch`,
|
|
226
230
|
error: `${namespace}${id}/error`,
|
|
231
|
+
...(reconnect === undefined ? {} : { reconnect: { $const: { max: reconnect.max } } }),
|
|
227
232
|
},
|
|
228
233
|
});
|
|
229
234
|
|
|
@@ -302,6 +307,21 @@ export function contractAppBinding(contract, options = {}) {
|
|
|
302
307
|
}
|
|
303
308
|
const ops = options.ops === undefined ? contract.ids : options.ops;
|
|
304
309
|
if (!Array.isArray(ops)) throw host('ops must be an array of operation ids');
|
|
310
|
+
const streamOptions = options.subs === undefined ? {} : options.subs;
|
|
311
|
+
if (streamOptions === null || typeof streamOptions !== 'object' || Array.isArray(streamOptions))
|
|
312
|
+
throw host('subs must be an operation-options object');
|
|
313
|
+
for (const id of Object.keys(streamOptions)) {
|
|
314
|
+
if (!ops.includes(id) || !Object.hasOwn(contract.operations, id) || contract.operations[id].kind !== 'subscribe')
|
|
315
|
+
throw host(`subs names '${id}', which is not a selected subscribe operation`);
|
|
316
|
+
const entry = streamOptions[id];
|
|
317
|
+
const reconnect = entry?.reconnect;
|
|
318
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
|
319
|
+
|| Object.keys(entry).some((key) => key !== 'reconnect')
|
|
320
|
+
|| reconnect === null || typeof reconnect !== 'object' || Array.isArray(reconnect)
|
|
321
|
+
|| Object.keys(reconnect).some((key) => key !== 'max')
|
|
322
|
+
|| !Number.isSafeInteger(reconnect.max) || reconnect.max < 0)
|
|
323
|
+
throw host(`subs['${id}'] must be { reconnect: { max } } with a non-negative safe integer max`);
|
|
324
|
+
}
|
|
305
325
|
const queryRoot = '$' + statePath.replaceAll('/', '.');
|
|
306
326
|
|
|
307
327
|
/** @type {Record<string, TaskSlot | StreamSlot>} */
|
|
@@ -328,7 +348,8 @@ export function contractAppBinding(contract, options = {}) {
|
|
|
328
348
|
const done = `${namespace}${id}/done`;
|
|
329
349
|
|
|
330
350
|
if (op.kind === 'subscribe') {
|
|
331
|
-
appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties
|
|
351
|
+
appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties,
|
|
352
|
+
Object.hasOwn(streamOptions, id) ? streamOptions[id].reconnect : undefined);
|
|
332
353
|
setObjectMember(slice, id, { id: 0, status: 'idle', kind: null, input: null, value: null, error: null, meta: null, seq: 0 });
|
|
333
354
|
required.push(id);
|
|
334
355
|
continue;
|
package/src/app/subscription.js
CHANGED
|
@@ -48,7 +48,7 @@ import { PORT_LOCAL_ERRORS } from '../pipeline.js';
|
|
|
48
48
|
/**
|
|
49
49
|
* The props of one generated subs entry: the operation, the slot id and
|
|
50
50
|
* input resolved from state, and the action names to dispatch.
|
|
51
|
-
* @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
|
|
51
|
+
* @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string, reconnect?: { max: number } }} StreamProps
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -86,12 +86,18 @@ export function createContractSubscription(client, options = {}) {
|
|
|
86
86
|
const { op, id } = p;
|
|
87
87
|
/** @type {unknown} */
|
|
88
88
|
let doc = null;
|
|
89
|
+
let seq = -1;
|
|
90
|
+
let stopped = false;
|
|
89
91
|
const sub = client.subscribe(op, p.input === undefined ? null : p.input, {
|
|
92
|
+
...(p.reconnect === undefined ? {} : { reconnect: p.reconnect }),
|
|
90
93
|
onSnapshot: (/** @type {unknown} */ value, /** @type {{ seq: number }} */ info) => {
|
|
94
|
+
if (stopped) return;
|
|
91
95
|
doc = value;
|
|
96
|
+
seq = info.seq;
|
|
92
97
|
dispatch(p.snapshot, { id, value, seq: info.seq });
|
|
93
98
|
},
|
|
94
99
|
onPatch: (/** @type {{ patch: any[], seq: number }} */ emission) => {
|
|
100
|
+
if (stopped || emission.seq <= seq) return;
|
|
95
101
|
try {
|
|
96
102
|
doc = compileJSONPatch(/** @type {any} */ (emission.patch))(doc);
|
|
97
103
|
}
|
|
@@ -107,10 +113,12 @@ export function createContractSubscription(client, options = {}) {
|
|
|
107
113
|
});
|
|
108
114
|
return;
|
|
109
115
|
}
|
|
116
|
+
seq = emission.seq;
|
|
110
117
|
dispatch(p.patch, { id, value: doc, seq: emission.seq });
|
|
111
118
|
},
|
|
112
|
-
onError: (/** @type {Outcome} */ outcome) => dispatch(p.error, { id, outcome }),
|
|
119
|
+
onError: (/** @type {Outcome} */ outcome) => { if (!stopped) dispatch(p.error, { id, outcome }); },
|
|
113
120
|
onEnd: () => {
|
|
121
|
+
if (stopped) return;
|
|
114
122
|
// the server ended the stream: the slot must say the channel is
|
|
115
123
|
// gone, so a view can offer a reconnect (a fresh start)
|
|
116
124
|
dispatch(p.error, {
|
|
@@ -121,7 +129,7 @@ export function createContractSubscription(client, options = {}) {
|
|
|
121
129
|
});
|
|
122
130
|
},
|
|
123
131
|
});
|
|
124
|
-
return () => sub.stop();
|
|
132
|
+
return () => { stopped = true; sub.stop(); };
|
|
125
133
|
};
|
|
126
134
|
}
|
|
127
135
|
|
package/src/client/http.js
CHANGED
|
@@ -289,7 +289,7 @@ function transportString(v) {
|
|
|
289
289
|
* @property {string} method
|
|
290
290
|
* @property {readonly import('../path.js').PathSegment[]} segments
|
|
291
291
|
* @property {readonly string[]} queryMembers
|
|
292
|
-
* @property {ReadonlySet<string>}
|
|
292
|
+
* @property {ReadonlySet<string>} queryJson
|
|
293
293
|
* @property {readonly string[]} headerMembers
|
|
294
294
|
* @property {readonly string[]} headerNames
|
|
295
295
|
* @property {readonly string[]} bodyMembers - body-located members when the body is their object
|
|
@@ -315,8 +315,6 @@ function prepare(op) {
|
|
|
315
315
|
const http = op.http;
|
|
316
316
|
/** @type {string[]} */
|
|
317
317
|
const queryMembers = [];
|
|
318
|
-
/** @type {Set<string>} */
|
|
319
|
-
const queryRepeated = new Set();
|
|
320
318
|
/** @type {string[]} */
|
|
321
319
|
const headerMembers = [];
|
|
322
320
|
/** @type {string[]} */
|
|
@@ -324,14 +322,12 @@ function prepare(op) {
|
|
|
324
322
|
/** @type {string[]} */
|
|
325
323
|
const bodyMembers = [];
|
|
326
324
|
const transport = op.input === null ? null : op.input.transport;
|
|
327
|
-
const repeated = new Set(transport === null ? [] : transport.members.repeated);
|
|
328
325
|
const members = Object.keys(http.in);
|
|
329
326
|
for (let i = 0; i < members.length; i++) {
|
|
330
327
|
const m = members[i];
|
|
331
328
|
const loc = http.in[m];
|
|
332
329
|
if (loc === 'query') {
|
|
333
330
|
queryMembers.push(m);
|
|
334
|
-
if (repeated.has(m)) queryRepeated.add(m);
|
|
335
331
|
}
|
|
336
332
|
else if (loc === 'header') {
|
|
337
333
|
headerMembers.push(m);
|
|
@@ -345,7 +341,7 @@ function prepare(op) {
|
|
|
345
341
|
method: http.method,
|
|
346
342
|
segments: http.template.segments,
|
|
347
343
|
queryMembers,
|
|
348
|
-
|
|
344
|
+
queryJson: new Set(transport === null ? [] : transport.queryJson),
|
|
349
345
|
headerMembers,
|
|
350
346
|
headerNames,
|
|
351
347
|
bodyMembers,
|
|
@@ -535,13 +531,12 @@ export function openHttpClient(contract, options = {}) {
|
|
|
535
531
|
for (let i = 0; i < route.queryMembers.length; i++) {
|
|
536
532
|
const m = route.queryMembers[i];
|
|
537
533
|
const v = value[m];
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (v[j] !== undefined && v[j] !== null) params.append(m, transportString(v[j]));
|
|
542
|
-
}
|
|
534
|
+
if (route.queryJson.has(m)) {
|
|
535
|
+
if (v !== undefined) params.append(m, JSON.stringify(v));
|
|
536
|
+
continue;
|
|
543
537
|
}
|
|
544
|
-
|
|
538
|
+
if (v === undefined || v === null) continue;
|
|
539
|
+
params.append(m, transportString(v));
|
|
545
540
|
}
|
|
546
541
|
const query = params.toString();
|
|
547
542
|
return query.length === 0 ? path : path + '?' + query;
|
package/src/compile.js
CHANGED
|
@@ -370,20 +370,18 @@ function checkRefs(node, docPath, scope, isRoot) {
|
|
|
370
370
|
*/
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
|
-
* The transport half of an operation's input
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* header member is a single line. Body members are never here.
|
|
381
|
-
* `schemas` holds each transport member's declared schema (what the
|
|
382
|
-
* normalizer was compiled over) and `required` the transport members the
|
|
383
|
-
* input schema requires — what a URL builder validates without the body.
|
|
373
|
+
* The transport half of an operation's input. Scalar path/query/header
|
|
374
|
+
* members use a compiled coercing normalizer; queryJson members carry
|
|
375
|
+
* one JSON value and bypass coercion. `repeated` marks array types (the
|
|
376
|
+
* header decoder collects their repeated lines or comma-separated list;
|
|
377
|
+
* JSON query encoding takes precedence for query members). Body members
|
|
378
|
+
* are never here. `schemas` and `required` describe every transport
|
|
379
|
+
* member for URL validation, including those excluded from normalization.
|
|
384
380
|
* @typedef {Object} InputTransport
|
|
385
381
|
* @property {(value: any) => any} normalize
|
|
386
382
|
* @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
|
|
383
|
+
* @property {readonly string[]} queryJson - query members with a declared
|
|
384
|
+
* object/array type, encoded as one JSON value (including nullable unions)
|
|
387
385
|
* @property {Readonly<Record<string, any>>} schemas
|
|
388
386
|
* @property {readonly string[]} required
|
|
389
387
|
*/
|
|
@@ -1074,6 +1072,7 @@ export function compileContract(doc, options = {}) {
|
|
|
1074
1072
|
const queryMembers = [];
|
|
1075
1073
|
const headerMembers = [];
|
|
1076
1074
|
const repeated = [];
|
|
1075
|
+
const queryJson = [];
|
|
1077
1076
|
/** @type {Record<string, any>} */
|
|
1078
1077
|
const pick = {};
|
|
1079
1078
|
for (let i = 0; i < p.members.length; i++) {
|
|
@@ -1089,18 +1088,24 @@ export function compileContract(doc, options = {}) {
|
|
|
1089
1088
|
const eff = effectiveSchema(schema, scope);
|
|
1090
1089
|
const type = isJsonObject(eff) ? eff.type : undefined;
|
|
1091
1090
|
if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
|
|
1091
|
+
if (loc === 'query' && (type === 'object' || type === 'array'
|
|
1092
|
+
|| (Array.isArray(type) && (type.includes('object') || type.includes('array'))))) queryJson.push(m);
|
|
1092
1093
|
}
|
|
1093
1094
|
}
|
|
1094
1095
|
if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
|
|
1095
1096
|
// the sub-schema is rooted on the document itself, so every
|
|
1096
1097
|
// same-document `$ref` a member schema carries resolves exactly as
|
|
1097
1098
|
// it does for the validator
|
|
1098
|
-
|
|
1099
|
+
// JSON query values have the same typing discipline as a JSON
|
|
1100
|
+
// body: validate them verbatim, never coerce their descendants.
|
|
1101
|
+
const scalarPick = Object.fromEntries(Object.entries(pick).filter(([name]) => !queryJson.includes(name)));
|
|
1102
|
+
const sub = { ...src, type: 'object', properties: scalarPick };
|
|
1099
1103
|
const normalize = compileNormalizer(sub, { coerceTypes: true });
|
|
1100
1104
|
const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
|
|
1101
1105
|
transport = {
|
|
1102
1106
|
normalize,
|
|
1103
1107
|
members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
|
|
1108
|
+
queryJson,
|
|
1104
1109
|
schemas: pick,
|
|
1105
1110
|
required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
|
|
1106
1111
|
};
|
package/src/http/dispatch.js
CHANGED
|
@@ -133,7 +133,7 @@ import {
|
|
|
133
133
|
* @property {ReadonlySet<string>} nonBody - path/query/header member names, never taken from the body
|
|
134
134
|
* @property {readonly string[]} pathMembers
|
|
135
135
|
* @property {ReadonlySet<string>} queryMembers
|
|
136
|
-
* @property {ReadonlySet<string>}
|
|
136
|
+
* @property {ReadonlySet<string>} queryJson - JSON-encoded query members
|
|
137
137
|
* @property {readonly string[]} headerMembers - member names
|
|
138
138
|
* @property {readonly string[]} headerNames - the lowercase header of each
|
|
139
139
|
* @property {readonly boolean[]} headerArray - array-typed, per header member
|
|
@@ -597,7 +597,7 @@ function afterIdentity(server, request, route, trace, hit, isHead, method, path,
|
|
|
597
597
|
const m = route.pathMembers[i];
|
|
598
598
|
setObjectMember(input, m, params[m]);
|
|
599
599
|
}
|
|
600
|
-
if (!decodeQuery(query, route.queryMembers, route.
|
|
600
|
+
if (!decodeQuery(query, route.queryMembers, input, route.queryJson)) {
|
|
601
601
|
return refuse(server, 'JC2012', trace, {}, undefined, null, null);
|
|
602
602
|
}
|
|
603
603
|
/** @type {Record<string, string>} */
|
package/src/http/serve.js
CHANGED
|
@@ -158,8 +158,6 @@ function prepare(op, handler, tag) {
|
|
|
158
158
|
const pathMembers = [];
|
|
159
159
|
/** @type {Set<string>} */
|
|
160
160
|
const queryMembers = new Set();
|
|
161
|
-
/** @type {Set<string>} */
|
|
162
|
-
const repeated = new Set();
|
|
163
161
|
/** @type {string[]} */
|
|
164
162
|
const headerMembers = [];
|
|
165
163
|
/** @type {string[]} */
|
|
@@ -178,10 +176,6 @@ function prepare(op, handler, tag) {
|
|
|
178
176
|
headerNames.push(headerNameOf(m));
|
|
179
177
|
headerArray.push(transport.members.repeated.includes(m));
|
|
180
178
|
}
|
|
181
|
-
for (let i = 0; i < transport.members.repeated.length; i++) {
|
|
182
|
-
const m = transport.members.repeated[i];
|
|
183
|
-
if (queryMembers.has(m)) repeated.add(m);
|
|
184
|
-
}
|
|
185
179
|
}
|
|
186
180
|
const members = Object.keys(http.in);
|
|
187
181
|
for (let i = 0; i < members.length; i++) {
|
|
@@ -203,7 +197,7 @@ function prepare(op, handler, tag) {
|
|
|
203
197
|
nonBody,
|
|
204
198
|
pathMembers,
|
|
205
199
|
queryMembers,
|
|
206
|
-
|
|
200
|
+
queryJson: new Set(transport === null ? [] : transport.queryJson),
|
|
207
201
|
headerMembers,
|
|
208
202
|
headerNames,
|
|
209
203
|
headerArray,
|
package/src/http/wire.js
CHANGED
|
@@ -340,18 +340,19 @@ export function formatEntityTag(tag, strong) {
|
|
|
340
340
|
/**
|
|
341
341
|
* Decode a query string into the declared members of an input object:
|
|
342
342
|
* only declared names are set (an undeclared key is never merged, so no
|
|
343
|
-
* request can smuggle a member);
|
|
344
|
-
*
|
|
343
|
+
* request can smuggle a member); JSON members decode exactly one value,
|
|
344
|
+
* scalar members are last-wins; a `+` is a
|
|
345
345
|
* space and escapes decode as `application/x-www-form-urlencoded`
|
|
346
346
|
* (`URLSearchParams`). Returns `false` when the query is not decodable
|
|
347
|
-
* (
|
|
347
|
+
* (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
|
|
348
|
+
* — the `JC2012` case.
|
|
348
349
|
* @param {string} query - the part after `?`, possibly empty
|
|
349
350
|
* @param {ReadonlySet<string>} declared - the query member names
|
|
350
|
-
* @param {ReadonlySet<string>} repeated - the array-typed ones
|
|
351
351
|
* @param {Record<string, unknown>} out - the input object under assembly
|
|
352
|
+
* @param {ReadonlySet<string>} json - schema-directed JSON members
|
|
352
353
|
* @returns {boolean} false when not decodable
|
|
353
354
|
*/
|
|
354
|
-
export function decodeQuery(query, declared,
|
|
355
|
+
export function decodeQuery(query, declared, out, json) {
|
|
355
356
|
if (query.length === 0) return true;
|
|
356
357
|
// URLSearchParams never throws: it keeps a malformed escape as its
|
|
357
358
|
// literal text and replaces invalid UTF-8; both are "not decodable"
|
|
@@ -368,12 +369,16 @@ export function decodeQuery(query, declared, repeated, out) {
|
|
|
368
369
|
const params = new URLSearchParams(query);
|
|
369
370
|
for (const [name, value] of params) {
|
|
370
371
|
if (!declared.has(name)) continue;
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
372
|
+
if (json.has(name)) {
|
|
373
|
+
// One JSON value per declared structured member. Scalar strings
|
|
374
|
+
// are untouched; repeated JSON members are ambiguous and refused.
|
|
375
|
+
const values = params.getAll(name);
|
|
376
|
+
if (values.length !== 1) return false;
|
|
377
|
+
try { setObjectMember(out, name, JSON.parse(value)); }
|
|
378
|
+
catch { return false; }
|
|
379
|
+
continue;
|
|
375
380
|
}
|
|
376
|
-
|
|
381
|
+
setObjectMember(out, name, value);
|
|
377
382
|
}
|
|
378
383
|
return true;
|
|
379
384
|
}
|
package/src/project/openapi.js
CHANGED
|
@@ -324,7 +324,7 @@ function docLines(doc) {
|
|
|
324
324
|
function operationView(op, projected, ctx) {
|
|
325
325
|
const base = at('/operations', op.id);
|
|
326
326
|
const http = op.http;
|
|
327
|
-
/** @type {
|
|
327
|
+
/** @type {any[]} */
|
|
328
328
|
const parameters = [];
|
|
329
329
|
/** @type {Record<string, any>} */
|
|
330
330
|
const bodyMembers = {};
|
|
@@ -351,6 +351,8 @@ function operationView(op, projected, ctx) {
|
|
|
351
351
|
bodyCount++;
|
|
352
352
|
if (name === http.body) bodyMemberRequired = required;
|
|
353
353
|
}
|
|
354
|
+
else if (loc === 'query' && op.input?.transport?.queryJson.includes(name))
|
|
355
|
+
parameters.push({ name, in: loc, required, content: { 'application/json': { schema: mapped } } });
|
|
354
356
|
else parameters.push({ name, in: loc, required, schema: mapped });
|
|
355
357
|
}
|
|
356
358
|
if (op.policy.idempotency !== 'none') {
|