@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,518 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `openPortClient(contract, options)`: the client half of the port
|
|
4
|
+
* binding (docs/CONTRACT-FORMAT.md §16). `invoke(op, input, ctx)`
|
|
5
|
+
* validates the input with the operation's compiled validator (a refusal
|
|
6
|
+
* is the `JC2050` outcome — nothing is posted), posts one request frame,
|
|
7
|
+
* and resolves a D6 outcome for every way a channel can answer: the
|
|
8
|
+
* matching response (success validated against the output schema,
|
|
9
|
+
* `JC2053`; a declared or taxonomy error a `failure` with `status:
|
|
10
|
+
* null`; a served-host fault `kind: "contract"` with the server's
|
|
11
|
+
* `JC2070`/`JC2071` code kept), no answer within `timeoutMs` (`JC2072`,
|
|
12
|
+
* network), a malformed response frame (`JC2073`, contract), a channel
|
|
13
|
+
* whose `postMessage` throws (`JC2074`, network), and a caller abort
|
|
14
|
+
* (`cancelled` — a cancel frame is also posted so the server can stop
|
|
15
|
+
* work; the id scoping is the guarantee, so a late response for a
|
|
16
|
+
* cancelled or timed-out id is dropped silently).
|
|
17
|
+
*
|
|
18
|
+
* Request ids are `"<clientId>:<seq>"` with a UUID client id, and every
|
|
19
|
+
* incoming frame is prefix-tested against `clientId + ":"` BEFORE any
|
|
20
|
+
* map lookup — two clients on one shared channel can never cross-settle,
|
|
21
|
+
* whatever the other one sends. `close()` rejects nothing: pending
|
|
22
|
+
* invokes resolve `cancelled` and the listener is removed (the channel
|
|
23
|
+
* itself is the host's to close).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { compileMessageCatalog } from '@jarenjs/core/message';
|
|
27
|
+
|
|
28
|
+
import { ContractHostError } from '../errors.js';
|
|
29
|
+
import { validateOperationInput, PORT_LOCAL_ERRORS } from '../pipeline.js';
|
|
30
|
+
import { renderMessage, projectValidationDetails, verdict } from '../http/wire.js';
|
|
31
|
+
import { createStreamConsumer } from '../stream/client.js';
|
|
32
|
+
import {
|
|
33
|
+
prepareOutcomeRoute, assembleOutcome, makeMeta, failedOutcome, outcomeError, clientError,
|
|
34
|
+
} from '../client/outcome.js';
|
|
35
|
+
import { requestFrame, cancelFrame, subscribeFrame, unsubscribeFrame, isContractFrame, attach, isChannel } from './frame.js';
|
|
36
|
+
|
|
37
|
+
export { PORT_LOCAL_ERRORS };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {import('../compile.js').Contract} Contract
|
|
41
|
+
* @typedef {import('../compile.js').CompiledOperation} CompiledOperation
|
|
42
|
+
* @typedef {import('../http/wire.js').Catalog} Catalog
|
|
43
|
+
* @typedef {import('../client/outcome.js').Outcome} Outcome
|
|
44
|
+
* @typedef {import('../client/outcome.js').OutcomeMeta} OutcomeMeta
|
|
45
|
+
* @typedef {import('../client/outcome.js').OutcomeRoute} OutcomeRoute
|
|
46
|
+
* @typedef {import('./frame.js').ChannelLike} ChannelLike
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} PortClientOptions
|
|
51
|
+
* @property {ChannelLike} channel - the channel to talk over (required)
|
|
52
|
+
* @property {number} [timeoutMs] - per request; default 15000; `0` disables
|
|
53
|
+
* @property {Record<string, string | ((params: object) => string)>} [catalog]
|
|
54
|
+
* - a message catalog consulted before the English one
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-call context of `invoke`.
|
|
59
|
+
* @typedef {Object} PortInvokeContext
|
|
60
|
+
* @property {AbortSignal} [signal] - resolves the outcome `cancelled` and posts a cancel frame
|
|
61
|
+
* @property {unknown} [attempt] - the caller's attempt id, echoed in `meta.attempt`, never sent
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The frozen capabilities table of the port client.
|
|
66
|
+
* @typedef {Object} PortClientCapabilities
|
|
67
|
+
* @property {'port'} name
|
|
68
|
+
* @property {false} status
|
|
69
|
+
* @property {false} headers
|
|
70
|
+
* @property {false} media
|
|
71
|
+
* @property {false} etag
|
|
72
|
+
* @property {false} idempotency
|
|
73
|
+
* @property {true} stream - `subscribe` carries push-frame streams (docs/CONTRACT-FORMAT.md §18.2)
|
|
74
|
+
* @property {'message'} cancel
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The options of one `subscribe` call (docs/CONTRACT-FORMAT.md §19).
|
|
79
|
+
* There is no heartbeat on a port — delivery is in-process — so no
|
|
80
|
+
* silence watchdog runs here.
|
|
81
|
+
* @typedef {Object} PortSubscribeOptions
|
|
82
|
+
* @property {(value: unknown, info: { seq: number, resumed: boolean }) => void} [onSnapshot]
|
|
83
|
+
* @property {(emission: { patch: unknown[], seq: number }) => void} [onPatch]
|
|
84
|
+
* @property {(outcome: Outcome) => void} [onError]
|
|
85
|
+
* @property {(info: { reason: string }) => void} [onEnd]
|
|
86
|
+
* @property {AbortSignal} [signal] - stops the subscription silently
|
|
87
|
+
* @property {number} [lastSeq] - the resume seq (what a reconnect passes)
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The port client — the binding-agnostic client shape over a channel.
|
|
92
|
+
* @typedef {Object} PortClient
|
|
93
|
+
* @property {(op: string, input?: unknown, ctx?: PortInvokeContext) => Promise<Outcome>} invoke
|
|
94
|
+
* @property {(op: string, input?: unknown, options?: PortSubscribeOptions) => { stop: () => void }} subscribe
|
|
95
|
+
* @property {PortClientCapabilities} capabilities
|
|
96
|
+
* @property {Contract} contract
|
|
97
|
+
* @property {() => any} describe
|
|
98
|
+
* @property {() => void} close
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/** The default answer window of one request, in ms. */
|
|
102
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} code
|
|
106
|
+
* @param {string} reason
|
|
107
|
+
* @returns {ContractHostError}
|
|
108
|
+
*/
|
|
109
|
+
function host(code, reason) {
|
|
110
|
+
return new ContractHostError(code, `openPortClient: ${reason}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One operation as the client prepared it.
|
|
115
|
+
* @typedef {Object} PortRoute
|
|
116
|
+
* @property {CompiledOperation} op
|
|
117
|
+
* @property {boolean} raw
|
|
118
|
+
* @property {boolean} hasInput
|
|
119
|
+
* @property {((value: unknown) => any) | null} validateInput
|
|
120
|
+
* @property {'none' | 'paths' | 'full'} details
|
|
121
|
+
* @property {OutcomeRoute} outcome
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {CompiledOperation} op
|
|
126
|
+
* @returns {PortRoute}
|
|
127
|
+
*/
|
|
128
|
+
function prepare(op) {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
op,
|
|
131
|
+
raw: op.http.opaque,
|
|
132
|
+
hasInput: op.input !== null,
|
|
133
|
+
validateInput: op.input === null ? null : op.input.validate,
|
|
134
|
+
details: op.policy.errors.details,
|
|
135
|
+
outcome: prepareOutcomeRoute(op),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One pending request: what the listener needs to settle it.
|
|
141
|
+
* @typedef {Object} Pending
|
|
142
|
+
* @property {(outcome: Outcome) => void} resolve
|
|
143
|
+
* @property {PortRoute} route
|
|
144
|
+
* @property {OutcomeMeta} meta
|
|
145
|
+
* @property {ReturnType<typeof setTimeout> | 0} timer
|
|
146
|
+
* @property {() => void} cleanup - removes the abort listener
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open a port client over a compiled contract.
|
|
151
|
+
*
|
|
152
|
+
* @param {Contract} contract
|
|
153
|
+
* @param {PortClientOptions} options
|
|
154
|
+
* @returns {PortClient}
|
|
155
|
+
* @throws {ContractHostError} `JC1008` for a malformed argument or option
|
|
156
|
+
* @example
|
|
157
|
+
* const worker = new Worker(new URL('./owner.js', import.meta.url), { type: 'module' });
|
|
158
|
+
* const client = openPortClient(contract, { channel: worker });
|
|
159
|
+
* const outcome = await client.invoke('data.rows', { collection: 'notes' });
|
|
160
|
+
* if (outcome.ok) render(outcome.value); // outcome.error.status is null on this binding
|
|
161
|
+
*/
|
|
162
|
+
export function openPortClient(contract, options) {
|
|
163
|
+
if (contract === null || typeof contract !== 'object' || typeof contract.match !== 'function'
|
|
164
|
+
|| contract.operations === null || typeof contract.operations !== 'object' || !Array.isArray(contract.ids)) {
|
|
165
|
+
throw host('JC1008', 'the first argument must be a compiled contract (compileContract)');
|
|
166
|
+
}
|
|
167
|
+
if (options === null || typeof options !== 'object') throw host('JC1008', 'options must be an object with the channel');
|
|
168
|
+
const channel = options.channel;
|
|
169
|
+
if (!isChannel(channel)) {
|
|
170
|
+
throw host('JC1008', 'options.channel must expose postMessage and a message listener surface (a MessagePort, Worker, BroadcastChannel, or the shape)');
|
|
171
|
+
}
|
|
172
|
+
const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs;
|
|
173
|
+
if (typeof timeoutMs !== 'number' || !(timeoutMs >= 0) || !Number.isFinite(timeoutMs)) {
|
|
174
|
+
throw host('JC1008', 'options.timeoutMs must be a non-negative finite number');
|
|
175
|
+
}
|
|
176
|
+
if (options.catalog !== undefined && (options.catalog === null || typeof options.catalog !== 'object')) {
|
|
177
|
+
throw host('JC1008', 'options.catalog must be a message catalog object');
|
|
178
|
+
}
|
|
179
|
+
/** @type {Catalog | null} */
|
|
180
|
+
const catalog = options.catalog === undefined ? null : compileMessageCatalog(options.catalog);
|
|
181
|
+
|
|
182
|
+
/** @type {Map<string, PortRoute>} */
|
|
183
|
+
const routes = new Map();
|
|
184
|
+
for (let i = 0; i < contract.ids.length; i++) {
|
|
185
|
+
const id = contract.ids[i];
|
|
186
|
+
routes.set(id, prepare(contract.operations[id]));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const clientId = globalThis.crypto.randomUUID();
|
|
190
|
+
const prefix = clientId + ':';
|
|
191
|
+
let seq = 0;
|
|
192
|
+
/** @type {Map<string, Pending>} */
|
|
193
|
+
const pending = new Map();
|
|
194
|
+
/** @type {Map<string, ReturnType<typeof createStreamConsumer>>} */
|
|
195
|
+
const streams = new Map();
|
|
196
|
+
let closed = false;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {PortRoute} route
|
|
200
|
+
* @param {OutcomeMeta} meta
|
|
201
|
+
* @returns {Outcome}
|
|
202
|
+
*/
|
|
203
|
+
function cancelled(route, meta) {
|
|
204
|
+
return failedOutcome('cancelled', clientError(catalog, 'JC2052', { op: route.op.id }, null, undefined), meta);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A binding-code outcome (`JC2072`/`JC2073`/`JC2074`), rendered here.
|
|
209
|
+
* @param {'network' | 'contract'} kind
|
|
210
|
+
* @param {keyof typeof PORT_LOCAL_ERRORS} code
|
|
211
|
+
* @param {Record<string, unknown>} params
|
|
212
|
+
* @param {OutcomeMeta} meta
|
|
213
|
+
* @returns {Outcome}
|
|
214
|
+
*/
|
|
215
|
+
function bindingOutcome(kind, code, params, meta) {
|
|
216
|
+
const row = PORT_LOCAL_ERRORS[code];
|
|
217
|
+
return failedOutcome(kind, outcomeError(code, renderMessage(catalog, row.msgid, params), null, null, row.retryable), meta);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Settle one pending entry: remove it first, so a second frame with
|
|
222
|
+
* the same id — or a late one after cancel/timeout — finds nothing and
|
|
223
|
+
* is dropped silently.
|
|
224
|
+
* @param {string} id
|
|
225
|
+
* @returns {Pending | undefined}
|
|
226
|
+
*/
|
|
227
|
+
function take(id) {
|
|
228
|
+
const entry = pending.get(id);
|
|
229
|
+
if (entry === undefined) return undefined;
|
|
230
|
+
pending.delete(id);
|
|
231
|
+
if (entry.timer !== 0) clearTimeout(entry.timer);
|
|
232
|
+
entry.cleanup();
|
|
233
|
+
return entry;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {any} event */
|
|
237
|
+
function listener(event) {
|
|
238
|
+
const frame = event === null || typeof event !== 'object' ? undefined : event.data;
|
|
239
|
+
if (!isContractFrame(frame)) return;
|
|
240
|
+
const f = /** @type {any} */ (frame);
|
|
241
|
+
// the cheap prefix test — before any map lookup, so a foreign id
|
|
242
|
+
// (another client's response, any request) costs one string check
|
|
243
|
+
if (typeof f.id !== 'string' || !f.id.startsWith(prefix)) return;
|
|
244
|
+
// our own request frame, echoed by a loopback channel: not a response
|
|
245
|
+
if (typeof f.op === 'string') return;
|
|
246
|
+
// a push frame: one stream event for a subscription this client holds
|
|
247
|
+
if (typeof f.event === 'string') {
|
|
248
|
+
const consumer = streams.get(f.id);
|
|
249
|
+
if (consumer === undefined) return;
|
|
250
|
+
const at = typeof f.seq === 'number' && Number.isFinite(f.seq) ? f.seq : null;
|
|
251
|
+
switch (f.event) {
|
|
252
|
+
case 'snapshot':
|
|
253
|
+
consumer.snapshot(at, f.data);
|
|
254
|
+
break;
|
|
255
|
+
case 'patch':
|
|
256
|
+
consumer.patch(at, f.data);
|
|
257
|
+
break;
|
|
258
|
+
case 'error':
|
|
259
|
+
consumer.error(f.data);
|
|
260
|
+
break;
|
|
261
|
+
case 'end':
|
|
262
|
+
consumer.end(f.data);
|
|
263
|
+
break;
|
|
264
|
+
// an unknown event name is ignored — the wire's forward compatibility
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const entry = take(f.id);
|
|
269
|
+
if (entry === undefined) return;
|
|
270
|
+
const { route, meta } = entry;
|
|
271
|
+
meta.trace = typeof f.trace === 'string' && f.trace.length > 0 ? f.trace : null;
|
|
272
|
+
if (f.ok === true) {
|
|
273
|
+
if (!Object.hasOwn(f, 'value')) {
|
|
274
|
+
entry.resolve(bindingOutcome('contract', 'JC2073', { op: route.op.id }, meta));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
entry.resolve(assembleOutcome(route.outcome, { status: null, headers: null, value: f.value }, meta, catalog));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (f.ok !== false) {
|
|
281
|
+
entry.resolve(bindingOutcome('contract', 'JC2073', { op: route.op.id }, meta));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const error = f.error;
|
|
285
|
+
if (error === null || typeof error !== 'object' || typeof error.code !== 'string') {
|
|
286
|
+
entry.resolve(bindingOutcome('contract', 'JC2073', { op: route.op.id }, meta));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// the serving host's own faults keep their code but classify as a
|
|
290
|
+
// contract violation — a peer's host bug is not a declared failure
|
|
291
|
+
if (error.code === 'JC2070' || error.code === 'JC2071') {
|
|
292
|
+
const message = typeof error.message === 'string'
|
|
293
|
+
? error.message
|
|
294
|
+
: renderMessage(catalog, PORT_LOCAL_ERRORS[/** @type {'JC2070' | 'JC2071'} */ (error.code)].msgid, { op: route.op.id });
|
|
295
|
+
entry.resolve(failedOutcome('contract', outcomeError(error.code, message, null, error.details, false), meta));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
entry.resolve(assembleOutcome(route.outcome, { status: null, headers: null, error }, meta, catalog));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const detach = attach(channel, listener);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} op
|
|
305
|
+
* @param {unknown} [input]
|
|
306
|
+
* @param {PortInvokeContext} [ctx]
|
|
307
|
+
* @returns {Promise<Outcome>}
|
|
308
|
+
*/
|
|
309
|
+
function invoke(op, input, ctx = {}) {
|
|
310
|
+
const route = routes.get(op);
|
|
311
|
+
if (route === undefined) {
|
|
312
|
+
throw new ContractHostError('JC1005', `client: '${String(op)}' is not an operation of the contract`);
|
|
313
|
+
}
|
|
314
|
+
if (route.raw) {
|
|
315
|
+
throw new ContractHostError('JC1005', `client: '${route.op.id}' is an opaque operation (media ${route.op.http.media}); the port binding carries JSON only (capabilities.media is false)`);
|
|
316
|
+
}
|
|
317
|
+
if (route.op.kind === 'subscribe') {
|
|
318
|
+
throw new ContractHostError('JC1005', `client: '${route.op.id}' is a subscribe operation — a port carries it as a stream; use client.subscribe`);
|
|
319
|
+
}
|
|
320
|
+
if (ctx === null || typeof ctx !== 'object') throw host('JC1008', 'ctx must be an object');
|
|
321
|
+
const meta = makeMeta(route.op.id, ctx.attempt, null);
|
|
322
|
+
const signal = ctx.signal === undefined || ctx.signal === null ? null : ctx.signal;
|
|
323
|
+
if ((signal !== null && signal.aborted) || closed) return Promise.resolve(cancelled(route, meta));
|
|
324
|
+
|
|
325
|
+
// validate before anything is posted — the same verdict the server
|
|
326
|
+
// will reach, and the pre-send refusal every client binding shares
|
|
327
|
+
let value;
|
|
328
|
+
if (!route.hasInput) {
|
|
329
|
+
if (input !== undefined && input !== null) {
|
|
330
|
+
return Promise.resolve(failedOutcome('contract',
|
|
331
|
+
clientError(catalog, 'JC2050', { op: route.op.id }, null, [{ path: '', keyword: 'input' }]), meta));
|
|
332
|
+
}
|
|
333
|
+
value = null;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
value = input === undefined || input === null ? {} : input;
|
|
337
|
+
const invalid = validateOperationInput(route, value);
|
|
338
|
+
if (invalid !== null && invalid.kind === 'contract') {
|
|
339
|
+
return Promise.resolve(failedOutcome('contract',
|
|
340
|
+
clientError(catalog, 'JC2050', { op: route.op.id }, null, invalid.details), meta));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const id = prefix + (++seq);
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
/** @type {() => void} */
|
|
347
|
+
let cleanup = () => {};
|
|
348
|
+
const onAbort = () => {
|
|
349
|
+
const entry = take(id);
|
|
350
|
+
if (entry === undefined) return;
|
|
351
|
+
// tell the server to stop working — an optimization; the id
|
|
352
|
+
// scoping already guarantees nothing late can settle here
|
|
353
|
+
try {
|
|
354
|
+
channel.postMessage(cancelFrame(id));
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// a channel that cannot carry the cancel changes nothing
|
|
358
|
+
}
|
|
359
|
+
resolve(cancelled(route, meta));
|
|
360
|
+
};
|
|
361
|
+
if (signal !== null) {
|
|
362
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
363
|
+
cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
364
|
+
}
|
|
365
|
+
const timer = timeoutMs === 0 ? 0 : setTimeout(() => {
|
|
366
|
+
const entry = pending.get(id);
|
|
367
|
+
if (entry === undefined) return;
|
|
368
|
+
pending.delete(id);
|
|
369
|
+
entry.cleanup();
|
|
370
|
+
resolve(bindingOutcome('network', 'JC2072', { op: route.op.id, ms: timeoutMs }, meta));
|
|
371
|
+
}, timeoutMs);
|
|
372
|
+
pending.set(id, { resolve, route, meta, timer, cleanup });
|
|
373
|
+
try {
|
|
374
|
+
channel.postMessage(requestFrame(id, route.op.id, value));
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
const entry = take(id);
|
|
378
|
+
if (entry !== undefined) resolve(bindingOutcome('network', 'JC2074', { op: route.op.id }, meta));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Subscribe to a subscribe operation's stream (docs/CONTRACT-FORMAT.md
|
|
385
|
+
* §19): one subscribe frame, the push frames delivered through the
|
|
386
|
+
* callbacks; snapshots validated against the output schema, `seq`
|
|
387
|
+
* strictly increasing (`JC2092`). `stop()` posts the unsubscribe
|
|
388
|
+
* frame; there is no heartbeat on a port. Reconnection is the
|
|
389
|
+
* caller's: pass the last delivered seq as `lastSeq`.
|
|
390
|
+
* @param {string} op
|
|
391
|
+
* @param {unknown} [input]
|
|
392
|
+
* @param {PortSubscribeOptions} [options]
|
|
393
|
+
* @returns {{ stop: () => void }}
|
|
394
|
+
* @throws {ContractHostError} `JC1010` for a non-subscribe operation, `JC1008` for a malformed option
|
|
395
|
+
*/
|
|
396
|
+
function subscribe(op, input, options = {}) {
|
|
397
|
+
const route = routes.get(op);
|
|
398
|
+
if (route === undefined) {
|
|
399
|
+
throw new ContractHostError('JC1005', `client: '${String(op)}' is not an operation of the contract`);
|
|
400
|
+
}
|
|
401
|
+
if (route.op.kind !== 'subscribe') {
|
|
402
|
+
throw new ContractHostError('JC1010', `client: '${route.op.id}' is a ${route.op.kind} operation — subscribe carries streams; use invoke`);
|
|
403
|
+
}
|
|
404
|
+
if (options === null || typeof options !== 'object') throw host('JC1008', 'subscribe options must be an object');
|
|
405
|
+
for (const name of ['onSnapshot', 'onPatch', 'onError', 'onEnd']) {
|
|
406
|
+
const cb = /** @type {any} */ (options)[name];
|
|
407
|
+
if (cb !== undefined && typeof cb !== 'function') throw host('JC1008', `options.${name} must be a function`);
|
|
408
|
+
}
|
|
409
|
+
/** @type {number | null} */
|
|
410
|
+
let lastSeq = null;
|
|
411
|
+
if (options.lastSeq !== undefined && options.lastSeq !== null) {
|
|
412
|
+
if (!Number.isInteger(options.lastSeq) || options.lastSeq < 0) throw host('JC1008', 'options.lastSeq must be a non-negative integer');
|
|
413
|
+
lastSeq = options.lastSeq;
|
|
414
|
+
}
|
|
415
|
+
const signal = options.signal === undefined || options.signal === null ? null : options.signal;
|
|
416
|
+
const meta = makeMeta(route.op.id, null, null);
|
|
417
|
+
const id = prefix + (++seq);
|
|
418
|
+
|
|
419
|
+
/** @type {() => void} */
|
|
420
|
+
let removeAbort = () => {};
|
|
421
|
+
const consumer = createStreamConsumer({
|
|
422
|
+
route: route.outcome,
|
|
423
|
+
catalog,
|
|
424
|
+
meta,
|
|
425
|
+
callbacks: { onSnapshot: options.onSnapshot, onPatch: options.onPatch, onError: options.onError, onEnd: options.onEnd },
|
|
426
|
+
finish: () => {
|
|
427
|
+
streams.delete(id);
|
|
428
|
+
removeAbort();
|
|
429
|
+
},
|
|
430
|
+
lastSeq,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const stop = () => {
|
|
434
|
+
const held = streams.has(id);
|
|
435
|
+
consumer.cancel();
|
|
436
|
+
if (held) {
|
|
437
|
+
try {
|
|
438
|
+
channel.postMessage(unsubscribeFrame(id));
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// a channel that cannot carry the unsubscribe changes nothing
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
if (closed || (signal !== null && signal.aborted)) {
|
|
447
|
+
queueMicrotask(() => consumer.cancel());
|
|
448
|
+
return { stop };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// validate before anything is posted — the shared pre-send refusal
|
|
452
|
+
let value;
|
|
453
|
+
let refusal = null;
|
|
454
|
+
if (!route.hasInput) {
|
|
455
|
+
if (input !== undefined && input !== null) refusal = [{ path: '', keyword: 'input' }];
|
|
456
|
+
value = null;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
value = input === undefined || input === null ? {} : input;
|
|
460
|
+
const v = verdict(/** @type {(value: unknown) => any} */ (route.validateInput), value);
|
|
461
|
+
if (!v.valid) refusal = projectValidationDetails(route.details, v.errors);
|
|
462
|
+
}
|
|
463
|
+
if (refusal !== null) {
|
|
464
|
+
const outcome = failedOutcome('contract', clientError(catalog, 'JC2050', { op: route.op.id }, null, refusal), meta);
|
|
465
|
+
queueMicrotask(() => consumer.fail(outcome));
|
|
466
|
+
return { stop };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
streams.set(id, consumer);
|
|
470
|
+
if (signal !== null) {
|
|
471
|
+
const onAbort = () => stop();
|
|
472
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
473
|
+
removeAbort = () => signal.removeEventListener('abort', onAbort);
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
channel.postMessage(subscribeFrame(id, route.op.id, value, lastSeq));
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
const outcome = bindingOutcome('network', 'JC2074', { op: route.op.id }, meta);
|
|
480
|
+
queueMicrotask(() => consumer.fail(outcome));
|
|
481
|
+
}
|
|
482
|
+
return { stop };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @type {PortClientCapabilities} */
|
|
486
|
+
const capabilities = Object.freeze({
|
|
487
|
+
name: 'port',
|
|
488
|
+
status: false,
|
|
489
|
+
headers: false,
|
|
490
|
+
media: false,
|
|
491
|
+
etag: false,
|
|
492
|
+
idempotency: false,
|
|
493
|
+
stream: true,
|
|
494
|
+
cancel: 'message',
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
return Object.freeze({
|
|
498
|
+
invoke,
|
|
499
|
+
subscribe,
|
|
500
|
+
capabilities,
|
|
501
|
+
contract,
|
|
502
|
+
describe: () => contract.describe(),
|
|
503
|
+
close: () => {
|
|
504
|
+
if (closed) return;
|
|
505
|
+
closed = true;
|
|
506
|
+
detach();
|
|
507
|
+
const entries = [...pending.values()];
|
|
508
|
+
pending.clear();
|
|
509
|
+
for (const entry of entries) {
|
|
510
|
+
if (entry.timer !== 0) clearTimeout(entry.timer);
|
|
511
|
+
entry.cleanup();
|
|
512
|
+
entry.resolve(cancelled(entry.route, entry.meta));
|
|
513
|
+
}
|
|
514
|
+
for (const consumer of [...streams.values()]) consumer.cancel();
|
|
515
|
+
streams.clear();
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
}
|