@scelar/nodepod 1.0.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 (134) hide show
  1. package/LICENSE +43 -0
  2. package/README.md +240 -0
  3. package/dist/child_process-BJOMsZje.js +8233 -0
  4. package/dist/child_process-BJOMsZje.js.map +1 -0
  5. package/dist/child_process-Cj8vOcuc.cjs +7434 -0
  6. package/dist/child_process-Cj8vOcuc.cjs.map +1 -0
  7. package/dist/index-Cb1Cgdnd.js +35308 -0
  8. package/dist/index-Cb1Cgdnd.js.map +1 -0
  9. package/dist/index-DsMGS-xc.cjs +37195 -0
  10. package/dist/index-DsMGS-xc.cjs.map +1 -0
  11. package/dist/index.cjs +65 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.mjs +59 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +95 -0
  16. package/src/__tests__/smoke.test.ts +11 -0
  17. package/src/constants/cdn-urls.ts +18 -0
  18. package/src/constants/config.ts +236 -0
  19. package/src/cross-origin.ts +26 -0
  20. package/src/engine-factory.ts +176 -0
  21. package/src/engine-types.ts +56 -0
  22. package/src/helpers/byte-encoding.ts +39 -0
  23. package/src/helpers/digest.ts +9 -0
  24. package/src/helpers/event-loop.ts +96 -0
  25. package/src/helpers/wasm-cache.ts +133 -0
  26. package/src/iframe-sandbox.ts +141 -0
  27. package/src/index.ts +192 -0
  28. package/src/isolation-helpers.ts +148 -0
  29. package/src/memory-volume.ts +941 -0
  30. package/src/module-transformer.ts +368 -0
  31. package/src/packages/archive-extractor.ts +248 -0
  32. package/src/packages/browser-bundler.ts +284 -0
  33. package/src/packages/installer.ts +396 -0
  34. package/src/packages/registry-client.ts +131 -0
  35. package/src/packages/version-resolver.ts +411 -0
  36. package/src/polyfills/assert.ts +384 -0
  37. package/src/polyfills/async_hooks.ts +144 -0
  38. package/src/polyfills/buffer.ts +628 -0
  39. package/src/polyfills/child_process.ts +2288 -0
  40. package/src/polyfills/chokidar.ts +336 -0
  41. package/src/polyfills/cluster.ts +106 -0
  42. package/src/polyfills/console.ts +136 -0
  43. package/src/polyfills/constants.ts +123 -0
  44. package/src/polyfills/crypto.ts +885 -0
  45. package/src/polyfills/dgram.ts +87 -0
  46. package/src/polyfills/diagnostics_channel.ts +76 -0
  47. package/src/polyfills/dns.ts +134 -0
  48. package/src/polyfills/domain.ts +68 -0
  49. package/src/polyfills/esbuild.ts +854 -0
  50. package/src/polyfills/events.ts +276 -0
  51. package/src/polyfills/fs.ts +2888 -0
  52. package/src/polyfills/fsevents.ts +79 -0
  53. package/src/polyfills/http.ts +1449 -0
  54. package/src/polyfills/http2.ts +199 -0
  55. package/src/polyfills/https.ts +76 -0
  56. package/src/polyfills/inspector.ts +62 -0
  57. package/src/polyfills/lightningcss.ts +105 -0
  58. package/src/polyfills/module.ts +191 -0
  59. package/src/polyfills/net.ts +353 -0
  60. package/src/polyfills/os.ts +238 -0
  61. package/src/polyfills/path.ts +206 -0
  62. package/src/polyfills/perf_hooks.ts +102 -0
  63. package/src/polyfills/process.ts +690 -0
  64. package/src/polyfills/punycode.ts +159 -0
  65. package/src/polyfills/querystring.ts +93 -0
  66. package/src/polyfills/quic.ts +118 -0
  67. package/src/polyfills/readdirp.ts +229 -0
  68. package/src/polyfills/readline.ts +692 -0
  69. package/src/polyfills/repl.ts +134 -0
  70. package/src/polyfills/rollup.ts +119 -0
  71. package/src/polyfills/sea.ts +33 -0
  72. package/src/polyfills/sqlite.ts +78 -0
  73. package/src/polyfills/stream.ts +1620 -0
  74. package/src/polyfills/string_decoder.ts +25 -0
  75. package/src/polyfills/tailwindcss-oxide.ts +309 -0
  76. package/src/polyfills/test.ts +197 -0
  77. package/src/polyfills/timers.ts +32 -0
  78. package/src/polyfills/tls.ts +105 -0
  79. package/src/polyfills/trace_events.ts +50 -0
  80. package/src/polyfills/tty.ts +71 -0
  81. package/src/polyfills/url.ts +174 -0
  82. package/src/polyfills/util.ts +559 -0
  83. package/src/polyfills/v8.ts +126 -0
  84. package/src/polyfills/vm.ts +132 -0
  85. package/src/polyfills/volume-registry.ts +15 -0
  86. package/src/polyfills/wasi.ts +44 -0
  87. package/src/polyfills/worker_threads.ts +326 -0
  88. package/src/polyfills/ws.ts +595 -0
  89. package/src/polyfills/zlib.ts +881 -0
  90. package/src/request-proxy.ts +716 -0
  91. package/src/script-engine.ts +3375 -0
  92. package/src/sdk/nodepod-fs.ts +93 -0
  93. package/src/sdk/nodepod-process.ts +86 -0
  94. package/src/sdk/nodepod-terminal.ts +350 -0
  95. package/src/sdk/nodepod.ts +509 -0
  96. package/src/sdk/types.ts +70 -0
  97. package/src/shell/commands/bun.ts +121 -0
  98. package/src/shell/commands/directory.ts +297 -0
  99. package/src/shell/commands/file-ops.ts +525 -0
  100. package/src/shell/commands/git.ts +2142 -0
  101. package/src/shell/commands/node.ts +80 -0
  102. package/src/shell/commands/npm.ts +198 -0
  103. package/src/shell/commands/pm-types.ts +45 -0
  104. package/src/shell/commands/pnpm.ts +82 -0
  105. package/src/shell/commands/search.ts +264 -0
  106. package/src/shell/commands/shell-env.ts +352 -0
  107. package/src/shell/commands/text-processing.ts +1152 -0
  108. package/src/shell/commands/yarn.ts +84 -0
  109. package/src/shell/shell-builtins.ts +19 -0
  110. package/src/shell/shell-helpers.ts +250 -0
  111. package/src/shell/shell-interpreter.ts +514 -0
  112. package/src/shell/shell-parser.ts +429 -0
  113. package/src/shell/shell-types.ts +85 -0
  114. package/src/syntax-transforms.ts +561 -0
  115. package/src/threading/engine-worker.ts +64 -0
  116. package/src/threading/inline-worker.ts +372 -0
  117. package/src/threading/offload-types.ts +112 -0
  118. package/src/threading/offload-worker.ts +383 -0
  119. package/src/threading/offload.ts +271 -0
  120. package/src/threading/process-context.ts +92 -0
  121. package/src/threading/process-handle.ts +275 -0
  122. package/src/threading/process-manager.ts +956 -0
  123. package/src/threading/process-worker-entry.ts +854 -0
  124. package/src/threading/shared-vfs.ts +352 -0
  125. package/src/threading/sync-channel.ts +135 -0
  126. package/src/threading/task-queue.ts +177 -0
  127. package/src/threading/vfs-bridge.ts +231 -0
  128. package/src/threading/worker-pool.ts +233 -0
  129. package/src/threading/worker-protocol.ts +358 -0
  130. package/src/threading/worker-vfs.ts +218 -0
  131. package/src/types/externals.d.ts +38 -0
  132. package/src/types/fs-streams.ts +142 -0
  133. package/src/types/manifest.ts +17 -0
  134. package/src/worker-sandbox.ts +90 -0
