tywrap 0.3.0 → 0.4.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 (44) hide show
  1. package/README.md +39 -5
  2. package/dist/cli.js +24 -6
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +19 -13
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/analyzer.d.ts.map +1 -1
  8. package/dist/core/analyzer.js +0 -1
  9. package/dist/core/analyzer.js.map +1 -1
  10. package/dist/dev.d.ts +57 -0
  11. package/dist/dev.d.ts.map +1 -0
  12. package/dist/dev.js +743 -0
  13. package/dist/dev.js.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/node.d.ts.map +1 -1
  18. package/dist/runtime/node.js +53 -17
  19. package/dist/runtime/node.js.map +1 -1
  20. package/dist/runtime/optimized-node.d.ts +5 -4
  21. package/dist/runtime/optimized-node.d.ts.map +1 -1
  22. package/dist/runtime/optimized-node.js +5 -4
  23. package/dist/runtime/optimized-node.js.map +1 -1
  24. package/dist/runtime/worker-pool.d.ts +6 -1
  25. package/dist/runtime/worker-pool.d.ts.map +1 -1
  26. package/dist/runtime/worker-pool.js +56 -29
  27. package/dist/runtime/worker-pool.js.map +1 -1
  28. package/dist/types/index.d.ts +0 -7
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/tywrap.d.ts +6 -0
  31. package/dist/tywrap.d.ts.map +1 -1
  32. package/dist/tywrap.js +10 -3
  33. package/dist/tywrap.js.map +1 -1
  34. package/package.json +17 -13
  35. package/src/cli.ts +27 -6
  36. package/src/config/index.ts +28 -15
  37. package/src/core/analyzer.ts +1 -2
  38. package/src/dev.ts +983 -0
  39. package/src/index.ts +1 -1
  40. package/src/runtime/node.ts +74 -28
  41. package/src/runtime/optimized-node.ts +5 -4
  42. package/src/runtime/worker-pool.ts +65 -30
  43. package/src/types/index.ts +0 -8
  44. package/src/tywrap.ts +17 -3
