tywrap 0.2.1 → 0.4.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 (144) hide show
  1. package/README.md +100 -32
  2. package/dist/cli.js +70 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts +11 -7
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +62 -20
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/analyzer.d.ts +1 -0
  9. package/dist/core/analyzer.d.ts.map +1 -1
  10. package/dist/core/analyzer.js +46 -12
  11. package/dist/core/analyzer.js.map +1 -1
  12. package/dist/core/annotation-parser.d.ts +8 -0
  13. package/dist/core/annotation-parser.d.ts.map +1 -0
  14. package/dist/core/annotation-parser.js +409 -0
  15. package/dist/core/annotation-parser.js.map +1 -0
  16. package/dist/core/discovery.d.ts +1 -0
  17. package/dist/core/discovery.d.ts.map +1 -1
  18. package/dist/core/discovery.js +9 -9
  19. package/dist/core/discovery.js.map +1 -1
  20. package/dist/core/generator.d.ts +9 -1
  21. package/dist/core/generator.d.ts.map +1 -1
  22. package/dist/core/generator.js +738 -104
  23. package/dist/core/generator.js.map +1 -1
  24. package/dist/core/mapper.d.ts +9 -7
  25. package/dist/core/mapper.d.ts.map +1 -1
  26. package/dist/core/mapper.js +129 -44
  27. package/dist/core/mapper.js.map +1 -1
  28. package/dist/dev.d.ts +57 -0
  29. package/dist/dev.d.ts.map +1 -0
  30. package/dist/dev.js +743 -0
  31. package/dist/dev.js.map +1 -0
  32. package/dist/index.d.ts +8 -7
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -4
  35. package/dist/index.js.map +1 -1
  36. package/dist/runtime/bridge-core.d.ts +1 -0
  37. package/dist/runtime/bridge-core.d.ts.map +1 -1
  38. package/dist/runtime/bridge-core.js +52 -17
  39. package/dist/runtime/bridge-core.js.map +1 -1
  40. package/dist/runtime/bridge-protocol.d.ts +17 -0
  41. package/dist/runtime/bridge-protocol.d.ts.map +1 -1
  42. package/dist/runtime/bridge-protocol.js +97 -0
  43. package/dist/runtime/bridge-protocol.js.map +1 -1
  44. package/dist/runtime/errors.d.ts +10 -0
  45. package/dist/runtime/errors.d.ts.map +1 -1
  46. package/dist/runtime/errors.js +9 -0
  47. package/dist/runtime/errors.js.map +1 -1
  48. package/dist/runtime/http-io.d.ts.map +1 -1
  49. package/dist/runtime/http-io.js.map +1 -1
  50. package/dist/runtime/http.d.ts +1 -0
  51. package/dist/runtime/http.d.ts.map +1 -1
  52. package/dist/runtime/http.js +6 -0
  53. package/dist/runtime/http.js.map +1 -1
  54. package/dist/runtime/node.d.ts.map +1 -1
  55. package/dist/runtime/node.js +206 -32
  56. package/dist/runtime/node.js.map +1 -1
  57. package/dist/runtime/optimized-node.d.ts +6 -5
  58. package/dist/runtime/optimized-node.d.ts.map +1 -1
  59. package/dist/runtime/optimized-node.js +6 -5
  60. package/dist/runtime/optimized-node.js.map +1 -1
  61. package/dist/runtime/pooled-transport.d.ts +4 -0
  62. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  63. package/dist/runtime/pooled-transport.js +2 -0
  64. package/dist/runtime/pooled-transport.js.map +1 -1
  65. package/dist/runtime/process-io.d.ts +5 -0
  66. package/dist/runtime/process-io.d.ts.map +1 -1
  67. package/dist/runtime/process-io.js +64 -30
  68. package/dist/runtime/process-io.js.map +1 -1
  69. package/dist/runtime/pyodide-io.d.ts +4 -5
  70. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  71. package/dist/runtime/pyodide-io.js +99 -37
  72. package/dist/runtime/pyodide-io.js.map +1 -1
  73. package/dist/runtime/safe-codec.d.ts +25 -3
  74. package/dist/runtime/safe-codec.d.ts.map +1 -1
  75. package/dist/runtime/safe-codec.js +231 -57
  76. package/dist/runtime/safe-codec.js.map +1 -1
  77. package/dist/runtime/transport.d.ts +6 -1
  78. package/dist/runtime/transport.d.ts.map +1 -1
  79. package/dist/runtime/transport.js.map +1 -1
  80. package/dist/runtime/validators.d.ts +0 -11
  81. package/dist/runtime/validators.d.ts.map +1 -1
  82. package/dist/runtime/validators.js +13 -3
  83. package/dist/runtime/validators.js.map +1 -1
  84. package/dist/runtime/worker-pool.d.ts +17 -0
  85. package/dist/runtime/worker-pool.d.ts.map +1 -1
  86. package/dist/runtime/worker-pool.js +97 -22
  87. package/dist/runtime/worker-pool.js.map +1 -1
  88. package/dist/types/index.d.ts +51 -8
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/tywrap.d.ts +6 -0
  91. package/dist/tywrap.d.ts.map +1 -1
  92. package/dist/tywrap.js +237 -331
  93. package/dist/tywrap.js.map +1 -1
  94. package/dist/utils/cache.d.ts.map +1 -1
  95. package/dist/utils/cache.js +53 -5
  96. package/dist/utils/cache.js.map +1 -1
  97. package/dist/utils/codec.d.ts.map +1 -1
  98. package/dist/utils/codec.js +10 -16
  99. package/dist/utils/codec.js.map +1 -1
  100. package/dist/utils/ir-cache.d.ts +28 -0
  101. package/dist/utils/ir-cache.d.ts.map +1 -0
  102. package/dist/utils/ir-cache.js +29 -0
  103. package/dist/utils/ir-cache.js.map +1 -0
  104. package/dist/utils/parallel-processor.d.ts +1 -1
  105. package/dist/utils/parallel-processor.d.ts.map +1 -1
  106. package/dist/utils/parallel-processor.js +111 -40
  107. package/dist/utils/parallel-processor.js.map +1 -1
  108. package/dist/utils/runtime.d.ts +5 -1
  109. package/dist/utils/runtime.d.ts.map +1 -1
  110. package/dist/utils/runtime.js +112 -18
  111. package/dist/utils/runtime.js.map +1 -1
  112. package/package.json +47 -21
  113. package/runtime/python_bridge.py +78 -12
  114. package/src/cli.ts +85 -5
  115. package/src/config/index.ts +91 -32
  116. package/src/core/analyzer.ts +51 -15
  117. package/src/core/annotation-parser.ts +500 -0
  118. package/src/core/discovery.ts +26 -12
  119. package/src/core/generator.ts +956 -116
  120. package/src/core/mapper.ts +158 -51
  121. package/src/dev.ts +983 -0
  122. package/src/index.ts +35 -6
  123. package/src/runtime/bridge-core.ts +58 -18
  124. package/src/runtime/bridge-protocol.ts +164 -0
  125. package/src/runtime/errors.ts +14 -0
  126. package/src/runtime/http-io.ts +2 -1
  127. package/src/runtime/http.ts +7 -0
  128. package/src/runtime/node.ts +312 -46
  129. package/src/runtime/optimized-node.ts +9 -5
  130. package/src/runtime/pooled-transport.ts +12 -1
  131. package/src/runtime/process-io.ts +83 -55
  132. package/src/runtime/pyodide-io.ts +110 -44
  133. package/src/runtime/safe-codec.ts +290 -82
  134. package/src/runtime/transport.ts +6 -1
  135. package/src/runtime/validators.ts +14 -3
  136. package/src/runtime/worker-pool.ts +120 -24
  137. package/src/types/global.d.ts +11 -1
  138. package/src/types/index.ts +63 -8
  139. package/src/tywrap.ts +296 -361
  140. package/src/utils/cache.ts +59 -5
  141. package/src/utils/codec.ts +11 -15
  142. package/src/utils/ir-cache.ts +51 -0
  143. package/src/utils/parallel-processor.ts +119 -43
  144. package/src/utils/runtime.ts +129 -18