@@ -0,0 +1,231 @@
1
+ // VFSBridge — syncs the canonical MemoryVolume with worker VFS clones.
2
+ // Creates snapshots for initialization, applies worker writes, broadcasts changes.
3
+
4
+ import type { MemoryVolume } from "../memory-volume";
5
+ import type { VFSBinarySnapshot, VFSSnapshotEntry } from "./worker-protocol";
6
+ import type { SharedVFSController } from "./shared-vfs";
7
+
8
+ /* ------------------------------------------------------------------ */
9
+ /* Constants */
10
+ /* ------------------------------------------------------------------ */
11
+
12
+ const VFS_CHUNK_SIZE = 4 * 1024 * 1024; // 4MB
13
+
14
+ /* ------------------------------------------------------------------ */
15
+ /* VFSBridge */
16
+ /* ------------------------------------------------------------------ */
17
+
18
+ export class VFSBridge {
19
+ private _volume: MemoryVolume;
20
+ private _broadcaster: ((path: string, content: ArrayBuffer | null, excludePid: number) => void) | null = null;
21
+ private _sharedVFS: SharedVFSController | null = null;
22
+ // Suppressed during handleWorkerWrite/Mkdir/Delete to prevent double-broadcasting
23
+ private _suppressWatch = false;
24
+
25
+ constructor(volume: MemoryVolume) {
26
+ this._volume = volume;
27
+ }
28
+
29
+ setBroadcaster(fn: (path: string, content: ArrayBuffer | null, excludePid: number) => void): void {
30
+ this._broadcaster = fn;
31
+ }
32
+
33
+ setSharedVFS(controller: SharedVFSController): void {
34
+ this._sharedVFS = controller;
35
+ }
36
+
37
+ // Packs all files into a single ArrayBuffer with a manifest
38
+ createSnapshot(): VFSBinarySnapshot {
39
+ const manifest: VFSSnapshotEntry[] = [];
40
+ const chunks: Uint8Array[] = [];
41
+ let totalSize = 0;
42
+
43
+ this._walkVolume("/", (path, isDirectory, content) => {
44
+ if (isDirectory) {
45
+ manifest.push({
46
+ path,
47
+ offset: 0,
48
+ length: 0,
49
+ isDirectory: true,
50
+ });
51
+ } else if (content) {
52
+ manifest.push({
53
+ path,
54
+ offset: totalSize,
55
+ length: content.byteLength,
56
+ isDirectory: false,
57
+ });
58
+ chunks.push(content);
59
+ totalSize += content.byteLength;
60
+ }
61
+ });
62
+
63
+ const data = new ArrayBuffer(totalSize);
64
+ const view = new Uint8Array(data);
65
+ let offset = 0;
66
+ for (const chunk of chunks) {
67
+ view.set(chunk, offset);
68
+ offset += chunk.byteLength;
69
+ }
70
+
71
+ return { manifest, data };
72
+ }
73
+
74
+ // Split into chunks for large transfers
75
+ createChunkedSnapshots(): { chunkIndex: number; totalChunks: number; data: ArrayBuffer; manifest: VFSSnapshotEntry[] }[] {
76
+ const fullSnapshot = this.createSnapshot();
77
+ const totalSize = fullSnapshot.data.byteLength;
78
+
79
+ if (totalSize <= VFS_CHUNK_SIZE) {
80
+ return [{
81
+ chunkIndex: 0,
82
+ totalChunks: 1,
83
+ data: fullSnapshot.data,
84
+ manifest: fullSnapshot.manifest,
85
+ }];
86
+ }
87
+
88
+ const chunks: { chunkIndex: number; totalChunks: number; data: ArrayBuffer; manifest: VFSSnapshotEntry[] }[] = [];
89
+ const fullData = new Uint8Array(fullSnapshot.data);
90
+ const totalChunks = Math.ceil(totalSize / VFS_CHUNK_SIZE);
91
+
92
+ let currentOffset = 0;
93
+ for (let i = 0; i < totalChunks; i++) {
94
+ const chunkEnd = Math.min(currentOffset + VFS_CHUNK_SIZE, totalSize);
95
+ const chunkData = fullData.slice(currentOffset, chunkEnd);
96
+
97
+ const chunkManifest = fullSnapshot.manifest.filter(entry => {
98
+ if (entry.isDirectory) return i === 0;
99
+ return entry.offset >= currentOffset && entry.offset < chunkEnd;
100
+ }).map(entry => ({
101
+ ...entry,
102
+ offset: entry.isDirectory ? 0 : entry.offset - currentOffset,
103
+ }));
104
+
105
+ chunks.push({
106
+ chunkIndex: i,
107
+ totalChunks,
108
+ data: chunkData.buffer.slice(chunkData.byteOffset, chunkData.byteOffset + chunkData.byteLength),
109
+ manifest: chunkManifest,
110
+ });
111
+
112
+ currentOffset = chunkEnd;
113
+ }
114
+
115
+ return chunks;
116
+ }
117
+
118
+ handleWorkerWrite(path: string, content: Uint8Array): void {
119
+ this._suppressWatch = true;
120
+ try {
121
+ const parentDir = path.substring(0, path.lastIndexOf("/")) || "/";
122
+ if (parentDir !== "/" && !this._volume.existsSync(parentDir)) {
123
+ this._volume.mkdirSync(parentDir, { recursive: true });
124
+ }
125
+ this._volume.writeFileSync(path, content);
126
+ if (this._sharedVFS) {
127
+ this._sharedVFS.writeFile(path, content);
128
+ }
129
+ } finally {
130
+ this._suppressWatch = false;
131
+ }
132
+ }
133
+
134
+ handleWorkerMkdir(path: string): void {
135
+ this._suppressWatch = true;
136
+ try {
137
+ if (!this._volume.existsSync(path)) {
138
+ this._volume.mkdirSync(path, { recursive: true });
139
+ }
140
+ if (this._sharedVFS) {
141
+ this._sharedVFS.writeDirectory(path);
142
+ }
143
+ } finally {
144
+ this._suppressWatch = false;
145
+ }
146
+ }
147
+
148
+ handleWorkerDelete(path: string): void {
149
+ this._suppressWatch = true;
150
+ try {
151
+ try {
152
+ if (this._volume.existsSync(path)) {
153
+ const stat = this._volume.statSync(path);
154
+ if (stat.isDirectory()) {
155
+ this._volume.rmdirSync(path);
156
+ } else {
157
+ this._volume.unlinkSync(path);
158
+ }
159
+ }
160
+ } catch (e) {
161
+ console.warn(`[VFSBridge] Failed to delete "${path}":`, e);
162
+ }
163
+ if (this._sharedVFS) {
164
+ this._sharedVFS.deleteFile(path);
165
+ }
166
+ } finally {
167
+ this._suppressWatch = false;
168
+ }
169
+ }
170
+
171
+ broadcastChange(path: string, content: ArrayBuffer | null, excludePid: number): void {
172
+ if (this._broadcaster) {
173
+ this._broadcaster(path, content, excludePid);
174
+ }
175
+ }
176
+
177
+ // Watches canonical volume for changes and pushes to workers. Returns unsubscribe fn.
178
+ watch(): () => void {
179
+ const handle = this._volume.watch("/", { recursive: true }, (event, filename) => {
180
+ if (!filename || this._suppressWatch) return;
181
+
182
+ try {
183
+ if (this._volume.existsSync(filename)) {
184
+ const stat = this._volume.statSync(filename);
185
+ if (stat.isDirectory()) {
186
+ this.broadcastChange(filename, new ArrayBuffer(0), -1);
187
+ if (this._sharedVFS) this._sharedVFS.writeDirectory(filename);
188
+ } else {
189
+ const data = this._volume.readFileSync(filename);
190
+ const buffer = (data.buffer as ArrayBuffer).slice(data.byteOffset, data.byteOffset + data.byteLength);
191
+ this.broadcastChange(filename, buffer, -1);
192
+ if (this._sharedVFS) this._sharedVFS.writeFile(filename, data);
193
+ }
194
+ } else {
195
+ this.broadcastChange(filename, null, -1);
196
+ if (this._sharedVFS) this._sharedVFS.deleteFile(filename);
197
+ }
198
+ } catch (e) {
199
+ console.warn(`[VFSBridge] Watch error for "${filename}":`, e);
200
+ }
201
+ });
202
+
203
+ return () => handle.close();
204
+ }
205
+
206
+ private _walkVolume(
207
+ dir: string,
208
+ visitor: (path: string, isDirectory: boolean, content: Uint8Array | null) => void,
209
+ ): void {
210
+ try {
211
+ const entries = this._volume.readdirSync(dir);
212
+ for (const name of entries) {
213
+ const fullPath = dir === "/" ? `/${name}` : `${dir}/${name}`;
214
+ try {
215
+ const stat = this._volume.statSync(fullPath);
216
+ if (stat.isDirectory()) {
217
+ visitor(fullPath, true, null);
218
+ this._walkVolume(fullPath, visitor);
219
+ } else {
220
+ const content = this._volume.readFileSync(fullPath);
221
+ visitor(fullPath, false, content);
222
+ }
223
+ } catch (e) {
224
+ console.warn(`[VFSBridge] Failed to stat/read "${fullPath}":`, e);
225
+ }
226
+ }
227
+ } catch (e) {
228
+ console.warn(`[VFSBridge] Failed to read directory "${dir}":`, e);
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,233 @@
1
+ // WorkerPool — on-demand Web Worker pool for offload tasks.
2
+ // Workers are pre-warmed, reused, and reaped after idle timeout.
3
+ // If Worker creation fails (CSP, etc.), acquire() throws so the caller can fall back.
4
+
5
+ import { wrap, type Remote } from "comlink";
6
+ import type { OffloadWorkerEndpoint, PoolConfig } from "./offload-types";
7
+ import { createInlineWorker, revokeInlineWorkerUrl } from "./inline-worker";
8
+ import { TIMEOUTS, LIMITS } from "../constants/config";
9
+
10
+ // --- Internal types ---
11
+
12
+ interface PooledWorker {
13
+ thread: Worker;
14
+ endpoint: Remote<OffloadWorkerEndpoint>;
15
+ busy: boolean;
16
+ initialized: boolean;
17
+ initPromise: Promise<void> | null;
18
+ lastUsed: number;
19
+ id: number;
20
+ }
21
+
22
+ interface WaitEntry {
23
+ resolve: (result: { worker: PooledWorker; release: () => void }) => void;
24
+ reject: (err: Error) => void;
25
+ }
26
+
27
+ // --- WorkerPool ---
28
+
29
+ export class WorkerPool {
30
+ private workers: PooledWorker[] = [];
31
+ private waitQueue: WaitEntry[] = [];
32
+ private nextId = 0;
33
+ private config: Required<PoolConfig>;
34
+ private idleTimer: ReturnType<typeof setInterval> | null = null;
35
+ private disposed = false;
36
+
37
+ broken = false;
38
+
39
+ constructor(config: PoolConfig = {}) {
40
+ const cores =
41
+ typeof navigator !== "undefined"
42
+ ? navigator.hardwareConcurrency || 4
43
+ : 4;
44
+
45
+ this.config = {
46
+ minWorkers: config.minWorkers ?? 1,
47
+ maxWorkers: config.maxWorkers ?? Math.min(cores, LIMITS.MAX_WORKERS_CAP),
48
+ idleTimeoutMs: config.idleTimeoutMs ?? TIMEOUTS.WORKER_IDLE_TIMEOUT,
49
+ warmUpOnCreate: config.warmUpOnCreate ?? true,
50
+ };
51
+
52
+ this.idleTimer = setInterval(() => this.reapIdle(), TIMEOUTS.WORKER_REAP_INTERVAL);
53
+ }
54
+
55
+ // --- Public API ---
56
+
57
+ // Returns a worker + release callback. Caller MUST call release() when done.
58
+ async acquire(): Promise<{ worker: PooledWorker; release: () => void }> {
59
+ if (this.disposed) throw new Error("WorkerPool is disposed");
60
+ if (this.broken) throw new Error("WorkerPool is broken — Workers unavailable");
61
+
62
+ // Try idle initialized worker first
63
+ const idle = this.workers.find((w) => !w.busy && w.initialized);
64
+ if (idle) {
65
+ idle.busy = true;
66
+ idle.lastUsed = Date.now();
67
+ return { worker: idle, release: () => this.release(idle) };
68
+ }
69
+
70
+ // Create new worker if under capacity
71
+ if (this.workers.length < this.config.maxWorkers) {
72
+ const pooled = this.tryCreateWorker();
73
+ if (!pooled) {
74
+ // Construction failed — mark pool as broken
75
+ this.broken = true;
76
+ this.rejectAllWaiters();
77
+ throw new Error("WorkerPool is broken — Worker construction failed");
78
+ }
79
+
80
+ pooled.busy = true;
81
+
82
+ // Wait for init to finish
83
+ if (!pooled.initialized && pooled.initPromise) {
84
+ try {
85
+ await pooled.initPromise;
86
+ } catch (err) {
87
+ // Init failed — mark pool as broken
88
+ this.terminateWorker(pooled);
89
+ this.broken = true;
90
+ this.rejectAllWaiters();
91
+ throw new Error(
92
+ `WorkerPool is broken — Worker init failed: ${err instanceof Error ? err.message : err}`,
93
+ );
94
+ }
95
+ }
96
+
97
+ pooled.lastUsed = Date.now();
98
+ return { worker: pooled, release: () => this.release(pooled) };
99
+ }
100
+
101
+ // At capacity — wait for a worker to become free
102
+ return new Promise<{ worker: PooledWorker; release: () => void }>(
103
+ (resolve, reject) => {
104
+ this.waitQueue.push({ resolve, reject });
105
+ },
106
+ );
107
+ }
108
+
109
+ dispose(): void {
110
+ this.disposed = true;
111
+ if (this.idleTimer) {
112
+ clearInterval(this.idleTimer);
113
+ this.idleTimer = null;
114
+ }
115
+ for (const w of [...this.workers]) {
116
+ this.terminateWorker(w);
117
+ }
118
+ this.rejectAllWaiters();
119
+ revokeInlineWorkerUrl();
120
+ }
121
+
122
+ stats(): {
123
+ total: number;
124
+ busy: number;
125
+ idle: number;
126
+ initialized: number;
127
+ } {
128
+ return {
129
+ total: this.workers.length,
130
+ busy: this.workers.filter((w) => w.busy).length,
131
+ idle: this.workers.filter((w) => !w.busy).length,
132
+ initialized: this.workers.filter((w) => w.initialized).length,
133
+ };
134
+ }
135
+
136
+ // --- Internal ---
137
+
138
+ private rejectAllWaiters(): void {
139
+ for (const entry of this.waitQueue) {
140
+ entry.reject(new Error("WorkerPool is no longer available"));
141
+ }
142
+ this.waitQueue.length = 0;
143
+ }
144
+
145
+ private release(pooled: PooledWorker): void {
146
+ pooled.busy = false;
147
+ pooled.lastUsed = Date.now();
148
+
149
+ // Wake up anyone waiting for a worker
150
+ if (this.waitQueue.length > 0) {
151
+ const entry = this.waitQueue.shift()!;
152
+ pooled.busy = true;
153
+ pooled.lastUsed = Date.now();
154
+ entry.resolve({ worker: pooled, release: () => this.release(pooled) });
155
+ }
156
+ }
157
+
158
+ // Returns null if Worker construction fails (CSP, etc.)
159
+ private tryCreateWorker(): PooledWorker | null {
160
+ const thread = createInlineWorker();
161
+ if (!thread) return null;
162
+
163
+ try {
164
+ const endpoint = wrap<OffloadWorkerEndpoint>(thread);
165
+ const id = this.nextId++;
166
+
167
+ const pooled: PooledWorker = {
168
+ thread,
169
+ endpoint,
170
+ busy: false,
171
+ initialized: false,
172
+ initPromise: null,
173
+ lastUsed: Date.now(),
174
+ id,
175
+ };
176
+
177
+ // Pre-warm esbuild-wasm + pako. Timeout prevents stalled CDN from hanging.
178
+ if (this.config.warmUpOnCreate) {
179
+ const INIT_TIMEOUT = TIMEOUTS.WORKER_INIT_TIMEOUT;
180
+ const initCall = endpoint.init();
181
+ const timeout = new Promise<never>((_, reject) =>
182
+ setTimeout(() => reject(new Error("Worker init timed out")), INIT_TIMEOUT),
183
+ );
184
+ pooled.initPromise = Promise.race([initCall, timeout])
185
+ .then(() => {
186
+ pooled.initialized = true;
187
+ pooled.initPromise = null;
188
+ })
189
+ .catch((err) => {
190
+ this.terminateWorker(pooled);
191
+ throw err; // propagate so acquire() can catch it
192
+ });
193
+ }
194
+
195
+ this.workers.push(pooled);
196
+ return pooled;
197
+ } catch {
198
+ try { thread.terminate(); } catch { /* ignore */ }
199
+ return null;
200
+ }
201
+ }
202
+
203
+ private reapIdle(): void {
204
+ const now = Date.now();
205
+ const candidates = this.workers.filter(
206
+ (w) =>
207
+ !w.busy &&
208
+ w.initialized &&
209
+ now - w.lastUsed > this.config.idleTimeoutMs,
210
+ );
211
+
212
+ // Keep at least minWorkers
213
+ const canRemove = Math.max(
214
+ 0,
215
+ this.workers.length - this.config.minWorkers,
216
+ );
217
+ const toRemove = candidates.slice(0, canRemove);
218
+
219
+ for (const w of toRemove) {
220
+ this.terminateWorker(w);
221
+ }
222
+ }
223
+
224
+ private terminateWorker(pooled: PooledWorker): void {
225
+ const idx = this.workers.indexOf(pooled);
226
+ if (idx >= 0) this.workers.splice(idx, 1);
227
+ try {
228
+ pooled.thread.terminate();
229
+ } catch {
230
+ /* ignore */
231
+ }
232
+ }
233
+ }