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
@@ -1,5 +1,10 @@
1
1
  /**
2
- * Node.js runtime bridge (minimal MVP)
2
+ * Node.js runtime bridge for BridgeProtocol.
3
+ *
4
+ * NodeBridge extends BridgeProtocol and uses ProcessIO transports with
5
+ * optional pooling for concurrent Python execution.
6
+ *
7
+ * @see https://github.com/bbopen/tywrap/issues/149
3
8
  */
4
9
 
5
10
  import { existsSync } from 'node:fs';
@@ -7,55 +12,127 @@ import { delimiter, isAbsolute, join, resolve } from 'node:path';
7
12
  import { fileURLToPath } from 'node:url';
8
13
  import { createRequire } from 'node:module';
9
14
 
10
- import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
15
+ import { autoRegisterArrowDecoder } from '../utils/codec.js';
11
16
  import { getDefaultPythonPath } from '../utils/python.js';
12
17
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
13
- import type { BridgeInfo } from '../types/index.js';
14
-
15
- import { RuntimeBridge } from './base.js';
16
- import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
17
- import {
18
- BridgeCore,
19
- type RpcRequest,
20
- ensureJsonFallback,
21
- ensurePythonEncoding,
22
- getMaxLineLengthFromEnv,
23
- getPathKey,
24
- normalizeEnv,
25
- validateBridgeInfo,
26
- } from './bridge-core.js';
18
+ import { globalCache } from '../utils/cache.js';
27
19
 
20
+ import { BridgeProtocol, type BridgeProtocolOptions } from './bridge-protocol.js';
21
+ import { BridgeProtocolError } from './errors.js';
22
+ import { ProcessIO } from './process-io.js';
23
+ import { PooledTransport } from './pooled-transport.js';
24
+ import type { CodecOptions } from './safe-codec.js';
25
+ import type { PooledWorker } from './worker-pool.js';
26
+
27
+ // =============================================================================
28
+ // OPTIONS
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Configuration options for NodeBridge.
33
+ */
28
34
  export interface NodeBridgeOptions {
35
+ /** Minimum number of Python processes to keep alive. Default: 1 */
36
+ minProcesses?: number;
37
+
38
+ /** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
39
+ maxProcesses?: number;
40
+
41
+ /** Maximum concurrent requests per process. Default: 10 */
42
+ maxConcurrentPerProcess?: number;
43
+
44
+ /** Path to Python executable. Auto-detected if not specified. */
29
45
  pythonPath?: string;
30
- scriptPath?: string; // path to python_bridge.py
46
+
47
+ /** Path to python_bridge.py script. Auto-detected if not specified. */
48
+ scriptPath?: string;
49
+
50
+ /** Path to Python virtual environment. */
31
51
  virtualEnv?: string;
52
+
53
+ /** Working directory for Python process. Default: process.cwd() */
32
54
  cwd?: string;
55
+
56
+ /** Timeout in ms for Python calls. Default: 30000 */
33
57
  timeoutMs?: number;
34
- maxLineLength?: number;
58
+
59
+ /** Timeout in ms for waiting in pool queue. Default: 30000 */
60
+ queueTimeoutMs?: number;
61
+
62
+ /** Inherit all environment variables from parent process. Default: false */
35
63
  inheritProcessEnv?: boolean;
64
+
65
+ /** Enable result caching for pure functions. Default: false */
66
+ enableCache?: boolean;
67
+
68
+ /** Optional extra environment variables to pass to the Python subprocess. */
69
+ env?: Record<string, string | undefined>;
70
+
71
+ /** Codec options for validation/serialization */
72
+ codec?: CodecOptions;
73
+
74
+ /** Commands to run on each process at startup for warming up. */
75
+ warmupCommands?: Array<
76
+ | { module: string; functionName: string; args?: unknown[] }
77
+ | { method: string; params: unknown } // Legacy format for backwards compatibility
78
+ >;
79
+
80
+ // ===========================================================================
81
+ // DEPRECATED OPTIONS (kept for backwards compatibility, ignored internally)
82
+ // ===========================================================================
83
+
84
+ /**
85
+ * @deprecated No longer used. Pool idle time is managed by WorkerPool.
86
+ */
87
+ maxIdleTime?: number;
88
+
89
+ /**
90
+ * @deprecated No longer used. Process restart is managed by ProcessIO.
91
+ */
92
+ maxRequestsPerProcess?: number;
93
+
36
94
  /**
37
- * When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
38
- * for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
95
+ * @deprecated Use codec.bytesHandling option instead.
39
96
  */
40
97
  enableJsonFallback?: boolean;
98
+
41
99
  /**
42
- * Optional extra environment variables to pass to the Python subprocess.
100
+ * @deprecated Use ProcessIO options instead.
43
101
  */
44
- env?: Record<string, string | undefined>;
102
+ maxLineLength?: number;
45
103
  }
