tywrap 0.6.0 → 0.6.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 (56) hide show
  1. package/dist/core/annotation-parser.d.ts.map +1 -1
  2. package/dist/core/annotation-parser.js +19 -14
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/discovery.d.ts +17 -0
  5. package/dist/core/discovery.d.ts.map +1 -1
  6. package/dist/core/discovery.js +59 -49
  7. package/dist/core/discovery.js.map +1 -1
  8. package/dist/core/validation.d.ts +23 -0
  9. package/dist/core/validation.d.ts.map +1 -1
  10. package/dist/core/validation.js +52 -48
  11. package/dist/core/validation.js.map +1 -1
  12. package/dist/dev.d.ts.map +1 -1
  13. package/dist/dev.js +175 -133
  14. package/dist/dev.js.map +1 -1
  15. package/dist/runtime/bridge-codec.d.ts +15 -0
  16. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  17. package/dist/runtime/bridge-codec.js +45 -48
  18. package/dist/runtime/bridge-codec.js.map +1 -1
  19. package/dist/runtime/rpc-client.d.ts +18 -0
  20. package/dist/runtime/rpc-client.d.ts.map +1 -1
  21. package/dist/runtime/rpc-client.js +30 -24
  22. package/dist/runtime/rpc-client.js.map +1 -1
  23. package/dist/runtime/subprocess-transport.d.ts +16 -0
  24. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  25. package/dist/runtime/subprocess-transport.js +54 -35
  26. package/dist/runtime/subprocess-transport.js.map +1 -1
  27. package/dist/tywrap.d.ts +0 -5
  28. package/dist/tywrap.d.ts.map +1 -1
  29. package/dist/tywrap.js +0 -20
  30. package/dist/tywrap.js.map +1 -1
  31. package/dist/utils/cache.d.ts +11 -0
  32. package/dist/utils/cache.d.ts.map +1 -1
  33. package/dist/utils/cache.js +50 -58
  34. package/dist/utils/cache.js.map +1 -1
  35. package/dist/utils/python.d.ts.map +1 -1
  36. package/dist/utils/python.js +28 -17
  37. package/dist/utils/python.js.map +1 -1
  38. package/dist/utils/runtime.d.ts.map +1 -1
  39. package/dist/utils/runtime.js +22 -16
  40. package/dist/utils/runtime.js.map +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +1 -1
  43. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  44. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  45. package/src/core/annotation-parser.ts +23 -18
  46. package/src/core/discovery.ts +70 -54
  47. package/src/core/validation.ts +84 -48
  48. package/src/dev.ts +237 -153
  49. package/src/runtime/bridge-codec.ts +58 -70
  50. package/src/runtime/rpc-client.ts +39 -32
  51. package/src/runtime/subprocess-transport.ts +69 -39
  52. package/src/tywrap.ts +0 -24
  53. package/src/utils/cache.ts +59 -61
  54. package/src/utils/python.ts +33 -20
  55. package/src/utils/runtime.ts +24 -13
  56. package/src/version.ts +1 -1
@@ -281,26 +281,7 @@ export class RpcClient extends DisposableBase {
281
281
  message: Omit<ProtocolMessage, 'id' | 'protocol'>,
282
282
  options?: ExecuteOptions<T>
283
283
  ): Promise<T> {
284
- const fullMessage: ProtocolMessage = {
285
- ...message,
286
- id: this.generateId(),
287
- protocol: PROTOCOL_ID,
288
- };
289
-
290
- return this.execute(async () => {
291
- // 1. Encode request (validates args)
292
- const encoded = this.codec.encodeRequest(fullMessage);
293
-
294
- // 2. Send via transport
295
- const responseStr = await this.transport.send(
296
- encoded,
297
- options?.timeoutMs ?? this.defaultTimeoutMs,
298
- options?.signal
299
- );
300
-
301
- // 3. Decode response (validates result)
302
- return this.codec.decodeResponse<T>(responseStr);
303
- }, options);
284
+ return this.sendVia(message, options, responseStr => this.codec.decodeResponse<T>(responseStr));
304
285
  }
