@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,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
+ */