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.
Files changed (88) hide show
  1. package/README.md +1 -1
  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 +97 -130
  31. package/dist/runtime/node.d.ts.map +1 -1
  32. package/dist/runtime/node.js +256 -523
  33. package/dist/runtime/node.js.map +1 -1
  34. package/dist/runtime/pooled-transport.d.ts +131 -0
  35. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  36. package/dist/runtime/pooled-transport.js +175 -0
  37. package/dist/runtime/pooled-transport.js.map +1 -0
  38. package/dist/runtime/process-io.d.ts +204 -0
  39. package/dist/runtime/process-io.d.ts.map +1 -0
  40. package/dist/runtime/process-io.js +695 -0
  41. package/dist/runtime/process-io.js.map +1 -0
  42. package/dist/runtime/pyodide-io.d.ts +155 -0
  43. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  44. package/dist/runtime/pyodide-io.js +397 -0
  45. package/dist/runtime/pyodide-io.js.map +1 -0
  46. package/dist/runtime/pyodide.d.ts +51 -19
  47. package/dist/runtime/pyodide.d.ts.map +1 -1
  48. package/dist/runtime/pyodide.js +57 -186
  49. package/dist/runtime/pyodide.js.map +1 -1
  50. package/dist/runtime/safe-codec.d.ts +81 -0
  51. package/dist/runtime/safe-codec.d.ts.map +1 -0
  52. package/dist/runtime/safe-codec.js +345 -0
  53. package/dist/runtime/safe-codec.js.map +1 -0
  54. package/dist/runtime/transport.d.ts +186 -0
  55. package/dist/runtime/transport.d.ts.map +1 -0
  56. package/dist/runtime/transport.js +86 -0
  57. package/dist/runtime/transport.js.map +1 -0
  58. package/dist/runtime/validators.d.ts +131 -0
  59. package/dist/runtime/validators.d.ts.map +1 -0
  60. package/dist/runtime/validators.js +219 -0
  61. package/dist/runtime/validators.js.map +1 -0
  62. package/dist/runtime/worker-pool.d.ts +196 -0
  63. package/dist/runtime/worker-pool.d.ts.map +1 -0
  64. package/dist/runtime/worker-pool.js +371 -0
  65. package/dist/runtime/worker-pool.js.map +1 -0
  66. package/dist/utils/codec.d.ts.map +1 -1
  67. package/dist/utils/codec.js +120 -1
  68. package/dist/utils/codec.js.map +1 -1
  69. package/package.json +2 -2
  70. package/runtime/python_bridge.py +30 -3
  71. package/runtime/safe_codec.py +344 -0
  72. package/src/index.ts +48 -5
  73. package/src/runtime/base.ts +18 -26
  74. package/src/runtime/bounded-context.ts +608 -0
  75. package/src/runtime/bridge-protocol.ts +319 -0
  76. package/src/runtime/disposable.ts +65 -0
  77. package/src/runtime/http-io.ts +244 -0
  78. package/src/runtime/http.ts +71 -117
  79. package/src/runtime/node.ts +358 -691
  80. package/src/runtime/pooled-transport.ts +252 -0
  81. package/src/runtime/process-io.ts +902 -0
  82. package/src/runtime/pyodide-io.ts +485 -0
  83. package/src/runtime/pyodide.ts +75 -215
  84. package/src/runtime/safe-codec.ts +443 -0
  85. package/src/runtime/transport.ts +273 -0
  86. package/src/runtime/validators.ts +241 -0
  87. package/src/runtime/worker-pool.ts +498 -0
  88. package/src/utils/codec.ts +126 -1
