tywrap 0.8.0 → 0.10.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 (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. package/src/runtime/transport-pool.ts +0 -538
@@ -32,9 +32,9 @@ import type {
32
32
  TypePreset,
33
33
  } from '../types/index.js';
34
34
 
35
- export type MappingContext = 'value' | 'return';
35
+ type MappingContext = 'value' | 'return';
36
36
 
37
- export interface TypeMapperOptions {
37
+ interface TypeMapperOptions {
38
38
  presets?: readonly TypePreset[];
39
39
  }
40
40
 
@@ -94,7 +94,7 @@ export class TypeMapper {
94
94
  : type.name === 'bool'
95
95
  ? 'boolean'
96
96
  : type.name === 'bytes'
97
- ? 'string'
97
+ ? 'Uint8Array'
98
98
  : // None
99
99
  context === 'return'
100
100
  ? 'void'
@@ -137,16 +137,15 @@ export class TypeMapper {
137
137
  return { kind: 'tuple', elementTypes } satisfies TSTupleType;
138
138
  }
139
139
 
140
- // set[T] -> Set<T>
140
+ // Python sets cross the JSON bridge as arrays in both directions.
141
141
  if (type.name === 'set' || type.name === 'frozenset') {
142
142
  const elementType = this.mapPythonType(
143
143
  type.itemTypes[0] ?? { kind: 'custom', name: 'Any', module: 'typing' }
144
144
  );
145
145
  return {
146
- kind: 'generic',
147
- name: 'Set',
148
- typeArgs: [elementType],
149
- } satisfies TSGenericType;
146
+ kind: 'array',
147
+ elementType,
148
+ } satisfies TSArrayType;
150
149
  }
151
150
 
152
151
  // dict[K, V] -> { [key: K]: V }
@@ -204,6 +203,7 @@ export class TypeMapper {
204
203
  return {
205
204
  kind: 'generic',
206
205
  name: normalized.name,
206
+ module: normalized.module,
207
207
  typeArgs,
208
208
  };
209
209
  }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * tywrap - TypeScript wrapper for Python libraries with full type safety
2
+ * tywrap - TypeScript bindings for Python libraries
3
3
  *
4
- * @description Build-time code generation system that makes Python libraries
5
- * feel native in TypeScript with zero runtime overhead
4
+ * @description Build-time code generation that produces typed wrappers for
5
+ * Python modules: precise types where annotations resolve, `unknown` where
6
+ * they cannot, and runtime validation of decoded returns against the
7
+ * declared types.
6
8
  *
7
9
  * This is the package root. It intentionally exposes only the stable,
8
10
  * consumer-facing surface. Runtime plumbing (codec, transport, bridge
@@ -21,6 +23,7 @@ export {
21
23
  BridgeError,
22
24
  BridgeCodecError,
23
25
  BridgeProtocolError,
26
+ BridgeValidationError,
24
27
  BridgeTimeoutError,
25
28
  BridgeDisposedError,
26
29
  BridgeExecutionError,
@@ -29,6 +32,7 @@ export {
29
32
  // Core types
30
33
  export type {
31
34
  PythonModule,
35
+ IrContract,
32
36
  PythonFunction,
33
37
  PythonClass,
34
38
  PythonTypeAlias,
@@ -68,7 +72,6 @@ export type {
68
72
  TypeMappingConfig,
69
73
  TypePreset,
70
74
  BridgeInfo,
71
- BridgeTransportInfo,
72
75
  AnalysisResult,
73
76
  AnalysisError,
74
77
  AnalysisWarning,
@@ -3,12 +3,11 @@
3
3
  *
4
4
  * The three bridge facades (NodeBridge/HttpBridge/PyodideBridge) all extend
5
5
  * DisposableBase (lifecycle/resources) and implement PythonRuntime by HOLDING
6
- * an RpcClient. The PythonRuntime delegation was byte-identical across all
6
+ * an RpcClient. The PythonRuntime delegation is byte-identical across all
7
7
  * three: each method does `await this.ensureReady()` then forwards to the held
8
8
  * RpcClient. This base collapses that duplication onto a single
9
9
  * `getRpcClient()` accessor while leaving each facade free to own its own
10
- * RpcClient field (for constructor wiring, resource tracking, and — in
11
- * NodeBridge's case — a caching override of call()).
10
+ * RpcClient field for constructor wiring and resource tracking.
12
11
  *
13
12
  * It carries no transport/codec/lifecycle specifics: doInit/doDispose remain
14
13
  * abstract on DisposableBase and stay per-facade.
@@ -44,35 +43,11 @@ export abstract class BasePythonBridge extends DisposableBase implements PythonR
44
43
  module: string,
45
44
  functionName: string,
46
45
  args: unknown[],
47
- kwargs?: Record<string, unknown>
46
+ kwargs?: Record<string, unknown>,
47
+ validate?: (result: T) => void
48
48
  ): Promise<T> {
49
49
  await this.ensureReady();
50
- return this.getRpcClient().call<T>(module, functionName, args, kwargs);
51
- }
52
-
53
- async instantiate<T = unknown>(
54
- module: string,
55
- className: string,
56
- args: unknown[],
57
- kwargs?: Record<string, unknown>
58
- ): Promise<T> {
59
- await this.ensureReady();
60
- return this.getRpcClient().instantiate<T>(module, className, args, kwargs);
61
- }
62
-
63
- async callMethod<T = unknown>(
64
- handle: string,
65
- methodName: string,
66
- args: unknown[],
67
- kwargs?: Record<string, unknown>
68
- ): Promise<T> {
69
- await this.ensureReady();
70
- return this.getRpcClient().callMethod<T>(handle, methodName, args, kwargs);
71
- }
72
-
73
- async disposeInstance(handle: string): Promise<void> {
74
- await this.ensureReady();
75
- return this.getRpcClient().disposeInstance(handle);
50
+ return this.getRpcClient().call<T>(module, functionName, args, kwargs, validate);
76
51
  }
77
52
 
78
53
  /**
@@ -2,9 +2,9 @@
2
2
  * DisposableBase - Lifecycle-and-resource base for cross-boundary components.
3
3
  *
4
4
  * Provides ONLY: init/dispose state machine, validation helpers, error
5
- * classification, bounded execution (timeout/retry/abort), and resource
5
+ * classification, single-attempt bounded execution (timeout/abort), and resource
6
6
  * ownership tracking. It carries ZERO RPC methods by design — the
7
- * cross-boundary RPC contract (call/instantiate/callMethod/disposeInstance)
7
+ * cross-boundary RPC contract (call)
8
8
  * is PythonRuntime, implemented only by the bridge facades, never by a base
9
9
  * class or a transport. This separation is what keeps transports (SubprocessTransport,
10
10
  * PooledTransport, TransportPool, PyodideTransport) and RpcClient from having to stub
@@ -53,10 +53,6 @@ export type ContextState = 'idle' | 'initializing' | 'ready' | 'disposing' | 'di
53
53
  export interface ExecuteOptions<T = unknown> {
54
54
  /** Timeout in milliseconds. Default: 30000 (30s). Set to 0 to disable. */
55
55
  timeoutMs?: number;
56
- /** Number of retry attempts on retryable errors. Default: 0. */
57
- retries?: number;
58
- /** Base delay between retries in ms. Multiplied by attempt number. Default: 100. */
59
- retryDelayMs?: number;
60
56
  /** Optional validation function applied to the result. */
61
57
  validate?: (result: T) => T;
62
58
  /** Optional abort signal for external cancellation. */
@@ -375,12 +371,11 @@ export abstract class DisposableBase {
375
371
  * This method:
376
372
  * - Auto-initializes if not ready
377
373
  * - Enforces timeout limits
378
- * - Supports retry on retryable errors
379
374
  * - Respects abort signals
380
375
  * - Validates results if a validator is provided
381
376
  *
382
377
  * @param operation - The async operation to execute
383
- * @param options - Execution options (timeout, retries, etc.)
378
+ * @param options - Execution options (timeout, validation, and abort)
384
379
  * @returns The operation result
385
380
  * @throws BridgeTimeoutError if the operation times out
386
381
  * @throws BridgeDisposedError if the context is disposed
@@ -400,35 +395,16 @@ export abstract class DisposableBase {
400
395
  throw new BridgeDisposedError('Context disposed');
401
396
  }
402
397
 
403
- const { timeoutMs = 30000, retries = 0, retryDelayMs = 100, validate, signal } = options;
404
-
405
- let lastError: BridgeError | undefined;
406
-
407
- for (let attempt = 0; attempt <= retries; attempt++) {
408
- // Check for abort before each attempt
409
- if (signal?.aborted) {
410
- throw new BridgeTimeoutError('Operation aborted');
411
- }
412
-
413
- try {
414
- const result = await this.withTimeout(operation(), timeoutMs, signal);
415
- return validate ? validate(result) : result;
416
- } catch (error) {
417
- lastError = this.classifyError(error);
418
-
419
- // Retry if appropriate
420
- if (attempt < retries && this.isRetryable(lastError)) {
421
- await this.delay(retryDelayMs * (attempt + 1));
422
- continue;
423
- }
424
-
425
- throw lastError;
426
- }
398
+ const { timeoutMs = 30000, validate, signal } = options;
399
+ if (signal?.aborted) {
400
+ throw new BridgeTimeoutError('Operation aborted');
401
+ }
402
+ try {
403
+ const result = await this.withTimeout(operation(), timeoutMs, signal);
404
+ return validate ? validate(result) : result;
405
+ } catch (error) {
406
+ throw this.classifyError(error);
427
407
  }
428
-
429
- // Should not reach here, but TypeScript doesn't know that
430
- /* istanbul ignore next */
431
- throw lastError ?? new BridgeExecutionError('Unexpected execution flow');
432
408
  }
433
409
 
434
410
  /**
@@ -498,37 +474,6 @@ export abstract class DisposableBase {
498
474
  });
499
475
  }
500
476
 
501
- /**
502
- * Determine if an error is retryable.
503
- * Override in subclasses to customize retry logic.
504
- *
505
- * @param error - The error to check
506
- * @returns True if the operation should be retried
507
- */
508
- protected isRetryable(error: BridgeError): boolean {
509
- // Timeout and connection errors are typically retryable
510
- if (error instanceof BridgeTimeoutError) {
511
- return true;
512
- }
513
-
514
- const message = error.message.toLowerCase();
515
- return (
516
- message.includes('econnreset') ||
517
- message.includes('econnrefused') ||
518
- message.includes('epipe') ||
519
- message.includes('connection reset')
520
- );
521
- }
522
-
523
- /**
524
- * Delay for a specified duration.
525
- *
526
- * @param ms - Milliseconds to delay
527
- */
528
- private delay(ms: number): Promise<void> {
529
- return new Promise(resolve => setTimeout(resolve, ms));
530
- }
531
-
532
477
  // ═══════════════════════════════════════════════════════════════════════════
533
478
  // RESOURCE OWNERSHIP (addresses #144, #148)
534
479
  // ═══════════════════════════════════════════════════════════════════════════
@@ -10,7 +10,11 @@
10
10
 
11
11
  import { BridgeCodecError, BridgeProtocolError, BridgeExecutionError } from './errors.js';
12
12
  import { containsSpecialFloat } from './validators.js';
13
- import { decodeValueAsync as decodeArrowValue } from '../utils/codec.js';
13
+ import {
14
+ decodeValueAsync as decodeScientificValue,
15
+ isScientificMarker,
16
+ ScientificDecodeError,
17
+ } from '../utils/codec.js';
14
18
  import { PROTOCOL_ID } from './transport.js';
15
19
 
16
20
  // ═══════════════════════════════════════════════════════════════════════════
@@ -28,7 +32,7 @@ export interface CodecOptions {
28
32
  /** Max payload size in bytes. Default: 10MB */
29
33
  maxPayloadBytes?: number;
30
34
  /** How to handle bytes/bytearray. Default: 'base64' */
31
- bytesHandling?: 'base64' | 'reject' | 'passthrough';
35
+ bytesHandling?: 'base64' | 'reject';
32
36
  }
33
37
 
34
38
  /**
@@ -77,6 +81,10 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
77
81
  return proto === Object.prototype || proto === null;
78
82
  }
79
83
 
84
+ function isPlainArray(value: unknown): value is unknown[] {
85
+ return Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
86
+ }
87
+
80
88
  /**
81
89
  * Build a path string for error messages.
82
90
  */
@@ -87,67 +95,126 @@ function buildPath(basePath: string, key: string | number): string {
87
95
  return typeof key === 'number' ? `${basePath}[${key}]` : `${basePath}.${key}`;
88
96
  }
89
97
 
98
+ function containsScientificEnvelope(value: unknown): boolean {
99
+ const pending: unknown[] = [value];
100
+ while (pending.length > 0) {
101
+ const current = pending.pop();
102
+ if (isPlainArray(current)) {
103
+ for (const item of current) {
104
+ pending.push(item);
105
+ }
106
+ continue;
107
+ }
108
+ if (!isPlainObject(current)) {
109
+ continue;
110
+ }
111
+ if (typeof current.__tywrap__ === 'string') {
112
+ return isScientificMarker(current.__tywrap__);
113
+ }
114
+ for (const item of Object.values(current)) {
115
+ pending.push(item);
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
90
121
  /**
91
- * Recursively check for non-string keys in objects and Maps.
92
- * Throws BridgeCodecError with path indication if found.
122
+ * Validate request-only restrictions in one traversal.
123
+ *
124
+ * Request validation historically checked every finite number before checking
125
+ * structure. Keep that precedence while combining the walks: capture the first
126
+ * structural error (Map, Set, or symbol key) in traversal order, and prefer
127
+ * the special-float error after the traversal.
93
128
  */
94
- function assertStringKeys(
129
+ function assertValidRequestValues(
95
130
  value: unknown,
96
- path: string = '',
97
- visited: WeakSet<object> = new WeakSet<object>()
131
+ rejectSpecialFloats: boolean,
132
+ rejectNonStringKeys: boolean,
133
+ path: string = ''
98
134
  ): void {
99
- if (value === null || typeof value !== 'object') {
100
- return;
101
- }
102
- if (visited.has(value)) {
103
- return;
104
- }
105
- visited.add(value);
135
+ let specialFloatPath: string | undefined;
136
+ let structuralError: BridgeCodecError | undefined;
137
+ const specialVisited = new WeakSet<object>();
138
+ const structVisited = new WeakSet<object>();
139
+
140
+ const visit = (current: unknown, currentPath: string, structuralScope: boolean): void => {
141
+ if (typeof current === 'number' && !Number.isFinite(current)) {
142
+ if (rejectSpecialFloats) {
143
+ specialFloatPath ??= currentPath || 'root';
144
+ }
145
+ return;
146
+ }
147
+ if (current === null || typeof current !== 'object') {
148
+ return;
149
+ }
106
150
 
107
- // Check Map instances for non-string keys
108
- if (value instanceof Map) {
109
- for (const key of value.keys()) {
110
- if (typeof key !== 'string') {
111
- const keyDesc = typeof key === 'symbol' ? key.toString() : String(key);
112
- const location = path ? ` at ${path}` : '';
113
- throw new BridgeCodecError(
114
- `Non-string key found in Map${location}: ${keyDesc} (${typeof key})`,
151
+ if (current instanceof Map || current instanceof Set) {
152
+ // JSON.stringify serializes every Map and Set as {}, including Maps with
153
+ // string keys reject unconditionally. Special-float validation
154
+ // historically does not inspect Map/Set contents, so no recursion.
155
+ if (structuralScope && !structVisited.has(current)) {
156
+ structVisited.add(current);
157
+ const location = currentPath ? ` at ${currentPath}` : '';
158
+ structuralError ??= new BridgeCodecError(
159
+ current instanceof Map
160
+ ? `Cannot encode request: Map found${location}; convert it to a plain object before sending`
161
+ : `Cannot encode request: Set found${location}; convert it to an array before sending`,
115
162
  { codecPhase: 'encode' }
116
163
  );
117
164
  }
165
+ return;
118
166
  }
119
- // Recurse into Map values
120
- for (const [key, val] of value.entries()) {
121
- assertStringKeys(val, buildPath(path, key), visited);
122
- }
123
- return;
124
- }
125
167
 
126
- // Check arrays
127
- if (Array.isArray(value)) {
128
- for (const [index, item] of value.entries()) {
129
- assertStringKeys(item, buildPath(path, index), visited);
168
+ const needsSpecialWalk = rejectSpecialFloats && !specialVisited.has(current);
169
+ const needsStructWalk = structuralScope && !structVisited.has(current);
170
+ if (!needsSpecialWalk && !needsStructWalk) {
171
+ return;
172
+ }
173
+ if (needsSpecialWalk) {
174
+ specialVisited.add(current);
175
+ }
176
+ if (needsStructWalk) {
177
+ structVisited.add(current);
130
178
  }
131
- return;
132
- }
133
179
 
134
- // Check plain objects for symbol keys
135
- if (isPlainObject(value)) {
136
- // Symbol keys are not enumerated by Object.keys, use getOwnPropertySymbols
137
- const symbolKeys = Object.getOwnPropertySymbols(value);
138
- const firstSymbol = symbolKeys[0];
139
- if (firstSymbol !== undefined) {
140
- const symbolDesc = firstSymbol.toString();
141
- const location = path ? ` at ${path}` : '';
142
- throw new BridgeCodecError(`Symbol key found in object${location}: ${symbolDesc}`, {
143
- codecPhase: 'encode',
144
- });
180
+ if (Array.isArray(current)) {
181
+ for (const [index, item] of current.entries()) {
182
+ visit(item, buildPath(currentPath, index), structuralScope);
183
+ }
184
+ return;
145
185
  }
146
186
 
147
- // Recurse into object values
148
- for (const [key, item] of Object.entries(value)) {
149
- assertStringKeys(item, buildPath(path, key), visited);
187
+ const plainObject = isPlainObject(current);
188
+ if (plainObject && needsStructWalk && rejectNonStringKeys) {
189
+ // Symbol keys are not enumerated by Object.keys, use getOwnPropertySymbols
190
+ const firstSymbol = Object.getOwnPropertySymbols(current)[0];
191
+ if (firstSymbol !== undefined) {
192
+ const location = currentPath ? ` at ${currentPath}` : '';
193
+ structuralError ??= new BridgeCodecError(
194
+ `Symbol key found in object${location}: ${firstSymbol.toString()}`,
195
+ { codecPhase: 'encode' }
196
+ );
197
+ }
198
+ }
199
+ // Special-float detection historically traversed every non-array object
200
+ // with enumerable values, including class instances. Structural validation
201
+ // remains restricted to arrays and plain objects, matching its prior scope.
202
+ if (needsSpecialWalk || plainObject) {
203
+ for (const [key, item] of Object.entries(current)) {
204
+ visit(item, buildPath(currentPath, key), structuralScope && plainObject);
205
+ }
150
206
  }
207
+ };
208
+
209
+ visit(value, path, true);
210
+ if (rejectSpecialFloats && specialFloatPath !== undefined) {
211
+ throw new BridgeCodecError(
212
+ `Cannot encode request: contains non-finite number (NaN or Infinity) at ${specialFloatPath}`,
213
+ { codecPhase: 'encode', valueType: 'number' }
214
+ );
215
+ }
216
+ if (structuralError !== undefined) {
217
+ throw structuralError;
151
218
  }
152
219
  }
153
220
 
@@ -318,7 +385,7 @@ export class BridgeCodec {
318
385
  private readonly rejectSpecialFloats: boolean;
319
386
  private readonly rejectNonStringKeys: boolean;
320
387
  private readonly maxPayloadBytes: number;
321
- private readonly bytesHandling: 'base64' | 'reject' | 'passthrough';
388
+ private readonly bytesHandling: 'base64' | 'reject';
322
389
  private readonly reviveValueBound: (key: string, value: unknown) => unknown;
323
390
  private static readonly base64Pattern =
324
391
  /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
@@ -506,19 +573,9 @@ export class BridgeCodec {
506
573
  * @throws BridgeCodecError if validation fails or encoding fails
507
574
  */
508
575
  encodeRequest(message: unknown): string {
509
- // Validate special floats if enabled
510
- if (this.rejectSpecialFloats && containsSpecialFloat(message)) {
511
- const floatPath = findSpecialFloatPath(message);
512
- throw new BridgeCodecError(
513
- `Cannot encode request: contains non-finite number (NaN or Infinity) at ${floatPath}`,
514
- { codecPhase: 'encode', valueType: 'number' }
515
- );
516
- }
517
-
518
- // Validate string keys if enabled
519
- if (this.rejectNonStringKeys) {
520
- assertStringKeys(message);
521
- }
576
+ // Reject, in one traversal, values JSON.stringify would silently mangle:
577
+ // non-finite numbers, Map/Set, and (optionally) non-string keys.
578
+ assertValidRequestValues(message, this.rejectSpecialFloats, this.rejectNonStringKeys);
522
579
 
523
580
  // Serialize to JSON with error handling
524
581
  let payload: string;
@@ -537,9 +594,6 @@ export class BridgeCodec {
537
594
  const b64 = this.toBase64(bytes);
538
595
  return { __tywrap_bytes__: true, b64 };
539
596
  }
540
- case 'passthrough':
541
- default:
542
- return value;
543
597
  }
544
598
  }
545
599
  return value;
@@ -558,7 +612,10 @@ export class BridgeCodec {
558
612
  }
559
613
 
560
614
  // Check payload size
561
- const payloadBytes = new TextEncoder().encode(payload).length;
615
+ const payloadBytes =
616
+ typeof Buffer !== 'undefined'
617
+ ? Buffer.byteLength(payload, 'utf8')
618
+ : new TextEncoder().encode(payload).length;
562
619
  if (payloadBytes > this.maxPayloadBytes) {
563
620
  throw new BridgeCodecError(
564
621
  `Payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`,
@@ -582,6 +639,13 @@ export class BridgeCodec {
582
639
  decodeResponse<T>(payload: string): T {
583
640
  const result = this.parseResponseResult(payload);
584
641
 
642
+ if (containsScientificEnvelope(result)) {
643
+ throw new BridgeCodecError('scientific envelopes require decodeResponseAsync', {
644
+ codecPhase: 'decode',
645
+ valueType: 'scientific-envelope',
646
+ });
647
+ }
648
+
585
649
  // Post-decode validation for special floats if enabled
586
650
  this.assertNoSpecialFloats(result);
587
651
 
@@ -589,8 +653,8 @@ export class BridgeCodec {
589
653
  }
590
654
 
591
655
  /**
592
- * Async version that applies Arrow decoders.
593
- * Use this when the response may contain encoded DataFrames or ndarrays.
656
+ * Async version that decodes scientific envelopes.
657
+ * Use this when the response may contain encoded scientific values.
594
658
  *
595
659
  * @param payload - The JSON string received from Python
596
660
  * @returns Decoded and validated result with Arrow decoding applied
@@ -601,22 +665,42 @@ export class BridgeCodec {
601
665
  async decodeResponseAsync<T>(payload: string): Promise<T> {
602
666
  const result = this.parseResponseResult(payload);
603
667
 
604
- // Apply Arrow decoding to the result
668
+ // Decode scientific envelopes in the result.
605
669
  let decoded: unknown;
606
670
  try {
607
- decoded = await decodeArrowValue(result);
671
+ decoded = await decodeScientificValue(result);
608
672
  } catch (err) {
609
673
  const errorMessage = err instanceof Error ? err.message : String(err);
610
- throw new BridgeCodecError(`Arrow decoding failed: ${errorMessage}`, {
611
- codecPhase: 'decode',
612
- valueType: 'arrow',
613
- });
674
+ const decodeError = err instanceof ScientificDecodeError ? err : undefined;
675
+ const marker =
676
+ decodeError?.marker ??
677
+ (isPlainObject(result) && isScientificMarker(result.__tywrap__)
678
+ ? result.__tywrap__
679
+ : 'unknown');
680
+ const genuineArrowError = decodeError?.kind === 'arrow';
681
+ const valueType = genuineArrowError
682
+ ? 'arrow'
683
+ : marker === 'unknown'
684
+ ? 'scientific-envelope'
685
+ : marker;
686
+ throw new BridgeCodecError(
687
+ genuineArrowError
688
+ ? `Arrow decoding failed: ${errorMessage}`
689
+ : `Scientific envelope decoding failed (${marker}): ${errorMessage}`,
690
+ {
691
+ codecPhase: 'decode',
692
+ valueType,
693
+ }
694
+ );
614
695
  }
615
696
 
616
697
  // Post-decode validation for special floats if enabled
617
698
  // Note: Arrow decoders can introduce NaN/Infinity from binary representations.
618
699
  this.assertNoSpecialFloats(decoded);
619
700
 
701
+ // Return-contract validation is intentionally downstream of this codec. At
702
+ // this boundary we guarantee only a sound wire envelope and decoded value;
703
+ // generated wrappers validate the Python annotation after Arrow/JSON decode.
620
704
  return decoded as T;
621
705
  }
622
706
 
@@ -14,6 +14,27 @@ export class BridgeProtocolError extends BridgeError {}
14
14
  export class BridgeTimeoutError extends BridgeError {}
15
15
  export class BridgeDisposedError extends BridgeError {}
16
16
 
17
+ /**
18
+ * A decoded value did not match the return annotation emitted into a wrapper.
19
+ *
20
+ * This is deliberately separate from protocol/codec errors: the wire response
21
+ * was sound, but the Python implementation violated its declared contract.
22
+ */
23
+ export class BridgeValidationError extends BridgeError {
24
+ readonly declaredType: string;
25
+ readonly receivedShape: string;
26
+ readonly callSite: string;
27
+
28
+ constructor(options: { declaredType: string; receivedShape: string; callSite: string }) {
29
+ super(
30
+ `Return validation failed for ${options.callSite}: expected ${options.declaredType}, received ${options.receivedShape}`
31
+ );
32
+ this.declaredType = options.declaredType;
33
+ this.receivedShape = options.receivedShape;
34
+ this.callSite = options.callSite;
35
+ }
36
+ }
37
+
17
38
  export class BridgeCodecError extends BridgeError {
18
39
  codecPhase?: string;
19
40
  valueType?: string;
@@ -148,7 +148,12 @@ export class HttpTransport implements Transport {
148
148
  * @throws BridgeExecutionError if the server returns a non-2xx status
149
149
  * @throws BridgeProtocolError if the response cannot be read
150
150
  */
151
- async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
151
+ async send(
152
+ message: string,
153
+ timeoutMs: number,
154
+ signal?: AbortSignal,
155
+ _requestId?: number
156
+ ): Promise<string> {
152
157
  if (this._isDisposed) {
153
158
  throw new BridgeDisposedError('Transport has been disposed');
154
159
  }
@@ -11,6 +11,12 @@ import type { RuntimeExecution } from '../types/index.js';
11
11
 
12
12
  // BridgeCodec — validation and serialization for the JS<->Python boundary
13
13
  export { BridgeCodec, type CodecOptions } from './bridge-codec.js';
14
+ export {
15
+ createReturnValidator,
16
+ describeReceivedShape,
17
+ type ReturnSchema,
18
+ type ReturnValidator,
19
+ } from './validators.js';
14
20
 
15
21
  // Transport contract — abstract I/O channel interface and guards
16
22
  export type {