@@ -8,7 +8,7 @@
8
8
  * - Binary data handling
9
9
  */
10
10
 
11
- import { BridgeProtocolError, BridgeExecutionError } from './errors.js';
11
+ import { BridgeCodecError, BridgeProtocolError, BridgeExecutionError } from './errors.js';
12
12
  import { containsSpecialFloat } from './validators.js';
13
13
  import { decodeValueAsync as decodeArrowValue } from '../utils/codec.js';
14
14
  import { PROTOCOL_ID } from './transport.js';
@@ -42,11 +42,25 @@ interface PythonErrorResponse {
42
42
  };
43
43
  }
44
44
 
45
+ interface NormalizedPythonError {
46
+ type: string;
47
+ message: string;
48
+ traceback?: string;
49
+ }
50
+
51
+ interface ProtocolEnvelope {
52
+ id: number;
53
+ protocol?: string;
54
+ result?: unknown;
55
+ error?: unknown;
56
+ }
57
+
45
58
  // ═══════════════════════════════════════════════════════════════════════════
46
59
  // CONSTANTS
47
60
  // ═══════════════════════════════════════════════════════════════════════════
48
61
 
49
62
  const DEFAULT_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10MB
63
+ const ERROR_PAYLOAD_SNIPPET_LENGTH = 200;
50
64
 
