tywrap 0.1.2 → 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.
Files changed (93) hide show
  1. package/README.md +11 -4
  2. package/dist/index.d.ts +20 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/base.d.ts +17 -7
  7. package/dist/runtime/base.d.ts.map +1 -1
  8. package/dist/runtime/base.js +18 -1
  9. package/dist/runtime/base.js.map +1 -1
  10. package/dist/runtime/bounded-context.d.ts +252 -0
  11. package/dist/runtime/bounded-context.d.ts.map +1 -0
  12. package/dist/runtime/bounded-context.js +454 -0
  13. package/dist/runtime/bounded-context.js.map +1 -0
  14. package/dist/runtime/bridge-protocol.d.ts +167 -0
  15. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  16. package/dist/runtime/bridge-protocol.js +247 -0
  17. package/dist/runtime/bridge-protocol.js.map +1 -0
  18. package/dist/runtime/disposable.d.ts +40 -0
  19. package/dist/runtime/disposable.d.ts.map +1 -0
  20. package/dist/runtime/disposable.js +49 -0
  21. package/dist/runtime/disposable.js.map +1 -0
  22. package/dist/runtime/http-io.d.ts +91 -0
  23. package/dist/runtime/http-io.d.ts.map +1 -0
  24. package/dist/runtime/http-io.js +195 -0
  25. package/dist/runtime/http-io.js.map +1 -0
  26. package/dist/runtime/http.d.ts +47 -13
  27. package/dist/runtime/http.d.ts.map +1 -1
  28. package/dist/runtime/http.js +55 -74
  29. package/dist/runtime/http.js.map +1 -1
  30. package/dist/runtime/node.d.ts +142 -28
  31. package/dist/runtime/node.d.ts.map +1 -1
  32. package/dist/runtime/node.js +321 -168
  33. package/dist/runtime/node.js.map +1 -1
  34. package/dist/runtime/optimized-node.d.ts +19 -125
  35. package/dist/runtime/optimized-node.d.ts.map +1 -1
  36. package/dist/runtime/optimized-node.js +19 -550
  37. package/dist/runtime/optimized-node.js.map +1 -1
  38. package/dist/runtime/pooled-transport.d.ts +131 -0
  39. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  40. package/dist/runtime/pooled-transport.js +175 -0
  41. package/dist/runtime/pooled-transport.js.map +1 -0
  42. package/dist/runtime/process-io.d.ts +204 -0
  43. package/dist/runtime/process-io.d.ts.map +1 -0
  44. package/dist/runtime/process-io.js +695 -0
  45. package/dist/runtime/process-io.js.map +1 -0
  46. package/dist/runtime/pyodide-io.d.ts +155 -0
  47. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  48. package/dist/runtime/pyodide-io.js +397 -0
  49. package/dist/runtime/pyodide-io.js.map +1 -0
  50. package/dist/runtime/pyodide.d.ts +51 -19
  51. package/dist/runtime/pyodide.d.ts.map +1 -1
  52. package/dist/runtime/pyodide.js +57 -186
  53. package/dist/runtime/pyodide.js.map +1 -1
  54. package/dist/runtime/safe-codec.d.ts +81 -0
  55. package/dist/runtime/safe-codec.d.ts.map +1 -0
  56. package/dist/runtime/safe-codec.js +345 -0
  57. package/dist/runtime/safe-codec.js.map +1 -0
  58. package/dist/runtime/transport.d.ts +186 -0
  59. package/dist/runtime/transport.d.ts.map +1 -0
  60. package/dist/runtime/transport.js +86 -0
  61. package/dist/runtime/transport.js.map +1 -0
  62. package/dist/runtime/validators.d.ts +131 -0
  63. package/dist/runtime/validators.d.ts.map +1 -0
  64. package/dist/runtime/validators.js +219 -0
  65. package/dist/runtime/validators.js.map +1 -0
  66. package/dist/runtime/worker-pool.d.ts +196 -0
  67. package/dist/runtime/worker-pool.d.ts.map +1 -0
  68. package/dist/runtime/worker-pool.js +371 -0
  69. package/dist/runtime/worker-pool.js.map +1 -0
  70. package/dist/utils/codec.d.ts.map +1 -1
  71. package/dist/utils/codec.js +120 -1
  72. package/dist/utils/codec.js.map +1 -1
  73. package/package.json +2 -2
  74. package/runtime/python_bridge.py +30 -3
  75. package/runtime/safe_codec.py +344 -0
  76. package/src/index.ts +48 -5
  77. package/src/runtime/base.ts +18 -26
  78. package/src/runtime/bounded-context.ts +608 -0
  79. package/src/runtime/bridge-protocol.ts +319 -0
  80. package/src/runtime/disposable.ts +65 -0
  81. package/src/runtime/http-io.ts +244 -0
  82. package/src/runtime/http.ts +71 -117
  83. package/src/runtime/node.ts +460 -217
  84. package/src/runtime/optimized-node.ts +19 -761
  85. package/src/runtime/pooled-transport.ts +252 -0
  86. package/src/runtime/process-io.ts +902 -0
  87. package/src/runtime/pyodide-io.ts +485 -0
  88. package/src/runtime/pyodide.ts +75 -215
  89. package/src/runtime/safe-codec.ts +443 -0
  90. package/src/runtime/transport.ts +273 -0
  91. package/src/runtime/validators.ts +241 -0
  92. package/src/runtime/worker-pool.ts +498 -0
  93. 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
+ }