tywrap 0.2.0 → 0.2.1
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 +1 -1
- package/dist/index.d.ts +20 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/base.d.ts +17 -7
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +18 -1
- package/dist/runtime/base.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +252 -0
- package/dist/runtime/bounded-context.d.ts.map +1 -0
- package/dist/runtime/bounded-context.js +454 -0
- package/dist/runtime/bounded-context.js.map +1 -0
- package/dist/runtime/bridge-protocol.d.ts +167 -0
- package/dist/runtime/bridge-protocol.d.ts.map +1 -0
- package/dist/runtime/bridge-protocol.js +247 -0
- package/dist/runtime/bridge-protocol.js.map +1 -0
- package/dist/runtime/disposable.d.ts +40 -0
- package/dist/runtime/disposable.d.ts.map +1 -0
- package/dist/runtime/disposable.js +49 -0
- package/dist/runtime/disposable.js.map +1 -0
- package/dist/runtime/http-io.d.ts +91 -0
- package/dist/runtime/http-io.d.ts.map +1 -0
- package/dist/runtime/http-io.js +195 -0
- package/dist/runtime/http-io.js.map +1 -0
- package/dist/runtime/http.d.ts +47 -13
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +55 -74
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/node.d.ts +97 -130
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +256 -523
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +131 -0
- package/dist/runtime/pooled-transport.d.ts.map +1 -0
- package/dist/runtime/pooled-transport.js +175 -0
- package/dist/runtime/pooled-transport.js.map +1 -0
- package/dist/runtime/process-io.d.ts +204 -0
- package/dist/runtime/process-io.d.ts.map +1 -0
- package/dist/runtime/process-io.js +695 -0
- package/dist/runtime/process-io.js.map +1 -0
- package/dist/runtime/pyodide-io.d.ts +155 -0
- package/dist/runtime/pyodide-io.d.ts.map +1 -0
- package/dist/runtime/pyodide-io.js +397 -0
- package/dist/runtime/pyodide-io.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +51 -19
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +57 -186
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/safe-codec.d.ts +81 -0
- package/dist/runtime/safe-codec.d.ts.map +1 -0
- package/dist/runtime/safe-codec.js +345 -0
- package/dist/runtime/safe-codec.js.map +1 -0
- package/dist/runtime/transport.d.ts +186 -0
- package/dist/runtime/transport.d.ts.map +1 -0
- package/dist/runtime/transport.js +86 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/validators.d.ts +131 -0
- package/dist/runtime/validators.d.ts.map +1 -0
- package/dist/runtime/validators.js +219 -0
- package/dist/runtime/validators.js.map +1 -0
- package/dist/runtime/worker-pool.d.ts +196 -0
- package/dist/runtime/worker-pool.d.ts.map +1 -0
- package/dist/runtime/worker-pool.js +371 -0
- package/dist/runtime/worker-pool.js.map +1 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +120 -1
- package/dist/utils/codec.js.map +1 -1
- package/package.json +2 -2
- package/runtime/python_bridge.py +30 -3
- package/runtime/safe_codec.py +344 -0
- package/src/index.ts +48 -5
- package/src/runtime/base.ts +18 -26
- package/src/runtime/bounded-context.ts +608 -0
- package/src/runtime/bridge-protocol.ts +319 -0
- package/src/runtime/disposable.ts +65 -0
- package/src/runtime/http-io.ts +244 -0
- package/src/runtime/http.ts +71 -117
- package/src/runtime/node.ts +358 -691
- package/src/runtime/pooled-transport.ts +252 -0
- package/src/runtime/process-io.ts +902 -0
- package/src/runtime/pyodide-io.ts +485 -0
- package/src/runtime/pyodide.ts +75 -215
- package/src/runtime/safe-codec.ts +443 -0
- package/src/runtime/transport.ts +273 -0
- package/src/runtime/validators.ts +241 -0
- package/src/runtime/worker-pool.ts +498 -0
- package/src/utils/codec.ts +126 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeCodec - Unified validation and serialization for JS<->Python boundary crossing.
|
|
3
|
+
*
|
|
4
|
+
* Provides safe encoding/decoding with configurable guardrails for:
|
|
5
|
+
* - Special float rejection (NaN, Infinity)
|
|
6
|
+
* - Non-string key detection
|
|
7
|
+
* - Payload size limits
|
|
8
|
+
* - Binary data handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BridgeProtocolError, BridgeExecutionError } from './errors.js';
|
|
12
|
+
import { containsSpecialFloat } from './validators.js';
|
|
13
|
+
import { decodeValueAsync as decodeArrowValue } from '../utils/codec.js';
|
|
14
|
+
import { PROTOCOL_ID } from './transport.js';
|
|
15
|
+
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
// TYPES
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration options for SafeCodec behavior.
|
|
22
|
+
*/
|
|
23
|
+
export interface CodecOptions {
|
|
24
|
+
/** Reject NaN/Infinity in arguments. Default: true */
|
|
25
|
+
rejectSpecialFloats?: boolean;
|
|
26
|
+
/** Reject non-string keys in objects. Default: true */
|
|
27
|
+
rejectNonStringKeys?: boolean;
|
|
28
|
+
/** Max payload size in bytes. Default: 10MB */
|
|
29
|
+
maxPayloadBytes?: number;
|
|
30
|
+
/** How to handle bytes/bytearray. Default: 'base64' */
|
|
31
|
+
bytesHandling?: 'base64' | 'reject' | 'passthrough';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Python error response format from the bridge.
|
|
36
|
+
*/
|
|
37
|
+
interface PythonErrorResponse {
|
|
38
|
+
error: {
|
|
39
|
+
type: string;
|
|
40
|
+
message: string;
|
|
41
|
+
traceback?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
// CONSTANTS
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10MB
|
|
50
|
+
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
+
// HELPER FUNCTIONS
|
|
53
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a value is a plain object (not null, not array, not Map/Set/etc).
|
|
57
|
+
*/
|
|
58
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
if (value === null || typeof value !== 'object') {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const proto = Object.getPrototypeOf(value);
|
|
63
|
+
return proto === Object.prototype || proto === null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a path string for error messages.
|
|
68
|
+
*/
|
|
69
|
+
function buildPath(basePath: string, key: string | number): string {
|
|
70
|
+
if (basePath === '') {
|
|
71
|
+
return typeof key === 'number' ? `[${key}]` : key;
|
|
72
|
+
}
|
|
73
|
+
return typeof key === 'number' ? `${basePath}[${key}]` : `${basePath}.${key}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Recursively check for non-string keys in objects and Maps.
|
|
78
|
+
* Throws BridgeProtocolError with path indication if found.
|
|
79
|
+
*/
|
|
80
|
+
function assertStringKeys(value: unknown, path: string = ''): void {
|
|
81
|
+
if (value === null || typeof value !== 'object') {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check Map instances for non-string keys
|
|
86
|
+
if (value instanceof Map) {
|
|
87
|
+
for (const key of value.keys()) {
|
|
88
|
+
if (typeof key !== 'string') {
|
|
89
|
+
const keyDesc = typeof key === 'symbol' ? key.toString() : String(key);
|
|
90
|
+
const location = path ? ` at ${path}` : '';
|
|
91
|
+
throw new BridgeProtocolError(
|
|
92
|
+
`Non-string key found in Map${location}: ${keyDesc} (${typeof key})`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Recurse into Map values
|
|
97
|
+
for (const [key, val] of value.entries()) {
|
|
98
|
+
assertStringKeys(val, buildPath(path, key));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check arrays
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
for (let i = 0; i < value.length; i++) {
|
|
106
|
+
assertStringKeys(value[i], buildPath(path, i));
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check plain objects for symbol keys
|
|
112
|
+
if (isPlainObject(value)) {
|
|
113
|
+
// Symbol keys are not enumerated by Object.keys, use getOwnPropertySymbols
|
|
114
|
+
const symbolKeys = Object.getOwnPropertySymbols(value);
|
|
115
|
+
const firstSymbol = symbolKeys[0];
|
|
116
|
+
if (firstSymbol !== undefined) {
|
|
117
|
+
const symbolDesc = firstSymbol.toString();
|
|
118
|
+
const location = path ? ` at ${path}` : '';
|
|
119
|
+
throw new BridgeProtocolError(
|
|
120
|
+
`Symbol key found in object${location}: ${symbolDesc}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Recurse into object values
|
|
125
|
+
for (const key of Object.keys(value)) {
|
|
126
|
+
assertStringKeys(value[key], buildPath(path, key));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if a value is a Python error response.
|
|
133
|
+
*/
|
|
134
|
+
function isPythonErrorResponse(value: unknown): value is PythonErrorResponse {
|
|
135
|
+
if (value === null || typeof value !== 'object') {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const obj = value as Record<string, unknown>;
|
|
139
|
+
if (!obj.error || typeof obj.error !== 'object' || obj.error === null) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const error = obj.error as Record<string, unknown>;
|
|
143
|
+
return typeof error.type === 'string' && typeof error.message === 'string';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Type guard for protocol result response.
|
|
148
|
+
* Checks if the value is an object with an id and result field.
|
|
149
|
+
* The protocol field is optional as Python may not always include it.
|
|
150
|
+
*/
|
|
151
|
+
interface ProtocolResultResponse {
|
|
152
|
+
id: number;
|
|
153
|
+
protocol?: string;
|
|
154
|
+
result: unknown;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isProtocolResultResponse(value: unknown): value is ProtocolResultResponse {
|
|
158
|
+
if (value === null || typeof value !== 'object') {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const obj = value as Record<string, unknown>;
|
|
162
|
+
return typeof obj.id === 'number' && 'result' in obj;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate the protocol version in a response.
|
|
167
|
+
* Only validates when the response looks like a protocol envelope (has 'id' field).
|
|
168
|
+
* Throws if protocol is present but doesn't match expected version.
|
|
169
|
+
* Allows missing protocol for backwards compatibility.
|
|
170
|
+
*/
|
|
171
|
+
function validateProtocolVersion(value: unknown): void {
|
|
172
|
+
if (value === null || typeof value !== 'object') {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const obj = value as Record<string, unknown>;
|
|
176
|
+
// Only validate protocol on protocol envelopes (responses with 'id' field)
|
|
177
|
+
// This avoids false positives on user data that happens to contain 'protocol' key
|
|
178
|
+
if (!('id' in obj)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if ('protocol' in obj && obj.protocol !== PROTOCOL_ID) {
|
|
182
|
+
throw new BridgeProtocolError(
|
|
183
|
+
`Invalid protocol version: expected "${PROTOCOL_ID}", got "${obj.protocol}"`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Find the path to a special float in a value structure.
|
|
190
|
+
* Returns undefined if no special float is found.
|
|
191
|
+
*/
|
|
192
|
+
function findSpecialFloatPath(value: unknown, path: string = ''): string | undefined {
|
|
193
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
194
|
+
return path || 'root';
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
for (let i = 0; i < value.length; i++) {
|
|
198
|
+
const result = findSpecialFloatPath(value[i], buildPath(path, i));
|
|
199
|
+
if (result !== undefined) {
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (isPlainObject(value)) {
|
|
205
|
+
for (const key of Object.keys(value)) {
|
|
206
|
+
const result = findSpecialFloatPath(value[key], buildPath(path, key));
|
|
207
|
+
if (result !== undefined) {
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
216
|
+
// SAFE CODEC CLASS
|
|
217
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* SafeCodec provides unified validation and serialization for JS<->Python
|
|
221
|
+
* boundary crossing with configurable guardrails.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```typescript
|
|
225
|
+
* const codec = new SafeCodec({ rejectSpecialFloats: true });
|
|
226
|
+
*
|
|
227
|
+
* // Encoding a request
|
|
228
|
+
* const payload = codec.encodeRequest({ data: [1, 2, 3] });
|
|
229
|
+
*
|
|
230
|
+
* // Decoding a response
|
|
231
|
+
* const result = codec.decodeResponse<MyType>(responsePayload);
|
|
232
|
+
*
|
|
233
|
+
* // Async decoding with Arrow support
|
|
234
|
+
* const dataframe = await codec.decodeResponseAsync<ArrowTable>(arrowPayload);
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export class SafeCodec {
|
|
238
|
+
private readonly rejectSpecialFloats: boolean;
|
|
239
|
+
private readonly rejectNonStringKeys: boolean;
|
|
240
|
+
private readonly maxPayloadBytes: number;
|
|
241
|
+
private readonly bytesHandling: 'base64' | 'reject' | 'passthrough';
|
|
242
|
+
|
|
243
|
+
constructor(options: CodecOptions = {}) {
|
|
244
|
+
this.rejectSpecialFloats = options.rejectSpecialFloats ?? true;
|
|
245
|
+
this.rejectNonStringKeys = options.rejectNonStringKeys ?? true;
|
|
246
|
+
this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
|
|
247
|
+
this.bytesHandling = options.bytesHandling ?? 'base64';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate and encode a request payload.
|
|
252
|
+
* Called before sending to Python.
|
|
253
|
+
*
|
|
254
|
+
* @param message - The message to encode
|
|
255
|
+
* @returns JSON string ready to send
|
|
256
|
+
* @throws BridgeProtocolError if validation fails or encoding fails
|
|
257
|
+
*/
|
|
258
|
+
encodeRequest(message: unknown): string {
|
|
259
|
+
// Validate special floats if enabled
|
|
260
|
+
if (this.rejectSpecialFloats && containsSpecialFloat(message)) {
|
|
261
|
+
const floatPath = findSpecialFloatPath(message);
|
|
262
|
+
throw new BridgeProtocolError(
|
|
263
|
+
`Cannot encode request: contains non-finite number (NaN or Infinity) at ${floatPath}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Validate string keys if enabled
|
|
268
|
+
if (this.rejectNonStringKeys) {
|
|
269
|
+
assertStringKeys(message);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Serialize to JSON with error handling
|
|
273
|
+
let payload: string;
|
|
274
|
+
try {
|
|
275
|
+
payload = JSON.stringify(message, (key, value) => {
|
|
276
|
+
// Handle bytes based on configuration
|
|
277
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
278
|
+
switch (this.bytesHandling) {
|
|
279
|
+
case 'reject':
|
|
280
|
+
throw new BridgeProtocolError(
|
|
281
|
+
`Cannot encode request: binary data found at ${key || 'root'} (bytesHandling: reject)`
|
|
282
|
+
);
|
|
283
|
+
case 'base64': {
|
|
284
|
+
const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
|
|
285
|
+
const b64 = this.toBase64(bytes);
|
|
286
|
+
return { __tywrap_bytes__: true, b64 };
|
|
287
|
+
}
|
|
288
|
+
case 'passthrough':
|
|
289
|
+
default:
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err instanceof BridgeProtocolError) {
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
300
|
+
throw new BridgeProtocolError(`JSON serialization failed: ${message}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check payload size
|
|
304
|
+
const payloadBytes = new TextEncoder().encode(payload).length;
|
|
305
|
+
if (payloadBytes > this.maxPayloadBytes) {
|
|
306
|
+
throw new BridgeProtocolError(
|
|
307
|
+
`Payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return payload;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Decode and validate a response payload.
|
|
316
|
+
* Called after receiving from Python.
|
|
317
|
+
*
|
|
318
|
+
* @param payload - The JSON string received from Python
|
|
319
|
+
* @returns Decoded and validated result
|
|
320
|
+
* @throws BridgeProtocolError if payload is invalid
|
|
321
|
+
* @throws BridgeExecutionError if response contains a Python error
|
|
322
|
+
*/
|
|
323
|
+
decodeResponse<T>(payload: string): T {
|
|
324
|
+
// Check payload size first
|
|
325
|
+
const payloadBytes = new TextEncoder().encode(payload).length;
|
|
326
|
+
if (payloadBytes > this.maxPayloadBytes) {
|
|
327
|
+
throw new BridgeProtocolError(
|
|
328
|
+
`Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Parse JSON
|
|
333
|
+
let parsed: unknown;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(payload);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
338
|
+
throw new BridgeProtocolError(`JSON parse failed: ${message}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Validate protocol version (if present)
|
|
342
|
+
validateProtocolVersion(parsed);
|
|
343
|
+
|
|
344
|
+
// Check for Python error response
|
|
345
|
+
if (isPythonErrorResponse(parsed)) {
|
|
346
|
+
const error = new BridgeExecutionError(
|
|
347
|
+
`${parsed.error.type}: ${parsed.error.message}`
|
|
348
|
+
);
|
|
349
|
+
error.traceback = parsed.error.traceback;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Extract the result field from the response envelope
|
|
354
|
+
const result = isProtocolResultResponse(parsed) ? parsed.result : parsed;
|
|
355
|
+
|
|
356
|
+
// Post-decode validation for special floats if enabled
|
|
357
|
+
if (this.rejectSpecialFloats && containsSpecialFloat(result)) {
|
|
358
|
+
const floatPath = findSpecialFloatPath(result);
|
|
359
|
+
throw new BridgeProtocolError(
|
|
360
|
+
`Response contains non-finite number (NaN or Infinity) at ${floatPath}`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return result as T;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Async version that applies Arrow decoders.
|
|
369
|
+
* Use this when the response may contain encoded DataFrames or ndarrays.
|
|
370
|
+
*
|
|
371
|
+
* @param payload - The JSON string received from Python
|
|
372
|
+
* @returns Decoded and validated result with Arrow decoding applied
|
|
373
|
+
* @throws BridgeProtocolError if payload is invalid
|
|
374
|
+
* @throws BridgeExecutionError if response contains a Python error
|
|
375
|
+
*/
|
|
376
|
+
async decodeResponseAsync<T>(payload: string): Promise<T> {
|
|
377
|
+
// Check payload size first
|
|
378
|
+
const payloadBytes = new TextEncoder().encode(payload).length;
|
|
379
|
+
if (payloadBytes > this.maxPayloadBytes) {
|
|
380
|
+
throw new BridgeProtocolError(
|
|
381
|
+
`Response payload size ${payloadBytes} bytes exceeds maximum ${this.maxPayloadBytes} bytes`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Parse JSON
|
|
386
|
+
let parsed: unknown;
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(payload);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
391
|
+
throw new BridgeProtocolError(`JSON parse failed: ${message}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Validate protocol version (if present)
|
|
395
|
+
validateProtocolVersion(parsed);
|
|
396
|
+
|
|
397
|
+
// Check for Python error response
|
|
398
|
+
if (isPythonErrorResponse(parsed)) {
|
|
399
|
+
const error = new BridgeExecutionError(
|
|
400
|
+
`${parsed.error.type}: ${parsed.error.message}`
|
|
401
|
+
);
|
|
402
|
+
error.traceback = parsed.error.traceback;
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Extract the result field from the response envelope
|
|
407
|
+
const result = isProtocolResultResponse(parsed) ? parsed.result : parsed;
|
|
408
|
+
|
|
409
|
+
// Apply Arrow decoding to the result
|
|
410
|
+
let decoded: unknown;
|
|
411
|
+
try {
|
|
412
|
+
decoded = await decodeArrowValue(result);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
415
|
+
throw new BridgeProtocolError(`Arrow decoding failed: ${message}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Post-decode validation for special floats if enabled
|
|
419
|
+
// Note: We check the result value since that's what we're returning
|
|
420
|
+
if (this.rejectSpecialFloats && containsSpecialFloat(result)) {
|
|
421
|
+
const floatPath = findSpecialFloatPath(result);
|
|
422
|
+
throw new BridgeProtocolError(
|
|
423
|
+
`Response contains non-finite number (NaN or Infinity) at ${floatPath}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return decoded as T;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Convert Uint8Array to base64 string.
|
|
432
|
+
*/
|
|
433
|
+
private toBase64(bytes: Uint8Array): string {
|
|
434
|
+
if (typeof Buffer !== 'undefined') {
|
|
435
|
+
return Buffer.from(bytes).toString('base64');
|
|
436
|
+
}
|
|
437
|
+
if (globalThis.btoa) {
|
|
438
|
+
const binary = String.fromCharCode(...bytes);
|
|
439
|
+
return globalThis.btoa(binary);
|
|
440
|
+
}
|
|
441
|
+
throw new BridgeProtocolError('Base64 encoding is not available in this runtime');
|
|
442
|
+
}
|
|
443
|
+
}
|