51
65
  // ═══════════════════════════════════════════════════════════════════════════
52
66
  // HELPER FUNCTIONS
@@ -75,12 +89,20 @@ function buildPath(basePath: string, key: string | number): string {
75
89
 
76
90
  /**
77
91
  * Recursively check for non-string keys in objects and Maps.
78
- * Throws BridgeProtocolError with path indication if found.
92
+ * Throws BridgeCodecError with path indication if found.
79
93
  */
80
- function assertStringKeys(value: unknown, path: string = ''): void {
94
+ function assertStringKeys(
95
+ value: unknown,
96
+ path: string = '',
97
+ visited: WeakSet<object> = new WeakSet<object>()
98
+ ): void {
81
99
  if (value === null || typeof value !== 'object') {
82
100
  return;
83
101
  }
102
+ if (visited.has(value)) {
103
+ return;
104
+ }
105
+ visited.add(value);
84
106
 
85
107
  // Check Map instances for non-string keys
86
108
  if (value instanceof Map) {
@@ -88,22 +110,23 @@ function assertStringKeys(value: unknown, path: string = ''): void {
88
110
  if (typeof key !== 'string') {
89
111
  const keyDesc = typeof key === 'symbol' ? key.toString() : String(key);
90
112
  const location = path ? ` at ${path}` : '';
91
- throw new BridgeProtocolError(
92
- `Non-string key found in Map${location}: ${keyDesc} (${typeof key})`
113
+ throw new BridgeCodecError(
114
+ `Non-string key found in Map${location}: ${keyDesc} (${typeof key})`,
115
+ { codecPhase: 'encode' }
93
116
  );
94
117
  }
95
118
  }
96
119
  // Recurse into Map values
97
120
  for (const [key, val] of value.entries()) {
98
- assertStringKeys(val, buildPath(path, key));
121
+ assertStringKeys(val, buildPath(path, key), visited);
99
122
  }
100
123
  return;
101
124
  }
102
125
 
103
126
  // Check arrays
104
127
  if (Array.isArray(value)) {
105
- for (let i = 0; i < value.length; i++) {
106
- assertStringKeys(value[i], buildPath(path, i));
128
+ for (const [index, item] of value.entries()) {
129
+ assertStringKeys(item, buildPath(path, index), visited);
107
130
  }
108
131
  return;
109
132
  }
@@ -116,14 +139,14 @@ function assertStringKeys(value: unknown, path: string = ''): void {
116
139
  if (firstSymbol !== undefined) {
117
140
  const symbolDesc = firstSymbol.toString();
118
141
  const location = path ? ` at ${path}` : '';
119
- throw new BridgeProtocolError(
120
- `Symbol key found in object${location}: ${symbolDesc}`
121
- );
142
+ throw new BridgeCodecError(`Symbol key found in object${location}: ${symbolDesc}`, {
143
+ codecPhase: 'encode',
144
+ });
122
145
  }
123
146
 
124
147
  // Recurse into object values
125
- for (const key of Object.keys(value)) {
126
- assertStringKeys(value[key], buildPath(path, key));
148
+ for (const [key, item] of Object.entries(value)) {
149
+ assertStringKeys(item, buildPath(path, key), visited);
127
150
  }
128
151
  }
129
152
  }
@@ -144,22 +167,68 @@ function isPythonErrorResponse(value: unknown): value is PythonErrorResponse {
144
167
  }
145
168
 
146
169
  /**
147
- * Type guard for protocol result response.
148
- * Checks if the value is an object with an id and result field.
149
- * The protocol field is optional as Python may not always include it.
170
+ * Type guard for protocol response envelope.
171
+ * Envelopes must include a numeric id.
150
172
  */
151
- interface ProtocolResultResponse {
152
- id: number;
153
- protocol?: string;
154
- result: unknown;
155
- }
156
-
157
- function isProtocolResultResponse(value: unknown): value is ProtocolResultResponse {
173
+ function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope {
158
174
  if (value === null || typeof value !== 'object') {
159
175
  return false;
160
176
  }
161
177
  const obj = value as Record<string, unknown>;
162
- return typeof obj.id === 'number' && 'result' in obj;
178
+ return typeof obj.id === 'number';
179
+ }
180
+
181
+ function hasOwnKey(value: object, key: string): boolean {
182
+ return Object.prototype.hasOwnProperty.call(value, key);
183
+ }
184
+
185
+ function normalizeErrorPayload(err: unknown): NormalizedPythonError | null {
186
+ if (!err || typeof err !== 'object' || Array.isArray(err)) {
187
+ return null;
188
+ }
189
+ const candidate = err as Record<string, unknown>;
190
+ if (typeof candidate.type !== 'string' || typeof candidate.message !== 'string') {
191
+ return null;
192
+ }
193
+ if (hasOwnKey(candidate, 'traceback') && typeof candidate.traceback !== 'string') {
194
+ return null;
195
+ }
196
+ const normalized: NormalizedPythonError = {
197
+ type: candidate.type,
198
+ message: candidate.message,
199
+ };
200
+ if (typeof candidate.traceback === 'string') {
201
+ normalized.traceback = candidate.traceback;
202
+ }
203
+ return normalized;
204
+ }
205
+
206
+ function describeInvalidErrorPayload(err: unknown): string {
207
+ if (!err || typeof err !== 'object' || Array.isArray(err)) {
208
+ return 'expected an object with string "type" and "message" fields';
209
+ }
210
+ const candidate = err as Record<string, unknown>;
211
+ if (typeof candidate.type !== 'string') {
212
+ return '"type" must be a string';
213
+ }
214
+ if (typeof candidate.message !== 'string') {
215
+ return '"message" must be a string';
216
+ }
217
+ if (hasOwnKey(candidate, 'traceback') && typeof candidate.traceback !== 'string') {
218
+ return '"traceback" must be a string when provided';
219
+ }
220
+ return 'expected an object with string "type" and "message" fields';
221
+ }
222
+
223
+ function summarizePayloadForError(payload: string): string {
224
+ const trimmed = payload.trim();
225
+ if (!trimmed) {
226
+ return '[empty payload]';
227
+ }
228
+ if (trimmed.length <= ERROR_PAYLOAD_SNIPPET_LENGTH) {
229
+ return trimmed;
230
+ }
231
+ return `${trimmed.slice(0, ERROR_PAYLOAD_SNIPPET_LENGTH)}...`;
163
232
  }
164
233
 
165
234
  /**
@@ -189,21 +258,32 @@ function validateProtocolVersion(value: unknown): void {
189
258
  * Find the path to a special float in a value structure.
190
259
  * Returns undefined if no special float is found.
191
260
  */
192
- function findSpecialFloatPath(value: unknown, path: string = ''): string | undefined {
261
+ function findSpecialFloatPath(
262
+ value: unknown,
263
+ path: string = '',
264
+ visited: WeakSet<object> = new WeakSet<object>()
265
+ ): string | undefined {
193
266
  if (typeof value === 'number' && !Number.isFinite(value)) {
194
267
  return path || 'root';
195
268
  }
269
+ if (value === null || typeof value !== 'object') {
270
+ return undefined;
271
+ }
272
+ if (visited.has(value)) {
273
+ return undefined;
274
+ }
275
+ visited.add(value);
196
276
  if (Array.isArray(value)) {
197
- for (let i = 0; i < value.length; i++) {
198
- const result = findSpecialFloatPath(value[i], buildPath(path, i));
277
+ for (const [index, item] of value.entries()) {
278
+ const result = findSpecialFloatPath(item, buildPath(path, index), visited);
199
279
  if (result !== undefined) {
200
280
  return result;
201
281
  }
202
282
  }
203
283
  }
204
284
  if (isPlainObject(value)) {
205
- for (const key of Object.keys(value)) {
206
- const result = findSpecialFloatPath(value[key], buildPath(path, key));
285
+ for (const [key, item] of Object.entries(value)) {
286
+ const result = findSpecialFloatPath(item, buildPath(path, key), visited);
207
287
  if (result !== undefined) {
208
288
  return result;
209
289
  }
@@ -239,12 +319,128 @@ export class SafeCodec {
239
319
  private readonly rejectNonStringKeys: boolean;
240
320
  private readonly maxPayloadBytes: number;
241
321
  private readonly bytesHandling: 'base64' | 'reject' | 'passthrough';
322
+ private readonly reviveValueBound: (key: string, value: unknown) => unknown;
323
+ private static readonly base64Pattern =
324
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
242
325
 
243
326
  constructor(options: CodecOptions = {}) {
244
327
  this.rejectSpecialFloats = options.rejectSpecialFloats ?? true;
245
328
  this.rejectNonStringKeys = options.rejectNonStringKeys ?? true;
246
329
  this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
247
330
  this.bytesHandling = options.bytesHandling ?? 'base64';
331
+ this.reviveValueBound = this.reviveValue.bind(this);
332
+ }
333
+
334
+ private assertValidBase64(b64: string): void {
335
+ if (!SafeCodec.base64Pattern.test(b64)) {
336
+ throw new BridgeCodecError('Invalid base64 in bytes envelope', {
337
+ codecPhase: 'decode',
338
+ valueType: 'bytes',
339
+ });
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Convert base64 string to Uint8Array.
345
+ *
346
+ * Why: Python bridge represents bytes/bytearray as base64 envelopes. Decoding them here
347
+ * restores ergonomic JS types at the boundary.
348
+ */
349
+ private fromBase64(b64: string): Uint8Array {
350
+ this.assertValidBase64(b64);
351
+
352
+ if (typeof Buffer !== 'undefined') {
353
+ const buf = Buffer.from(b64, 'base64');
354
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
355
+ }
356
+ if (globalThis.atob) {
357
+ const bin = globalThis.atob(b64);
358
+ const arr = Array.from(bin, c => c.charCodeAt(0));
359
+ return new Uint8Array(arr);
360
+ }
361
+ throw new BridgeCodecError('Base64 decoding is not available in this runtime', {
362
+ codecPhase: 'decode',
363
+ valueType: 'bytes',
364
+ });
365
+ }
366
+
367
+ /**
368
+ * JSON.parse reviver that decodes bytes envelopes.
369
+ *
370
+ * Supported shapes:
371
+ * - { "__tywrap_bytes__": true, "b64": "..." } (JS SafeCodec.encodeRequest; also allowed in responses)
372
+ * - { "__type__": "bytes", "encoding": "base64", "data": "..." } (Python SafeCodec default encoder)
373
+ */
374
+ private reviveValue(_key: string, value: unknown): unknown {
375
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
376
+ return value;
377
+ }
378
+ const obj = value as Record<string, unknown>;
379
+
380
+ if (obj.__tywrap_bytes__ === true && typeof obj.b64 === 'string') {
381
+ try {
382
+ return this.fromBase64(obj.b64);
383
+ } catch (err) {
384
+ const errorMessage = err instanceof Error ? err.message : String(err);
385
+ throw new BridgeCodecError(`Bytes envelope decode failed: ${errorMessage}`, {
386
+ codecPhase: 'decode',
387
+ valueType: 'bytes',
388
+ });
389
+ }
390
+ }
391
+
392
+ if (obj.__type__ === 'bytes' && obj.encoding === 'base64' && typeof obj.data === 'string') {
393
+ try {
394
+ return this.fromBase64(obj.data);
395
+ } catch (err) {
396
+ const errorMessage = err instanceof Error ? err.message : String(err);
397
+ throw new BridgeCodecError(`Bytes envelope decode failed: ${errorMessage}`, {
398
+ codecPhase: 'decode',
399
+ valueType: 'bytes',
400
+ });
401
+ }
402
+ }
403
+
404
+ return value;
405
+ }
406
+
407
+ private toBridgeExecutionError(error: NormalizedPythonError): BridgeExecutionError {
408
+ const bridgeError = new BridgeExecutionError(`${error.type}: ${error.message}`);
409
+ bridgeError.traceback = error.traceback;
410
+ return bridgeError;
411
+ }
412
+
413
+ private extractResultFromResponseEnvelope(parsed: unknown): unknown {
414
+ if (isProtocolEnvelope(parsed)) {
415
+ const envelope = parsed;
416
+ const hasResult = hasOwnKey(envelope, 'result');
417
+ const hasError = hasOwnKey(envelope, 'error');
418
+
419
+ if (hasResult && hasError) {
420
+ throw new BridgeProtocolError('Protocol response cannot include both "result" and "error"');
421
+ }
422
+
423
+ if (hasError) {
424
+ const normalizedError = normalizeErrorPayload(envelope.error);
425
+ if (!normalizedError) {
426
+ const details = describeInvalidErrorPayload(envelope.error);
427
+ throw new BridgeProtocolError(`Invalid response "error" payload: ${details}`);
428
+ }
429
+ throw this.toBridgeExecutionError(normalizedError);
430
+ }
431
+
432
+ if (!hasResult) {
433
+ throw new BridgeProtocolError('Protocol response missing "result" or "error" field');
434
+ }
435
+
436
+ return envelope.result;
437
+ }
438
+
439
+ if (isPythonErrorResponse(parsed)) {
440
+ throw this.toBridgeExecutionError(parsed.error);
441
+ }
442
+
443
+ return parsed;
248
444
  }
249
445
 
250
446
  /**
@@ -253,14 +449,15 @@ export class SafeCodec {
253
449
  *
254
450
  * @param message - The message to encode
255
451
  * @returns JSON string ready to send
256
- * @throws BridgeProtocolError if validation fails or encoding fails
452
+ * @throws BridgeCodecError if validation fails or encoding fails
257
453
  */
258
454
  encodeRequest(message: unknown): string {
259
455
  // Validate special floats if enabled
260
456
  if (this.rejectSpecialFloats && containsSpecialFloat(message)) {
261
457
  const floatPath = findSpecialFloatPath(message);
262
- throw new BridgeProtocolError(
263
- `Cannot encode request: contains non-finite number (NaN or Infinity) at ${floatPath}`
458
+ throw new BridgeCodecError(
459
+ `Cannot encode request: contains non-finite number (NaN or Infinity) at ${floatPath}`,
460
+ { codecPhase: 'encode', valueType: 'number' }
264
461
  );
265
462
  }
266
463
 
@@ -277,8 +474,9 @@ export class SafeCodec {
277
474
  if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
278
475
  switch (this.bytesHandling) {
279
476
  case 'reject':
280
- throw new BridgeProtocolError(
281
- `Cannot encode request: binary data found at ${key || 'root'} (bytesHandling: reject)`
477
+ throw new BridgeCodecError(
478
+ `Cannot encode request: binary data found at ${key || 'root'} (bytesHandling: reject)`,
479
+ { codecPhase: 'encode', valueType: 'bytes' }
282
480
  );
283
481
  case 'base64': {
284
482
  const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
@@ -293,18 +491,24 @@ export class SafeCodec {
293
491
  return value;
294
492
  });
295
493
  } catch (err) {
494
+ if (err instanceof BridgeCodecError) {
495
+ throw err;
496
+ }
296
497
  if (err instanceof BridgeProtocolError) {
297
498
  throw err;
298
499
  }
299
- const message = err instanceof Error ? err.message : String(err);
300
- throw new BridgeProtocolError(`JSON serialization failed: ${message}`);
500
+ const errorMessage = err instanceof Error ? err.message : String(err);
501
+ throw new BridgeCodecError(`JSON serialization failed: ${errorMessage}`, {
502
+ codecPhase: 'encode',
503
+ });
301
504
  }
302
505
 
303
506
  // Check payload size
304
507
  const payloadBytes = new TextEncoder().encode(payload).length;
305
508
  if (payloadBytes > this.maxPayloadBytes) {
306
- throw new BridgeProtocolError(
307
- `Payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
509
+ throw new BridgeCodecError(
510
+ `Payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`,
511
+ { codecPhase: 'encode', valueType: 'payload' }
308
512
  );
309
513
  }
310
514
 
@@ -317,47 +521,46 @@ export class SafeCodec {
317
521
  *
318
522
  * @param payload - The JSON string received from Python
319
523
  * @returns Decoded and validated result
320
- * @throws BridgeProtocolError if payload is invalid
524
+ * @throws BridgeCodecError if payload is invalid or decoding fails
525
+ * @throws BridgeProtocolError if protocol envelope is invalid
321
526
  * @throws BridgeExecutionError if response contains a Python error
322
527
  */
323
528
  decodeResponse<T>(payload: string): T {
324
529
  // Check payload size first
325
530
  const payloadBytes = new TextEncoder().encode(payload).length;
326
531
  if (payloadBytes > this.maxPayloadBytes) {
327
- throw new BridgeProtocolError(
328
- `Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
532
+ throw new BridgeCodecError(
533
+ `Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`,
534
+ { codecPhase: 'decode', valueType: 'payload' }
329
535
  );
330
536
  }
331
537
 
332
538
  // Parse JSON
333
539
  let parsed: unknown;
334
540
  try {
335
- parsed = JSON.parse(payload);
541
+ parsed = JSON.parse(payload, this.reviveValueBound);
336
542
  } catch (err) {
337
- const message = err instanceof Error ? err.message : String(err);
338
- throw new BridgeProtocolError(`JSON parse failed: ${message}`);
543
+ if (err instanceof BridgeCodecError || err instanceof BridgeProtocolError) {
544
+ throw err;
545
+ }
546
+ const errorMessage = err instanceof Error ? err.message : String(err);
547
+ throw new BridgeCodecError(
548
+ `JSON parse failed: ${errorMessage}. Payload snippet: ${summarizePayloadForError(payload)}`,
549
+ { codecPhase: 'decode', valueType: 'json' }
550
+ );
339
551
  }
340
552
 
341
553
  // Validate protocol version (if present)
342
554
  validateProtocolVersion(parsed);
343
555
 
344
- // Check for Python error response
345
- if (isPythonErrorResponse(parsed)) {
346
- const error = new BridgeExecutionError(
347
- `${parsed.error.type}: ${parsed.error.message}`
348
- );
349
- error.traceback = parsed.error.traceback;
350
- throw error;
351
- }
352
-
353
- // Extract the result field from the response envelope
354
- const result = isProtocolResultResponse(parsed) ? parsed.result : parsed;
556
+ const result = this.extractResultFromResponseEnvelope(parsed);
355
557
 
356
558
  // Post-decode validation for special floats if enabled
357
559
  if (this.rejectSpecialFloats && containsSpecialFloat(result)) {
358
560
  const floatPath = findSpecialFloatPath(result);
359
- throw new BridgeProtocolError(
360
- `Response contains non-finite number (NaN or Infinity) at ${floatPath}`
561
+ throw new BridgeCodecError(
562
+ `Response contains non-finite number (NaN or Infinity) at ${floatPath}`,
563
+ { codecPhase: 'decode', valueType: 'number' }
361
564
  );
362
565
  }
363
566
 
@@ -370,57 +573,59 @@ export class SafeCodec {
370
573
  *
371
574
  * @param payload - The JSON string received from Python
372
575
  * @returns Decoded and validated result with Arrow decoding applied
373
- * @throws BridgeProtocolError if payload is invalid
576
+ * @throws BridgeCodecError if payload is invalid or decoding fails
577
+ * @throws BridgeProtocolError if protocol envelope is invalid
374
578
  * @throws BridgeExecutionError if response contains a Python error
375
579
  */
376
580
  async decodeResponseAsync<T>(payload: string): Promise<T> {
377
581
  // Check payload size first
378
582
  const payloadBytes = new TextEncoder().encode(payload).length;
379
583
  if (payloadBytes > this.maxPayloadBytes) {
380
- throw new BridgeProtocolError(
381
- `Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
584
+ throw new BridgeCodecError(
585
+ `Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`,
586
+ { codecPhase: 'decode', valueType: 'payload' }
382
587
  );
383
588
  }
384
589
 
385
590
  // Parse JSON
386
591
  let parsed: unknown;
387
592
  try {
388
- parsed = JSON.parse(payload);
593
+ parsed = JSON.parse(payload, this.reviveValueBound);
389
594
  } catch (err) {
390
- const message = err instanceof Error ? err.message : String(err);
391
- throw new BridgeProtocolError(`JSON parse failed: ${message}`);
595
+ if (err instanceof BridgeCodecError || err instanceof BridgeProtocolError) {
596
+ throw err;
597
+ }
598
+ const errorMessage = err instanceof Error ? err.message : String(err);
599
+ throw new BridgeCodecError(
600
+ `JSON parse failed: ${errorMessage}. Payload snippet: ${summarizePayloadForError(payload)}`,
601
+ { codecPhase: 'decode', valueType: 'json' }
602
+ );
392
603
  }
393
604
 
394
605
  // Validate protocol version (if present)
395
606
  validateProtocolVersion(parsed);
396
607
 
397
- // Check for Python error response
398
- if (isPythonErrorResponse(parsed)) {
399
- const error = new BridgeExecutionError(
400
- `${parsed.error.type}: ${parsed.error.message}`
401
- );
402
- error.traceback = parsed.error.traceback;
403
- throw error;
404
- }
405
-
406
- // Extract the result field from the response envelope
407
- const result = isProtocolResultResponse(parsed) ? parsed.result : parsed;
608
+ const result = this.extractResultFromResponseEnvelope(parsed);
408
609
 
409
610
  // Apply Arrow decoding to the result
410
611
  let decoded: unknown;
411
612
  try {
412
613
  decoded = await decodeArrowValue(result);
413
614
  } catch (err) {
414
- const message = err instanceof Error ? err.message : String(err);
415
- throw new BridgeProtocolError(`Arrow decoding failed: ${message}`);
615
+ const errorMessage = err instanceof Error ? err.message : String(err);
616
+ throw new BridgeCodecError(`Arrow decoding failed: ${errorMessage}`, {
617
+ codecPhase: 'decode',
618
+ valueType: 'arrow',
619
+ });
416
620
  }
417
621
 
418
622
  // Post-decode validation for special floats if enabled
419
- // Note: We check the result value since that's what we're returning
420
- if (this.rejectSpecialFloats && containsSpecialFloat(result)) {
421
- const floatPath = findSpecialFloatPath(result);
422
- throw new BridgeProtocolError(
423
- `Response contains non-finite number (NaN or Infinity) at ${floatPath}`
623
+ // Note: Arrow decoders can introduce NaN/Infinity from binary representations.
624
+ if (this.rejectSpecialFloats && containsSpecialFloat(decoded)) {
625
+ const floatPath = findSpecialFloatPath(decoded);
626
+ throw new BridgeCodecError(
627
+ `Response contains non-finite number (NaN or Infinity) at ${floatPath}`,
628
+ { codecPhase: 'decode', valueType: 'number' }
424
629
  );
425
630
  }
426
631
 
@@ -438,6 +643,9 @@ export class SafeCodec {
438
643
  const binary = String.fromCharCode(...bytes);
439
644
  return globalThis.btoa(binary);
440
645
  }
441
- throw new BridgeProtocolError('Base64 encoding is not available in this runtime');
646
+ throw new BridgeCodecError('Base64 encoding is not available in this runtime', {
647
+ codecPhase: 'encode',
648
+ valueType: 'bytes',
649
+ });
442
650
  }
443
651
  }
@@ -122,7 +122,12 @@ export interface ProtocolResponse {
122
122
  * await transport.init();
123
123
  *
124
124
  * const response = await transport.send(
125
- * JSON.stringify({ id: '1', type: 'call', module: 'math', functionName: 'sqrt', args: [16] }),
125
+ * JSON.stringify({
126
+ * id: 1,
127
+ * protocol: 'tywrap/1',
128
+ * method: 'call',
129
+ * params: { module: 'math', functionName: 'sqrt', args: [16], kwargs: {} },
130
+ * }),
126
131
  * 5000
127
132
  * );
128
133
  *
@@ -183,19 +183,30 @@ export function assertObject(value: unknown, name: string): Record<string, unkno
183
183
  * @param value - The value to check
184
184
  * @returns True if the value contains NaN or Infinity anywhere
185
185
  */
186
- export function containsSpecialFloat(value: unknown): boolean {
186
+ function containsSpecialFloatRecursive(value: unknown, visited: WeakSet<object>): boolean {
187
187
  if (typeof value === 'number') {
188
188
  return !Number.isFinite(value);
189
189
  }
190
+ if (value === null || typeof value !== 'object') {
191
+ return false;
192
+ }
193
+ if (visited.has(value)) {
194
+ return false;
195
+ }
196
+ visited.add(value);
190
197
  if (Array.isArray(value)) {
191
- return value.some(containsSpecialFloat);
198
+ return value.some(item => containsSpecialFloatRecursive(item, visited));
192
199
  }
193
200
  if (isPlainObject(value)) {
194
- return Object.values(value).some(containsSpecialFloat);
201
+ return Object.values(value).some(item => containsSpecialFloatRecursive(item, visited));
195
202
  }
196
203
  return false;
197
204
  }
198
205
 
206
+ export function containsSpecialFloat(value: unknown): boolean {
207
+ return containsSpecialFloatRecursive(value, new WeakSet<object>());
208
+ }
209
+
199
210
  /**
200
211
  * Assert that a value does not contain non-finite numbers.
201
212
  *