tywrap 0.6.1 → 0.8.0

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 (110) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +40 -9
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/dev.d.ts.map +1 -1
  11. package/dist/dev.js +1 -3
  12. package/dist/dev.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/runtime/base-bridge.d.ts +57 -0
  17. package/dist/runtime/base-bridge.d.ts.map +1 -0
  18. package/dist/runtime/base-bridge.js +72 -0
  19. package/dist/runtime/base-bridge.js.map +1 -0
  20. package/dist/runtime/frame-codec.d.ts +111 -0
  21. package/dist/runtime/frame-codec.d.ts.map +1 -0
  22. package/dist/runtime/frame-codec.js +352 -0
  23. package/dist/runtime/frame-codec.js.map +1 -0
  24. package/dist/runtime/http-transport.d.ts +11 -1
  25. package/dist/runtime/http-transport.d.ts.map +1 -1
  26. package/dist/runtime/http-transport.js +19 -0
  27. package/dist/runtime/http-transport.js.map +1 -1
  28. package/dist/runtime/http.d.ts +5 -12
  29. package/dist/runtime/http.d.ts.map +1 -1
  30. package/dist/runtime/http.js +6 -29
  31. package/dist/runtime/http.js.map +1 -1
  32. package/dist/runtime/index.d.ts +2 -2
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/node.d.ts +25 -19
  37. package/dist/runtime/node.d.ts.map +1 -1
  38. package/dist/runtime/node.js +19 -34
  39. package/dist/runtime/node.js.map +1 -1
  40. package/dist/runtime/pooled-transport.d.ts +21 -2
  41. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  42. package/dist/runtime/pooled-transport.js +16 -0
  43. package/dist/runtime/pooled-transport.js.map +1 -1
  44. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  45. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  47. package/dist/runtime/pyodide-transport.d.ts +12 -1
  48. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  49. package/dist/runtime/pyodide-transport.js +20 -0
  50. package/dist/runtime/pyodide-transport.js.map +1 -1
  51. package/dist/runtime/pyodide.d.ts +5 -12
  52. package/dist/runtime/pyodide.d.ts.map +1 -1
  53. package/dist/runtime/pyodide.js +6 -29
  54. package/dist/runtime/pyodide.js.map +1 -1
  55. package/dist/runtime/rpc-client.d.ts +14 -1
  56. package/dist/runtime/rpc-client.d.ts.map +1 -1
  57. package/dist/runtime/rpc-client.js +68 -6
  58. package/dist/runtime/rpc-client.js.map +1 -1
  59. package/dist/runtime/subprocess-transport.d.ts +177 -3
  60. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  61. package/dist/runtime/subprocess-transport.js +526 -26
  62. package/dist/runtime/subprocess-transport.js.map +1 -1
  63. package/dist/runtime/transport.d.ts +141 -0
  64. package/dist/runtime/transport.d.ts.map +1 -1
  65. package/dist/runtime/transport.js +21 -0
  66. package/dist/runtime/transport.js.map +1 -1
  67. package/dist/types/index.d.ts +59 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/dist/tywrap.d.ts.map +1 -1
  70. package/dist/tywrap.js +204 -149
  71. package/dist/tywrap.js.map +1 -1
  72. package/dist/utils/codec.d.ts +2 -0
  73. package/dist/utils/codec.d.ts.map +1 -1
  74. package/dist/utils/codec.js +205 -6
  75. package/dist/utils/codec.js.map +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +7 -1
  78. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  79. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  80. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  81. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  82. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  83. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  84. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  85. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  86. package/runtime/frame_codec.py +424 -0
  87. package/runtime/python_bridge.py +241 -42
  88. package/runtime/tywrap_bridge_core.py +152 -13
  89. package/src/core/annotation-parser.ts +2 -1
  90. package/src/core/emit-call.ts +1 -7
  91. package/src/core/generator.ts +50 -11
  92. package/src/dev.ts +1 -3
  93. package/src/index.ts +1 -0
  94. package/src/runtime/base-bridge.ts +106 -0
  95. package/src/runtime/frame-codec.ts +469 -0
  96. package/src/runtime/http-transport.ts +21 -1
  97. package/src/runtime/http.ts +7 -51
  98. package/src/runtime/index.ts +2 -6
  99. package/src/runtime/node.ts +42 -53
  100. package/src/runtime/pooled-transport.ts +25 -2
  101. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  102. package/src/runtime/pyodide-transport.ts +22 -0
  103. package/src/runtime/pyodide.ts +7 -52
  104. package/src/runtime/rpc-client.ts +91 -7
  105. package/src/runtime/subprocess-transport.ts +629 -30
  106. package/src/runtime/transport.ts +169 -0
  107. package/src/types/index.ts +62 -0
  108. package/src/tywrap.ts +265 -162
  109. package/src/utils/codec.ts +245 -7
  110. package/src/version.ts +1 -1
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Pure frame codec + reassembler for the `tywrap-frame/1` framing protocol.
3
+ *
4
+ * This module is the transport-agnostic core of large-payload chunking: it
5
+ * fragments one complete logical JSON message into {@link ChunkFrame}s and
6
+ * reassembles a stream of frames back into the original string. It performs NO
7
+ * I/O and knows nothing about subprocesses, stdin/stdout, or timeouts — the
8
+ * transport layer (W4/W5) wires these functions into the read/write loop.
9
+ *
10
+ * Encoding is `utf8-slice` (plan decision #6, docs/transport-framing.md): the
11
+ * logical payload is already valid-UTF-8 JSON, so each frame's `data` is a raw
12
+ * substring split on a UTF-8 codepoint boundary at or before `maxFrameBytes`
13
+ * UTF-8 bytes. Reassembly is plain concatenation — no base64, no ~33% inflation,
14
+ * no extra decode pass. A frame's `data` MUST NOT split a multi-byte UTF-8
15
+ * sequence; {@link encodeFrames} guarantees this by snapping every boundary back
16
+ * to the nearest codepoint boundary.
17
+ *
18
+ * The mirror implementation lives in `runtime/frame_codec.py`; the two MUST
19
+ * agree byte-for-byte on the wire (see test/frame-codec.test.ts and
20
+ * test/python/test_frame_codec.py for the cross-language parity vectors).
21
+ *
22
+ * @see docs/transport-framing.md
23
+ */
24
+
25
+ import { BridgeProtocolError } from './errors.js';
26
+ import { FRAME_PROTOCOL_ID, type ChunkFrame, type ChunkFrameEncoding } from './transport.js';
27
+
28
+ // =============================================================================
29
+ // OPTIONS
30
+ // =============================================================================
31
+
32
+ /** Which logical stream a set of frames belongs to. */
33
+ export type FrameStream = ChunkFrame['stream'];
34
+
35
+ /** Options for {@link encodeFrames}. */
36
+ export interface EncodeFramesOptions {
37
+ /** RPC correlation id, shared with the logical {@link ProtocolMessage.id}. */
38
+ readonly id: number;
39
+
40
+ /** Which logical stream these frames belong to (`request` or `response`). */
41
+ readonly stream: FrameStream;
42
+
43
+ /**
44
+ * Maximum UTF-8 byte length of a single frame's `data` field. Each frame's
45
+ * `data` is guaranteed to be at most this many UTF-8 bytes, snapped back to a
46
+ * codepoint boundary so no multi-byte sequence is split. Must be a positive
47
+ * integer large enough to hold at least one codepoint (>= 4 bytes).
48
+ */
49
+ readonly maxFrameBytes: number;
50
+ }
51
+
52
+ // =============================================================================
53
+ // UTF-8 HELPERS (pure, codepoint-boundary aware)
54
+ // =============================================================================
55
+
56
+ const utf8Encoder = new TextEncoder();
57
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
58
+
59
+ /** UTF-8 byte length of a single Unicode codepoint. */
60
+ function utf8ByteLengthOfCodePoint(codePoint: number): number {
61
+ if (codePoint <= 0x7f) {
62
+ return 1;
63
+ }
64
+ if (codePoint <= 0x7ff) {
65
+ return 2;
66
+ }
67
+ if (codePoint <= 0xffff) {
68
+ return 3;
69
+ }
70
+ return 4;
71
+ }
72
+
73
+ /** Exact UTF-8 byte length of a JS string (no allocation of the encoded bytes). */
74
+ export function utf8ByteLength(value: string): number {
75
+ let bytes = 0;
76
+ for (const ch of value) {
77
+ bytes += utf8ByteLengthOfCodePoint(ch.codePointAt(0) ?? 0);
78
+ }
79
+ return bytes;
80
+ }
81
+
82
+ // =============================================================================
83
+ // ENCODE
84
+ // =============================================================================
85
+
86
+ /**
87
+ * Fragment a complete logical JSON message into `tywrap-frame/1` frames.
88
+ *
89
+ * Splits `logicalJson` on UTF-8 codepoint boundaries so each frame's `data` is
90
+ * at most `maxFrameBytes` UTF-8 bytes and never splits a multi-byte sequence.
91
+ * `totalBytes` is the exact UTF-8 byte length of the full message; `total` is
92
+ * the resulting frame count; `seq` is zero-based and dense.
93
+ *
94
+ * An empty `logicalJson` still produces exactly one (empty) frame so the
95
+ * receiver always sees `total >= 1` and a well-formed stream.
96
+ *
97
+ * @throws BridgeProtocolError if `maxFrameBytes` is not a positive integer of at
98
+ * least 4 bytes (the worst-case single-codepoint size).
99
+ */
100
+ export function encodeFrames(logicalJson: string, opts: EncodeFramesOptions): ChunkFrame[] {
101
+ const { id, stream, maxFrameBytes } = opts;
102
+
103
+ if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 4) {
104
+ throw new BridgeProtocolError(
105
+ `encodeFrames: maxFrameBytes must be an integer >= 4 (got ${String(maxFrameBytes)})`,
106
+ { code: 'FRAME_BAD_MAX_BYTES' }
107
+ );
108
+ }
109
+ // Validate the protocol fields locally too (parseChunkFrame enforces them on
110
+ // the decode side, but catching misuse at the encoder fails fast and keeps the
111
+ // two sides symmetric with the Python encoder).
112
+ if (stream !== 'request' && stream !== 'response') {
113
+ throw new BridgeProtocolError(
114
+ `encodeFrames: stream must be 'request' or 'response' (got ${String(stream)})`,
115
+ { code: 'FRAME_BAD_STREAM' }
116
+ );
117
+ }
118
+ if (!Number.isInteger(id)) {
119
+ throw new BridgeProtocolError(`encodeFrames: id must be an integer (got ${String(id)})`, {
120
+ code: 'FRAME_BAD_ID',
121
+ });
122
+ }
123
+
124
+ const totalBytes = utf8ByteLength(logicalJson);
125
+ const encoding: ChunkFrameEncoding = 'utf8-slice';
126
+
127
+ // Walk codepoints, accumulating UTF-8 bytes into the current slice until the
128
+ // next codepoint would exceed maxFrameBytes; that boundary is, by
129
+ // construction, a codepoint boundary so no multi-byte sequence is split.
130
+ const slices: string[] = [];
131
+ let current = '';
132
+ let currentBytes = 0;
133
+ for (const ch of logicalJson) {
134
+ const chBytes = utf8ByteLengthOfCodePoint(ch.codePointAt(0) ?? 0);
135
+ if (currentBytes + chBytes > maxFrameBytes && current.length > 0) {
136
+ slices.push(current);
137
+ current = '';
138
+ currentBytes = 0;
139
+ }
140
+ current += ch;
141
+ currentBytes += chBytes;
142
+ }
143
+ // Always emit a final slice (covers the empty-string case: one empty frame).
144
+ slices.push(current);
145
+
146
+ const total = slices.length;
147
+ return slices.map((data, seq) => ({
148
+ __tywrap_frame__: 'chunk',
149
+ frameProtocol: FRAME_PROTOCOL_ID,
150
+ stream,
151
+ id,
152
+ seq,
153
+ total,
154
+ totalBytes,
155
+ encoding,
156
+ data,
157
+ }));
158
+ }
159
+
160
+ // =============================================================================
161
+ // REASSEMBLE
162
+ // =============================================================================
163
+
164
+ /** Per-id accumulation state inside the {@link Reassembler}. */
165
+ interface StreamState {
166
+ total: number;
167
+ totalBytes: number;
168
+ /** Running UTF-8 byte count of slices received so far (memory bound). */
169
+ bytesSoFar: number;
170
+ /** seq -> frame data slice. */
171
+ readonly slices: Map<number, string>;
172
+ }
173
+
174
+ /**
175
+ * Validates that a raw value is a structurally well-formed `tywrap-frame/1` data
176
+ * frame. Returns the frame typed, or throws a {@link BridgeProtocolError}.
177
+ *
178
+ * Only `__tywrap_frame__: 'chunk'` frames flow through reassembly; `'error'`
179
+ * frames are a transport-layer concern handled above this module.
180
+ */
181
+ export function parseChunkFrame(value: unknown): ChunkFrame {
182
+ if (value === null || typeof value !== 'object') {
183
+ throw new BridgeProtocolError('frame: expected an object', {
184
+ code: 'FRAME_MALFORMED',
185
+ });
186
+ }
187
+ const f = value as Record<string, unknown>;
188
+
189
+ if (f.__tywrap_frame__ !== 'chunk') {
190
+ throw new BridgeProtocolError(
191
+ `frame: __tywrap_frame__ must be "chunk" (got ${JSON.stringify(f.__tywrap_frame__)})`,
192
+ { code: 'FRAME_MALFORMED' }
193
+ );
194
+ }
195
+ if (f.frameProtocol !== FRAME_PROTOCOL_ID) {
196
+ throw new BridgeProtocolError(
197
+ `frame: unknown frameProtocol ${JSON.stringify(f.frameProtocol)} (expected ${FRAME_PROTOCOL_ID})`,
198
+ { code: 'FRAME_UNKNOWN_PROTOCOL' }
199
+ );
200
+ }
201
+ if (f.stream !== 'request' && f.stream !== 'response') {
202
+ throw new BridgeProtocolError(
203
+ `frame: stream must be "request" or "response" (got ${JSON.stringify(f.stream)})`,
204
+ { code: 'FRAME_MALFORMED' }
205
+ );
206
+ }
207
+ if (f.encoding !== 'utf8-slice') {
208
+ // utf8-base64 is reserved in the schema but never emitted/accepted in 0.8.0.
209
+ throw new BridgeProtocolError(
210
+ `frame: unsupported encoding ${JSON.stringify(f.encoding)} (only "utf8-slice" in 0.8.0)`,
211
+ { code: 'FRAME_MALFORMED' }
212
+ );
213
+ }
214
+ if (!Number.isInteger(f.id)) {
215
+ throw new BridgeProtocolError(`frame: id must be an integer (got ${JSON.stringify(f.id)})`, {
216
+ code: 'FRAME_MALFORMED',
217
+ });
218
+ }
219
+ if (!Number.isInteger(f.seq) || (f.seq as number) < 0) {
220
+ throw new BridgeProtocolError(
221
+ `frame: seq must be a non-negative integer (got ${JSON.stringify(f.seq)})`,
222
+ { code: 'FRAME_MALFORMED' }
223
+ );
224
+ }
225
+ if (!Number.isInteger(f.total) || (f.total as number) < 1) {
226
+ throw new BridgeProtocolError(
227
+ `frame: total must be an integer >= 1 (got ${JSON.stringify(f.total)})`,
228
+ { code: 'FRAME_MALFORMED' }
229
+ );
230
+ }
231
+ if (!Number.isInteger(f.totalBytes) || (f.totalBytes as number) < 0) {
232
+ throw new BridgeProtocolError(
233
+ `frame: totalBytes must be a non-negative integer (got ${JSON.stringify(f.totalBytes)})`,
234
+ { code: 'FRAME_MALFORMED' }
235
+ );
236
+ }
237
+ if (typeof f.data !== 'string') {
238
+ throw new BridgeProtocolError(`frame: data must be a string (got ${typeof f.data})`, {
239
+ code: 'FRAME_MALFORMED',
240
+ });
241
+ }
242
+ if ((f.seq as number) >= (f.total as number)) {
243
+ throw new BridgeProtocolError(
244
+ `frame: seq ${String(f.seq)} out of range for total ${String(f.total)}`,
245
+ { code: 'FRAME_MALFORMED' }
246
+ );
247
+ }
248
+ return f as unknown as ChunkFrame;
249
+ }
250
+
251
+ /**
252
+ * Accumulates `tywrap-frame/1` frames by `id` and reconstructs the logical JSON
253
+ * string once a stream is complete.
254
+ *
255
+ * A single instance handles many concurrent ids (each correlated by
256
+ * {@link ChunkFrame.id}). Validation is enforced on every {@link accept} so a
257
+ * malformed/duplicate/inconsistent frame fails fast rather than corrupting an
258
+ * in-flight reassembly:
259
+ *
260
+ * - matching {@link FRAME_PROTOCOL_ID} on every frame;
261
+ * - consistent `total` / `totalBytes` across all frames of an id;
262
+ * - no duplicate `seq`;
263
+ * - on completion, exactly `total` frames covering `[0, total)`;
264
+ * - the concatenated payload's UTF-8 byte length equals `totalBytes` exactly;
265
+ * - the concatenated payload decodes as strict UTF-8.
266
+ *
267
+ * Timed-out ids: the transport marks an id timed out via {@link discard}. Every
268
+ * subsequent frame for that id is dropped (returning `null`) until its final
269
+ * frame arrives, at which point the id is forgotten so the slot can be reused.
270
+ * This prevents late multi-frame responses from desyncing the stream.
271
+ */
272
+ /**
273
+ * Defensive bounds on per-id reassembly state. The peer is the local Python
274
+ * bridge, but a buggy or corrupt bridge must not grow memory without limit:
275
+ * cap how many distinct ids may be mid-reassembly at once (fail loud past the
276
+ * cap), and FIFO-bound the timed-out-id discard set so a long-lived process
277
+ * that times out many chunked requests whose final frames never arrive (e.g.
278
+ * the bridge died mid-stream) does not leak markers forever.
279
+ */
280
+ const MAX_CONCURRENT_STREAMS = 1024;
281
+ const MAX_DISCARDED_IDS = 4096;
282
+
283
+ export class Reassembler {
284
+ private readonly streams = new Map<number, StreamState>();
285
+ private readonly discarded = new Set<number>();
286
+ private readonly maxReassemblyBytes: number;
287
+ private readonly expectedStream: FrameStream | undefined;
288
+
289
+ /**
290
+ * @param options.maxReassemblyBytes Reject (fail loud) any stream whose
291
+ * declared OR accumulated payload exceeds this many UTF-8 bytes, so a huge
292
+ * response cannot be buffered into memory before a higher layer's size cap
293
+ * would reject it anyway. Default: no limit (`Infinity`).
294
+ * @param options.expectedStream If set, every frame must carry this `stream`
295
+ * direction; a frame for the other direction is rejected. The response and
296
+ * request reassemblers are distinct instances, so this is defense-in-depth.
297
+ */
298
+ constructor(options: { maxReassemblyBytes?: number; expectedStream?: FrameStream } = {}) {
299
+ this.maxReassemblyBytes = options.maxReassemblyBytes ?? Number.POSITIVE_INFINITY;
300
+ this.expectedStream = options.expectedStream;
301
+ }
302
+
303
+ /**
304
+ * Feed one frame. Returns the fully reassembled logical string when this
305
+ * frame completes the stream for its id, `null` if more frames are still
306
+ * needed (or the frame was dropped because its id is timed out).
307
+ *
308
+ * @throws BridgeProtocolError on any framing violation (malformed frame,
309
+ * wrong stream direction, declared/accumulated payload over the cap,
310
+ * duplicate `seq`, inconsistent `total`/`totalBytes`, byte-count mismatch,
311
+ * invalid UTF-8, or unknown `frameProtocol`).
312
+ */
313
+ accept(rawFrame: unknown): string | null {
314
+ const frame = parseChunkFrame(rawFrame);
315
+ const { id, seq, total, totalBytes, data, stream } = frame;
316
+
317
+ if (this.expectedStream !== undefined && stream !== this.expectedStream) {
318
+ throw new BridgeProtocolError(
319
+ `frame: unexpected stream '${stream}' for id ${id} (this reassembler handles '${this.expectedStream}')`,
320
+ { code: 'FRAME_WRONG_STREAM' }
321
+ );
322
+ }
323
+
324
+ // Late-frame discard: drop frames for a timed-out id; forget the id once its
325
+ // declared final frame has been seen so the stream stays aligned and the id
326
+ // can be reused.
327
+ if (this.discarded.has(id)) {
328
+ if (seq === total - 1) {
329
+ this.discarded.delete(id);
330
+ }
331
+ return null;
332
+ }
333
+
334
+ let state = this.streams.get(id);
335
+ if (state === undefined) {
336
+ if (this.streams.size >= MAX_CONCURRENT_STREAMS) {
337
+ throw new BridgeProtocolError(
338
+ `frame: too many concurrent reassembly streams (>= ${MAX_CONCURRENT_STREAMS}); refusing to buffer id ${id}`,
339
+ { code: 'FRAME_TOO_MANY_STREAMS' }
340
+ );
341
+ }
342
+ // Reject early: a declared payload past the cap can never be accepted by
343
+ // the higher-layer size guard, so refuse to buffer it instead of OOMing.
344
+ if (totalBytes > this.maxReassemblyBytes) {
345
+ throw new BridgeProtocolError(
346
+ `frame: declared payload ${totalBytes} bytes exceeds max reassembly ${this.maxReassemblyBytes} bytes for id ${id}`,
347
+ { code: 'FRAME_PAYLOAD_TOO_LARGE' }
348
+ );
349
+ }
350
+ state = { total, totalBytes, bytesSoFar: 0, slices: new Map<number, string>() };
351
+ this.streams.set(id, state);
352
+ } else {
353
+ if (state.total !== total) {
354
+ this.streams.delete(id);
355
+ throw new BridgeProtocolError(
356
+ `frame: total mismatch for id ${id} (saw ${state.total}, frame says ${total})`,
357
+ { code: 'FRAME_INCONSISTENT' }
358
+ );
359
+ }
360
+ if (state.totalBytes !== totalBytes) {
361
+ this.streams.delete(id);
362
+ throw new BridgeProtocolError(
363
+ `frame: totalBytes mismatch for id ${id} (saw ${state.totalBytes}, frame says ${totalBytes})`,
364
+ { code: 'FRAME_INCONSISTENT' }
365
+ );
366
+ }
367
+ }
368
+
369
+ if (state.slices.has(seq)) {
370
+ this.streams.delete(id);
371
+ throw new BridgeProtocolError(`frame: duplicate seq ${seq} for id ${id}`, {
372
+ code: 'FRAME_DUPLICATE_SEQ',
373
+ });
374
+ }
375
+ state.slices.set(seq, data);
376
+
377
+ // Running memory bound: a peer that under-declares totalBytes then overshoots
378
+ // is caught here (and the exact-count check at completion catches the rest)
379
+ // before the full payload is buffered.
380
+ state.bytesSoFar += utf8ByteLength(data);
381
+ if (state.bytesSoFar > this.maxReassemblyBytes) {
382
+ this.streams.delete(id);
383
+ throw new BridgeProtocolError(
384
+ `frame: accumulated payload exceeds max reassembly ${this.maxReassemblyBytes} bytes for id ${id}`,
385
+ { code: 'FRAME_PAYLOAD_TOO_LARGE' }
386
+ );
387
+ }
388
+
389
+ if (state.slices.size < total) {
390
+ return null;
391
+ }
392
+
393
+ // All `total` frames present; the dense [0, total) range is guaranteed
394
+ // because each seq is in range, unique, and there are exactly `total` of
395
+ // them. Concatenate in seq order.
396
+ this.streams.delete(id);
397
+ let payload = '';
398
+ for (let i = 0; i < total; i += 1) {
399
+ const slice = state.slices.get(i);
400
+ if (slice === undefined) {
401
+ // Unreachable given the count + uniqueness + range invariants above,
402
+ // but kept explicit rather than a silent gap.
403
+ throw new BridgeProtocolError(`frame: missing seq ${i} for id ${id}`, {
404
+ code: 'FRAME_SEQ_GAP',
405
+ });
406
+ }
407
+ payload += slice;
408
+ }
409
+
410
+ const actualBytes = utf8ByteLength(payload);
411
+ if (actualBytes !== totalBytes) {
412
+ throw new BridgeProtocolError(
413
+ `frame: reassembled byte length ${actualBytes} != declared totalBytes ${totalBytes} for id ${id}`,
414
+ { code: 'FRAME_BYTES_MISMATCH' }
415
+ );
416
+ }
417
+
418
+ // Strict UTF-8 validation: re-encode then decode in fatal mode. With
419
+ // utf8-slice the concatenation cannot introduce invalid sequences (each
420
+ // slice is whole codepoints), but the spec requires the check explicitly.
421
+ try {
422
+ utf8Decoder.decode(utf8Encoder.encode(payload));
423
+ } catch (cause) {
424
+ throw new BridgeProtocolError(`frame: reassembled payload is not valid UTF-8 for id ${id}`, {
425
+ code: 'FRAME_INVALID_UTF8',
426
+ cause,
427
+ });
428
+ }
429
+
430
+ return payload;
431
+ }
432
+
433
+ /**
434
+ * Mark an id as timed out / aborted. Any partial state is dropped immediately
435
+ * and every subsequent frame for this id is discarded until its declared
436
+ * final frame arrives. Idempotent.
437
+ */
438
+ discard(id: number): void {
439
+ this.streams.delete(id);
440
+ this.discarded.add(id);
441
+ // Bound the discard set (FIFO): a timed-out id whose declared final frame
442
+ // never arrives would otherwise linger forever. Set iteration order is
443
+ // insertion order, so the first key is the oldest marker.
444
+ if (this.discarded.size > MAX_DISCARDED_IDS) {
445
+ const oldest = this.discarded.values().next().value;
446
+ if (oldest !== undefined) {
447
+ this.discarded.delete(oldest);
448
+ }
449
+ }
450
+ }
451
+
452
+ /** Whether any frame for `id` is still being accumulated (not yet complete). */
453
+ isPending(id: number): boolean {
454
+ return this.streams.has(id);
455
+ }
456
+
457
+ /** Number of ids currently mid-reassembly (for diagnostics/tests). */
458
+ get pendingCount(): number {
459
+ return this.streams.size;
460
+ }
461
+
462
+ /**
463
+ * Number of timed-out ids whose late frames are still being dropped
464
+ * (FIFO-bounded by `MAX_DISCARDED_IDS`; for diagnostics/tests).
465
+ */
466
+ get discardedCount(): number {
467
+ return this.discarded.size;
468
+ }
469
+ }
@@ -13,7 +13,7 @@ import {
13
13
  BridgeProtocolError,
14
14
  BridgeTimeoutError,
15
15
  } from './errors.js';
