tywrap 0.7.0 → 0.9.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 (165) hide show
  1. package/README.md +36 -11
  2. package/SECURITY.md +39 -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/emit-call.d.ts.map +1 -1
  11. package/dist/core/emit-call.js +1 -1
  12. package/dist/core/emit-call.js.map +1 -1
  13. package/dist/core/generator.d.ts +23 -4
  14. package/dist/core/generator.d.ts.map +1 -1
  15. package/dist/core/generator.js +243 -170
  16. package/dist/core/generator.js.map +1 -1
  17. package/dist/core/mapper.d.ts +3 -2
  18. package/dist/core/mapper.d.ts.map +1 -1
  19. package/dist/core/mapper.js +5 -5
  20. package/dist/core/mapper.js.map +1 -1
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +1 -3
  23. package/dist/dev.js.map +1 -1
  24. package/dist/index.d.ts +7 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/base-bridge.d.ts +3 -7
  29. package/dist/runtime/base-bridge.d.ts.map +1 -1
  30. package/dist/runtime/base-bridge.js +4 -17
  31. package/dist/runtime/base-bridge.js.map +1 -1
  32. package/dist/runtime/bounded-context.d.ts +3 -22
  33. package/dist/runtime/bounded-context.d.ts.map +1 -1
  34. package/dist/runtime/bounded-context.js +13 -53
  35. package/dist/runtime/bounded-context.js.map +1 -1
  36. package/dist/runtime/bridge-codec.d.ts +1 -1
  37. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  38. package/dist/runtime/bridge-codec.js +78 -55
  39. package/dist/runtime/bridge-codec.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +16 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +17 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/frame-codec.d.ts +111 -0
  45. package/dist/runtime/frame-codec.d.ts.map +1 -0
  46. package/dist/runtime/frame-codec.js +352 -0
  47. package/dist/runtime/frame-codec.js.map +1 -0
  48. package/dist/runtime/http-transport.d.ts +1 -1
  49. package/dist/runtime/http-transport.d.ts.map +1 -1
  50. package/dist/runtime/http-transport.js +1 -1
  51. package/dist/runtime/http-transport.js.map +1 -1
  52. package/dist/runtime/index.d.ts +2 -1
  53. package/dist/runtime/index.d.ts.map +1 -1
  54. package/dist/runtime/index.js +2 -1
  55. package/dist/runtime/index.js.map +1 -1
  56. package/dist/runtime/node.d.ts +7 -23
  57. package/dist/runtime/node.d.ts.map +1 -1
  58. package/dist/runtime/node.js +5 -78
  59. package/dist/runtime/node.js.map +1 -1
  60. package/dist/runtime/pooled-transport.d.ts +120 -59
  61. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  62. package/dist/runtime/pooled-transport.js +345 -78
  63. package/dist/runtime/pooled-transport.js.map +1 -1
  64. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  65. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  66. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  67. package/dist/runtime/pyodide-transport.d.ts +1 -1
  68. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  69. package/dist/runtime/pyodide-transport.js +2 -3
  70. package/dist/runtime/pyodide-transport.js.map +1 -1
  71. package/dist/runtime/rpc-client.d.ts +7 -36
  72. package/dist/runtime/rpc-client.d.ts.map +1 -1
  73. package/dist/runtime/rpc-client.js +37 -72
  74. package/dist/runtime/rpc-client.js.map +1 -1
  75. package/dist/runtime/subprocess-transport.d.ts +134 -8
  76. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  77. package/dist/runtime/subprocess-transport.js +490 -65
  78. package/dist/runtime/subprocess-transport.js.map +1 -1
  79. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  80. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  81. package/dist/runtime/transport.d.ts +90 -18
  82. package/dist/runtime/transport.d.ts.map +1 -1
  83. package/dist/runtime/transport.js +21 -1
  84. package/dist/runtime/transport.js.map +1 -1
  85. package/dist/runtime/validators.d.ts +49 -0
  86. package/dist/runtime/validators.d.ts.map +1 -1
  87. package/dist/runtime/validators.js +152 -0
  88. package/dist/runtime/validators.js.map +1 -1
  89. package/dist/types/index.d.ts +14 -15
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/tywrap.d.ts +3 -2
  92. package/dist/tywrap.d.ts.map +1 -1
  93. package/dist/tywrap.js +140 -21
  94. package/dist/tywrap.js.map +1 -1
  95. package/dist/utils/cache.d.ts +3 -16
  96. package/dist/utils/cache.d.ts.map +1 -1
  97. package/dist/utils/codec.d.ts +1 -0
  98. package/dist/utils/codec.d.ts.map +1 -1
  99. package/dist/utils/codec.js +165 -17
  100. package/dist/utils/codec.js.map +1 -1
  101. package/dist/utils/ir-cache.d.ts +2 -1
  102. package/dist/utils/ir-cache.d.ts.map +1 -1
  103. package/dist/utils/runtime.d.ts +0 -29
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +16 -107
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +7 -6
  109. package/runtime/frame_codec.py +430 -0
  110. package/runtime/python_bridge.py +213 -54
  111. package/runtime/tywrap_bridge_core.py +217 -147
  112. package/src/config/index.ts +11 -0
  113. package/src/core/annotation-parser.ts +9 -5
  114. package/src/core/emit-call.ts +1 -7
  115. package/src/core/generator.ts +315 -205
  116. package/src/core/mapper.ts +8 -8
  117. package/src/dev.ts +1 -3
  118. package/src/index.ts +7 -3
  119. package/src/runtime/base-bridge.ts +5 -30
  120. package/src/runtime/bounded-context.ts +12 -67
  121. package/src/runtime/bridge-codec.ts +94 -65
  122. package/src/runtime/errors.ts +21 -0
  123. package/src/runtime/frame-codec.ts +469 -0
  124. package/src/runtime/http-transport.ts +6 -1
  125. package/src/runtime/index.ts +7 -6
  126. package/src/runtime/node.ts +17 -104
  127. package/src/runtime/pooled-transport.ts +424 -90
  128. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  129. package/src/runtime/pyodide-transport.ts +7 -3
  130. package/src/runtime/rpc-client.ts +58 -93
  131. package/src/runtime/subprocess-transport.ts +585 -80
  132. package/src/runtime/timed-out-request-tracker.ts +1 -1
  133. package/src/runtime/transport.ts +112 -22
  134. package/src/runtime/validators.ts +204 -0
  135. package/src/types/index.ts +21 -35
  136. package/src/tywrap.ts +157 -30
  137. package/src/utils/cache.ts +3 -3
  138. package/src/utils/codec.ts +205 -16
  139. package/src/utils/ir-cache.ts +1 -1
  140. package/src/utils/runtime.ts +17 -128
  141. package/src/version.ts +1 -1
  142. package/dist/core/discovery.d.ts +0 -103
  143. package/dist/core/discovery.d.ts.map +0 -1
  144. package/dist/core/discovery.js +0 -380
  145. package/dist/core/discovery.js.map +0 -1
  146. package/dist/core/validation.d.ts +0 -102
  147. package/dist/core/validation.d.ts.map +0 -1
  148. package/dist/core/validation.js +0 -490
  149. package/dist/core/validation.js.map +0 -1
  150. package/dist/runtime/base.d.ts +0 -22
  151. package/dist/runtime/base.d.ts.map +0 -1
  152. package/dist/runtime/base.js +0 -23
  153. package/dist/runtime/base.js.map +0 -1
  154. package/dist/runtime/transport-pool.d.ts +0 -196
  155. package/dist/runtime/transport-pool.d.ts.map +0 -1
  156. package/dist/runtime/transport-pool.js +0 -418
  157. package/dist/runtime/transport-pool.js.map +0 -1
  158. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  159. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  160. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  161. package/runtime/safe_codec.py +0 -352
  162. package/src/core/discovery.ts +0 -477
  163. package/src/core/validation.ts +0 -729
  164. package/src/runtime/base.ts +0 -24
  165. 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/dev.ts CHANGED