package/src/dev.ts ADDED
@@ -0,0 +1,983 @@
1
+ /**
2
+ * Dev-only bridge reload helpers.
3
+ *
4
+ * These helpers support wrapper regeneration plus runtime bridge replacement.
5
+ * They do not provide application-level hot module reloading.
6
+ */
7
+
8
+ import { watch as createWatcher, type FSWatcher } from 'node:fs';
9
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { delimiter, dirname, join, relative, resolve, sep } from 'node:path';
12
+
13
+ import { resolveConfig } from './config/index.js';
14
+ import { clearRuntimeBridge, getRuntimeBridge, setRuntimeBridge } from './runtime/index.js';
15
+ import type { Disposable } from './runtime/disposable.js';
16
+ import type { RuntimeExecution, TywrapOptions } from './types/index.js';
17
+ import { generate, type GenerateFailure } from './tywrap.js';
18
+ import { resolvePythonExecutable } from './utils/python.js';
19
+ import { isNodejs, processUtils } from './utils/runtime.js';
20
+
21
+ type BridgeFactory<T extends RuntimeExecution & Disposable> = () => T | Promise<T>;
22
+
23
+ interface Initializable {
24
+ init(): Promise<void>;
25
+ }
26
+
27
+ type WatchTargetKind = 'file' | 'directory';
28
+ type WatchSourceKind = 'file' | 'tree';
29
+ type NodeWatchBridgeFactory<T extends RuntimeExecution & Disposable> = (
30
+ config: TywrapOptions
31
+ ) => T | Promise<T>;
32
+
33
+ const IGNORED_WATCH_DIRECTORY_NAMES = new Set([
34
+ '__pycache__',
35
+ '.pytest_cache',
36
+ '.mypy_cache',
37
+ '.ruff_cache',
38
+ ]);
39
+
40
+ interface WatchTarget {
41
+ moduleName: string;
42
+ path: string;
43
+ kind: WatchTargetKind;
44
+ }
45
+
46
+ interface WatchSource {
47
+ path: string;
48
+ kind: WatchSourceKind;
49
+ }
50
+
51
+ interface StageResult {
52
+ config: TywrapOptions;
53
+ tempDir: string;
54
+ files: Map<string, string>;
55
+ written: string[];
56
+ warnings: string[];
57
+ failures: GenerateFailure[];
58
+ watchSources: WatchSource[];
59
+ outputDir: string;
60
+ }
61
+
62
+ export interface BridgeReloaderOptions {
63
+ setAsGlobal?: boolean;
64
+ }
65
+
66
+ export interface BridgeReloader<T extends RuntimeExecution & Disposable> {
67
+ current(): T;
68
+ reload(): Promise<T>;
69
+ dispose(): Promise<void>;
70
+ }
71
+
72
+ export type NodeWatchEvent =
73
+ | { type: 'watchPaths'; paths: string[] }
74
+ | { type: 'change'; path: string; manual: boolean }
75
+ | { type: 'reload-start'; path?: string; manual: boolean }
76
+ | {
77
+ type: 'reload-success';
78
+ path?: string;
79
+ manual: boolean;
80
+ paths: string[];
81
+ written: string[];
82
+ warnings: string[];
83
+ }
84
+ | { type: 'reload-error'; path?: string; manual: boolean; error: Error };
85
+
86
+ export interface StartNodeWatchSessionOptions<T extends RuntimeExecution & Disposable> {
87
+ configFile: string;
88
+ createBridge: NodeWatchBridgeFactory<T>;
89
+ extraWatchPaths?: string[];
90
+ debounceMs?: number;
91
+ onEvent?: (event: NodeWatchEvent) => void;
92
+ }
93
+
94
+ export interface NodeWatchSession {
95
+ reloadNow(): Promise<boolean>;
96
+ close(): Promise<void>;
97
+ }
98
+
99
+ interface PreparedWatchers {
100
+ paths: string[];
101
+ sources: WatchSource[];
102
+ watchers: FSWatcher[];
103
+ }
104
+
105
+ function safeEmit(
106
+ onEvent: StartNodeWatchSessionOptions<RuntimeExecution & Disposable>['onEvent'],
107
+ event: NodeWatchEvent
108
+ ): void {
109
+ try {
110
+ onEvent?.(event);
111
+ } catch {
112
+ // Dev hooks must not break reload handling.
113
+ }
114
+ }
115
+
116
+ function isInitializable(value: unknown): value is Initializable {
117
+ return (
118
+ value !== null &&
119
+ typeof value === 'object' &&
120
+ typeof (value as Initializable).init === 'function'
121
+ );
122
+ }
123
+
124
+ async function initializeBridge<T extends RuntimeExecution & Disposable>(bridge: T): Promise<T> {
125
+ if (isInitializable(bridge)) {
126
+ await bridge.init();
127
+ }
128
+ return bridge;
129
+ }
130
+
131
+ function safeGetRuntimeBridge(): RuntimeExecution | null {
132
+ try {
133
+ return getRuntimeBridge();
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ class ManagedBridgeReloader<T extends RuntimeExecution & Disposable> implements BridgeReloader<T> {
140
+ private currentBridge: T | null = null;
141
+ private disposed = false;
142
+ private queue: Promise<void> = Promise.resolve();
143
+
144
+ private constructor(
145
+ private readonly createBridge: BridgeFactory<T>,
146
+ private readonly options: Required<BridgeReloaderOptions>
147
+ ) {}
148
+
149
+ static async create<T extends RuntimeExecution & Disposable>(
150
+ createBridge: BridgeFactory<T>,
151
+ options: BridgeReloaderOptions = {}
152
+ ): Promise<ManagedBridgeReloader<T>> {
153
+ const manager = new ManagedBridgeReloader(createBridge, {
154
+ setAsGlobal: options.setAsGlobal ?? true,
155
+ });
156
+ const initialBridge = await manager.prepareNextBridge();
157
+ await manager.activatePreparedBridge(initialBridge);
158
+ return manager;
159
+ }
160
+
161
+ current(): T {
162
+ if (this.disposed || !this.currentBridge) {
163
+ throw new Error('Bridge reloader has been disposed');
164
+ }
165
+ return this.currentBridge;
166
+ }
167
+
168
+ async prepareNextBridge(): Promise<T> {
169
+ if (this.disposed) {
170
+ throw new Error('Bridge reloader has been disposed');
171
+ }
172
+ return initializeBridge(await this.createBridge());
173
+ }
174
+
175
+ async activatePreparedBridge(nextBridge: T): Promise<T> {
176
+ if (this.disposed) {
177
+ await nextBridge.dispose();
178
+ throw new Error('Bridge reloader has been disposed');
179
+ }
180
+
181
+ const previousBridge = this.currentBridge;
182
+ this.currentBridge = nextBridge;
183
+
184
+ if (this.options.setAsGlobal) {
185
+ setRuntimeBridge(nextBridge);
186
+ }
187
+
188
+ if (previousBridge && previousBridge !== nextBridge) {
189
+ previousBridge.dispose().catch(() => {
190
+ // Keep the new bridge active even if old cleanup fails.
191
+ });
192
+ }
193
+
194
+ return nextBridge;
195
+ }
196
+
197
+ async reload(): Promise<T> {
198
+ return this.enqueue(async () => {
199
+ const nextBridge = await this.prepareNextBridge();
200
+ return this.activatePreparedBridge(nextBridge);
201
+ });
202
+ }
203
+
204
+ async dispose(): Promise<void> {
205
+ await this.enqueue(async () => {
206
+ if (this.disposed) {
207
+ return;
208
+ }
209
+
210
+ this.disposed = true;
211
+ const activeBridge = this.currentBridge;
212
+ this.currentBridge = null;
213
+
214
+ if (this.options.setAsGlobal && activeBridge && safeGetRuntimeBridge() === activeBridge) {
215
+ clearRuntimeBridge();
216
+ }
217
+
218
+ if (activeBridge) {
219
+ await activeBridge.dispose().catch(() => {
220
+ // Session shutdown is best-effort.
221
+ });
222
+ }
223
+ });
224
+ }
225
+
226
+ private enqueue<R>(task: () => Promise<R>): Promise<R> {
227
+ const result = this.queue.then(task, task);
228
+ this.queue = result.then(
229
+ () => undefined,
230
+ () => undefined
231
+ );
232
+ return result;
233
+ }
234
+ }
235
+
236
+ function normalizePaths(paths: Iterable<string>): string[] {
237
+ return [...new Set(Array.from(paths, path => resolve(path)))].sort();
238
+ }
239
+
240
+ function normalizeWatchSources(sources: Iterable<WatchSource>): WatchSource[] {
241
+ const entries = new Map<string, WatchSourceKind>();
242
+
243
+ for (const source of sources) {
244
+ const path = resolve(source.path);
245
+ const existingKind = entries.get(path);
246
+ if (existingKind === 'tree' || existingKind === source.kind) {
247
+ continue;
248
+ }
249
+ entries.set(path, source.kind);
250
+ }
251
+
252
+ return [...entries.entries()]
253
+ .sort(([left], [right]) => left.localeCompare(right))
254
+ .map(([path, kind]) => ({ path, kind }));
255
+ }
256
+
257
+ function isSameOrNestedPath(candidate: string, root: string): boolean {
258
+ const resolvedCandidate = resolve(candidate);
259
+ const resolvedRoot = resolve(root);
260
+ return (
261
+ resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${sep}`)
262
+ );
263
+ }
264
+
265
+ function hasIgnoredWatchDirectory(path: string): boolean {
266
+ return resolve(path)
267
+ .split(sep)
268
+ .filter(Boolean)
269
+ .some(segment => IGNORED_WATCH_DIRECTORY_NAMES.has(segment));
270
+ }
271
+
272
+ function shouldIgnoreWatchPath(candidate: string, ignoredPaths: string[]): boolean {
273
+ return (
274
+ hasIgnoredWatchDirectory(candidate) ||
275
+ ignoredPaths.some(ignoredPath => isSameOrNestedPath(candidate, ignoredPath))
276
+ );
277
+ }
278
+
279
+ function buildFailureError(failures: GenerateFailure[]): Error {
280
+ const heading =
281
+ failures.length === 1
282
+ ? 'Generation failed for 1 module:'
283
+ : `Generation failed for ${failures.length} modules:`;
284
+ const details = failures.map(failure => `- ${failure.message}`).join('\n');
285
+ return new Error(`${heading}\n${details}`);
286
+ }
287
+
288
+ async function pathExists(path: string): Promise<boolean> {
289
+ try {
290
+ await access(path);
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ async function maybeReadText(path: string): Promise<string | null> {
298
+ try {
299
+ return await readFile(path, 'utf-8');
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ async function listExistingManagedFiles(outputDir: string): Promise<Set<string>> {
306
+ if (!(await pathExists(outputDir))) {
307
+ return new Set();
308
+ }
309
+
310
+ const managedFiles = new Set<string>();
311
+ const queue = [resolve(outputDir)];
312
+
313
+ while (queue.length > 0) {
314
+ const current = queue.shift()!;
315
+ const entries = await readdir(current, { withFileTypes: true });
316
+
317
+ for (const entry of entries) {
318
+ const fullPath = join(current, entry.name);
319
+ if (entry.isDirectory()) {
320
+ queue.push(fullPath);
321
+ continue;
322
+ }
323
+
324
+ if (
325
+ entry.isFile() &&
326
+ (entry.name.endsWith('.generated.ts') ||
327
+ entry.name.endsWith('.generated.d.ts') ||
328
+ entry.name.endsWith('.generated.ts.map'))
329
+ ) {
330
+ managedFiles.add(relative(outputDir, fullPath));
331
+ }
332
+ }
333
+ }
334
+
335
+ return managedFiles;
336
+ }
337
+
338
+ async function collectWatchDirectories(root: string, ignoredPaths: string[]): Promise<string[]> {
339
+ const queue = [resolve(root)];
340
+ const directories: string[] = [];
341
+
342
+ while (queue.length > 0) {
343
+ const current = queue.shift()!;
344
+ if (shouldIgnoreWatchPath(current, ignoredPaths)) {
345
+ continue;
346
+ }
347
+
348
+ let currentStats;
349
+ try {
350
+ currentStats = await stat(current);
351
+ } catch {
352
+ continue;
353
+ }
354
+
355
+ if (!currentStats.isDirectory()) {
356
+ continue;
357
+ }
358
+
359
+ directories.push(current);
360
+
361
+ let entries;
362
+ try {
363
+ entries = await readdir(current, { withFileTypes: true });
364
+ } catch {
365
+ continue;
366
+ }
367
+
368
+ for (const entry of entries) {
369
+ if (!entry.isDirectory()) {
370
+ continue;
371
+ }
372
+ queue.push(join(current, entry.name));
373
+ }
374
+ }
375
+
376
+ return normalizePaths(directories);
377
+ }
378
+
379
+ function toWatchSource(target: WatchTarget): WatchSource {
380
+ return {
381
+ path: target.path,
382
+ kind: target.kind === 'directory' ? 'tree' : 'file',
383
+ };
384
+ }
385
+
386
+ async function resolveExtraWatchSources(paths: string[]): Promise<WatchSource[]> {
387
+ const sources: WatchSource[] = [];
388
+
389
+ for (const watchPath of normalizePaths(paths)) {
390
+ const watchStats = await stat(watchPath);
391
+ if (watchStats.isDirectory()) {
392
+ sources.push({ path: watchPath, kind: 'tree' });
393
+ continue;
394
+ }
395
+ if (watchStats.isFile()) {
396
+ sources.push({ path: watchPath, kind: 'file' });
397
+ continue;
398
+ }
399
+ throw new Error(`Watch path "${watchPath}" is not a file or directory`);
400
+ }
401
+
402
+ return sources;
403
+ }
404
+
405
+ async function promoteManagedFiles(
406
+ outputDir: string,
407
+ nextFiles: Map<string, string>,
408
+ previousManagedFiles: Set<string>
409
+ ): Promise<Set<string>> {
410
+ const touchedPaths = new Set<string>([...previousManagedFiles, ...nextFiles.keys()]);
411
+ const previousContents = new Map<string, string | null>();
412
+
413
+ for (const relativePath of touchedPaths) {
414
+ previousContents.set(relativePath, await maybeReadText(join(outputDir, relativePath)));
415
+ }
416
+
417
+ try {
418
+ await mkdir(outputDir, { recursive: true });
419
+
420
+ for (const [relativePath, content] of nextFiles) {
421
+ const destination = join(outputDir, relativePath);
422
+ await mkdir(dirname(destination), { recursive: true });
423
+ await writeFile(destination, content, 'utf-8');
424
+ }
425
+
426
+ for (const relativePath of previousManagedFiles) {
427
+ if (!nextFiles.has(relativePath)) {
428
+ await rm(join(outputDir, relativePath), { force: true });
429
+ }
430
+ }
431
+
432
+ return new Set(nextFiles.keys());
433
+ } catch (error) {
434
+ for (const [relativePath, previousContent] of previousContents) {
435
+ const destination = join(outputDir, relativePath);
436
+ if (previousContent === null) {
437
+ await rm(destination, { force: true });
438
+ } else {
439
+ await mkdir(dirname(destination), { recursive: true });
440
+ await writeFile(destination, previousContent, 'utf-8');
441
+ }
442
+ }
443
+ throw error;
444
+ }
445
+ }
446
+
447
+ async function resolveWatchTargets(config: TywrapOptions): Promise<WatchTarget[]> {
448
+ const pythonPath = await resolvePythonExecutable({
449
+ pythonPath: config.runtime?.node?.pythonPath,
450
+ virtualEnv: config.runtime?.node?.virtualEnv,
451
+ });
452
+
453
+ const extraImportPaths = config.pythonImportPath ?? [];
454
+ const mergedPyPath = [
455
+ ...extraImportPaths,
456
+ ...(process.env.PYTHONPATH ? [process.env.PYTHONPATH] : []),
457
+ ]
458
+ .filter(Boolean)
459
+ .join(delimiter);
460
+ const env = mergedPyPath ? { PYTHONPATH: mergedPyPath } : undefined;
461
+ const timeoutMs = config.runtime?.node?.timeout ?? 30000;
462
+
463
+ const watchTargets: WatchTarget[] = [];
464
+
465
+ for (const moduleName of Object.keys(config.pythonModules ?? {})) {
466
+ const resolutionScript = [
467
+ 'import importlib',
468
+ 'import importlib.util',
469
+ 'import json',
470
+ 'import sys',
471
+ '',
472
+ 'module_name = sys.argv[1]',
473
+ 'spec = importlib.util.find_spec(module_name)',
474
+ 'if spec is None:',
475
+ " print(json.dumps({'missing': True}))",
476
+ ' raise SystemExit(0)',
477
+ '',
478
+ 'module_path = None',
479
+ 'module_origin = spec.origin',
480
+ 'if spec.submodule_search_locations:',
481
+ ' try:',
482
+ ' module = importlib.import_module(module_name)',
483
+ ' except Exception:',
484
+ ' module = None',
485
+ ' if module is not None:',
486
+ ' module_path = getattr(module, "__path__", None)',
487
+ ' module_origin = getattr(module, "__file__", None) or spec.origin',
488
+ 'payload = {',
489
+ " 'missing': False,",
490
+ " 'origin': module_origin,",
491
+ " 'locations': list(module_path) if module_path is not None else list(spec.submodule_search_locations or []),",
492
+ " 'has_location': bool(spec.has_location),",
493
+ '}',
494
+ 'print(json.dumps(payload))',
495
+ ].join('\n');
496
+
497
+ const result = await processUtils.exec(pythonPath, ['-c', resolutionScript, moduleName], {
498
+ timeoutMs,
499
+ env,
500
+ });
501
+
502
+ if (result.code !== 0) {
503
+ throw new Error(
504
+ `Failed to resolve watch target for module "${moduleName}": ${result.stderr.trim() || result.stdout.trim() || 'unknown error'}`
505
+ );
506
+ }
507
+
508
+ let payload: {
509
+ missing?: boolean;
510
+ origin?: string | null;
511
+ locations?: string[];
512
+ has_location?: boolean;
513
+ };
514
+ try {
515
+ payload = JSON.parse(result.stdout) as {
516
+ missing?: boolean;
517
+ origin?: string | null;
518
+ locations?: string[];
519
+ has_location?: boolean;
520
+ };
521
+ } catch (error) {
522
+ throw new Error(
523
+ `Failed to parse watch target for module "${moduleName}": ${(error as Error).message}`
524
+ );
525
+ }
526
+
527
+ if (payload.missing) {
528
+ throw new Error(`Module "${moduleName}" could not be resolved for watching`);
529
+ }
530
+
531
+ const packageLocations = Array.isArray(payload.locations) ? payload.locations : [];
532
+ if (packageLocations.length > 0) {
533
+ for (const location of packageLocations) {
534
+ const packageDir = resolve(location);
535
+ const stats = await stat(packageDir);
536
+ if (!stats.isDirectory()) {
537
+ throw new Error(
538
+ `Module "${moduleName}" resolved to "${packageDir}", but it is not a package directory`
539
+ );
540
+ }
541
+ watchTargets.push({ moduleName, path: packageDir, kind: 'directory' });
542
+ }
543
+ continue;
544
+ }
545
+
546
+ if (typeof payload.origin !== 'string' || !payload.origin.endsWith('.py')) {
547
+ throw new Error(
548
+ `Module "${moduleName}" is not backed by a local Python source file or package directory and cannot be watched`
549
+ );
550
+ }
551
+
552
+ const modulePath = resolve(payload.origin);
553
+ const stats = await stat(modulePath);
554
+ if (!stats.isFile()) {
555
+ throw new Error(
556
+ `Module "${moduleName}" resolved to "${modulePath}", but it is not a Python source file`
557
+ );
558
+ }
559
+
560
+ watchTargets.push({ moduleName, path: modulePath, kind: 'file' });
561
+ }
562
+
563
+ return watchTargets;
564
+ }
565
+
566
+ async function generateToStage(configFile: string): Promise<StageResult> {
567
+ const config = await resolveConfig({ configFile, requireConfig: true });
568
+ const watchTargets = await resolveWatchTargets(config);
569
+ const tempDir = await mkdtemp(join(tmpdir(), 'tywrap-dev-'));
570
+ const stageOutputDir = join(tempDir, 'output');
571
+
572
+ try {
573
+ const result = await generate({
574
+ ...config,
575
+ output: {
576
+ ...config.output,
577
+ dir: stageOutputDir,
578
+ },
579
+ performance: {
580
+ ...config.performance,
581
+ caching: false,
582
+ },
583
+ });
584
+
585
+ if (result.failures.length > 0) {
586
+ throw buildFailureError(result.failures);
587
+ }
588
+
589
+ const files = new Map<string, string>();
590
+ for (const writtenPath of result.written) {
591
+ const relativePath = relative(stageOutputDir, writtenPath);
592
+ files.set(relativePath, await readFile(writtenPath, 'utf-8'));
593
+ }
594
+
595
+ const outputDir = resolve(config.output.dir);
596
+ const watchSources = normalizeWatchSources(watchTargets.map(toWatchSource));
597
+
598
+ return {
599
+ config,
600
+ tempDir,
601
+ files,
602
+ written: result.written,
603
+ warnings: result.warnings,
604
+ failures: result.failures,
605
+ watchSources,
606
+ outputDir,
607
+ };
608
+ } catch (error) {
609
+ await rm(tempDir, { recursive: true, force: true });
610
+ throw error;
611
+ }
612
+ }
613
+
614
+ function buildIgnoredPaths(outputDir: string): string[] {
615
+ return normalizePaths([
616
+ outputDir,
617
+ resolve(process.cwd(), '.tywrap', 'cache'),
618
+ resolve(process.cwd(), '.tywrap', 'reports'),
619
+ ]);
620
+ }
621
+
622
+ export async function createBridgeReloader<T extends RuntimeExecution & Disposable>(
623
+ createBridge: BridgeFactory<T>,
624
+ options: BridgeReloaderOptions = {}
625
+ ): Promise<BridgeReloader<T>> {
626
+ return ManagedBridgeReloader.create(createBridge, options);
627
+ }
628
+
629
+ export async function startNodeWatchSession<T extends RuntimeExecution & Disposable>(
630
+ options: StartNodeWatchSessionOptions<T>
631
+ ): Promise<NodeWatchSession> {
632
+ if (!isNodejs()) {
633
+ throw new Error('startNodeWatchSession() is only available in Node.js');
634
+ }
635
+
636
+ const configFile = resolve(options.configFile);
637
+ const extraWatchPaths = normalizePaths(
638
+ (options.extraWatchPaths ?? []).map(path => resolve(path))
639
+ );
640
+ const debounceMs = options.debounceMs ?? 100;
641
+
642
+ let bridgeManager: ManagedBridgeReloader<T> | null = null;
643
+ let currentBridgeConfig: TywrapOptions | null = null;
644
+ let managedFiles = new Set<string>();
645
+ let activeOutputDir: string | null = null;
646
+ let activeWatchSources: WatchSource[] = [];
647
+ let watchers: FSWatcher[] = [];
648
+ let ignoredPaths: string[] = [];
649
+ let reloadPromise: Promise<boolean> | null = null;
650
+ let watcherRefreshPromise: Promise<void> = Promise.resolve();
651
+ let queuedTrigger: { path?: string; manual: boolean } | null = null;
652
+ let debounceTimer: NodeJS.Timeout | undefined;
653
+ let lastReloadError: Error | null = null;
654
+ let closed = false;
655
+
656
+ const emit = (event: NodeWatchEvent): void => {
657
+ safeEmit(options.onEvent, event);
658
+ };
659
+
660
+ const shouldIgnorePath = (path: string): boolean => shouldIgnoreWatchPath(path, ignoredPaths);
661
+
662
+ const createBridgeForSession = async (): Promise<T> => {
663
+ if (!currentBridgeConfig) {
664
+ throw new Error('No resolved tywrap config is available for bridge creation');
665
+ }
666
+ return options.createBridge(currentBridgeConfig);
667
+ };
668
+
669
+ const closeWatchers = (): void => {
670
+ const active = watchers;
671
+ watchers = [];
672
+ for (const watcher of active) {
673
+ watcher.close();
674
+ }
675
+ };
676
+
677
+ const closePreparedWatchers = (prepared: PreparedWatchers | null): void => {
678
+ if (!prepared) {
679
+ return;
680
+ }
681
+ for (const watcher of prepared.watchers) {
682
+ watcher.close();
683
+ }
684
+ };
685
+
686
+ const prepareWatchers = async (
687
+ sources: WatchSource[],
688
+ nextIgnoredPaths: string[]
689
+ ): Promise<PreparedWatchers> => {
690
+ const prepared: FSWatcher[] = [];
691
+ const normalizedSources = normalizeWatchSources(sources);
692
+ try {
693
+ for (const source of normalizedSources) {
694
+ if (source.kind === 'tree') {
695
+ const directories = await collectWatchDirectories(source.path, nextIgnoredPaths);
696
+ for (const directoryPath of directories) {
697
+ const watcher = createWatcher(
698
+ directoryPath,
699
+ (eventType: string, filename: string | Buffer | null): void => {
700
+ const fileName =
701
+ typeof filename === 'string'
702
+ ? filename
703
+ : filename instanceof Buffer
704
+ ? filename.toString()
705
+ : '';
706
+ const changedPath =
707
+ fileName.length > 0 ? resolve(directoryPath, fileName) : directoryPath;
708
+ if (shouldIgnorePath(changedPath)) {
709
+ return;
710
+ }
711
+ if (eventType === 'rename' || fileName.length === 0) {
712
+ queueWatcherRefresh().catch(() => {
713
+ // Surface the next reload error rather than crashing the session.
714
+ });
715
+ }
716
+ emit({ type: 'change', path: changedPath, manual: false });
717
+ scheduleReload({ path: changedPath, manual: false });
718
+ }
719
+ );
720
+ watcher.on('error', () => {
721
+ // Surface the next reload error rather than crashing the session.
722
+ });
723
+ prepared.push(watcher);
724
+ }
725
+ continue;
726
+ }
727
+
728
+ const watcher = createWatcher(source.path, () => {
729
+ if (shouldIgnorePath(source.path)) {
730
+ return;
731
+ }
732
+ emit({ type: 'change', path: source.path, manual: false });
733
+ scheduleReload({ path: source.path, manual: false });
734
+ });
735
+ watcher.on('error', () => {
736
+ // Surface the next reload error rather than crashing the session.
737
+ });
738
+ prepared.push(watcher);
739
+ }
740
+
741
+ return {
742
+ paths: normalizePaths(normalizedSources.map(source => source.path)),
743
+ sources: normalizedSources,
744
+ watchers: prepared,
745
+ };
746
+ } catch (error) {
747
+ closePreparedWatchers({
748
+ paths: [],
749
+ sources: [],
750
+ watchers: prepared,
751
+ });
752
+ throw error;
753
+ }
754
+ };
755
+
756
+ const commitWatchers = (prepared: PreparedWatchers): void => {
757
+ closeWatchers();
758
+ activeWatchSources = prepared.sources;
759
+ watchers = prepared.watchers;
760
+ emit({ type: 'watchPaths', paths: prepared.paths });
761
+ };
762
+
763
+ const refreshActiveWatchers = async (): Promise<void> => {
764
+ if (closed || reloadPromise || activeWatchSources.length === 0) {
765
+ return;
766
+ }
767
+
768
+ let prepared: PreparedWatchers | null = null;
769
+ try {
770
+ prepared = await prepareWatchers(activeWatchSources, ignoredPaths);
771
+ if (closed || reloadPromise) {
772
+ closePreparedWatchers(prepared);
773
+ return;
774
+ }
775
+ commitWatchers(prepared);
776
+ } catch {
777
+ closePreparedWatchers(prepared);
778
+ }
779
+ };
780
+
781
+ const queueWatcherRefresh = async (): Promise<void> => {
782
+ watcherRefreshPromise = watcherRefreshPromise.then(
783
+ () => refreshActiveWatchers(),
784
+ () => refreshActiveWatchers()
785
+ );
786
+ return watcherRefreshPromise;
787
+ };
788
+
789
+ const runReload = async (trigger: { path?: string; manual: boolean }): Promise<boolean> => {
790
+ if (closed) {
791
+ return false;
792
+ }
793
+
794
+ emit({ type: 'reload-start', path: trigger.path, manual: trigger.manual });
795
+
796
+ let stage: StageResult | null = null;
797
+ let nextBridge: T | null = null;
798
+ let preparedWatchers: PreparedWatchers | null = null;
799
+
800
+ const abortReload = async (
801
+ managerToDispose: ManagedBridgeReloader<T> | null = null
802
+ ): Promise<boolean> => {
803
+ if (preparedWatchers) {
804
+ closePreparedWatchers(preparedWatchers);
805
+ preparedWatchers = null;
806
+ }
807
+ if (nextBridge) {
808
+ await nextBridge.dispose().catch(() => {});
809
+ nextBridge = null;
810
+ }
811
+ if (managerToDispose) {
812
+ await managerToDispose.dispose().catch(() => {});
813
+ }
814
+ return false;
815
+ };
816
+
817
+ try {
818
+ stage = await generateToStage(configFile);
819
+ if (closed) {
820
+ return abortReload();
821
+ }
822
+ currentBridgeConfig = stage.config;
823
+ const nextIgnoredPaths = buildIgnoredPaths(stage.outputDir);
824
+ const extraWatchSources = await resolveExtraWatchSources(extraWatchPaths);
825
+ const watchSources = normalizeWatchSources([
826
+ { path: configFile, kind: 'file' },
827
+ ...stage.watchSources,
828
+ ...extraWatchSources,
829
+ ]);
830
+ const watchPaths = normalizePaths(watchSources.map(source => source.path));
831
+ preparedWatchers = await prepareWatchers(watchSources, nextIgnoredPaths);
832
+ if (closed) {
833
+ return abortReload();
834
+ }
835
+
836
+ if (bridgeManager) {
837
+ nextBridge = await bridgeManager.prepareNextBridge();
838
+ if (closed) {
839
+ return abortReload();
840
+ }
841
+ }
842
+
843
+ const previousManagedFiles =
844
+ activeOutputDir === stage.outputDir
845
+ ? managedFiles
846
+ : await listExistingManagedFiles(stage.outputDir);
847
+ const promotedManagedFiles = await promoteManagedFiles(
848
+ stage.outputDir,
849
+ stage.files,
850
+ previousManagedFiles
851
+ );
852
+ if (closed) {
853
+ return abortReload();
854
+ }
855
+
856
+ if (bridgeManager && nextBridge) {
857
+ await bridgeManager.activatePreparedBridge(nextBridge);
858
+ nextBridge = null;
859
+ if (closed) {
860
+ return abortReload();
861
+ }
862
+ } else if (!bridgeManager) {
863
+ const createdBridgeManager = await ManagedBridgeReloader.create(createBridgeForSession);
864
+ if (closed) {
865
+ return abortReload(createdBridgeManager);
866
+ }
867
+ bridgeManager = createdBridgeManager;
868
+ }
869
+
870
+ managedFiles = promotedManagedFiles;
871
+ activeOutputDir = stage.outputDir;
872
+ ignoredPaths = nextIgnoredPaths;
873
+ commitWatchers(preparedWatchers);
874
+ emit({
875
+ type: 'reload-success',
876
+ path: trigger.path,
877
+ manual: trigger.manual,
878
+ paths: watchPaths,
879
+ written: [...stage.files.keys()].sort(),
880
+ warnings: stage.warnings,
881
+ });
882
+ lastReloadError = null;
883
+ return true;
884
+ } catch (error) {
885
+ lastReloadError = error instanceof Error ? error : new Error(String(error));
886
+ if (preparedWatchers) {
887
+ closePreparedWatchers(preparedWatchers);
888
+ }
889
+ if (nextBridge) {
890
+ await nextBridge.dispose().catch(() => {});
891
+ }
892
+ emit({
893
+ type: 'reload-error',
894
+ path: trigger.path,
895
+ manual: trigger.manual,
896
+ error: lastReloadError,
897
+ });
898
+ return false;
899
+ } finally {
900
+ if (stage) {
901
+ await rm(stage.tempDir, { recursive: true, force: true });
902
+ }
903
+ }
904
+ };
905
+
906
+ const flushReloadQueue = async (): Promise<boolean> => {
907
+ const trigger = queuedTrigger ?? { manual: true };
908
+ queuedTrigger = null;
909
+ const success = await runReload(trigger);
910
+ if (queuedTrigger) {
911
+ return flushReloadQueue();
912
+ }
913
+ return success;
914
+ };
915
+
916
+ const enqueueReload = async (trigger: { path?: string; manual: boolean }): Promise<boolean> => {
917
+ queuedTrigger = trigger;
918
+
919
+ if (!reloadPromise) {
920
+ reloadPromise = flushReloadQueue().finally(() => {
921
+ reloadPromise = null;
922
+ });
923
+ }
924
+
925
+ return reloadPromise;
926
+ };
927
+
928
+ const scheduleReload = (trigger: { path?: string; manual: boolean }): void => {
929
+ if (closed) {
930
+ return;
931
+ }
932
+
933
+ queuedTrigger = trigger;
934
+ if (debounceTimer) {
935
+ clearTimeout(debounceTimer);
936
+ }
937
+ debounceTimer = setTimeout(() => {
938
+ debounceTimer = undefined;
939
+ enqueueReload(trigger).catch(() => {
940
+ // Reload errors are emitted via the session event hook.
941
+ });
942
+ }, debounceMs);
943
+ };
944
+
945
+ const initialSuccess = await enqueueReload({ manual: true });
946
+ if (!initialSuccess) {
947
+ closeWatchers();
948
+ const disposeBridgeManager = async (
949
+ manager: ManagedBridgeReloader<T> | null
950
+ ): Promise<void> => {
951
+ if (manager) {
952
+ await manager.dispose();
953
+ }
954
+ };
955
+ await disposeBridgeManager(bridgeManager);
956
+ throw lastReloadError ?? new Error('Initial watch session setup failed');
957
+ }
958
+
959
+ return {
960
+ async reloadNow(): Promise<boolean> {
961
+ if (closed) {
962
+ return false;
963
+ }
964
+ if (debounceTimer) {
965
+ clearTimeout(debounceTimer);
966
+ debounceTimer = undefined;
967
+ }
968
+ return enqueueReload({ manual: true });
969
+ },
970
+ async close(): Promise<void> {
971
+ if (closed) {
972
+ return;
973
+ }
974
+ closed = true;
975
+ if (debounceTimer) {
976
+ clearTimeout(debounceTimer);
977
+ debounceTimer = undefined;
978
+ }
979
+ closeWatchers();
980
+ await bridgeManager?.dispose();
981
+ },
982
+ };
983
+ }