46
104
 
47
- interface ResolvedNodeBridgeOptions {
105
+ // =============================================================================
106
+ // INTERNAL TYPES
107
+ // =============================================================================
108
+
109
+ interface ResolvedOptions {
110
+ minProcesses: number;
111
+ maxProcesses: number;
112
+ maxConcurrentPerProcess: number;
48
113
  pythonPath: string;
49
114
  scriptPath: string;
50
115
  virtualEnv?: string;
51
116
  cwd: string;
52
117
  timeoutMs: number;
53
- maxLineLength?: number;
118
+ queueTimeoutMs: number;
54
119
  inheritProcessEnv: boolean;
55
- enableJsonFallback: boolean;
120
+ enableCache: boolean;
56
121
  env: Record<string, string | undefined>;
122
+ codec?: CodecOptions;
123
+ warmupCommands: Array<
124
+ | { module: string; functionName: string; args?: unknown[] }
125
+ | { method: string; params: unknown }
126
+ >;
57
127
  }
58
128
 
129
+ // =============================================================================
130
+ // UTILITIES
131
+ // =============================================================================
132
+
133
+ /**
134
+ * Resolve the default bridge script path.
135
+ */
59
136
  function resolveDefaultScriptPath(): string {
60
137
  try {
61
138
  return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
@@ -64,261 +141,427 @@ function resolveDefaultScriptPath(): string {
64
141
  }
65
142
  }
66
143
 
144
+ /**
145
+ * Resolve virtual environment paths.
146
+ */
67
147
  function resolveVirtualEnv(
68
148
  virtualEnv: string,
69
149
  cwd: string
70
- ): {
71
- venvPath: string;
72
- binDir: string;
73
- pythonPath: string;
74
- } {
150
+ ): { venvPath: string; binDir: string; pythonPath: string } {
75
151
  const venvPath = resolve(cwd, virtualEnv);
76
152
  const binDir = join(venvPath, getVenvBinDir());
77
153
  const pythonPath = join(binDir, getVenvPythonExe());
78
154
  return { venvPath, binDir, pythonPath };
79
155
  }
80
156
 
81
- export class NodeBridge extends RuntimeBridge {
82
- private child?: import('child_process').ChildProcess;
83
- private core?: BridgeCore;
84
- private readonly options: ResolvedNodeBridgeOptions;
85
- private disposed = false;
86
- private initPromise?: Promise<void>;
87
- private bridgeInfo?: BridgeInfo;
157
+ /**
158
+ * Get the environment variable key for PATH (case-insensitive on Windows).
159
+ */
160
+ function getPathKey(env: Record<string, string | undefined>): string {
161
+ for (const key of Object.keys(env)) {
162
+ if (key.toLowerCase() === 'path') {
163
+ return key;
164
+ }
165
+ }
166
+ return 'PATH';
167
+ }
168
+
169
+ // =============================================================================
170
+ // NODE BRIDGE
171
+ // =============================================================================
88
172
 
173
+ /**
174
+ * Node.js runtime bridge for executing Python code.
175
+ *
176
+ * NodeBridge provides subprocess-based Python execution with optional pooling
177
+ * for high-throughput workloads. By default, it runs in single-process mode.
178
+ *
179
+ * Features:
180
+ * - Single or multi-process execution via process pooling
181
+ * - Virtual environment support
182
+ * - Full SafeCodec validation (NaN/Infinity rejection, key validation)
183
+ * - Automatic Arrow decoding for DataFrames/ndarrays
184
+ * - Optional result caching for pure functions
185
+ * - Process warmup commands
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * // Single-process mode (default)
190
+ * const bridge = new NodeBridge();
191
+ * await bridge.init();
192
+ *
193
+ * const result = await bridge.call('math', 'sqrt', [16]);
194
+ * console.log(result); // 4.0
195
+ *
196
+ * await bridge.dispose();
197
+ * ```
198
+ *
199
+ * @example
200
+ * ```typescript
201
+ * // Multi-process pooling for high throughput
202
+ * const pooledBridge = new NodeBridge({
203
+ * maxProcesses: 4,
204
+ * maxConcurrentPerProcess: 2,
205
+ * enableCache: true,
206
+ * });
207
+ * await pooledBridge.init();
208
+ * ```
209
+ */
210
+ export class NodeBridge extends BridgeProtocol {
211
+ private readonly resolvedOptions: ResolvedOptions;
212
+ private readonly pooledTransport: PooledTransport;
213
+
214
+ /**
215
+ * Create a new NodeBridge instance.
216
+ *
217
+ * @param options - Configuration options for the bridge
218
+ */
89
219
  constructor(options: NodeBridgeOptions = {}) {
90
- super();
91
220
  const cwd = options.cwd ?? process.cwd();
92
221
  const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
93
222
  const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
94
223
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
95
224
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
96
- this.options = {
225
+
226
+ const maxProcesses = options.maxProcesses ?? 1;
227
+ const minProcesses = Math.min(options.minProcesses ?? 1, maxProcesses);
228
+
229
+ const resolvedOptions: ResolvedOptions = {
230
+ minProcesses,
231
+ maxProcesses,
232
+ maxConcurrentPerProcess: options.maxConcurrentPerProcess ?? 10,
97
233
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
98
234
  scriptPath: resolvedScriptPath,
99
235
  virtualEnv,
100
236
  cwd,
101
237
  timeoutMs: options.timeoutMs ?? 30000,
102
- maxLineLength: options.maxLineLength,
238
+ queueTimeoutMs: options.queueTimeoutMs ?? 30000,
103
239
  inheritProcessEnv: options.inheritProcessEnv ?? false,
104
- enableJsonFallback: options.enableJsonFallback ?? false,
240
+ enableCache: options.enableCache ?? false,
105
241
  env: options.env ?? {},
242
+ codec: options.codec,
243
+ warmupCommands: options.warmupCommands ?? [],
244
+ };
245
+
246
+ // Build environment for ProcessIO
247
+ const processEnv = buildProcessEnv(resolvedOptions);
248
+
249
+ // Create warmup callback for per-worker initialization
250
+ const onWorkerReady = resolvedOptions.warmupCommands.length > 0
251
+ ? createWarmupCallback(resolvedOptions.warmupCommands, resolvedOptions.timeoutMs)
252
+ : undefined;
253
+
254
+ // Create pooled transport with ProcessIO workers
255
+ const transport = new PooledTransport({
256
+ createTransport: () =>
257
+ new ProcessIO({
258
+ pythonPath: resolvedOptions.pythonPath,
259
+ bridgeScript: resolvedOptions.scriptPath,
260
+ env: processEnv,
261
+ cwd: resolvedOptions.cwd,
262
+ }),
263
+ maxWorkers: resolvedOptions.maxProcesses,
264
+ minWorkers: resolvedOptions.minProcesses,
265
+ queueTimeoutMs: resolvedOptions.queueTimeoutMs,
266
+ maxConcurrentPerWorker: resolvedOptions.maxConcurrentPerProcess,
267
+ onWorkerReady,
268
+ });
269
+
270
+ // Initialize BridgeProtocol with pooled transport
271
+ const protocolOptions: BridgeProtocolOptions = {
272
+ transport,
273
+ codec: resolvedOptions.codec,
274
+ defaultTimeoutMs: resolvedOptions.timeoutMs,
106
275
  };
276
+
277
+ super(protocolOptions);
278
+
279
+ this.resolvedOptions = resolvedOptions;
280
+ this.pooledTransport = transport;
107
281
  }
108
282
 
109
- async init(): Promise<void> {
110
- if (this.disposed) {
111
- throw new BridgeDisposedError('Bridge has been disposed');
112
- }
113
- if (this.child) {
114
- return;
115
- }
116
- if (this.initPromise) {
117
- return this.initPromise;
118
- }
283
+ // ===========================================================================
284
+ // LIFECYCLE
285
+ // ===========================================================================
286
+
287
+ /**
288
+ * Initialize the bridge.
289
+ *
290
+ * Validates the bridge script exists, registers Arrow decoder,
291
+ * and initializes the transport pool (which runs warmup commands per-worker).
292
+ */
293
+ protected override async doInit(): Promise<void> {
294
+ // Validate script exists
119
295
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
120
- if (!existsSync(this.options.scriptPath)) {
121
- throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
296
+ if (!existsSync(this.resolvedOptions.scriptPath)) {
297
+ throw new BridgeProtocolError(
298
+ `Python bridge script not found at ${this.resolvedOptions.scriptPath}`
299
+ );
122
300
  }
123
- this.initPromise = this.startProcess();
124
- return this.initPromise;
125
- }
126
301
 
127
- async getBridgeInfo(options: { refresh?: boolean } = {}): Promise<BridgeInfo> {
128
- await this.init();
129
- if (!this.bridgeInfo || options.refresh) {
130
- await this.refreshBridgeInfo();
131
- }
132
- if (!this.bridgeInfo) {
133
- throw new BridgeProtocolError('Bridge info unavailable');
134
- }
135
- return this.bridgeInfo;
136
- }
302
+ // Register Arrow decoder for DataFrames/ndarrays
303
+ const require = createRequire(import.meta.url);
304
+ await autoRegisterArrowDecoder({
305
+ loader: () => require('apache-arrow'),
306
+ });
137
307
 
138
- async call<T = unknown>(
139
- module: string,
140
- functionName: string,
141
- args: unknown[],
142
- kwargs?: Record<string, unknown>
143
- ): Promise<T> {
144
- await this.init();
145
- return this.send<T>({ method: 'call', params: { module, functionName, args, kwargs } });
308
+ // Initialize parent (which initializes transport and runs warmup per-worker)
309
+ await super.doInit();
146
310
  }
147
311
 
148
- async instantiate<T = unknown>(
149
- module: string,
150
- className: string,
151
- args: unknown[],
152
- kwargs?: Record<string, unknown>
153
- ): Promise<T> {
154
- await this.init();
155
- return this.send<T>({ method: 'instantiate', params: { module, className, args, kwargs } });
156
- }
312
+ // ===========================================================================
313
+ // CACHING OVERRIDE
314
+ // ===========================================================================
157
315
 
158
- async callMethod<T = unknown>(
159
- handle: string,
160
- methodName: string,
316
+ /**
317
+ * Override call() to add optional caching.
318
+ */
319
+ override async call<T = unknown>(
320
+ module: string,
321
+ functionName: string,
161
322
  args: unknown[],
162
323
  kwargs?: Record<string, unknown>
163
324
  ): Promise<T> {
164
- await this.init();
165
- return this.send<T>({ method: 'call_method', params: { handle, methodName, args, kwargs } });
166
- }
167
-
168
- async disposeInstance(handle: string): Promise<void> {
169
- await this.init();
170
- await this.send<void>({ method: 'dispose_instance', params: { handle } });
171
- }
172
-
173
- async dispose(): Promise<void> {
174
- this.disposed = true;
175
- this.core?.handleProcessExit();
176
- this.resetProcess();
177
- }
178
-
179
- private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
180
- if (this.disposed) {
181
- throw new BridgeDisposedError('Bridge has been disposed');
182
- }
183
- if (!this.core) {
184
- throw new BridgeProtocolError('Python process not available');
185
- }
186
- return this.core.send<T>(payload);
187
- }
188
-
189
- private async startProcess(): Promise<void> {
190
- try {
191
- const require = createRequire(import.meta.url);
192
- await autoRegisterArrowDecoder({
193
- loader: () => require('apache-arrow'),
194
- });
195
- const { spawn } = await import('child_process');
325
+ // Check cache if enabled
326
+ if (this.resolvedOptions.enableCache) {
327
+ const cacheKey = this.safeCacheKey('runtime_call', module, functionName, args, kwargs);
328
+ if (cacheKey) {
329
+ const cached = await globalCache.get<T>(cacheKey);
330
+ if (cached !== null) {
331
+ return cached;
332
+ }
333
+ }
196
334
 
197
- const env = this.buildEnv();
198
- const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
335
+ // Execute and cache if pure function
336
+ const startTime = performance.now();
337
+ const result = await super.call<T>(module, functionName, args, kwargs);
338
+ const duration = performance.now() - startTime;
199
339
 
200
- let child: ReturnType<typeof spawn>;
201
- try {
202
- child = spawn(this.options.pythonPath, [this.options.scriptPath], {
203
- cwd: this.options.cwd,
204
- stdio: ['pipe', 'pipe', 'pipe'],
205
- env,
340
+ if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
341
+ await globalCache.set(cacheKey, result, {
342
+ computeTime: duration,
343
+ dependencies: [module],
206
344
  });
207
- } catch (err) {
208
- throw new BridgeProtocolError(`Failed to start Python process: ${(err as Error).message}`);
209
345
  }
210
346
 
211
- const startupError = await new Promise<Error | null>(done => {
212
- child.once('error', e => done(e));
213
- child.once('spawn', () => done(null));
214
- });
215
- if (startupError) {
216
- throw new BridgeProtocolError(`Failed to start Python process: ${startupError.message}`);
217
- }
347
+ return result;
348
+ }
218
349
 
219
- this.child = child;
220
- this.core = new BridgeCore(
221
- {
222
- write: (data: string): void => {
223
- if (!this.child?.stdin) {
224
- throw new BridgeProtocolError('Python process not available');
225
- }
226
- this.child.stdin.write(data);
227
- },
228
- },
229
- {
230
- timeoutMs: this.options.timeoutMs,
231
- maxLineLength,
232
- decodeValue: decodeValueAsync,
233
- onFatalError: (): void => this.resetProcess(),
234
- }
235
- );
350
+ // No caching - direct call
351
+ return super.call<T>(module, functionName, args, kwargs);
352
+ }
236
353
 
237
- this.child.stdout?.on('data', chunk => {
238
- this.core?.handleStdoutData(chunk);
239
- });
354
+ // ===========================================================================
355
+ // POOL STATISTICS
356
+ // ===========================================================================
240
357
 
241
- this.child.stderr?.on('data', chunk => {
242
- this.core?.handleStderrData(chunk);
243
- });
358
+ /**
359
+ * Get current pool statistics.
360
+ */
361
+ getPoolStats(): { workerCount: number; queueLength: number; totalInFlight: number } {
362
+ return {
363
+ workerCount: this.pooledTransport.workerCount,
364
+ queueLength: this.pooledTransport.queueLength,
365
+ totalInFlight: this.pooledTransport.totalInFlight,
366
+ };
367
+ }
244
368
 
245
- this.child.on('error', err => {
246
- this.core?.handleProcessError(err);
247
- });
369
+ /**
370
+ * Get bridge statistics.
371
+ *
372
+ * @deprecated Use getPoolStats() instead. This method is provided for
373
+ * backwards compatibility and returns a subset of the previous stats.
374
+ */
375
+ getStats(): {
376
+ totalRequests: number;
377
+ totalTime: number;
378
+ cacheHits: number;
379
+ poolHits: number;
380
+ poolMisses: number;
381
+ processSpawns: number;
382
+ processDeaths: number;
383
+ memoryPeak: number;
384
+ averageTime: number;
385
+ cacheHitRate: number;
386
+ poolSize: number;
387
+ busyWorkers: number;
388
+ } {
389
+ const poolStats = this.getPoolStats();
390
+ return {
391
+ // Legacy stats (no longer tracked, return 0)
392
+ totalRequests: 0,
393
+ totalTime: 0,
394
+ cacheHits: 0,
395
+ poolHits: 0,
396
+ poolMisses: 0,
397
+ processSpawns: 0,
398
+ processDeaths: 0,
399
+ memoryPeak: 0,
400
+ averageTime: 0,
401
+ cacheHitRate: 0,
402
+ // Current pool stats
403
+ poolSize: poolStats.workerCount,
404
+ busyWorkers: poolStats.totalInFlight,
405
+ };
406
+ }
248
407
 
249
- this.child.on('exit', () => {
250
- this.core?.handleProcessExit();
251
- this.resetProcess();
252
- });
408
+ // ===========================================================================
409
+ // PRIVATE HELPERS
410
+ // ===========================================================================
253
411
 
254
- await this.refreshBridgeInfo();
255
- } catch (err) {
256
- this.resetProcess();
257
- throw err;
412
+ /**
413
+ * Generate a cache key, returning null if generation fails.
414
+ */
415
+ private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
416
+ try {
417
+ return globalCache.generateKey(prefix, ...inputs);
418
+ } catch {
419
+ return null;
258
420
  }
259
421
  }
260
422
 
261
- private buildEnv(): NodeJS.ProcessEnv {
262
- const allowedPrefixes = ['TYWRAP_'];
263
- const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
264
- const baseEnv: Record<string, string | undefined> = {};
265
- if (this.options.inheritProcessEnv) {
266
- for (const [key, value] of Object.entries(process.env)) {
267
- // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
268
- baseEnv[key] = value;
269
- }
270
- } else {
271
- for (const [key, value] of Object.entries(process.env)) {
272
- if (
273
- allowedKeys.has(key.toLowerCase()) ||
274
- allowedPrefixes.some(prefix => key.startsWith(prefix))
275
- ) {
276
- // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
277
- baseEnv[key] = value;
278
- }
279
- }
423
+ /**
424
+ * Heuristic to determine if function result should be cached.
425
+ */
426
+ private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
427
+ const pureFunctionPatterns = [
428
+ /^(get|fetch|read|load|find|search|query|select)_/i,
429
+ /^(compute|calculate|process|transform|convert)_/i,
430
+ /^(encode|decode|serialize|deserialize)_/i,
431
+ ];
432
+
433
+ const impureFunctionPatterns = [
434
+ /^(set|save|write|update|insert|delete|create|modify)_/i,
435
+ /^(send|post|put|patch)_/i,
436
+ /random|uuid|timestamp|now|current/i,
437
+ ];
438
+
439
+ if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
440
+ return false;
280
441
  }
281
442
 
282
- let env = normalizeEnv(baseEnv, this.options.env);
283
-
284
- if (this.options.virtualEnv) {
285
- const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
286
- env.VIRTUAL_ENV = venv.venvPath;
287
- const pathKey = getPathKey(env);
288
- // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
289
- const currentPath = env[pathKey] ?? '';
290
- // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
291
- env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
443
+ if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
444
+ return true;
292
445
  }
293
446
 
294
- ensurePythonEncoding(env);
295
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
296
- ensureJsonFallback(env, this.options.enableJsonFallback);
447
+ const hasComplexArgs = args.some(
448
+ arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
449
+ );
297
450
 
298
- env = normalizeEnv(env, {});
299
- return env;
451
+ return !hasComplexArgs && args.length <= 3;
300
452
  }
453
+ }
301
454
 
302
- private resetProcess(): void {
303
- this.core?.clear();
304
- this.core = undefined;
305
- if (this.child) {
455
+ // =============================================================================
456
+ // WARMUP CALLBACK
457
+ // =============================================================================
458
+
459
+ /**
460
+ * Simple request ID generator for warmup commands.
461
+ */
462
+ let warmupRequestId = 0;
463
+ function generateWarmupId(): string {
464
+ return `warmup_${++warmupRequestId}_${Date.now()}`;
465
+ }
466
+
467
+ /**
468
+ * Create a callback that runs warmup commands on each worker.
469
+ *
470
+ * The callback sends warmup commands directly to the worker's transport,
471
+ * bypassing the pool to ensure each worker gets warmed up individually.
472
+ */
473
+ function createWarmupCallback(
474
+ warmupCommands: Array<
475
+ | { module: string; functionName: string; args?: unknown[] }
476
+ | { method: string; params: unknown }
477
+ >,
478
+ timeoutMs: number
479
+ ): (worker: PooledWorker) => Promise<void> {
480
+ return async (worker: PooledWorker) => {
481
+ for (const cmd of warmupCommands) {
306
482
  try {
307
- if (this.child.exitCode === null) {
308
- this.child.kill('SIGTERM');
483
+ // Handle both new and legacy warmup command formats
484
+ if ('module' in cmd && 'functionName' in cmd) {
485
+ // Build the protocol message
486
+ const message = JSON.stringify({
487
+ id: generateWarmupId(),
488
+ type: 'call',
489
+ module: cmd.module,
490
+ functionName: cmd.functionName,
491
+ args: cmd.args ?? [],
492
+ kwargs: {},
493
+ });
494
+
495
+ // Send directly to this worker's transport
496
+ await worker.transport.send(message, timeoutMs);
309
497
  }
498
+ // Legacy format { method, params } is ignored as it's not supported
310
499
  } catch {
311
- // ignore
500
+ // Ignore warmup errors - they're not critical
312
501
  }
313
502
  }
314
- this.child = undefined;
315
- this.initPromise = undefined;
316
- this.bridgeInfo = undefined;
503
+ };
504
+ }
505
+
506
+ // =============================================================================
507
+ // ENVIRONMENT BUILDING
508
+ // =============================================================================
509
+
510
+ /**
511
+ * Build environment variables for ProcessIO.
512
+ */
513
+ function buildProcessEnv(options: ResolvedOptions): Record<string, string> {
514
+ const allowedPrefixes = ['TYWRAP_'];
515
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
516
+ const env: Record<string, string> = {};
517
+
518
+ // Copy allowed env vars from process.env
519
+ if (options.inheritProcessEnv) {
520
+ for (const [key, value] of Object.entries(process.env)) {
521
+ if (value !== undefined) {
522
+ env[key] = value;
523
+ }
524
+ }
525
+ } else {
526
+ for (const [key, value] of Object.entries(process.env)) {
527
+ if (
528
+ value !== undefined &&
529
+ (allowedKeys.has(key.toLowerCase()) || allowedPrefixes.some(p => key.startsWith(p)))
530
+ ) {
531
+ env[key] = value;
532
+ }
533
+ }
534
+ }
535
+
536
+ // Apply user overrides
537
+ for (const [key, value] of Object.entries(options.env)) {
538
+ if (value !== undefined) {
539
+ env[key] = value;
540
+ }
317
541
  }
318
542
 
319
- private async refreshBridgeInfo(): Promise<void> {
320
- const info = await this.send<BridgeInfo>({ method: 'meta', params: {} });
321
- validateBridgeInfo(info);
322
- this.bridgeInfo = info;
543
+ // Configure virtual environment
544
+ if (options.virtualEnv) {
545
+ const venv = resolveVirtualEnv(options.virtualEnv, options.cwd);
546
+ env.VIRTUAL_ENV = venv.venvPath;
547
+ const pathKey = getPathKey(env);
548
+ const currentPath = env[pathKey] ?? '';
549
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
323
550
  }
551
+
552
+ // Add cwd to PYTHONPATH so Python can find modules in the working directory
553
+ if (options.cwd) {
554
+ const currentPythonPath = env.PYTHONPATH ?? '';
555
+ env.PYTHONPATH = currentPythonPath
556
+ ? `${options.cwd}${delimiter}${currentPythonPath}`
557
+ : options.cwd;
558
+ }
559
+
560
+ // Ensure Python uses UTF-8
561
+ env.PYTHONUTF8 = '1';
562
+ env.PYTHONIOENCODING = 'UTF-8';
563
+ env.PYTHONUNBUFFERED = '1';
564
+ env.PYTHONDONTWRITEBYTECODE = '1';
565
+
566
+ return env;
324
567
  }