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.
- package/README.md +42 -22
- package/SECURITY.md +38 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/core/annotation-parser.d.ts +2 -1
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js +6 -3
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/generator.d.ts +23 -4
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +254 -170
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts +3 -2
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +5 -5
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/base-bridge.d.ts +3 -7
- package/dist/runtime/base-bridge.d.ts.map +1 -1
- package/dist/runtime/base-bridge.js +4 -17
- package/dist/runtime/base-bridge.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +3 -22
- package/dist/runtime/bounded-context.d.ts.map +1 -1
- package/dist/runtime/bounded-context.js +13 -53
- package/dist/runtime/bounded-context.js.map +1 -1
- package/dist/runtime/bridge-codec.d.ts +3 -3
- package/dist/runtime/bridge-codec.d.ts.map +1 -1
- package/dist/runtime/bridge-codec.js +129 -62
- package/dist/runtime/bridge-codec.js.map +1 -1
- package/dist/runtime/errors.d.ts +16 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +17 -0
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/http-transport.d.ts +1 -1
- package/dist/runtime/http-transport.d.ts.map +1 -1
- package/dist/runtime/http-transport.js +1 -1
- package/dist/runtime/http-transport.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +7 -36
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +2 -80
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +120 -59
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +345 -78
- package/dist/runtime/pooled-transport.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/pyodide-transport.d.ts +1 -1
- package/dist/runtime/pyodide-transport.d.ts.map +1 -1
- package/dist/runtime/pyodide-transport.js +2 -3
- package/dist/runtime/pyodide-transport.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +7 -36
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +20 -102
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +30 -69
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +179 -236
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
- package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
- package/dist/runtime/transport.d.ts +9 -19
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +1 -1
- package/dist/runtime/transport.js.map +1 -1
- package/dist/runtime/validators.d.ts +50 -0
- package/dist/runtime/validators.d.ts.map +1 -1
- package/dist/runtime/validators.js +154 -0
- package/dist/runtime/validators.js.map +1 -1
- package/dist/types/index.d.ts +14 -39
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts +3 -2
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +140 -13
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/cache.d.ts +3 -16
- package/dist/utils/cache.d.ts.map +1 -1
- package/dist/utils/codec.d.ts +17 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +500 -85
- package/dist/utils/codec.js.map +1 -1
- package/dist/utils/ir-cache.d.ts +2 -1
- package/dist/utils/ir-cache.d.ts.map +1 -1
- package/dist/utils/runtime.d.ts +0 -29
- package/dist/utils/runtime.d.ts.map +1 -1
- package/dist/utils/runtime.js +16 -107
- package/dist/utils/runtime.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +7 -6
- package/runtime/frame_codec.py +7 -1
- package/runtime/python_bridge.py +85 -125
- package/runtime/tywrap_bridge_core.py +515 -187
- package/src/config/index.ts +11 -0
- package/src/core/annotation-parser.ts +7 -4
- package/src/core/generator.ts +330 -205
- package/src/core/mapper.ts +8 -8
- package/src/index.ts +7 -4
- package/src/runtime/base-bridge.ts +5 -30
- package/src/runtime/bounded-context.ts +12 -67
- package/src/runtime/bridge-codec.ts +158 -74
- package/src/runtime/errors.ts +21 -0
- package/src/runtime/http-transport.ts +6 -1
- package/src/runtime/index.ts +6 -0
- package/src/runtime/node.ts +9 -120
- package/src/runtime/pooled-transport.ts +424 -90
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/pyodide-transport.ts +7 -3
- package/src/runtime/rpc-client.ts +37 -139
- package/src/runtime/subprocess-transport.ts +210 -285
- package/src/runtime/timed-out-request-tracker.ts +1 -1
- package/src/runtime/transport.ts +15 -23
- package/src/runtime/validators.ts +212 -0
- package/src/types/index.ts +21 -60
- package/src/tywrap.ts +157 -22
- package/src/utils/cache.ts +3 -3
- package/src/utils/codec.ts +749 -112
- package/src/utils/ir-cache.ts +1 -1
- package/src/utils/runtime.ts +17 -128
- package/src/version.ts +1 -1
- package/dist/core/discovery.d.ts +0 -103
- package/dist/core/discovery.d.ts.map +0 -1
- package/dist/core/discovery.js +0 -380
- package/dist/core/discovery.js.map +0 -1
- package/dist/core/validation.d.ts +0 -102
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js +0 -490
- package/dist/core/validation.js.map +0 -1
- package/dist/runtime/base.d.ts +0 -22
- package/dist/runtime/base.d.ts.map +0 -1
- package/dist/runtime/base.js +0 -23
- package/dist/runtime/base.js.map +0 -1
- package/dist/runtime/transport-pool.d.ts +0 -196
- package/dist/runtime/transport-pool.d.ts.map +0 -1
- package/dist/runtime/transport-pool.js +0 -418
- package/dist/runtime/transport-pool.js.map +0 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/safe_codec.py +0 -352
- package/src/core/discovery.ts +0 -477
- package/src/core/validation.ts +0 -729
- package/src/runtime/base.ts +0 -24
- package/src/runtime/transport-pool.ts +0 -538
package/src/core/mapper.ts
CHANGED
|
@@ -32,9 +32,9 @@ import type {
|
|
|
32
32
|
TypePreset,
|
|
33
33
|
} from '../types/index.js';
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
type MappingContext = 'value' | 'return';
|
|
36
36
|
|
|
37
|
-
|
|
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
|
-
? '
|
|
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
|
-
//
|
|
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: '
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
2
|
+
* tywrap - TypeScript bindings for Python libraries
|
|
3
3
|
*
|
|
4
|
-
* @description Build-time code generation
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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,
|
|
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,
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 {
|
|
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'
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
129
|
+
function assertValidRequestValues(
|
|
95
130
|
value: unknown,
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
rejectSpecialFloats: boolean,
|
|
132
|
+
rejectNonStringKeys: boolean,
|
|
133
|
+
path: string = ''
|
|
98
134
|
): void {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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'
|
|
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
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
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 =
|
|
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
|
|
593
|
-
* Use this when the response may contain encoded
|
|
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
|
-
//
|
|
668
|
+
// Decode scientific envelopes in the result.
|
|
605
669
|
let decoded: unknown;
|
|
606
670
|
try {
|
|
607
|
-
decoded = await
|
|
671
|
+
decoded = await decodeScientificValue(result);
|
|
608
672
|
} catch (err) {
|
|
609
673
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
package/src/runtime/errors.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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 {
|