@@ -343,9 +343,7 @@ async function listChildDirectories(current: string): Promise<string[]> {
343
343
  return [];
344
344
  }
345
345
 
346
- return entries
347
- .filter(entry => entry.isDirectory())
348
- .map(entry => join(current, entry.name));
346
+ return entries.filter(entry => entry.isDirectory()).map(entry => join(current, entry.name));
349
347
  }
350
348
 
351
349
  async function isWatchableDirectory(current: string, ignoredPaths: string[]): Promise<boolean> {
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,
@@ -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
  // ═══════════════════════════════════════════════════════════════════════════
@@ -28,7 +28,7 @@ export interface CodecOptions {
28
28
  /** Max payload size in bytes. Default: 10MB */
29
29
  maxPayloadBytes?: number;
30
30
  /** How to handle bytes/bytearray. Default: 'base64' */
31
- bytesHandling?: 'base64' | 'reject' | 'passthrough';
31
+ bytesHandling?: 'base64' | 'reject';
32
32
  }
33
33
 
34
34
  /**
@@ -88,66 +88,102 @@ function buildPath(basePath: string, key: string | number): string {
88
88
  }
89
89
 
90
90
  /**
91
- * Recursively check for non-string keys in objects and Maps.
92
- * Throws BridgeCodecError with path indication if found.
91
+ * Validate request-only restrictions in one traversal.
92
+ *
93
+ * Request validation historically checked every finite number before checking
94
+ * structure. Keep that precedence while combining the walks: capture the first
95
+ * structural error (Map, Set, or symbol key) in traversal order, and prefer
96
+ * the special-float error after the traversal.
93
97
  */
94
- function assertStringKeys(
98
+ function assertValidRequestValues(
95
99
  value: unknown,
96
- path: string = '',
97
- visited: WeakSet<object> = new WeakSet<object>()
100
+ rejectSpecialFloats: boolean,
101
+ rejectNonStringKeys: boolean,
102
+ path: string = ''
98
103
  ): void {
99
- if (value === null || typeof value !== 'object') {
100
- return;
101
- }
102
- if (visited.has(value)) {
103
- return;
104
- }
105
- visited.add(value);
104
+ let specialFloatPath: string | undefined;
105
+ let structuralError: BridgeCodecError | undefined;
106
+ const specialVisited = new WeakSet<object>();
107
+ const structVisited = new WeakSet<object>();
108
+
109
+ const visit = (current: unknown, currentPath: string, structuralScope: boolean): void => {
110
+ if (typeof current === 'number' && !Number.isFinite(current)) {
111
+ if (rejectSpecialFloats) {
112
+ specialFloatPath ??= currentPath || 'root';
113
+ }
114
+ return;
115
+ }
116
+ if (current === null || typeof current !== 'object') {
117
+ return;
118
+ }
106
119
 
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})`,
120
+ if (current instanceof Map || current instanceof Set) {
121
+ // JSON.stringify serializes every Map and Set as {}, including Maps with
122
+ // string keys reject unconditionally. Special-float validation
123
+ // historically does not inspect Map/Set contents, so no recursion.
124
+ if (structuralScope && !structVisited.has(current)) {
125
+ structVisited.add(current);
126
+ const location = currentPath ? ` at ${currentPath}` : '';
127
+ structuralError ??= new BridgeCodecError(
128
+ current instanceof Map
129
+ ? `Cannot encode request: Map found${location}; convert it to a plain object before sending`
130
+ : `Cannot encode request: Set found${location}; convert it to an array before sending`,
115
131
  { codecPhase: 'encode' }
116
132
  );
117
133
  }
134
+ return;
118
135
  }
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
136
 
126
- // Check arrays
127
- if (Array.isArray(value)) {
128
- for (const [index, item] of value.entries()) {
129
- assertStringKeys(item, buildPath(path, index), visited);
137
+ const needsSpecialWalk = rejectSpecialFloats && !specialVisited.has(current);
138
+ const needsStructWalk = structuralScope && !structVisited.has(current);
139
+ if (!needsSpecialWalk && !needsStructWalk) {
140
+ return;
141
+ }
142
+ if (needsSpecialWalk) {
143
+ specialVisited.add(current);
144
+ }
145
+ if (needsStructWalk) {
146
+ structVisited.add(current);
130
147
  }
131
- return;
132
- }
133
148
 
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
- });
149
+ if (Array.isArray(current)) {
150
+ for (const [index, item] of current.entries()) {
151
+ visit(item, buildPath(currentPath, index), structuralScope);
152
+ }
153
+ return;
145
154
  }
146
155
 
147
- // Recurse into object values
148
- for (const [key, item] of Object.entries(value)) {
149
- assertStringKeys(item, buildPath(path, key), visited);
156
+ const plainObject = isPlainObject(current);
157
+ if (plainObject && needsStructWalk && rejectNonStringKeys) {
158
+ // Symbol keys are not enumerated by Object.keys, use getOwnPropertySymbols
159
+ const firstSymbol = Object.getOwnPropertySymbols(current)[0];
160
+ if (firstSymbol !== undefined) {
161
+ const location = currentPath ? ` at ${currentPath}` : '';
162
+ structuralError ??= new BridgeCodecError(
163
+ `Symbol key found in object${location}: ${firstSymbol.toString()}`,
164
+ { codecPhase: 'encode' }
165
+ );
166
+ }
167
+ }
168
+ // Special-float detection historically traversed every non-array object
169
+ // with enumerable values, including class instances. Structural validation
170
+ // remains restricted to arrays and plain objects, matching its prior scope.
171
+ if (needsSpecialWalk || plainObject) {
172
+ for (const [key, item] of Object.entries(current)) {
173
+ visit(item, buildPath(currentPath, key), structuralScope && plainObject);
174
+ }
150
175
  }
176
+ };
177
+
178
+ visit(value, path, true);
179
+ if (rejectSpecialFloats && specialFloatPath !== undefined) {
180
+ throw new BridgeCodecError(
181
+ `Cannot encode request: contains non-finite number (NaN or Infinity) at ${specialFloatPath}`,
182
+ { codecPhase: 'encode', valueType: 'number' }
183
+ );
184
+ }
185
+ if (structuralError !== undefined) {
186
+ throw structuralError;
151
187
  }
152
188
  }
153
189
 
@@ -318,7 +354,7 @@ export class BridgeCodec {
318
354
  private readonly rejectSpecialFloats: boolean;
319
355
  private readonly rejectNonStringKeys: boolean;
320
356
  private readonly maxPayloadBytes: number;
321
- private readonly bytesHandling: 'base64' | 'reject' | 'passthrough';
357
+ private readonly bytesHandling: 'base64' | 'reject';
322
358
  private readonly reviveValueBound: (key: string, value: unknown) => unknown;
323
359
  private static readonly base64Pattern =
324
360
  /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
@@ -506,19 +542,9 @@ export class BridgeCodec {
506
542
  * @throws BridgeCodecError if validation fails or encoding fails
507
543
  */
508
544
  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
- }
545
+ // Reject, in one traversal, values JSON.stringify would silently mangle:
546
+ // non-finite numbers, Map/Set, and (optionally) non-string keys.
547
+ assertValidRequestValues(message, this.rejectSpecialFloats, this.rejectNonStringKeys);
522
548
 
523
549
  // Serialize to JSON with error handling
524
550
  let payload: string;
@@ -537,9 +563,6 @@ export class BridgeCodec {
537
563
  const b64 = this.toBase64(bytes);
538
564
  return { __tywrap_bytes__: true, b64 };
539
565
  }
540
- case 'passthrough':
541
- default:
542
- return value;
543
566
  }
544
567
  }
545
568
  return value;
@@ -558,7 +581,10 @@ export class BridgeCodec {
558
581
  }
559
582
 
560
583
  // Check payload size
561
- const payloadBytes = new TextEncoder().encode(payload).length;
584
+ const payloadBytes =
585
+ typeof Buffer !== 'undefined'
586
+ ? Buffer.byteLength(payload, 'utf8')
587
+ : new TextEncoder().encode(payload).length;
562
588
  if (payloadBytes > this.maxPayloadBytes) {
563
589
  throw new BridgeCodecError(
564
590
  `Payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`,
@@ -617,6 +643,9 @@ export class BridgeCodec {
617
643
  // Note: Arrow decoders can introduce NaN/Infinity from binary representations.
618
644
  this.assertNoSpecialFloats(decoded);
619
645
 
646
+ // Return-contract validation is intentionally downstream of this codec. At
647
+ // this boundary we guarantee only a sound wire envelope and decoded value;
648
+ // generated wrappers validate the Python annotation after Arrow/JSON decode.
620
649
  return decoded as T;
621
650
  }
622
651
 
@@ -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;