@@ -0,0 +1,485 @@
1
+ /**
2
+ * PyodideIO Transport - In-memory Pyodide communication for browser environments.
3
+ *
4
+ * This transport implements the Transport interface for direct in-memory
5
+ * communication with Pyodide (Python compiled to WebAssembly). It is designed
6
+ * for browser environments where Python code runs within the same process.
7
+ *
8
+ * Key features:
9
+ * - Zero network overhead (in-memory calls)
10
+ * - Automatic Pyodide loading from CDN or module
11
+ * - Python package loading support
12
+ * - Proper proxy cleanup to prevent memory leaks
13
+ *
14
+ * @see https://github.com/bbopen/tywrap/issues/149
15
+ */
16
+
17
+ import { BoundedContext } from './bounded-context.js';
18
+ import { BridgeExecutionError, BridgeProtocolError } from './errors.js';
19
+ import { PROTOCOL_ID, type Transport, type ProtocolMessage, type ProtocolResponse } from './transport.js';
20
+
21
+ // =============================================================================
22
+ // TYPES
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Options for configuring the PyodideIO transport.
27
+ */
28
+ export interface PyodideIOOptions {
29
+ /** URL for Pyodide CDN. Default: official CDN */
30
+ indexURL?: string;
31
+ /** Python packages to load during initialization */
32
+ packages?: string[];
33
+ }
34
+
35
+ /**
36
+ * Pyodide loader function type.
37
+ * Avoids direct dependency on pyodide types.
38
+ */
39
+ type LoadPyodide = (options: { indexURL: string }) => Promise<PyodideInstance>;
40
+
41
+ /**
42
+ * Minimal Pyodide instance interface.
43
+ * Avoids direct dependency on pyodide types.
44
+ */
45
+ interface PyodideInstance {
46
+ runPython: (code: string) => unknown;
47
+ runPythonAsync: (code: string) => Promise<unknown>;
48
+ globals: { get: (key: string) => unknown; set: (k: string, v: unknown) => void };
49
+ toPy: (obj: unknown) => unknown;
50
+ loadPackage: (name: string | string[]) => Promise<void>;
51
+ }
52
+
53
+ // =============================================================================
54
+ // CONSTANTS
55
+ // =============================================================================
56
+
57
+ /** Default Pyodide CDN URL */
58
+ const DEFAULT_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/';
59
+
60
+ /**
61
+ * Bootstrap Python code that sets up the dispatch function.
62
+ *
63
+ * This code is injected into Pyodide during initialization and provides
64
+ * a single entry point for all protocol messages. The dispatch function
65
+ * parses JSON messages, routes to appropriate handlers, and returns
66
+ * JSON responses.
67
+ */
68
+ const BOOTSTRAP_PYTHON = `
69
+ import json
70
+ import importlib
71
+
72
+ __tywrap_instances = {}
73
+
74
+ def __tywrap_dispatch(message_json):
75
+ """
76
+ Dispatch a protocol message and return a JSON response.
77
+
78
+ Args:
79
+ message_json: JSON-encoded ProtocolMessage string
80
+
81
+ Returns:
82
+ JSON-encoded ProtocolResponse string
83
+ """
84
+ msg = None
85
+ try:
86
+ msg = json.loads(message_json)
87
+ msg_id = msg['id']
88
+ msg_type = msg['type']
89
+
90
+ if msg_type == 'call':
91
+ mod = importlib.import_module(msg['module'])
92
+ fn = getattr(mod, msg['functionName'])
93
+ result = fn(*msg.get('args', []), **msg.get('kwargs', {}))
94
+ return json.dumps({'id': msg_id, 'result': result})
95
+
96
+ elif msg_type == 'instantiate':
97
+ mod = importlib.import_module(msg['module'])
98
+ cls = getattr(mod, msg['className'])
99
+ obj = cls(*msg.get('args', []), **msg.get('kwargs', {}))
100
+ handle = str(id(obj))
101
+ __tywrap_instances[handle] = obj
102
+ return json.dumps({'id': msg_id, 'result': handle})
103
+
104
+ elif msg_type == 'call_method':
105
+ obj = __tywrap_instances[msg['handle']]
106
+ method = getattr(obj, msg['methodName'])
107
+ result = method(*msg.get('args', []), **msg.get('kwargs', {}))
108
+ return json.dumps({'id': msg_id, 'result': result})
109
+
110
+ elif msg_type == 'dispose_instance':
111
+ __tywrap_instances.pop(msg['handle'], None)
112
+ return json.dumps({'id': msg_id, 'result': None})
113
+
114
+ else:
115
+ return json.dumps({
116
+ 'id': msg_id,
117
+ 'error': {
118
+ 'type': 'ValueError',
119
+ 'message': f'Unknown message type: {msg_type}'
120
+ }
121
+ })
122
+
123
+ except Exception as e:
124
+ import traceback
125
+ return json.dumps({
126
+ 'id': msg.get('id', 'unknown') if msg else 'unknown',
127
+ 'error': {
128
+ 'type': type(e).__name__,
129
+ 'message': str(e),
130
+ 'traceback': traceback.format_exc()
131
+ }
132
+ })
133
+ `;
134
+
135
+ // =============================================================================
136
+ // PYODIDE IO TRANSPORT
137
+ // =============================================================================
138
+
139
+ /**
140
+ * Transport implementation for in-memory Pyodide communication.
141
+ *
142
+ * This transport extends BoundedContext for lifecycle management and
143
+ * implements the Transport interface for message-based communication.
144
+ *
145
+ * @example
146
+ * ```typescript
147
+ * const transport = new PyodideIO({ packages: ['numpy'] });
148
+ * await transport.init();
149
+ *
150
+ * const response = await transport.send(
151
+ * JSON.stringify({
152
+ * id: '1',
153
+ * type: 'call',
154
+ * module: 'math',
155
+ * functionName: 'sqrt',
156
+ * args: [16]
157
+ * }),
158
+ * 5000
159
+ * );
160
+ *
161
+ * await transport.dispose();
162
+ * ```
163
+ */
164
+ export class PyodideIO extends BoundedContext implements Transport {
165
+ private readonly indexURL: string;
166
+ private readonly packages: readonly string[];
167
+ private requestId = 0;
168
+ private py?: PyodideInstance;
169
+
170
+ /**
171
+ * Create a new PyodideIO transport.
172
+ *
173
+ * @param options - Configuration options
174
+ */
175
+ constructor(options: PyodideIOOptions = {}) {
176
+ super();
177
+ this.indexURL = options.indexURL ?? DEFAULT_INDEX_URL;
178
+ this.packages = Object.freeze([...(options.packages ?? [])]);
179
+ }
180
+
181
+ // ===========================================================================
182
+ // LIFECYCLE (BoundedContext implementation)
183
+ // ===========================================================================
184
+
185
+ /**
186
+ * Initialize the Pyodide runtime.
187
+ *
188
+ * This method:
189
+ * 1. Resolves the loadPyodide function (global or dynamic import)
190
+ * 2. Loads Pyodide with the configured indexURL
191
+ * 3. Loads any requested Python packages
192
+ * 4. Bootstraps the dispatch helper function
193
+ *
194
+ * @throws BridgeProtocolError if Pyodide is not available
195
+ */
196
+ protected async doInit(): Promise<void> {
197
+ const loadPyodideFn = await this.resolveLoadPyodide();
198
+ if (!loadPyodideFn) {
199
+ throw new BridgeProtocolError(
200
+ 'Pyodide is not available in this environment. ' +
201
+ 'Include the Pyodide script tag or install the pyodide package.'
202
+ );
203
+ }
204
+
205
+ this.py = await loadPyodideFn({ indexURL: this.indexURL });
206
+
207
+ if (this.packages.length > 0) {
208
+ await this.py.loadPackage([...this.packages]);
209
+ }
210
+
211
+ await this.bootstrapDispatcher();
212
+ }
213
+
214
+ /**
215
+ * Clean up Pyodide resources.
216
+ *
217
+ * Note: Pyodide does not have an explicit dispose mechanism.
218
+ * We clear our reference and rely on garbage collection.
219
+ */
220
+ protected async doDispose(): Promise<void> {
221
+ this.py = undefined;
222
+ }
223
+
224
+ // ===========================================================================
225
+ // TRANSPORT INTERFACE
226
+ // ===========================================================================
227
+
228
+ /**
229
+ * Send a protocol message to Pyodide and wait for the response.
230
+ *
231
+ * This method:
232
+ * 1. Validates the transport is ready
233
+ * 2. Calls the Python dispatch function with the JSON message
234
+ * 3. Returns the JSON response string
235
+ *
236
+ * The timeout and abort signal are respected, though in-memory calls
237
+ * are typically fast enough that timeouts rarely trigger.
238
+ *
239
+ * @param message - JSON-encoded ProtocolMessage
240
+ * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
241
+ * @param signal - Optional AbortSignal for cancellation
242
+ * @returns JSON-encoded ProtocolResponse
243
+ *
244
+ * @throws BridgeDisposedError if transport is disposed
245
+ * @throws BridgeProtocolError if message is invalid or Pyodide not ready
246
+ * @throws BridgeTimeoutError if operation times out or is aborted
247
+ * @throws BridgeExecutionError for Python execution errors
248
+ */
249
+ async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
250
+ return this.execute(
251
+ async () => {
252
+ if (!this.py) {
253
+ throw new BridgeProtocolError('Pyodide not initialized');
254
+ }
255
+
256
+ // Validate message is parseable JSON
257
+ let parsed: ProtocolMessage;
258
+ try {
259
+ parsed = JSON.parse(message) as ProtocolMessage;
260
+ } catch (e) {
261
+ throw new BridgeProtocolError(`Invalid JSON message: ${(e as Error).message}`);
262
+ }
263
+
264
+ // Validate required fields
265
+ if (!parsed.id || !parsed.method) {
266
+ throw new BridgeProtocolError('Message missing required fields: id, method');
267
+ }
268
+
269
+ // Get the dispatch function
270
+ const dispatchFn = this.py.globals.get('__tywrap_dispatch');
271
+ if (!dispatchFn) {
272
+ throw new BridgeProtocolError('Pyodide dispatch function not initialized');
273
+ }
274
+
275
+ try {
276
+ // Call the dispatch function
277
+ const invoke = dispatchFn as (messageJson: string) => string;
278
+ const responseJson = invoke(message);
279
+
280
+ // Validate response is valid JSON
281
+ try {
282
+ const response = JSON.parse(responseJson) as ProtocolResponse;
283
+ if (response.error) {
284
+ // Return the response as-is; let the caller handle the error
285
+ return responseJson;
286
+ }
287
+ return responseJson;
288
+ } catch {
289
+ throw new BridgeProtocolError(`Invalid JSON response from Python: ${responseJson}`);
290
+ }
291
+ } finally {
292
+ // Clean up the proxy
293
+ this.destroyPyProxy(dispatchFn);
294
+ }
295
+ },
296
+ { timeoutMs, signal }
297
+ );
298
+ }
299
+
300
+ // ===========================================================================
301
+ // RUNTIME EXECUTION (BoundedContext abstract methods)
302
+ // ===========================================================================
303
+
304
+ /**
305
+ * Call a Python function.
306
+ *
307
+ * Convenience method that constructs a 'call' message and sends it.
308
+ */
309
+ async call<T = unknown>(
310
+ module: string,
311
+ functionName: string,
312
+ args: unknown[],
313
+ kwargs?: Record<string, unknown>
314
+ ): Promise<T> {
315
+ const message: ProtocolMessage = {
316
+ id: this.generateId(),
317
+ protocol: PROTOCOL_ID,
318
+ method: 'call',
319
+ params: {
320
+ module,
321
+ functionName,
322
+ args: args ?? [],
323
+ kwargs,
324
+ },
325
+ };
326
+
327
+ const responseJson = await this.send(JSON.stringify(message), 30000);
328
+ return this.parseResponse<T>(responseJson);
329
+ }
330
+
331
+ /**
332
+ * Instantiate a Python class.
333
+ *
334
+ * Convenience method that constructs an 'instantiate' message and sends it.
335
+ */
336
+ async instantiate<T = unknown>(
337
+ module: string,
338
+ className: string,
339
+ args: unknown[],
340
+ kwargs?: Record<string, unknown>
341
+ ): Promise<T> {
342
+ const message: ProtocolMessage = {
343
+ id: this.generateId(),
344
+ protocol: PROTOCOL_ID,
345
+ method: 'instantiate',
346
+ params: {
347
+ module,
348
+ className,
349
+ args: args ?? [],
350
+ kwargs,
351
+ },
352
+ };
353
+
354
+ const responseJson = await this.send(JSON.stringify(message), 30000);
355
+ return this.parseResponse<T>(responseJson);
356
+ }
357
+
358
+ /**
359
+ * Call a method on a Python instance.
360
+ *
361
+ * Convenience method that constructs a 'call_method' message and sends it.
362
+ */
363
+ async callMethod<T = unknown>(
364
+ handle: string,
365
+ methodName: string,
366
+ args: unknown[],
367
+ kwargs?: Record<string, unknown>
368
+ ): Promise<T> {
369
+ const message: ProtocolMessage = {
370
+ id: this.generateId(),
371
+ protocol: PROTOCOL_ID,
372
+ method: 'call_method',
373
+ params: {
374
+ handle,
375
+ methodName,
376
+ args: args ?? [],
377
+ kwargs,
378
+ },
379
+ };
380
+
381
+ const responseJson = await this.send(JSON.stringify(message), 30000);
382
+ return this.parseResponse<T>(responseJson);
383
+ }
384
+
385
+ /**
386
+ * Dispose a Python instance.
387
+ *
388
+ * Convenience method that constructs a 'dispose_instance' message and sends it.
389
+ */
390
+ async disposeInstance(handle: string): Promise<void> {
391
+ const message: ProtocolMessage = {
392
+ id: this.generateId(),
393
+ protocol: PROTOCOL_ID,
394
+ method: 'dispose_instance',
395
+ params: {
396
+ handle,
397
+ },
398
+ };
399
+
400
+ const responseJson = await this.send(JSON.stringify(message), 30000);
401
+ this.parseResponse<void>(responseJson);
402
+ }
403
+
404
+ // ===========================================================================
405
+ // PRIVATE HELPERS
406
+ // ===========================================================================
407
+
408
+ /**
409
+ * Resolve the loadPyodide function.
410
+ *
411
+ * First checks for a global loadPyodide (browser script tag include),
412
+ * then falls back to dynamic import of the 'pyodide' module.
413
+ */
414
+ private async resolveLoadPyodide(): Promise<LoadPyodide | undefined> {
415
+ // Check for global loadPyodide (browser script tag)
416
+ const g = globalThis as unknown as { loadPyodide?: LoadPyodide };
417
+ if (typeof g.loadPyodide === 'function') {
418
+ return g.loadPyodide;
419
+ }
420
+
421
+ // Try dynamic import
422
+ try {
423
+ const mod = (await import('pyodide')) as unknown as { loadPyodide?: LoadPyodide };
424
+ if (typeof mod.loadPyodide === 'function') {
425
+ return mod.loadPyodide;
426
+ }
427
+ } catch {
428
+ // Pyodide module not available
429
+ // This is expected in most environments
430
+ }
431
+
432
+ return undefined;
433
+ }
434
+
435
+ /**
436
+ * Bootstrap the Python dispatch function.
437
+ */
438
+ private async bootstrapDispatcher(): Promise<void> {
439
+ if (!this.py) {
440
+ return;
441
+ }
442
+ await this.py.runPythonAsync(BOOTSTRAP_PYTHON);
443
+ }
444
+
445
+ /**
446
+ * Parse a JSON response and extract the result or throw an error.
447
+ */
448
+ private parseResponse<T>(responseJson: string): T {
449
+ const response = JSON.parse(responseJson) as ProtocolResponse;
450
+
451
+ if (response.error) {
452
+ const err = new BridgeExecutionError(
453
+ `${response.error.type}: ${response.error.message}`,
454
+ { code: response.error.type }
455
+ );
456
+ err.traceback = response.error.traceback;
457
+ throw err;
458
+ }
459
+
460
+ return response.result as T;
461
+ }
462
+
463
+ /**
464
+ * Generate a unique message ID.
465
+ */
466
+ private generateId(): number {
467
+ return ++this.requestId;
468
+ }
469
+
470
+ /**
471
+ * Safely destroy a Pyodide proxy object.
472
+ *
473
+ * Pyodide proxy objects must be explicitly destroyed to prevent memory leaks.
474
+ * This method checks if the value has a destroy method and calls it.
475
+ */
476
+ private destroyPyProxy(value: unknown): void {
477
+ if (value && typeof (value as { destroy?: () => void }).destroy === 'function') {
478
+ try {
479
+ (value as { destroy: () => void }).destroy();
480
+ } catch {
481
+ // Ignore cleanup failures
482
+ }
483
+ }
484
+ }
485
+ }