305
286
 
306
287
  /**
@@ -321,11 +302,27 @@ export class RpcClient extends DisposableBase {
321
302
  message: Omit<ProtocolMessage, 'id' | 'protocol'>,
322
303
  options?: ExecuteOptions<T>
323
304
  ): Promise<T> {
324
- const fullMessage: ProtocolMessage = {
325
- ...message,
326
- id: this.generateId(),
327
- protocol: PROTOCOL_ID,
328
- };
305
+ return this.sendVia(message, options, responseStr =>
306
+ this.codec.decodeResponseAsync<T>(responseStr)
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Shared body for sendMessage/sendMessageAsync: stamp the frame, run the
312
+ * encode -> transport.send -> decode pipeline inside this.execute() (which
313
+ * supplies auto-init, timeout/retry/abort), where the only difference between
314
+ * the sync and Arrow-aware paths is the supplied `decode` step.
315
+ *
316
+ * Behavior-preserving extraction of the two twins; ordering, the
317
+ * `options?.timeoutMs ?? this.defaultTimeoutMs` fallback, and the
318
+ * `this.execute(..., options)` wrapping are unchanged.
319
+ */
320
+ private async sendVia<T>(
321
+ message: Omit<ProtocolMessage, 'id' | 'protocol'>,
322
+ options: ExecuteOptions<T> | undefined,
323
+ decode: (responseStr: string) => T | Promise<T>
324
+ ): Promise<T> {
325
+ const fullMessage = this.stampMessage(message);
329
326
 
330
327
  return this.execute(async () => {
331
328
  // 1. Encode request (validates args)
@@ -338,8 +335,8 @@ export class RpcClient extends DisposableBase {
338
335
  options?.signal
339
336
  );
340
337
 
341
- // 3. Decode response with Arrow support
342
- return this.codec.decodeResponseAsync<T>(responseStr);
338
+ // 3. Decode response (sync or Arrow-aware, per caller)
339
+ return decode(responseStr);
343
340
  }, options);
344
341
  }
345
342
 