16
- import type { Transport } from './transport.js';
16
+ import type { Transport, TransportCapabilities } from './transport.js';
17
17
 
18
18
  // =============================================================================
19
19
  // OPTIONS
@@ -111,6 +111,26 @@ export class HttpTransport implements Transport {
111
111
  return !this._isDisposed;
112
112
  }
113
113
 
114
+ /**
115
+ * Static capability descriptor for the HTTP backend.
116
+ *
117
+ * The Python server can return Arrow IPC and arbitrary binary (bytes
118
+ * envelopes) in the HTTP response body. Chunking/streaming are not implemented
119
+ * (0.8.0). HTTP imposes no transport-level frame ceiling — the whole body is
120
+ * read in one shot — so `maxFrameBytes` is `Number.POSITIVE_INFINITY` (a higher
121
+ * layer, e.g. the codec payload limit, may still cap the size).
122
+ */
123
+ capabilities(): TransportCapabilities {
124
+ return {
125
+ backend: 'http',
126
+ supportsArrow: true,
127
+ supportsBinary: true,
128
+ supportsChunking: false,
129
+ supportsStreaming: false,
130
+ maxFrameBytes: Number.POSITIVE_INFINITY,
131
+ };
132
+ }
133
+
114
134
  // ===========================================================================
115
135
  // SEND
