@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The frame grammar of the port binding (docs/CONTRACT-FORMAT.md
|
|
4
|
+
* §16), shared by `servePort` and `openPortClient`: JSON-safe plain
|
|
5
|
+
* objects marked `jaren: "contract/0.1"`, so contract traffic is
|
|
6
|
+
* distinguishable BY SHAPE from anything else on a shared channel — a
|
|
7
|
+
* frame without the marker is never touched, and never answered.
|
|
8
|
+
*
|
|
9
|
+
* Request ids are `"<clientId>:<seq>"` — the client id a UUID per
|
|
10
|
+
* client instance, the sequence a per-client counter — so two clients
|
|
11
|
+
* on one broadcast channel can never collide, and a client recognizes
|
|
12
|
+
* its own responses with one cheap prefix test before any map lookup.
|
|
13
|
+
* The request members `attempt` and `key` are reserved by the grammar
|
|
14
|
+
* (`schemas/jaren-contract-port.schema.json`) and ignored by this
|
|
15
|
+
* order's server: the attempt id stays caller-side in `meta` (D6 — the
|
|
16
|
+
* identities live in state, never in the transport) and idempotency is
|
|
17
|
+
* not carried on this binding (`capabilities` says so).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The frame marker: the protocol and its version. */
|
|
21
|
+
export const FRAME_MARKER = 'contract/0.1';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A request frame. `input` is the whole (validated) input object, or
|
|
25
|
+
* `null` for an input-less operation.
|
|
26
|
+
* @typedef {{ jaren: string, id: string, op: string, input: unknown }} RequestFrame
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A response frame: success with the value, or an error envelope —
|
|
31
|
+
* `code` a declared error code or a `JC2xxx` taxonomy/binding code,
|
|
32
|
+
* `details` present only when the failure carries some. Every response
|
|
33
|
+
* carries the server's `trace`.
|
|
34
|
+
* @typedef {{ jaren: string, id: string, ok: true, value: unknown, trace: string }
|
|
35
|
+
* | { jaren: string, id: string, ok: false, error: { code: string, message: string, details?: unknown, retryable?: boolean }, trace: string }} ResponseFrame
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cancel frame: aborts the server-side signal of the named request —
|
|
40
|
+
* an optimization; the client's id scoping is the guarantee. Never
|
|
41
|
+
* answered.
|
|
42
|
+
* @typedef {{ jaren: string, cancel: string }} CancelFrame
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A subscribe frame: opens one stream (docs/CONTRACT-FORMAT.md §18.2).
|
|
47
|
+
* `input` is the whole (validated) input object or `null`; `lastSeq`,
|
|
48
|
+
* when present, asks to resume after that seq.
|
|
49
|
+
* @typedef {{ jaren: string, subscribe: string, op: string, input: unknown, lastSeq?: number }} SubscribeFrame
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An unsubscribe frame: closes the named stream. Never answered.
|
|
54
|
+
* @typedef {{ jaren: string, unsubscribe: string }} UnsubscribeFrame
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A push frame: one stream event, server → client. `event` is
|
|
59
|
+
* `snapshot | patch | error | end` with the §18 data shapes; `seq` is
|
|
60
|
+
* the event's seq (`error`/`end` carry the last delivered one).
|
|
61
|
+
* @typedef {{ jaren: string, id: string, event: string, seq: number, data: unknown }} PushFrame
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The channel shape both halves accept: a `MessagePort`, a `Worker`, a
|
|
66
|
+
* `BroadcastChannel`, a worker's own `self`, or any object with
|
|
67
|
+
* `postMessage` and a message-listener surface. `start` is called when
|
|
68
|
+
* present (a `MessagePort` queues until it is).
|
|
69
|
+
* @typedef {{ postMessage: (message: any) => void,
|
|
70
|
+
* addEventListener?: (type: string, listener: (event: any) => void) => void,
|
|
71
|
+
* removeEventListener?: (type: string, listener: (event: any) => void) => void,
|
|
72
|
+
* onmessage?: ((event: any) => void) | null,
|
|
73
|
+
* start?: () => void }} ChannelLike
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a value is a contract frame at all — the marker test every
|
|
78
|
+
* listener applies first, so foreign traffic on a shared channel is
|
|
79
|
+
* ignored by shape. Reads one member of a plain object; total.
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @returns {value is { jaren: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function isContractFrame(value) {
|
|
84
|
+
return value !== null && typeof value === 'object' && /** @type {any} */ (value).jaren === FRAME_MARKER;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} id
|
|
89
|
+
* @param {string} op
|
|
90
|
+
* @param {unknown} input
|
|
91
|
+
* @returns {RequestFrame}
|
|
92
|
+
*/
|
|
93
|
+
export function requestFrame(id, op, input) {
|
|
94
|
+
return { jaren: FRAME_MARKER, id, op, input };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} id
|
|
99
|
+
* @param {unknown} value - `undefined` is carried as `null` (frames are JSON)
|
|
100
|
+
* @param {string} trace
|
|
101
|
+
* @returns {ResponseFrame}
|
|
102
|
+
*/
|
|
103
|
+
export function valueFrame(id, value, trace) {
|
|
104
|
+
return { jaren: FRAME_MARKER, id, ok: true, value: value === undefined ? null : value, trace };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} id
|
|
109
|
+
* @param {string} code
|
|
110
|
+
* @param {string} message
|
|
111
|
+
* @param {unknown} details - omitted from the frame when `undefined`
|
|
112
|
+
* @param {boolean} retryable
|
|
113
|
+
* @param {string} trace
|
|
114
|
+
* @returns {ResponseFrame}
|
|
115
|
+
*/
|
|
116
|
+
export function errorFrame(id, code, message, details, retryable, trace) {
|
|
117
|
+
return details === undefined
|
|
118
|
+
? { jaren: FRAME_MARKER, id, ok: false, error: { code, message, retryable }, trace }
|
|
119
|
+
: { jaren: FRAME_MARKER, id, ok: false, error: { code, message, details, retryable }, trace };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} id
|
|
124
|
+
* @returns {CancelFrame}
|
|
125
|
+
*/
|
|
126
|
+
export function cancelFrame(id) {
|
|
127
|
+
return { jaren: FRAME_MARKER, cancel: id };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} id
|
|
132
|
+
* @param {string} op
|
|
133
|
+
* @param {unknown} input
|
|
134
|
+
* @param {number | null} lastSeq - omitted from the frame when `null`
|
|
135
|
+
* @returns {SubscribeFrame}
|
|
136
|
+
*/
|
|
137
|
+
export function subscribeFrame(id, op, input, lastSeq) {
|
|
138
|
+
return lastSeq === null
|
|
139
|
+
? { jaren: FRAME_MARKER, subscribe: id, op, input }
|
|
140
|
+
: { jaren: FRAME_MARKER, subscribe: id, op, input, lastSeq };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} id
|
|
145
|
+
* @returns {UnsubscribeFrame}
|
|
146
|
+
*/
|
|
147
|
+
export function unsubscribeFrame(id) {
|
|
148
|
+
return { jaren: FRAME_MARKER, unsubscribe: id };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} id
|
|
153
|
+
* @param {string} event - `snapshot | patch | error | end`
|
|
154
|
+
* @param {number} seq
|
|
155
|
+
* @param {unknown} data
|
|
156
|
+
* @returns {PushFrame}
|
|
157
|
+
*/
|
|
158
|
+
export function pushFrame(id, event, seq, data) {
|
|
159
|
+
return { jaren: FRAME_MARKER, id, event, seq, data: data === undefined ? null : data };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Attach a message listener to a channel: `addEventListener` where the
|
|
164
|
+
* channel has one (plus `start()`, which a `MessagePort` needs to begin
|
|
165
|
+
* delivery), the `onmessage` slot otherwise. Returns the detacher.
|
|
166
|
+
* @param {ChannelLike} channel
|
|
167
|
+
* @param {(event: any) => void} listener
|
|
168
|
+
* @returns {() => void}
|
|
169
|
+
*/
|
|
170
|
+
export function attach(channel, listener) {
|
|
171
|
+
if (typeof channel.addEventListener === 'function') {
|
|
172
|
+
channel.addEventListener('message', listener);
|
|
173
|
+
if (typeof channel.start === 'function') channel.start();
|
|
174
|
+
return () => {
|
|
175
|
+
if (typeof channel.removeEventListener === 'function') channel.removeEventListener('message', listener);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
channel.onmessage = listener;
|
|
179
|
+
return () => {
|
|
180
|
+
if (channel.onmessage === listener) channel.onmessage = null;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether a value is a usable channel. The listener surface is either
|
|
186
|
+
* `addEventListener` or an assignable `onmessage` slot — `in` sees the
|
|
187
|
+
* slot on platform channels and plain fakes alike.
|
|
188
|
+
* @param {unknown} value
|
|
189
|
+
* @returns {value is ChannelLike}
|
|
190
|
+
*/
|
|
191
|
+
export function isChannel(value) {
|
|
192
|
+
if (value === null || typeof value !== 'object') return false;
|
|
193
|
+
const c = /** @type {any} */ (value);
|
|
194
|
+
return typeof c.postMessage === 'function'
|
|
195
|
+
&& (typeof c.addEventListener === 'function' || 'onmessage' in c);
|
|
196
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `servePort(contract, handlers, options)`: the server half of the
|
|
4
|
+
* port binding (docs/CONTRACT-FORMAT.md §16) — request/response over a
|
|
5
|
+
* `MessagePort`, a `Worker`, a `BroadcastChannel` or a worker's own
|
|
6
|
+
* `self`. Requests run through the same neutral pipeline as every other
|
|
7
|
+
* server binding, under one `AbortController` per request id; a cancel
|
|
8
|
+
* frame aborts it (an optimization — the client's id scoping is the
|
|
9
|
+
* guarantee), and the response is posted back on the same channel.
|
|
10
|
+
*
|
|
11
|
+
* The listener touches nothing without the frame marker — other traffic
|
|
12
|
+
* may share the channel and is never answered — and never answers a
|
|
13
|
+
* RESPONSE frame either, so two ends on one broadcast channel cannot
|
|
14
|
+
* echo at each other. A request naming an operation this channel does
|
|
15
|
+
* not serve (unknown, or opaque — a port carries JSON only) is answered
|
|
16
|
+
* `JC2071` without echoing what it asked for; an invalid input is
|
|
17
|
+
* `JC2006` with details by the operation's policy; a handler fault of
|
|
18
|
+
* any class is `JC2070` with the cause reported to `onError`, never
|
|
19
|
+
* onto the wire. Exactly ONE server should serve a shared channel — two
|
|
20
|
+
* would both answer every request.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
24
|
+
|
|
25
|
+
import { ContractHostError, ContractFailure } from '../errors.js';
|
|
26
|
+
import { validateOperationInput, settleOperation, safeTrace, PORT_LOCAL_ERRORS } from '../pipeline.js';
|
|
27
|
+
import { isSubscriptionLike, runSubscription, STREAM_ERRORS } from '../stream/server.js';
|
|
28
|
+
import { HTTP_ERRORS, renderMessage, declaredMessage } from '../http/wire.js';
|
|
29
|
+
import { isContractFrame, valueFrame, errorFrame, pushFrame, attach, isChannel } from './frame.js';
|
|
30
|
+
|
|
31
|
+
export { openPortClient } from './client.js';
|
|
32
|
+
export { FRAME_MARKER, isContractFrame } from './frame.js';
|
|
33
|
+
export { PORT_LOCAL_ERRORS };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {import('../compile.js').Contract} Contract
|
|
37
|
+
* @typedef {import('../compile.js').CompiledOperation} CompiledOperation
|
|
38
|
+
* @typedef {import('../http/wire.js').Catalog} Catalog
|
|
39
|
+
* @typedef {import('../http/dispatch.js').Handler} Handler
|
|
40
|
+
* @typedef {import('../pipeline.js').PipelineRoute} PipelineRoute
|
|
41
|
+
* @typedef {import('./frame.js').ChannelLike} ChannelLike
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} ServePortOptions
|
|
46
|
+
* @property {ChannelLike} channel - the channel to serve (required)
|
|
47
|
+
* @property {() => string} [trace] - the server trace generator; default `crypto.randomUUID`
|
|
48
|
+
* @property {'always' | 'never'} [validateOutput] - `'never'` is a declared
|
|
49
|
+
* downgrade, reported in `capabilities.validatedOutput`
|
|
50
|
+
* @property {Record<string, string | ((params: object) => string)>} [catalog]
|
|
51
|
+
* - a message catalog consulted before the English one
|
|
52
|
+
* @property {(error: unknown, ctx: { op: string, trace: string } | null) => void} [onError]
|
|
53
|
+
* - observes the cause behind every `JC2070` frame, validator throws
|
|
54
|
+
* and a channel whose `postMessage` throws
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The frozen capabilities table of the port server: JSON frames only —
|
|
59
|
+
* no statuses, headers, media or entity tags, no idempotency carriage
|
|
60
|
+
* (a declared policy is inert here, like on `local`), and cancellation
|
|
61
|
+
* arrives as a message.
|
|
62
|
+
* @typedef {Object} PortServerCapabilities
|
|
63
|
+
* @property {'port'} name
|
|
64
|
+
* @property {false} status
|
|
65
|
+
* @property {false} headers
|
|
66
|
+
* @property {false} media
|
|
67
|
+
* @property {false} etag
|
|
68
|
+
* @property {false} idempotency
|
|
69
|
+
* @property {boolean} validatedOutput
|
|
70
|
+
* @property {true} stream - subscribe operations stream as push frames (docs/CONTRACT-FORMAT.md §18.2)
|
|
71
|
+
* @property {'message'} cancel
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The server binding.
|
|
76
|
+
* @typedef {Object} PortServer
|
|
77
|
+
* @property {PortServerCapabilities} capabilities
|
|
78
|
+
* @property {Contract} contract
|
|
79
|
+
* @property {() => any} describe
|
|
80
|
+
* @property {() => void} close - ends every live stream with `end`
|
|
81
|
+
* (`server-shutdown`), detaches the listener and aborts every in-flight request
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** The frozen empty header table every port handler context carries. */
|
|
85
|
+
const NO_HEADERS = Object.freeze({});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} code
|
|
89
|
+
* @param {string} reason
|
|
90
|
+
* @returns {ContractHostError}
|
|
91
|
+
*/
|
|
92
|
+
function host(code, reason) {
|
|
93
|
+
return new ContractHostError(code, `servePort: ${reason}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One served operation: the neutral pipeline route plus whether it is a
|
|
98
|
+
* subscribe operation (a stream, never a request/response).
|
|
99
|
+
* @typedef {PipelineRoute & { stream: boolean }} PortRoute
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The pipeline route of one served operation.
|
|
104
|
+
* @param {CompiledOperation} op
|
|
105
|
+
* @param {Handler | null} handler
|
|
106
|
+
* @returns {PortRoute}
|
|
107
|
+
*/
|
|
108
|
+
function prepare(op, handler) {
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
op,
|
|
111
|
+
handler,
|
|
112
|
+
raw: op.http.opaque,
|
|
113
|
+
stream: op.kind === 'subscribe',
|
|
114
|
+
validateInput: op.input === null ? null : op.input.validate,
|
|
115
|
+
validateOutput: op.output.validate,
|
|
116
|
+
details: op.policy.errors.details,
|
|
117
|
+
errors: op.errors,
|
|
118
|
+
retryOn: new Set(op.policy.retry === null ? [] : op.policy.retry.on),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serve a compiled contract over a message channel. Construction refuses
|
|
124
|
+
* host mistakes (`JC1001` handler table, channel or option, `JC1002`
|
|
125
|
+
* missing handler — an opaque operation is exempt, it is answered
|
|
126
|
+
* `JC2071` instead of served).
|
|
127
|
+
*
|
|
128
|
+
* @param {Contract} contract
|
|
129
|
+
* @param {Record<string, Handler>} handlers - operation id → handler
|
|
130
|
+
* @param {ServePortOptions} options
|
|
131
|
+
* @returns {PortServer}
|
|
132
|
+
* @throws {ContractHostError}
|
|
133
|
+
* @example
|
|
134
|
+
* // inside a worker: serve the worker's own channel
|
|
135
|
+
* servePort(contract, handlers, { channel: self });
|
|
136
|
+
* // and the same handlers to every tab on a BroadcastChannel
|
|
137
|
+
* servePort(contract, handlers, { channel: new BroadcastChannel('app') });
|
|
138
|
+
*/
|
|
139
|
+
export function servePort(contract, handlers, options) {
|
|
140
|
+
if (contract === null || typeof contract !== 'object' || typeof contract.match !== 'function'
|
|
141
|
+
|| contract.operations === null || typeof contract.operations !== 'object' || !Array.isArray(contract.ids)) {
|
|
142
|
+
throw host('JC1001', 'the first argument must be a compiled contract (compileContract)');
|
|
143
|
+
}
|
|
144
|
+
if (handlers === null || typeof handlers !== 'object' || Array.isArray(handlers)) {
|
|
145
|
+
throw host('JC1001', 'handlers must be an object of operation id → function');
|
|
146
|
+
}
|
|
147
|
+
const names = Object.keys(handlers);
|
|
148
|
+
for (let i = 0; i < names.length; i++) {
|
|
149
|
+
const id = names[i];
|
|
150
|
+
if (!Object.hasOwn(contract.operations, id)) {
|
|
151
|
+
throw host('JC1001', `handlers names '${id}', which is not an operation of the contract`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof handlers[id] !== 'function') {
|
|
154
|
+
throw host('JC1001', `the handler of '${id}' must be a function, got ${typeof handlers[id]}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (options === null || typeof options !== 'object') throw host('JC1001', 'options must be an object with the channel');
|
|
158
|
+
const channel = options.channel;
|
|
159
|
+
if (!isChannel(channel)) {
|
|
160
|
+
throw host('JC1001', 'options.channel must expose postMessage and a message listener surface (a MessagePort, Worker, BroadcastChannel, a worker\'s self, or the shape)');
|
|
161
|
+
}
|
|
162
|
+
const validateOutput = options.validateOutput === undefined ? 'always' : options.validateOutput;
|
|
163
|
+
if (validateOutput !== 'always' && validateOutput !== 'never') {
|
|
164
|
+
throw host('JC1001', "options.validateOutput must be 'always' or 'never'");
|
|
165
|
+
}
|
|
166
|
+
for (const [name, value] of [['trace', options.trace], ['onError', options.onError]]) {
|
|
167
|
+
if (value !== undefined && typeof value !== 'function') throw host('JC1001', `options.${name} must be a function`);
|
|
168
|
+
}
|
|
169
|
+
if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
|
|
170
|
+
throw host('JC1001', 'options.catalog must be a message catalog object');
|
|
171
|
+
}
|
|
172
|
+
const traceGen = options.trace === undefined ? () => globalThis.crypto.randomUUID() : options.trace;
|
|
173
|
+
const onError = options.onError === undefined ? null : options.onError;
|
|
174
|
+
/** @type {Catalog | null} */
|
|
175
|
+
const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
|
|
176
|
+
const validate = validateOutput === 'always';
|
|
177
|
+
|
|
178
|
+
/** @type {Map<string, PortRoute>} */
|
|
179
|
+
const routes = new Map();
|
|
180
|
+
for (let i = 0; i < contract.ids.length; i++) {
|
|
181
|
+
const id = contract.ids[i];
|
|
182
|
+
const op = contract.operations[id];
|
|
183
|
+
const handler = Object.hasOwn(handlers, id) ? handlers[id] : null;
|
|
184
|
+
if (handler === null && !op.http.opaque) {
|
|
185
|
+
throw host('JC1002', `operation '${id}' has no handler`);
|
|
186
|
+
}
|
|
187
|
+
routes.set(id, prepare(op, handler));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @type {Map<string, AbortController>} */
|
|
191
|
+
const active = new Map();
|
|
192
|
+
let closed = false;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {unknown} error
|
|
196
|
+
* @param {{ op: string, trace: string } | null} ctx
|
|
197
|
+
*/
|
|
198
|
+
function observe(error, ctx) {
|
|
199
|
+
if (onError === null) return;
|
|
200
|
+
try {
|
|
201
|
+
onError(error, ctx);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// an observer that throws never reaches the response
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Post a frame. TOTAL: a channel that throws (closed, detached) is
|
|
210
|
+
* reported, never a crash on the serving side.
|
|
211
|
+
* @param {unknown} frame
|
|
212
|
+
* @param {{ op: string, trace: string } | null} ctx
|
|
213
|
+
*/
|
|
214
|
+
function post(frame, ctx) {
|
|
215
|
+
try {
|
|
216
|
+
channel.postMessage(frame);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
observe(err, ctx);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One request frame through the pipeline and back onto the channel.
|
|
225
|
+
* @param {string} id
|
|
226
|
+
* @param {unknown} op
|
|
227
|
+
* @param {unknown} input
|
|
228
|
+
*/
|
|
229
|
+
function serveRequest(id, op, input) {
|
|
230
|
+
const trace = safeTrace(traceGen);
|
|
231
|
+
const route = typeof op === 'string' ? routes.get(op) : undefined;
|
|
232
|
+
// a subscribe operation is a stream, never a request/response —
|
|
233
|
+
// asked as one, it is "not served on this channel" like an opaque
|
|
234
|
+
if (route === undefined || route.raw || route.stream) {
|
|
235
|
+
// never echo what the frame asked for — it is the request's own value
|
|
236
|
+
post(errorFrame(id, 'JC2071', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2071.msgid, {}), undefined, false, trace), null);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const opId = route.op.id;
|
|
240
|
+
const value = route.validateInput === null ? null : input === undefined ? null : input;
|
|
241
|
+
const invalid = validateOperationInput(route, value);
|
|
242
|
+
if (invalid !== null && invalid.kind === 'contract') {
|
|
243
|
+
if (invalid.cause !== undefined) observe(invalid.cause, { op: opId, trace });
|
|
244
|
+
post(errorFrame(id, 'JC2006', renderMessage(catalog, HTTP_ERRORS.JC2006.msgid, { op: opId }),
|
|
245
|
+
invalid.details, false, trace), { op: opId, trace });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
active.set(id, controller);
|
|
250
|
+
const ctx = Object.freeze({
|
|
251
|
+
op: route.op, trace, signal: controller.signal, params: null, headers: NO_HEADERS,
|
|
252
|
+
fail: ContractFailure, idempotency: null,
|
|
253
|
+
});
|
|
254
|
+
settleOperation(route, value, ctx, validate).then((result) => {
|
|
255
|
+
if (active.get(id) === controller) active.delete(id);
|
|
256
|
+
// a cancelled request's client is gone and drops late responses
|
|
257
|
+
// anyway; not answering just keeps the channel quiet
|
|
258
|
+
if (controller.signal.aborted || closed) return;
|
|
259
|
+
if (result.kind === 'value') {
|
|
260
|
+
post(valueFrame(id, result.value, trace), { op: opId, trace });
|
|
261
|
+
}
|
|
262
|
+
else if (result.kind === 'failure') {
|
|
263
|
+
post(errorFrame(id, result.code, declaredMessage(catalog, opId, result.code, result.params),
|
|
264
|
+
result.details, result.retryable, trace), { op: opId, trace });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
if (result.cause !== undefined) observe(result.cause, { op: opId, trace });
|
|
268
|
+
post(errorFrame(id, 'JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }),
|
|
269
|
+
undefined, false, trace), { op: opId, trace });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The live streams by id. An entry exists from the subscribe frame
|
|
276
|
+
* on, so an unsubscribe that races the handler's settlement still
|
|
277
|
+
* lands: `stop` is re-pointed at the runner once it exists.
|
|
278
|
+
* @type {Map<string, { stopped: boolean, stop: (reason: string | null) => void }>}
|
|
279
|
+
*/
|
|
280
|
+
const streams = new Map();
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* An error push frame's data: the wire error record (§7.3's body
|
|
284
|
+
* without a status; `details` only when present).
|
|
285
|
+
* @param {string} code
|
|
286
|
+
* @param {string} message
|
|
287
|
+
* @param {string} trace
|
|
288
|
+
* @param {unknown} details
|
|
289
|
+
* @param {boolean} retryable
|
|
290
|
+
*/
|
|
291
|
+
function wireError(code, message, trace, details, retryable) {
|
|
292
|
+
return details === undefined
|
|
293
|
+
? { code, message, requestId: trace, retryable }
|
|
294
|
+
: { code, message, requestId: trace, details, retryable };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One subscribe frame: open the stream (docs/CONTRACT-FORMAT.md
|
|
299
|
+
* §18.2). Pre-stream failures — unknown or non-subscribe op, invalid
|
|
300
|
+
* input, a handler fault, an invalid snapshot — arrive as `error`
|
|
301
|
+
* push frames; then the carrier-neutral runner forwards the
|
|
302
|
+
* subscription's events as push frames until the stream ends.
|
|
303
|
+
* @param {string} id
|
|
304
|
+
* @param {unknown} op
|
|
305
|
+
* @param {unknown} input
|
|
306
|
+
* @param {unknown} lastSeqRaw
|
|
307
|
+
*/
|
|
308
|
+
function serveSubscribe(id, op, input, lastSeqRaw) {
|
|
309
|
+
const trace = safeTrace(traceGen);
|
|
310
|
+
const route = typeof op === 'string' ? routes.get(op) : undefined;
|
|
311
|
+
if (route === undefined || !route.stream) {
|
|
312
|
+
post(pushFrame(id, 'error', 0, wireError('JC2071', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2071.msgid, {}), trace, undefined, false)), null);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const opId = route.op.id;
|
|
316
|
+
const value = route.validateInput === null ? null : input === undefined ? null : input;
|
|
317
|
+
const invalid = validateOperationInput(route, value);
|
|
318
|
+
if (invalid !== null && invalid.kind === 'contract') {
|
|
319
|
+
if (invalid.cause !== undefined) observe(invalid.cause, { op: opId, trace });
|
|
320
|
+
post(pushFrame(id, 'error', 0, wireError('JC2006', renderMessage(catalog, HTTP_ERRORS.JC2006.msgid, { op: opId }), trace, invalid.details, false)), { op: opId, trace });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const lastSeq = Number.isInteger(lastSeqRaw) && /** @type {number} */ (lastSeqRaw) >= 0 ? /** @type {number} */ (lastSeqRaw) : null;
|
|
324
|
+
const entry = { stopped: false, stop: /** @type {(reason: string | null) => void} */ (() => { entry.stopped = true; }) };
|
|
325
|
+
streams.set(id, entry);
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
const ctx = Object.freeze({
|
|
328
|
+
op: route.op, trace, signal: controller.signal, params: null, headers: NO_HEADERS,
|
|
329
|
+
fail: ContractFailure, idempotency: null,
|
|
330
|
+
});
|
|
331
|
+
const pushCtx = { op: opId, trace };
|
|
332
|
+
settleOperation(route, value, ctx, false).then((result) => {
|
|
333
|
+
if (closed || entry.stopped) {
|
|
334
|
+
streams.delete(id);
|
|
335
|
+
// the settlement may still hold a live subscription — release it
|
|
336
|
+
if (result.kind === 'value' && isSubscriptionLike(result.value)) {
|
|
337
|
+
try {
|
|
338
|
+
result.value.close();
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// a throwing close changes nothing for a gone client
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (result.kind === 'failure') {
|
|
347
|
+
streams.delete(id);
|
|
348
|
+
post(pushFrame(id, 'error', 0, wireError(result.code, declaredMessage(catalog, opId, result.code, result.params), trace, result.details, result.retryable)), pushCtx);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (result.kind === 'contract') {
|
|
352
|
+
streams.delete(id);
|
|
353
|
+
if (result.cause !== undefined) observe(result.cause, pushCtx);
|
|
354
|
+
post(pushFrame(id, 'error', 0, wireError('JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, undefined, false)), pushCtx);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const sub = result.value;
|
|
358
|
+
if (!isSubscriptionLike(sub)) {
|
|
359
|
+
streams.delete(id);
|
|
360
|
+
observe(new TypeError(`the handler of subscribe operation '${opId}' did not answer a subscription ({ result | snapshot(), subscribe, close })`), pushCtx);
|
|
361
|
+
post(pushFrame(id, 'error', 0, wireError('JC2070', renderMessage(catalog, PORT_LOCAL_ERRORS.JC2070.msgid, { op: opId }), trace, undefined, false)), pushCtx);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const runner = runSubscription(route, sub, {
|
|
365
|
+
snapshot: (seq, snapValue, resumed) => post(pushFrame(id, 'snapshot', seq, { value: snapValue, resumed }), pushCtx),
|
|
366
|
+
patch: (seq, emission) => post(pushFrame(id, 'patch', seq, emission), pushCtx),
|
|
367
|
+
error: (intent, cause, seq) => {
|
|
368
|
+
observe(cause, pushCtx);
|
|
369
|
+
const code = intent === 'invalid-snapshot' ? 'JC2091' : 'JC2070';
|
|
370
|
+
const msgid = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091.msgid : PORT_LOCAL_ERRORS.JC2070.msgid;
|
|
371
|
+
post(pushFrame(id, 'error', seq, wireError(code, renderMessage(catalog, msgid, { op: opId }), trace, undefined, false)), pushCtx);
|
|
372
|
+
},
|
|
373
|
+
end: (reason, seq) => post(pushFrame(id, 'end', seq, { reason }), pushCtx),
|
|
374
|
+
done: () => streams.delete(id),
|
|
375
|
+
}, { lastSeq, validate });
|
|
376
|
+
entry.stop = (reason) => runner.stop(reason);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** @param {any} event */
|
|
381
|
+
function listener(event) {
|
|
382
|
+
const frame = event === null || typeof event !== 'object' ? undefined : event.data;
|
|
383
|
+
if (closed || !isContractFrame(frame)) return;
|
|
384
|
+
const f = /** @type {any} */ (frame);
|
|
385
|
+
if (Object.hasOwn(f, 'cancel')) {
|
|
386
|
+
const controller = typeof f.cancel === 'string' ? active.get(f.cancel) : undefined;
|
|
387
|
+
if (controller !== undefined) controller.abort();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (Object.hasOwn(f, 'subscribe')) {
|
|
391
|
+
if (typeof f.subscribe === 'string' && f.subscribe.length > 0 && !streams.has(f.subscribe)) {
|
|
392
|
+
serveSubscribe(f.subscribe, f.op, f.input, f.lastSeq);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (Object.hasOwn(f, 'unsubscribe')) {
|
|
397
|
+
const entry = typeof f.unsubscribe === 'string' ? streams.get(f.unsubscribe) : undefined;
|
|
398
|
+
if (entry !== undefined) {
|
|
399
|
+
streams.delete(f.unsubscribe);
|
|
400
|
+
entry.stop(null);
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// a response or push frame on a shared channel is another server's answer
|
|
405
|
+
if (Object.hasOwn(f, 'ok') || Object.hasOwn(f, 'event')) return;
|
|
406
|
+
// without a string id there is nothing to address an answer to
|
|
407
|
+
if (typeof f.id !== 'string' || f.id.length === 0) return;
|
|
408
|
+
serveRequest(f.id, f.op, f.input);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const detach = attach(channel, listener);
|
|
412
|
+
|
|
413
|
+
/** @type {PortServerCapabilities} */
|
|
414
|
+
const capabilities = Object.freeze({
|
|
415
|
+
name: 'port',
|
|
416
|
+
status: false,
|
|
417
|
+
headers: false,
|
|
418
|
+
media: false,
|
|
419
|
+
etag: false,
|
|
420
|
+
idempotency: false,
|
|
421
|
+
validatedOutput: validate,
|
|
422
|
+
stream: true,
|
|
423
|
+
cancel: 'message',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
capabilities,
|
|
428
|
+
contract,
|
|
429
|
+
describe: () => contract.describe(),
|
|
430
|
+
close: () => {
|
|
431
|
+
if (closed) return;
|
|
432
|
+
// every live stream ends with server-shutdown BEFORE the channel
|
|
433
|
+
// detaches, so the clients hear it
|
|
434
|
+
for (const entry of [...streams.values()]) entry.stop('server-shutdown');
|
|
435
|
+
streams.clear();
|
|
436
|
+
closed = true;
|
|
437
|
+
detach();
|
|
438
|
+
for (const controller of active.values()) controller.abort();
|
|
439
|
+
active.clear();
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The projections of a compiled contract (`@jarenjs/contract/project`):
|
|
4
|
+
* `publicProjection` (the browser-safe subset — itself a `$contract`
|
|
5
|
+
* document, and what the revision hashes), `toOpenApi` (OpenAPI 3.1
|
|
6
|
+
* through a JSLT stylesheet), `toTypeScript` and `toMarkdown` (on
|
|
7
|
+
* `@jarenjs/emit`'s type model), `contractTools` (`@jarenjs/ai` tool
|
|
8
|
+
* definitions, no import edge) and the same-document bundler they share.
|
|
9
|
+
* This is the one subpath that imports `@jarenjs/emit`; a consumer that
|
|
10
|
+
* never imports it never loads it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { publicProjection } from '../public.js';
|
|
14
|
+
export { toOpenApi } from './openapi.js';
|
|
15
|
+
export { toTypeScript } from './typescript.js';
|
|
16
|
+
export { toMarkdown } from './markdown.js';
|
|
17
|
+
export { contractTools } from './tools.js';
|
|
18
|
+
export { reachableDefs, bundleSameDocument } from '../bundle.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {import('../public.js').PublicProjectionOptions} PublicProjectionOptions
|
|
22
|
+
* @typedef {import('./openapi.js').OpenApiOptions} OpenApiOptions
|
|
23
|
+
* @typedef {import('./openapi.js').OpenApiResult} OpenApiResult
|
|
24
|
+
* @typedef {import('./openapi.js').DroppedKeyword} DroppedKeyword
|
|
25
|
+
* @typedef {import('./typescript.js').TypeScriptOptions} TypeScriptOptions
|
|
26
|
+
* @typedef {import('./markdown.js').MarkdownOptions} MarkdownOptions
|
|
27
|
+
* @typedef {import('./tools.js').ToolDefinition} ToolDefinition
|
|
28
|
+
* @typedef {import('./tools.js').ContractToolsOptions} ContractToolsOptions
|
|
29
|
+
*/
|