@jarenjs/contract 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +508 -0
- package/dist/types/adapters/fetch.d.ts +27 -0
- package/dist/types/adapters/node.d.ts +47 -0
- package/dist/types/app/binding.d.ts +122 -0
- package/dist/types/app/effect.d.ts +77 -0
- package/dist/types/app/index.d.ts +31 -0
- package/dist/types/app/subscription.d.ts +82 -0
- package/dist/types/bundle.d.ts +43 -0
- package/dist/types/cli.d.ts +15 -0
- package/dist/types/client/http.d.ts +242 -0
- package/dist/types/client/outcome.d.ts +289 -0
- package/dist/types/compat.d.ts +36 -0
- package/dist/types/compile.d.ts +196 -0
- package/dist/types/describe.d.ts +115 -0
- package/dist/types/diff.d.ts +91 -0
- package/dist/types/errors.d.ts +205 -0
- package/dist/types/http/dispatch.d.ts +148 -0
- package/dist/types/http/serve.d.ts +154 -0
- package/dist/types/http/wire.d.ts +334 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/ledger.d.ts +207 -0
- package/dist/types/local/index.d.ts +127 -0
- package/dist/types/messages.d.ts +63 -0
- package/dist/types/path.d.ts +119 -0
- package/dist/types/pipeline.d.ts +157 -0
- package/dist/types/port/client.d.ts +142 -0
- package/dist/types/port/frame.d.ts +195 -0
- package/dist/types/port/serve.d.ts +102 -0
- package/dist/types/project/index.d.ts +34 -0
- package/dist/types/project/markdown.d.ts +28 -0
- package/dist/types/project/openapi.d.ts +102 -0
- package/dist/types/project/tools.d.ts +57 -0
- package/dist/types/project/typescript.d.ts +59 -0
- package/dist/types/public.d.ts +73 -0
- package/dist/types/revision.d.ts +36 -0
- package/dist/types/stream/client.d.ts +104 -0
- package/dist/types/stream/server.d.ts +106 -0
- package/dist/types/stream/sse.d.ts +62 -0
- package/docs/APP-INTEGRATION.md +301 -0
- package/docs/CONTRACT-FORMAT.md +1923 -0
- package/package.json +110 -0
- package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
- package/schemas/jaren-contract-port.schema.json +241 -0
- package/schemas/jaren-contract.draft-07.schema.json +287 -0
- package/schemas/jaren-contract.schema.json +287 -0
- package/src/adapters/fetch.js +109 -0
- package/src/adapters/node.js +238 -0
- package/src/app/binding.js +426 -0
- package/src/app/effect.js +190 -0
- package/src/app/index.js +26 -0
- package/src/app/subscription.js +130 -0
- package/src/bundle.js +168 -0
- package/src/cli.js +264 -0
- package/src/client/http.js +1150 -0
- package/src/client/outcome.js +364 -0
- package/src/compat.js +62 -0
- package/src/compile.js +1162 -0
- package/src/describe.js +109 -0
- package/src/diff.js +610 -0
- package/src/errors.js +236 -0
- package/src/http/dispatch.js +1054 -0
- package/src/http/serve.js +301 -0
- package/src/http/wire.js +469 -0
- package/src/index.js +33 -0
- package/src/ledger.js +225 -0
- package/src/local/index.js +363 -0
- package/src/messages.js +68 -0
- package/src/path.js +471 -0
- package/src/pipeline.js +241 -0
- package/src/port/client.js +518 -0
- package/src/port/frame.js +196 -0
- package/src/port/serve.js +442 -0
- package/src/project/index.js +29 -0
- package/src/project/markdown.js +244 -0
- package/src/project/openapi.js +564 -0
- package/src/project/openapi.jslt.json +149 -0
- package/src/project/tools.js +139 -0
- package/src/project/typescript.js +152 -0
- package/src/project/typescript.jtlt.json +72 -0
- package/src/public.js +206 -0
- package/src/revision.js +90 -0
- package/src/stream/client.js +212 -0
- package/src/stream/server.js +306 -0
- package/src/stream/sse.js +67 -0
package/src/http/wire.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The wire shapes of the HTTP server binding: the request-time
|
|
4
|
+
* error taxonomy as data (code → status, msgid, retryable), the error
|
|
5
|
+
* body every non-2xx JSON response carries, header reading, media
|
|
6
|
+
* matching, entity-tag comparison and the query decoder. Nothing here
|
|
7
|
+
* calls a handler or touches a ledger; `dispatch.js` composes these.
|
|
8
|
+
*
|
|
9
|
+
* Every response header name is lowercase; every error response carries
|
|
10
|
+
* `x-jaren-trace` (the server trace, `requestId` in the body) and
|
|
11
|
+
* `cache-control: no-store`. A message is rendered from the msgid
|
|
12
|
+
* through the catalog and never interpolates a request value — the
|
|
13
|
+
* parameters are the operation id, a declared limit, a media type, a
|
|
14
|
+
* method list, a declared header name or a declared error code
|
|
15
|
+
* (docs/CONTRACT-FORMAT.md §7).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isJsonValue, setObjectMember } from '@jarenjs/core/object';
|
|
19
|
+
|
|
20
|
+
import { contractCatalogEn } from '../messages.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A request as the binding sees it — what an adapter builds and what a
|
|
24
|
+
* test hands to `dispatch` directly. `url` is origin-less: the path plus
|
|
25
|
+
* an optional `?query`; header names are lowercase; a header value is a
|
|
26
|
+
* string, or an array of strings when the adapter can see repeated field
|
|
27
|
+
* lines; `body` is the bytes/text as received (`null` for none);
|
|
28
|
+
* `signal` is the request's abort signal when the host has one.
|
|
29
|
+
* @typedef {Object} HttpRequest
|
|
30
|
+
* @property {string} method
|
|
31
|
+
* @property {string} url
|
|
32
|
+
* @property {Readonly<Record<string, string | readonly string[]>>} headers
|
|
33
|
+
* @property {string | Uint8Array | null} body
|
|
34
|
+
* @property {AbortSignal | null} [signal]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A response as the binding answers it: a status, lowercase header names,
|
|
39
|
+
* and a body that is a string (JSON text), bytes (an opaque operation) or
|
|
40
|
+
* `null` (HEAD, 204, 304). A streaming response (a subscribe operation
|
|
41
|
+
* under `accept: text/event-stream`) carries `body: null` plus `stream`:
|
|
42
|
+
* the adapter writes the headers, then MUST call `stream` exactly once
|
|
43
|
+
* with its sink — the pump writes SSE text through `sink.write` and
|
|
44
|
+
* calls `sink.end()` when the stream terminates; the returned function
|
|
45
|
+
* stops the stream when the consumer cancels.
|
|
46
|
+
* @typedef {Object} HttpResponse
|
|
47
|
+
* @property {number} status
|
|
48
|
+
* @property {Readonly<Record<string, string>>} headers
|
|
49
|
+
* @property {string | Uint8Array | null} body
|
|
50
|
+
* @property {(sink: { write: (chunk: string) => void, end: () => void }) => (() => void)} [stream]
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The D7 error body of every non-2xx JSON response.
|
|
55
|
+
* @typedef {Object} WireErrorBody
|
|
56
|
+
* @property {string} code - a `JC2xxx` code, or the declared error code
|
|
57
|
+
* @property {string} message
|
|
58
|
+
* @property {string} requestId - the server trace (`x-jaren-trace`)
|
|
59
|
+
* @property {unknown} [details]
|
|
60
|
+
* @property {boolean} retryable
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The taxonomy row of one request-time code.
|
|
65
|
+
* @typedef {{ status: number, msgid: string, retryable: boolean }} WireErrorRow
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The request-time taxonomy as data: code → `{ status, msgid, retryable }`.
|
|
70
|
+
* The normative table is docs/CONTRACT-FORMAT.md §7; a test holds the
|
|
71
|
+
* two equal, and equal to `CONTRACT_CODES` and the English catalog. The
|
|
72
|
+
* client binding assembles outcomes from exactly these statuses.
|
|
73
|
+
*/
|
|
74
|
+
export const HTTP_ERRORS = Object.freeze({
|
|
75
|
+
JC2001: Object.freeze({ status: 404, msgid: 'contract/not-found', retryable: false }),
|
|
76
|
+
JC2002: Object.freeze({ status: 405, msgid: 'contract/method-not-allowed', retryable: false }),
|
|
77
|
+
JC2003: Object.freeze({ status: 413, msgid: 'contract/body-too-large', retryable: false }),
|
|
78
|
+
JC2004: Object.freeze({ status: 415, msgid: 'contract/unsupported-media', retryable: false }),
|
|
79
|
+
JC2005: Object.freeze({ status: 400, msgid: 'contract/malformed-json', retryable: false }),
|
|
80
|
+
JC2006: Object.freeze({ status: 400, msgid: 'contract/invalid-input', retryable: false }),
|
|
81
|
+
JC2007: Object.freeze({ status: 400, msgid: 'contract/idempotency-key-required', retryable: false }),
|
|
82
|
+
JC2008: Object.freeze({ status: 500, msgid: 'contract/handler-failed', retryable: false }),
|
|
83
|
+
JC2009: Object.freeze({ status: 409, msgid: 'contract/idempotency-conflict', retryable: false }),
|
|
84
|
+
JC2010: Object.freeze({ status: 500, msgid: 'contract/invalid-output', retryable: false }),
|
|
85
|
+
JC2011: Object.freeze({ status: 400, msgid: 'contract/malformed-path', retryable: false }),
|
|
86
|
+
JC2012: Object.freeze({ status: 400, msgid: 'contract/malformed-query', retryable: false }),
|
|
87
|
+
JC2013: Object.freeze({ status: 501, msgid: 'contract/not-implemented', retryable: false }),
|
|
88
|
+
JC2014: Object.freeze({ status: 412, msgid: 'contract/precondition-failed', retryable: false }),
|
|
89
|
+
JC2015: Object.freeze({ status: 400, msgid: 'contract/invalid-header', retryable: false }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The msgid of a declared operation error that has no message of its own. */
|
|
93
|
+
export const HANDLER_ERROR_MSGID = 'contract/handler-error';
|
|
94
|
+
|
|
95
|
+
/** The default well-known negotiation path, served by `serveHttp` and asked by the client. */
|
|
96
|
+
export const WELL_KNOWN_PATH = '/.well-known/jaren-contract';
|
|
97
|
+
|
|
98
|
+
/** The default JSON media of the binding. */
|
|
99
|
+
export const JSON_MEDIA = 'application/json';
|
|
100
|
+
|
|
101
|
+
/** The response `content-type` of a JSON body. */
|
|
102
|
+
export const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
|
103
|
+
|
|
104
|
+
/** The catalog fallback for a code that renders in no catalog. */
|
|
105
|
+
const NO_MESSAGE = 'request failed';
|
|
106
|
+
|
|
107
|
+
//#region messages
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A compiled catalog: msgid → render.
|
|
111
|
+
* @typedef {Readonly<Record<string, (params: object, error?: object) => string>>} Catalog
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render a message: the host catalog first, the English catalog second,
|
|
116
|
+
* a fixed fallback last. TOTAL: a rendering closure that throws yields
|
|
117
|
+
* the fallback rather than escaping into the response path.
|
|
118
|
+
* @param {Catalog | null} catalog - the host catalog, or null for English only
|
|
119
|
+
* @param {string} msgid
|
|
120
|
+
* @param {Record<string, unknown>} params
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function renderMessage(catalog, msgid, params) {
|
|
124
|
+
let render = catalog !== null ? catalog[msgid] : undefined;
|
|
125
|
+
if (render === undefined) render = contractCatalogEn[msgid];
|
|
126
|
+
if (render === undefined) return NO_MESSAGE;
|
|
127
|
+
try {
|
|
128
|
+
const text = render(params);
|
|
129
|
+
return typeof text === 'string' ? text : NO_MESSAGE;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return NO_MESSAGE;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The message of a declared operation error, as every server-side
|
|
138
|
+
* binding renders it: `contract/error/<code>` from the host catalog when
|
|
139
|
+
* it defines one, else the generic `contract/handler-error` — with the
|
|
140
|
+
* failure's own params plus `op` and `code`.
|
|
141
|
+
* @param {Catalog | null} catalog
|
|
142
|
+
* @param {string} op - the operation id
|
|
143
|
+
* @param {string} code - the declared error code
|
|
144
|
+
* @param {Readonly<Record<string, unknown>>} params
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function declaredMessage(catalog, op, code, params) {
|
|
148
|
+
const messageParams = { ...params, op, code };
|
|
149
|
+
const own = catalog !== null ? catalog[`contract/error/${code}`] : undefined;
|
|
150
|
+
return own !== undefined
|
|
151
|
+
? renderMessage(catalog, `contract/error/${code}`, messageParams)
|
|
152
|
+
: renderMessage(catalog, HANDLER_ERROR_MSGID, messageParams);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
|
|
157
|
+
//#region headers
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The single string value of a header: a repeated field is combined with
|
|
161
|
+
* `, ` (RFC 9110 §5.3), an absent one is `undefined`. For the protocol
|
|
162
|
+
* headers the binding itself reads (`content-type`, `content-length`,
|
|
163
|
+
* `idempotency-key`, `if-match`, `if-none-match`).
|
|
164
|
+
* @param {Readonly<Record<string, string | readonly string[]>>} headers
|
|
165
|
+
* @param {string} name - lowercase
|
|
166
|
+
* @returns {string | undefined}
|
|
167
|
+
*/
|
|
168
|
+
export function headerValue(headers, name) {
|
|
169
|
+
const v = headers[name];
|
|
170
|
+
if (v === undefined || typeof v === 'string') return v;
|
|
171
|
+
if (Array.isArray(v)) {
|
|
172
|
+
if (v.length === 0) return undefined;
|
|
173
|
+
if (v.length === 1) return typeof v[0] === 'string' ? v[0] : undefined;
|
|
174
|
+
let out = '';
|
|
175
|
+
for (let i = 0; i < v.length; i++) {
|
|
176
|
+
if (typeof v[i] !== 'string') return undefined;
|
|
177
|
+
out += i === 0 ? v[i] : ', ' + v[i];
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The declared `content-length` as a non-negative integer, or `-1` when
|
|
186
|
+
* absent or not a plain decimal number (a malformed value never blocks
|
|
187
|
+
* the read-length check that follows).
|
|
188
|
+
* @param {Readonly<Record<string, string | readonly string[]>>} headers
|
|
189
|
+
* @returns {number}
|
|
190
|
+
*/
|
|
191
|
+
export function contentLength(headers) {
|
|
192
|
+
const raw = headerValue(headers, 'content-length');
|
|
193
|
+
if (raw === undefined || raw.length === 0 || raw.length > 15) return -1;
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (let i = 0; i < raw.length; i++) {
|
|
196
|
+
const c = raw.charCodeAt(i) - 48;
|
|
197
|
+
if (c < 0 || c > 9) return -1;
|
|
198
|
+
n = n * 10 + c;
|
|
199
|
+
}
|
|
200
|
+
return n;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether a request `content-type` names the operation's media:
|
|
205
|
+
* type/subtype compared case-insensitively, parameters ignored, and a
|
|
206
|
+
* `+json` structured-syntax suffix accepted for `application/json`.
|
|
207
|
+
* @param {string | undefined} contentType
|
|
208
|
+
* @param {string} media - the operation's declared media
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
export function mediaMatches(contentType, media) {
|
|
212
|
+
if (contentType === undefined) return false;
|
|
213
|
+
const bare = bareMedia(contentType);
|
|
214
|
+
const want = bareMedia(media);
|
|
215
|
+
if (bare === want) return true;
|
|
216
|
+
return want === JSON_MEDIA && bare.endsWith('+json') && bare.indexOf('/') !== -1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `type/subtype` of a media type, lowercased, parameters and whitespace
|
|
221
|
+
* dropped.
|
|
222
|
+
* @param {string} value
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
function bareMedia(value) {
|
|
226
|
+
const semi = value.indexOf(';');
|
|
227
|
+
return (semi === -1 ? value : value.slice(0, semi)).trim().toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The byte length of a body — text measured as UTF-8. Cheap on the
|
|
232
|
+
* common path: a string shorter than the limit in code units cannot
|
|
233
|
+
* exceed it, and one longer than a third of the limit in code units is
|
|
234
|
+
* measured exactly only when it could.
|
|
235
|
+
* @param {string | Uint8Array} body
|
|
236
|
+
* @param {number} limit
|
|
237
|
+
* @returns {boolean} true when the body exceeds `limit` bytes
|
|
238
|
+
*/
|
|
239
|
+
export function exceedsBytes(body, limit) {
|
|
240
|
+
if (typeof body !== 'string') return body.byteLength > limit;
|
|
241
|
+
const units = body.length;
|
|
242
|
+
if (units * 3 <= limit) return false;
|
|
243
|
+
if (units > limit) return true;
|
|
244
|
+
return new TextEncoder().encode(body).byteLength > limit;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
|
|
249
|
+
//#region entity tags
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse an `If-Match`/`If-None-Match` field into its opaque tags. `*` is
|
|
253
|
+
* reported as `null` in the list; a weak indicator is dropped
|
|
254
|
+
* (`W/"x"` → `x`) — the caller decides weak/strong comparison because a
|
|
255
|
+
* strong comparison must reject weak tags, which `weak[i]` records.
|
|
256
|
+
* @param {string} value
|
|
257
|
+
* @returns {{ any: boolean, tags: string[], weak: boolean[] }}
|
|
258
|
+
*/
|
|
259
|
+
export function parseEntityTags(value) {
|
|
260
|
+
/** @type {string[]} */
|
|
261
|
+
const tags = [];
|
|
262
|
+
/** @type {boolean[]} */
|
|
263
|
+
const weak = [];
|
|
264
|
+
let any = false;
|
|
265
|
+
const parts = value.split(',');
|
|
266
|
+
for (let i = 0; i < parts.length; i++) {
|
|
267
|
+
let part = parts[i].trim();
|
|
268
|
+
if (part === '*') {
|
|
269
|
+
any = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
let isWeak = false;
|
|
273
|
+
if (part.startsWith('W/') || part.startsWith('w/')) {
|
|
274
|
+
isWeak = true;
|
|
275
|
+
part = part.slice(2);
|
|
276
|
+
}
|
|
277
|
+
if (part.length >= 2 && part.charCodeAt(0) === 0x22 && part.charCodeAt(part.length - 1) === 0x22) {
|
|
278
|
+
part = part.slice(1, -1);
|
|
279
|
+
}
|
|
280
|
+
else if (part.length === 0) continue;
|
|
281
|
+
tags.push(part);
|
|
282
|
+
weak.push(isWeak);
|
|
283
|
+
}
|
|
284
|
+
return { any, tags, weak };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Whether a conditional header matches the tag the handler armed.
|
|
289
|
+
* Weak comparison ignores the weak indicators (RFC 9110 §8.8.3.2);
|
|
290
|
+
* strong comparison requires both tags strong.
|
|
291
|
+
* @param {string} header - the raw `if-match`/`if-none-match` value
|
|
292
|
+
* @param {string} tag - the armed opaque tag
|
|
293
|
+
* @param {boolean} tagStrong - whether the armed tag is strong
|
|
294
|
+
* @param {boolean} strong - strong comparison
|
|
295
|
+
* @returns {boolean}
|
|
296
|
+
*/
|
|
297
|
+
export function entityTagMatches(header, tag, tagStrong, strong) {
|
|
298
|
+
const parsed = parseEntityTags(header);
|
|
299
|
+
if (parsed.any) return true;
|
|
300
|
+
for (let i = 0; i < parsed.tags.length; i++) {
|
|
301
|
+
if (parsed.tags[i] !== tag) continue;
|
|
302
|
+
if (!strong || (tagStrong && !parsed.weak[i])) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The `etag` header value of an armed tag.
|
|
309
|
+
* @param {string} tag
|
|
310
|
+
* @param {boolean} strong
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
export function formatEntityTag(tag, strong) {
|
|
314
|
+
return (strong ? '"' : 'W/"') + tag + '"';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//#endregion
|
|
318
|
+
|
|
319
|
+
//#region query
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Decode a query string into the declared members of an input object:
|
|
323
|
+
* only declared names are set (an undeclared key is never merged, so no
|
|
324
|
+
* request can smuggle a member); a `repeated` member collects every
|
|
325
|
+
* occurrence into an array, every other member is last-wins; a `+` is a
|
|
326
|
+
* space and escapes decode as `application/x-www-form-urlencoded`
|
|
327
|
+
* (`URLSearchParams`). Returns `false` when the query is not decodable
|
|
328
|
+
* (a malformed percent-escape or invalid UTF-8) — the `JC2012` case.
|
|
329
|
+
* @param {string} query - the part after `?`, possibly empty
|
|
330
|
+
* @param {ReadonlySet<string>} declared - the query member names
|
|
331
|
+
* @param {ReadonlySet<string>} repeated - the array-typed ones
|
|
332
|
+
* @param {Record<string, unknown>} out - the input object under assembly
|
|
333
|
+
* @returns {boolean} false when not decodable
|
|
334
|
+
*/
|
|
335
|
+
export function decodeQuery(query, declared, repeated, out) {
|
|
336
|
+
if (query.length === 0) return true;
|
|
337
|
+
// URLSearchParams never throws: it keeps a malformed escape as its
|
|
338
|
+
// literal text and replaces invalid UTF-8; both are "not decodable"
|
|
339
|
+
// for a contract, so they are detected first (only when an escape is
|
|
340
|
+
// present) with the strict decoder
|
|
341
|
+
if (query.indexOf('%') !== -1) {
|
|
342
|
+
try {
|
|
343
|
+
decodeURIComponent(query);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const params = new URLSearchParams(query);
|
|
350
|
+
for (const [name, value] of params) {
|
|
351
|
+
if (!declared.has(name)) continue;
|
|
352
|
+
if (repeated.has(name)) {
|
|
353
|
+
const list = out[name];
|
|
354
|
+
if (Array.isArray(list)) list.push(value);
|
|
355
|
+
else setObjectMember(out, name, [value]);
|
|
356
|
+
}
|
|
357
|
+
else setObjectMember(out, name, value);
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
|
|
364
|
+
//#region validation
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The verdict of a compiled validator under either contract: the
|
|
368
|
+
* default `{ valid, errors }` or a host-injected boolean validator.
|
|
369
|
+
* TOTAL: a validator that throws is a failed verdict carrying the throw.
|
|
370
|
+
* Shared by the server pipeline and the client.
|
|
371
|
+
* @param {(value: unknown) => any} validate
|
|
372
|
+
* @param {unknown} value
|
|
373
|
+
* @returns {{ valid: boolean, errors: any[], thrown: unknown }}
|
|
374
|
+
*/
|
|
375
|
+
export function verdict(validate, value) {
|
|
376
|
+
try {
|
|
377
|
+
const r = validate(value);
|
|
378
|
+
if (typeof r === 'boolean') return { valid: r, errors: [], thrown: undefined };
|
|
379
|
+
if (r !== null && typeof r === 'object' && typeof r.valid === 'boolean') {
|
|
380
|
+
return { valid: r.valid, errors: Array.isArray(r.errors) ? r.errors : [], thrown: undefined };
|
|
381
|
+
}
|
|
382
|
+
return { valid: false, errors: [], thrown: undefined };
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
return { valid: false, errors: [], thrown: err };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
|
|
391
|
+
//#region the error response
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The details member of a validation failure by policy: `none` → absent,
|
|
395
|
+
* `paths` → `[{ path, keyword }]`, `full` → the validator's own error
|
|
396
|
+
* records (`toJSON()` when it has one) with their `params`.
|
|
397
|
+
* @param {'none' | 'paths' | 'full'} policy
|
|
398
|
+
* @param {any[]} errors - the validator's error list
|
|
399
|
+
* @returns {unknown}
|
|
400
|
+
*/
|
|
401
|
+
export function projectValidationDetails(policy, errors) {
|
|
402
|
+
if (policy === 'none' || !Array.isArray(errors)) return undefined;
|
|
403
|
+
const out = new Array(errors.length);
|
|
404
|
+
for (let i = 0; i < errors.length; i++) {
|
|
405
|
+
const e = errors[i];
|
|
406
|
+
if (policy === 'paths') {
|
|
407
|
+
out[i] = {
|
|
408
|
+
path: e !== null && typeof e === 'object' && typeof e.instancePath === 'string' ? e.instancePath : '',
|
|
409
|
+
keyword: e !== null && typeof e === 'object' && typeof e.keyword === 'string' ? e.keyword : 'unknown',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
out[i] = e !== null && typeof e === 'object' && typeof e.toJSON === 'function' ? e.toJSON() : e;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Build the D7 error body and the response around it. `override` is the
|
|
421
|
+
* host's `errorBody` option: called with the wire record (the body plus
|
|
422
|
+
* `status`) and the request context; TOTAL — a projector that throws or
|
|
423
|
+
* returns a non-JSON value falls back to the D7 shape.
|
|
424
|
+
* @param {number} status
|
|
425
|
+
* @param {string} code
|
|
426
|
+
* @param {string} message
|
|
427
|
+
* @param {string} trace
|
|
428
|
+
* @param {unknown} details - `undefined` for none
|
|
429
|
+
* @param {boolean} retryable
|
|
430
|
+
* @param {Readonly<Record<string, string>> | null} extraHeaders - `allow`, `retry-after`, `etag`
|
|
431
|
+
* @param {((wire: WireErrorBody & { status: number }, ctx: any) => unknown) | null} override
|
|
432
|
+
* @param {any} ctx - the request context, or null before an operation matched
|
|
433
|
+
* @returns {HttpResponse}
|
|
434
|
+
*/
|
|
435
|
+
export function errorResponse(status, code, message, trace, details, retryable, extraHeaders, override, ctx) {
|
|
436
|
+
/** @type {WireErrorBody & { status: number }} */
|
|
437
|
+
const wire = details === undefined
|
|
438
|
+
? { code, message, requestId: trace, retryable, status }
|
|
439
|
+
: { code, message, requestId: trace, details, retryable, status };
|
|
440
|
+
let body;
|
|
441
|
+
if (override !== null) {
|
|
442
|
+
try {
|
|
443
|
+
const projected = override(wire, ctx);
|
|
444
|
+
body = projected !== undefined && isJsonValue(projected) ? projected : null;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
body = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else body = null;
|
|
451
|
+
if (body === null) {
|
|
452
|
+
body = details === undefined
|
|
453
|
+
? { code, message, requestId: trace, retryable }
|
|
454
|
+
: { code, message, requestId: trace, details, retryable };
|
|
455
|
+
}
|
|
456
|
+
/** @type {Record<string, string>} */
|
|
457
|
+
const headers = {
|
|
458
|
+
'content-type': JSON_CONTENT_TYPE,
|
|
459
|
+
'x-jaren-trace': trace,
|
|
460
|
+
'cache-control': 'no-store',
|
|
461
|
+
};
|
|
462
|
+
if (extraHeaders !== null) {
|
|
463
|
+
const names = Object.keys(extraHeaders);
|
|
464
|
+
for (let i = 0; i < names.length; i++) headers[names[i]] = extraHeaders[names[i]];
|
|
465
|
+
}
|
|
466
|
+
return { status, headers, body: JSON.stringify(body) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
//#endregion
|
package/src/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Public surface of @jarenjs/contract: `compileContract` turns a
|
|
4
|
+
* `$contract` document into a frozen `Contract` (docs/CONTRACT-FORMAT.md);
|
|
5
|
+
* the error classes, the `JC` code table and the `ContractFailure`
|
|
6
|
+
* factory are what a host catches, reads and returns. The bindings live
|
|
7
|
+
* behind their own subpaths (`./http`, `./fetch`, `./node`, `./ledger`)
|
|
8
|
+
* so a consumer that only compiles never loads them. The path matcher is
|
|
9
|
+
* deliberately NOT exported — it is reached only through `contract.match`
|
|
10
|
+
* and `contract.allowed`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { compileContract } from './compile.js';
|
|
14
|
+
export {
|
|
15
|
+
ContractCompileError, ContractRuntimeError, ContractHostError,
|
|
16
|
+
ContractFailure, isContractFailure, CONTRACT_CODES,
|
|
17
|
+
} from './errors.js';
|
|
18
|
+
export { contractMessagesEn, contractCatalogEn } from './messages.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {import('./compile.js').Contract} Contract
|
|
22
|
+
* @typedef {import('./compile.js').CompiledOperation} CompiledOperation
|
|
23
|
+
* @typedef {import('./compile.js').CompiledHttp} CompiledHttp
|
|
24
|
+
* @typedef {import('./compile.js').CompiledPolicy} CompiledPolicy
|
|
25
|
+
* @typedef {import('./compile.js').CompiledInput} CompiledInput
|
|
26
|
+
* @typedef {import('./compile.js').CompiledOutput} CompiledOutput
|
|
27
|
+
* @typedef {import('./compile.js').CompiledErrorDecl} CompiledErrorDecl
|
|
28
|
+
* @typedef {import('./compile.js').InputTransport} InputTransport
|
|
29
|
+
* @typedef {import('./compile.js').CompileContractOptions} CompileContractOptions
|
|
30
|
+
* @typedef {import('./describe.js').ContractDescription} ContractDescription
|
|
31
|
+
* @typedef {import('./describe.js').OperationDescription} OperationDescription
|
|
32
|
+
* @typedef {import('./errors.js').ContractFailureValue} ContractFailureValue
|
|
33
|
+
*/
|