@@ -364,11 +361,7 @@ export class RpcClient extends DisposableBase {
364
361
  message: Omit<ProtocolMessage, 'id' | 'protocol'>,
365
362
  opts?: { timeoutMs?: number; signal?: AbortSignal }
366
363
  ): Promise<T> {
367
- const fullMessage: ProtocolMessage = {
368
- ...message,
369
- id: this.generateId(),
370
- protocol: PROTOCOL_ID,
371
- };
364
+ const fullMessage = this.stampMessage(message);
372
365
  const encoded = this.codec.encodeRequest(fullMessage);
373
366
  const responseStr = await transport.send(
374
367
  encoded,
@@ -378,6 +371,20 @@ export class RpcClient extends DisposableBase {
378
371
  return this.codec.decodeResponseAsync<T>(responseStr);
379
372
  }
380
373
 
374
+ /**
375
+ * Stamp a partial message into a full wire frame: assign the next request id
376
+ * and the protocol marker. The single id counter + protocol stamping live
377
+ * here so every send path (sendMessage/sendMessageAsync/sendOn) is correlated
378
+ * identically.
379
+ */
380
+ private stampMessage(message: Omit<ProtocolMessage, 'id' | 'protocol'>): ProtocolMessage {
381
+ return {
382
+ ...message,
383
+ id: this.generateId(),
384
+ protocol: PROTOCOL_ID,
385
+ };
386
+ }
387
+
381
388
  /**
382
389
  * Generate a unique request ID.
383
390
  *
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { spawn, type ChildProcess } from 'child_process';
17
+ import type { Writable } from 'stream';
17
18
  import { DisposableBase } from './bounded-context.js';
18
19
  import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
19
20
  import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
@@ -764,6 +765,67 @@ export class SubprocessTransport extends DisposableBase implements Transport {
764
765
  });
765
766
  }
766
767
 
768
+ /**
769
+ * Reject and clear every entry currently in the write queue.
770
+ * Clears each entry's timeout before rejecting so no late timer fires.
771
+ */
772
+ private rejectAllQueuedWrites(error: Error): void {
773
+ for (const q of this.writeQueue) {
774
+ this.clearQueuedWriteTimeout(q);
775
+ q.reject(error);
776
+ }
777
+ this.writeQueue.length = 0;
778
+ }
779
+
780
+ /**
781
+ * Process a single dequeued write entry: enforce the fallback queue timeout,
782
+ * then attempt the stdin write. The returned status tells {@link flushWriteQueue}
783
+ * how to proceed:
784
+ * - `'continue'`: entry settled (written or timed out); process the next entry.
785
+ * - `'backpressure'`: write accepted but stream is full; pause until the next drain.
786
+ * - `'error'`: synchronous write failure; the caller must reject the remaining queue.
787
+ *
788
+ * The entry's own timeout must already be cleared by the caller before this runs.
789
+ */
790
+ private processQueuedWrite(
791
+ queued: QueuedWrite,
792
+ stdin: Writable,
793
+ now: number
794
+ ): 'continue' | 'backpressure' | 'error' {
795
+ // Check for write queue timeout (fallback check, timer should have handled this)
796
+ if (now - queued.queuedAt > this.writeQueueTimeoutMs) {
797
+ queued.reject(
798
+ new BridgeTimeoutError(
799
+ `Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`
800
+ )
801
+ );
802
+ return 'continue';
803
+ }
804
+
805
+ try {
806
+ const canWrite = stdin.write(queued.data);
807
+
808
+ if (canWrite) {
809
+ queued.resolve();
810
+ return 'continue';
811
+ }
812
+
813
+ // Backpressure - this write has been accepted by stream buffer.
814
+ // Pause further queued writes until the next "drain" event.
815
+ queued.resolve();
816
+ this.draining = true;
817
+ return 'backpressure';
818
+ } catch (err) {
819
+ // Synchronous write error (e.g., EPIPE) - reject this entry; caller rejects the rest.
820
+ const errorMessage = err instanceof Error ? err.message : 'unknown';
821
+ const error = new BridgeProtocolError(this.withStderrTail(`Write error: ${errorMessage}`));
822
+ queued.reject(error);
823
+ this.rejectAllQueuedWrites(error);
824
+ this.markForRestart();
825
+ return 'error';
826
+ }
827
+ }
828
+
767
829
  /**
768
830
  * Flush queued writes when backpressure clears.
769
831
  */
@@ -771,13 +833,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
771
833
  const now = Date.now();
772
834
 
773
835
  while (this.writeQueue.length > 0 && !this.draining) {
774
- if (!this.process?.stdin || this.processExited) {
836
+ const stdin = this.process?.stdin;
837
+ if (!stdin || this.processExited) {
775
838
  // Process died - reject all queued writes
776
- for (const q of this.writeQueue) {
777
- this.clearQueuedWriteTimeout(q);
778
- q.reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
779
- }
780
- this.writeQueue.length = 0;
839
+ this.rejectAllQueuedWrites(
840
+ new BridgeProtocolError(this.withStderrTail('Process stdin not available'))
841
+ );
781
842
  this.markForRestart();
782
843
  return;
783
844
  }
@@ -790,39 +851,8 @@ export class SubprocessTransport extends DisposableBase implements Transport {
790
851
  // Clear the timeout since we're processing this entry now
791
852
  this.clearQueuedWriteTimeout(queued);
792
853
 
793
- // Check for write queue timeout (fallback check, timer should have handled this)
794
- if (now - queued.queuedAt > this.writeQueueTimeoutMs) {
795
- queued.reject(
796
- new BridgeTimeoutError(
797
- `Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`
798
- )
799
- );
800
- continue; // Process next entry
801
- }
802
-
803
- try {
804
- const canWrite = this.process.stdin.write(queued.data);
805
-
806
- if (canWrite) {
807
- queued.resolve();
808
- } else {
809
- // Backpressure - this write has been accepted by stream buffer.
810
- // Pause further queued writes until the next "drain" event.
811
- queued.resolve();
812
- this.draining = true;
813
- return;
814
- }
815
- } catch (err) {
816
- // Synchronous write error (e.g., EPIPE) - reject this and all remaining writes
817
- const errorMessage = err instanceof Error ? err.message : 'unknown';
818
- const error = new BridgeProtocolError(this.withStderrTail(`Write error: ${errorMessage}`));
819
- queued.reject(error);
820
- for (const q of this.writeQueue) {
821
- this.clearQueuedWriteTimeout(q);
822
- q.reject(error);
823
- }
824
- this.writeQueue.length = 0;
825
- this.markForRestart();
854
+ const status = this.processQueuedWrite(queued, stdin, now);
855
+ if (status === 'backpressure' || status === 'error') {
826
856
  return;
827
857
  }
828
858
  }
package/src/tywrap.ts CHANGED
@@ -52,30 +52,6 @@ export async function tywrap(options: Partial<TywrapOptions> = {}): Promise<Tywr
52
52
  };
53
53
  }
54
54
 
55
- export default tywrap;
56
-
57
- /**
58
- * Append minimal tsd tests for a generated module (optional dev aid)
59
- */
60
- export async function emitTypeTestsForModule(
61
- moduleName: string,
62
- outDir = 'test-d/generated'
63
- ): Promise<void> {
64
- const { writeFile, mkdir } = await import('fs/promises');
65
- const { join } = await import('path');
66
- try {
67
- await mkdir(outDir, { recursive: true });
68
- } catch {}
69
- const filePath = join(outDir, `${moduleName}.test-d.ts`);
70
- const content = `import { expectType } from 'tsd';
71
- import * as mod from '../../generated/${moduleName}.generated.ts';
72
-
73
- // Opportunistic: ensure module namespace is an object
74
- expectType<Record<string, unknown>>(mod);
75
- `;
76
- await writeFile(filePath, content, 'utf-8');
77
- }
78
-
79
55
  export interface GenerateRunOptions {
80
56
  /**
81
57
  * If true, do not write files; instead compare generated output to what's on disk.
@@ -149,75 +149,73 @@ export class ArtifactCache {
149
149
  hash.update(prefix);
150
150
  hash.update('\0');
151
151
 
152
- // Add inputs
152
+ // Add inputs. Each input contributes a type tag, an optional payload, and a
153
+ // boundary separator so that "1" vs 1 and ["a","bc"] vs ["ab","c"] never collide.
153
154
  for (const input of inputs) {
154
- // Why: disambiguate types so "1" and 1 don't collide, and keep input boundaries so
155
- // ["a", "bc"] doesn't collide with ["ab", "c"].
156
- if (input === undefined) {
157
- hash.update('undef:');
158
- hash.update('\0');
159
- continue;
155
+ const { tag, payload } = ArtifactCache.encodeKeyInput(input);
156
+ hash.update(tag);
157
+ if (payload !== undefined) {
158
+ hash.update(payload);
160
159
  }
161
- if (input === null) {
162
- hash.update('null:');
163
- hash.update('\0');
164
- continue;
165
- }
166
- if (typeof input === 'string') {
167
- hash.update('str:');
168
- hash.update(input);
169
- hash.update('\0');
170
- continue;
171
- }
172
- if (typeof input === 'number') {
173
- hash.update('num:');
174
- hash.update(String(input));
175
- hash.update('\0');
176
- continue;
177
- }
178
- if (typeof input === 'boolean') {
179
- hash.update('bool:');
180
- hash.update(input ? '1' : '0');
181
- hash.update('\0');
182
- continue;
183
- }
184
- if (typeof input === 'bigint') {
185
- hash.update('bigint:');
186
- hash.update(input.toString());
187
- hash.update('\0');
188
- continue;
189
- }
190
- if (Buffer.isBuffer(input)) {
191
- hash.update('buf:');
192
- hash.update(input);
193
- hash.update('\0');
194
- continue;
195
- }
196
-
197
- if (typeof input === 'symbol') {
198
- hash.update('sym:');
199
- hash.update(input.toString());
200
- hash.update('\0');
201
- continue;
202
- }
203
-
204
- hash.update('json:');
205
- let json: string;
206
- try {
207
- json =
208
- JSON.stringify(input, (_key, value) =>
209
- typeof value === 'symbol' ? value.toString() : value
210
- ) ?? 'undefined';
211
- } catch {
212
- json = String(input);
213
- }
214
- hash.update(json);
215
160
  hash.update('\0');
216
161
  }
217
162
 
218
163
  return hash.digest('hex').substring(0, 16); // Use first 16 chars for readability
219
164
  }
220
165
 
166
+ /**
167
+ * Encode a single key input as a type tag plus optional payload.
168
+ * Why: disambiguate types so "1" and 1 don't collide, and keep input boundaries so
169
+ * ["a", "bc"] doesn't collide with ["ab", "c"].
170
+ */
171
+ private static encodeKeyInput(input: unknown): {
172
+ tag: string;
173
+ payload?: string | Buffer;
174
+ } {
175
+ if (input === undefined) {
176
+ return { tag: 'undef:' };
177
+ }
178
+ if (input === null) {
179
+ return { tag: 'null:' };
180
+ }
181
+ if (typeof input === 'string') {
182
+ return { tag: 'str:', payload: input };
183
+ }
184
+ if (typeof input === 'number') {
185
+ return { tag: 'num:', payload: String(input) };
186
+ }
187
+ if (typeof input === 'boolean') {
188
+ return { tag: 'bool:', payload: input ? '1' : '0' };
189
+ }
190
+ if (typeof input === 'bigint') {
191
+ return { tag: 'bigint:', payload: input.toString() };
192
+ }
193
+ if (Buffer.isBuffer(input)) {
194
+ return { tag: 'buf:', payload: input };
195
+ }
196
+ if (typeof input === 'symbol') {
197
+ return { tag: 'sym:', payload: input.toString() };
198
+ }
199
+
200
+ return { tag: 'json:', payload: ArtifactCache.stringifyKeyInput(input) };
201
+ }
202
+
203
+ /**
204
+ * Stringify an arbitrary key input for hashing, normalizing symbols and
205
+ * falling back to String() when JSON serialization fails.
206
+ */
207
+ private static stringifyKeyInput(input: unknown): string {
208
+ try {
209
+ return (
210
+ JSON.stringify(input, (_key, value) =>
211
+ typeof value === 'symbol' ? value.toString() : value
212
+ ) ?? 'undefined'
213
+ );
214
+ } catch {
215
+ return String(input);
216
+ }
217
+ }
218
+
221
219
  /**
222
220
  * Get entry from cache with performance tracking
223
221
  */
@@ -44,30 +44,43 @@ export function getDefaultPythonPath(): string {
44
44
  return getPythonExecutableName();
45
45
  }
46
46
 
47
+ /**
48
+ * Whether the configured pythonPath is the default interpreter (or unset),
49
+ * meaning a virtual environment lookup should take precedence.
50
+ */
51
+ function usesDefaultPython(pythonPath: string | undefined): boolean {
52
+ return !pythonPath || pythonPath === 'python3' || pythonPath === 'python';
53
+ }
54
+
55
+ /**
56
+ * Resolve the Python executable inside a virtual environment, preferring the
57
+ * Node path module when available and falling back to cross-runtime pathUtils.
58
+ */
59
+ async function resolveVenvPython(virtualEnv: string, cwd: string): Promise<string> {
60
+ const binDir = getVenvBinDir();
61
+ const exe = getVenvPythonExe();
62
+ const pathMod = await loadNodePathModule();
63
+
64
+ if (pathMod) {
65
+ const venvRoot = pathMod.resolve(cwd, virtualEnv);
66
+ return pathMod.join(venvRoot, binDir, exe);
67
+ }
68
+
69
+ const venvRoot = isAbsolutePath(virtualEnv)
70
+ ? pathUtils.join(virtualEnv)
71
+ : pathUtils.join(cwd, virtualEnv);
72
+ return pathUtils.join(venvRoot, binDir, exe);
73
+ }
74
+
47
75
  export async function resolvePythonExecutable(options: PythonResolveOptions = {}): Promise<string> {
48
76
  const pythonPath = options.pythonPath?.trim();
49
77
  const virtualEnv = options.virtualEnv?.trim();
50
78
 
51
- if (virtualEnv) {
52
- const usesDefaultPython = !pythonPath || pythonPath === 'python3' || pythonPath === 'python';
53
- if (usesDefaultPython) {
54
- const cwd =
55
- options.cwd ??
56
- (typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.');
57
- const binDir = getVenvBinDir();
58
- const exe = getVenvPythonExe();
59
- const pathMod = await loadNodePathModule();
60
-
61
- if (pathMod) {
62
- const venvRoot = pathMod.resolve(cwd, virtualEnv);
63
- return pathMod.join(venvRoot, binDir, exe);
64
- }
65
-
66
- const venvRoot = isAbsolutePath(virtualEnv)
67
- ? pathUtils.join(virtualEnv)
68
- : pathUtils.join(cwd, virtualEnv);
69
- return pathUtils.join(venvRoot, binDir, exe);
70
- }
79
+ if (virtualEnv && usesDefaultPython(pythonPath)) {
80
+ const cwd =
81
+ options.cwd ??
82
+ (typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.');
83
+ return resolveVenvPython(virtualEnv, cwd);
71
84
  }
72
85
 
73
86
  if (pythonPath) {
@@ -229,26 +229,37 @@ async function loadPathModule(): Promise<PathModule | null> {
229
229
  return null;
230
230
  }
231
231
 
232
+ /**
233
+ * Apply a single path segment to the accumulated normalized segments,
234
+ * resolving '..' and dropping '.'/empty segments in place.
235
+ */
236
+ function applyPathSegment(normalized: string[], segment: string, isAbsolute: boolean): void {
237
+ if (segment === '.' || segment === '') {
238
+ return; // Skip current directory and empty segments
239
+ }
240
+
241
+ if (segment !== '..') {
242
+ normalized.push(segment);
243
+ return;
244
+ }
245
+
246
+ // '..' segment: go up one directory when possible.
247
+ if (normalized.length > 0 && normalized[normalized.length - 1] !== '..') {
248
+ normalized.pop();
249
+ } else if (!isAbsolute) {
250
+ normalized.push(segment); // Keep '..' if not absolute and at root
251
+ }
252
+ }
253
+
232
254
  /**
233
255
  * Normalize path by stripping '.' and resolving '..' components
234
256
  */
235
257
  function normalizePath(path: string): string {
236
258
  const isAbsolute = path.startsWith('/');
237
- const segments = path.split('/');
238
259
  const normalized: string[] = [];
239
260
 
240
- for (const segment of segments) {
241
- if (segment === '.' || segment === '') {
242
- continue; // Skip current directory and empty segments
243
- } else if (segment === '..') {
244
- if (normalized.length > 0 && normalized[normalized.length - 1] !== '..') {
245
- normalized.pop(); // Go up one directory
246
- } else if (!isAbsolute) {
247
- normalized.push(segment); // Keep '..' if not absolute and at root
248
- }
249
- } else {
250
- normalized.push(segment);
251
- }
261
+ for (const segment of path.split('/')) {
262
+ applyPathSegment(normalized, segment, isAbsolute);
252
263
  }
253
264
 
254
265
  const result = normalized.join('/');
package/src/version.ts CHANGED
@@ -9,4 +9,4 @@
9
9
  * Regenerate with: node scripts/generate-version.mjs
10
10
  */
11
11
 
12
- export const VERSION: string = "0.6.0";
12
+ export const VERSION: string = "0.6.1";