tywrap 0.2.0 → 0.3.0

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