@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.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. 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
+ }