116
136
  // ===========================================================================
@@ -8,11 +8,10 @@
8
8
  * @see https://github.com/bbopen/tywrap/issues/149
9
9
  */
10
10
 
11
- import type { PythonRuntime, BridgeInfo } from '../types/index.js';
12
11
  import { autoRegisterArrowDecoder } from '../utils/codec.js';
13
12
 
14
- import { DisposableBase } from './bounded-context.js';
15
- import { RpcClient, type GetBridgeInfoOptions } from './rpc-client.js';
13
+ import { BasePythonBridge } from './base-bridge.js';
14
+ import { RpcClient } from './rpc-client.js';
16
15
  import { HttpTransport } from './http-transport.js';
17
16
  import type { CodecOptions } from './bridge-codec.js';
18
17
 
@@ -65,7 +64,7 @@ export interface HttpBridgeOptions {
65
64
  * await bridge.dispose();
66
65
  * ```
67
66
  */
68
- export class HttpBridge extends DisposableBase implements PythonRuntime {
67
+ export class HttpBridge extends BasePythonBridge {
69
68
  private readonly rpc: RpcClient;
70
69
 
71
70
  /**
@@ -110,56 +109,13 @@ export class HttpBridge extends DisposableBase implements PythonRuntime {
110
109
  }
111
110
 
112
111
  // ===========================================================================
113
- // RPC METHODS (delegate to the held RpcClient)
112
+ // RPC DELEGATION (the held RpcClient)
114
113
  // ===========================================================================
115
114
 
116
- async call<T = unknown>(
117
- module: string,
118
- functionName: string,
119
- args: unknown[],
120
- kwargs?: Record<string, unknown>
121
- ): Promise<T> {
122
- await this.ensureReady();
123
- return this.rpc.call<T>(module, functionName, args, kwargs);
124
- }
125
-
126
- async instantiate<T = unknown>(
127
- module: string,
128
- className: string,
129
- args: unknown[],
130
- kwargs?: Record<string, unknown>
131
- ): Promise<T> {
132
- await this.ensureReady();
133
- return this.rpc.instantiate<T>(module, className, args, kwargs);
134
- }
135
-
136
- async callMethod<T = unknown>(
137
- handle: string,
138
- methodName: string,
139
- args: unknown[],
140
- kwargs?: Record<string, unknown>
141
- ): Promise<T> {
142
- await this.ensureReady();
143
- return this.rpc.callMethod<T>(handle, methodName, args, kwargs);
144
- }
145
-
146
- async disposeInstance(handle: string): Promise<void> {
147
- await this.ensureReady();
148
- return this.rpc.disposeInstance(handle);
149
- }
150
-
151
- async getBridgeInfo(options?: GetBridgeInfoOptions): Promise<BridgeInfo> {
152
- await this.ensureReady();
153
- return this.rpc.getBridgeInfo(options);
154
- }
155
-
156
115
  /**
157
- * Ensure the facade is initialized before delegating an RPC, replicating the
158
- * auto-init that the bounded execute path provided pre-composition.
116
+ * Expose the held RpcClient to BasePythonBridge's shared delegating methods.
159
117
  */
160
- private async ensureReady(): Promise<void> {
161
- if (!this.isReady) {
162
- await this.init();
163
- }
118
+ protected getRpcClient(): RpcClient {
119
+ return this.rpc;
164
120
  }
165
121
  }
@@ -15,16 +15,12 @@ export { BridgeCodec, type CodecOptions } from './bridge-codec.js';
15
15
  // Transport contract — abstract I/O channel interface and guards
16
16
  export type {
17
17
  Transport,
18
+ TransportCapabilities,
18
19
  TransportOptions,
19
20
  ProtocolMessage,
20
21
  ProtocolResponse,
21
22
  } from './transport.js';
22
- export {
23
- PROTOCOL_ID,
24
- isTransport,
25
- isProtocolMessage,
26
- isProtocolResponse,
27
- } from './transport.js';
23
+ export { PROTOCOL_ID, isTransport, isProtocolMessage, isProtocolResponse } from './transport.js';
28
24
 
29
25
  let runtimeBridge: RuntimeExecution | null = null;
30
26