@ricsam/isolate-client 0.1.5 → 0.1.6

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.
package/README.md CHANGED
@@ -18,6 +18,7 @@ npm add @ricsam/isolate-client
18
18
  - Module loader for custom ES module resolution
19
19
  - Custom functions callable from isolate code
20
20
  - Test environment and Playwright support
21
+ - **Namespace-based runtime caching** for performance optimization
21
22
 
22
23
  ## Basic Usage
23
24
 
@@ -69,6 +70,79 @@ await runtime.dispose();
69
70
  await client.close();
70
71
  ```
71
72
 
73
+ ## Namespace-Based Runtime Caching
74
+
75
+ For performance-critical applications, use **namespaces** to cache and reuse runtimes. Namespaced runtimes preserve their V8 isolate, context, and compiled module cache across dispose/create cycles:
76
+
77
+ ```typescript
78
+ import { connect } from "@ricsam/isolate-client";
79
+
80
+ const client = await connect({ socket: "/tmp/isolate.sock" });
81
+
82
+ // Create a namespace for a tenant/user/session
83
+ const namespace = client.createNamespace("tenant-123");
84
+
85
+ // Create a runtime in this namespace
86
+ const runtime = await namespace.createRuntime({
87
+ memoryLimitMB: 128,
88
+ moduleLoader: async (name) => loadModule(name),
89
+ });
90
+
91
+ console.log(runtime.reused); // false - first time
92
+
93
+ // Import heavy modules (gets compiled and cached)
94
+ await runtime.eval(`
95
+ import { heavyLibrary } from "@/heavy-module";
96
+ console.log("Module loaded!");
97
+ `);
98
+
99
+ // Dispose returns runtime to pool (soft-delete)
100
+ await runtime.dispose();
101
+
102
+ // Later: reuse the same namespace (same or different connection!)
103
+ const client2 = await connect({ socket: "/tmp/isolate.sock" });
104
+ const namespace2 = client2.createNamespace("tenant-123");
105
+ const runtime2 = await namespace2.createRuntime({ /* options */ });
106
+
107
+ console.log(runtime2.reused); // true - reused from pool!
108
+ // Module cache preserved - no recompilation needed
109
+ await runtime2.eval(`
110
+ import { heavyLibrary } from "@/heavy-module"; // instant!
111
+ `);
112
+ ```
113
+
114
+ ### Namespace Interface
115
+
116
+ ```typescript
117
+ interface Namespace {
118
+ /** The namespace ID */
119
+ readonly id: string;
120
+ /** Create a runtime in this namespace (cacheable on dispose) */
121
+ createRuntime(options?: RuntimeOptions): Promise<RemoteRuntime>;
122
+ }
123
+ ```
124
+
125
+ ### What's Preserved vs Reset
126
+
127
+ **Preserved on reuse (performance benefit):**
128
+ - V8 Isolate instance
129
+ - V8 Context
130
+ - Compiled ES module cache
131
+ - Global state and imported modules
132
+
133
+ **Reset on reuse:**
134
+ - Owner connection (new owner)
135
+ - Callbacks (re-registered from new client)
136
+ - Timers (cleared)
137
+ - Console state (counters, timers, groups reset)
138
+
139
+ ### Behavior Notes
140
+
141
+ - Non-namespaced runtimes (`client.createRuntime()`) work as before - true disposal
142
+ - Namespaced runtimes are cached on dispose and evicted via LRU when `maxIsolates` limit is reached
143
+ - Cross-client reuse is allowed - any connection can reuse a namespace by ID
144
+ - A namespace can only have one active runtime at a time; creating a second runtime with the same namespace ID while one is active will fail
145
+
72
146
  ## Module Loader
73
147
 
74
148
  Register a custom module loader to handle dynamic `import()` calls:
@@ -340,7 +414,11 @@ await browser.close();
340
414
  ```typescript
341
415
  interface RemoteRuntime {
342
416
  readonly id: string;
417
+ /** True if runtime was reused from namespace pool */
418
+ readonly reused?: boolean;
419
+
343
420
  eval(code: string, filename?: string): Promise<void>;
421
+ /** Dispose runtime (soft-delete if namespaced, hard delete otherwise) */
344
422
  dispose(): Promise<void>;
345
423
 
346
424
  // Module handles
@@ -351,6 +429,17 @@ interface RemoteRuntime {
351
429
  readonly playwright: RemotePlaywrightHandle;
352
430
  }
353
431
 
432
+ interface DaemonConnection {
433
+ /** Create a new runtime in the daemon */
434
+ createRuntime(options?: RuntimeOptions): Promise<RemoteRuntime>;
435
+ /** Create a namespace for runtime pooling/reuse */
436
+ createNamespace(id: string): Namespace;
437
+ /** Close the connection */
438
+ close(): Promise<void>;
439
+ /** Check if connected */
440
+ isConnected(): boolean;
441
+ }
442
+
354
443
  interface RemoteFetchHandle {
355
444
  dispatchRequest(request: Request, options?: DispatchOptions): Promise<Response>;
356
445
  hasServeHandler(): Promise<boolean>;
@@ -49,7 +49,8 @@ async function connect(options = {}) {
49
49
  nextStreamId: 1,
50
50
  connected: true,
51
51
  streamResponses: new Map,
52
- uploadStreams: new Map
52
+ uploadStreams: new Map,
53
+ moduleSourceCache: new Map
53
54
  };
54
55
  const parser = import_isolate_protocol.createFrameParser();
55
56
  socket.on("data", (data) => {
@@ -64,15 +65,38 @@ async function connect(options = {}) {
64
65
  socket.on("close", () => {
65
66
  state.connected = false;
66
67
  for (const [, pending] of state.pendingRequests) {
68
+ if (pending.timeoutId) {
69
+ clearTimeout(pending.timeoutId);
70
+ }
67
71
  pending.reject(new Error("Connection closed"));
68
72
  }
69
73
  state.pendingRequests.clear();
74
+ for (const [, receiver] of state.streamResponses) {
75
+ receiver.state = "errored";
76
+ receiver.error = new Error("Connection closed");
77
+ const resolvers = receiver.pullResolvers.splice(0);
78
+ for (const resolver of resolvers) {
79
+ resolver();
80
+ }
81
+ }
82
+ state.streamResponses.clear();
83
+ for (const [, session] of state.uploadStreams) {
84
+ session.state = "closed";
85
+ if (session.creditResolver) {
86
+ session.creditResolver();
87
+ }
88
+ }
89
+ state.uploadStreams.clear();
70
90
  });
71
91
  socket.on("error", (err) => {
72
92
  console.error("Socket error:", err);
73
93
  });
74
94
  return {
75
95
  createRuntime: (runtimeOptions) => createRuntime(state, runtimeOptions),
96
+ createNamespace: (id) => ({
97
+ id,
98
+ createRuntime: (runtimeOptions) => createRuntime(state, runtimeOptions, id)
99
+ }),
76
100
  close: async () => {
77
101
  state.connected = false;
78
102
  socket.destroy();
@@ -175,23 +199,35 @@ function handleMessage(message, state) {
175
199
  metadata: msg.metadata,
176
200
  controller: null,
177
201
  state: "active",
178
- pendingChunks: []
202
+ pendingChunks: [],
203
+ pullResolvers: [],
204
+ controllerFinalized: false
179
205
  };
180
206
  const readableStream = new ReadableStream({
181
207
  start(controller) {
182
208
  receiver.controller = controller;
183
209
  },
184
210
  pull(_controller) {
211
+ if (receiver.controllerFinalized) {
212
+ return;
213
+ }
185
214
  while (receiver.pendingChunks.length > 0) {
186
215
  const chunk = receiver.pendingChunks.shift();
187
216
  receiver.controller.enqueue(chunk);
188
217
  }
189
218
  if (receiver.state === "closed") {
190
- receiver.controller.close();
191
- return;
219
+ if (!receiver.controllerFinalized) {
220
+ receiver.controllerFinalized = true;
221
+ receiver.controller.close();
222
+ }
223
+ return Promise.resolve();
192
224
  }
193
225
  if (receiver.state === "errored") {
194
- return;
226
+ if (!receiver.controllerFinalized && receiver.error) {
227
+ receiver.controllerFinalized = true;
228
+ receiver.controller.error(receiver.error);
229
+ }
230
+ return Promise.resolve();
195
231
  }
196
232
  sendMessage(state.socket, {
197
233
  type: import_isolate_protocol.MessageType.STREAM_PULL,
@@ -199,17 +235,23 @@ function handleMessage(message, state) {
199
235
  maxBytes: import_isolate_protocol.STREAM_DEFAULT_CREDIT
200
236
  });
201
237
  return new Promise((resolve) => {
202
- receiver.pullResolver = resolve;
238
+ receiver.pullResolvers.push(resolve);
203
239
  });
204
240
  },
205
241
  cancel(_reason) {
206
- receiver.state = "errored";
242
+ receiver.state = "closed";
243
+ receiver.controllerFinalized = true;
244
+ const resolvers = receiver.pullResolvers.splice(0);
245
+ for (const resolver of resolvers) {
246
+ resolver();
247
+ }
207
248
  sendMessage(state.socket, {
208
249
  type: import_isolate_protocol.MessageType.STREAM_ERROR,
209
250
  streamId: msg.streamId,
210
251
  error: "Stream cancelled by consumer"
211
252
  });
212
253
  state.streamResponses.delete(msg.streamId);
254
+ return new Promise((resolve) => setTimeout(resolve, 0));
213
255
  }
214
256
  });
215
257
  state.streamResponses.set(msg.streamId, receiver);
@@ -236,10 +278,9 @@ function handleMessage(message, state) {
236
278
  const msg = message;
237
279
  const receiver = state.streamResponses.get(msg.streamId);
238
280
  if (receiver && receiver.state === "active") {
239
- if (receiver.pullResolver) {
281
+ if (receiver.pullResolvers.length > 0) {
240
282
  receiver.controller.enqueue(msg.chunk);
241
- const resolver = receiver.pullResolver;
242
- receiver.pullResolver = undefined;
283
+ const resolver = receiver.pullResolvers.shift();
243
284
  resolver();
244
285
  } else {
245
286
  receiver.pendingChunks.push(msg.chunk);
@@ -256,10 +297,12 @@ function handleMessage(message, state) {
256
297
  const chunk = receiver.pendingChunks.shift();
257
298
  receiver.controller.enqueue(chunk);
258
299
  }
259
- receiver.controller.close();
260
- if (receiver.pullResolver) {
261
- const resolver = receiver.pullResolver;
262
- receiver.pullResolver = undefined;
300
+ if (!receiver.controllerFinalized) {
301
+ receiver.controllerFinalized = true;
302
+ receiver.controller.close();
303
+ }
304
+ const resolvers = receiver.pullResolvers.splice(0);
305
+ for (const resolver of resolvers) {
263
306
  resolver();
264
307
  }
265
308
  state.streamResponses.delete(msg.streamId);
@@ -288,10 +331,13 @@ function handleMessage(message, state) {
288
331
  const receiver = state.streamResponses.get(msg.streamId);
289
332
  if (receiver) {
290
333
  receiver.state = "errored";
291
- receiver.controller.error(new Error(msg.error));
292
- if (receiver.pullResolver) {
293
- const resolver = receiver.pullResolver;
294
- receiver.pullResolver = undefined;
334
+ receiver.error = new Error(msg.error);
335
+ while (receiver.pendingChunks.length > 0) {
336
+ const chunk = receiver.pendingChunks.shift();
337
+ receiver.controller.enqueue(chunk);
338
+ }
339
+ const resolvers = receiver.pullResolvers.splice(0);
340
+ for (const resolver of resolvers) {
295
341
  resolver();
296
342
  }
297
343
  state.streamResponses.delete(msg.streamId);
@@ -351,7 +397,7 @@ function sendRequest(state, message, timeout = DEFAULT_TIMEOUT) {
351
397
  sendMessage(state.socket, message);
352
398
  });
353
399
  }
354
- async function createRuntime(state, options = {}) {
400
+ async function createRuntime(state, options = {}, namespaceId) {
355
401
  const callbacks = {};
356
402
  if (options.console) {
357
403
  callbacks.console = registerConsoleCallbacks(state, options.console);
@@ -474,11 +520,13 @@ async function createRuntime(state, options = {}) {
474
520
  memoryLimitMB: options.memoryLimitMB,
475
521
  cwd: options.cwd,
476
522
  callbacks,
477
- testEnvironment: testEnvironmentOption
523
+ testEnvironment: testEnvironmentOption,
524
+ namespaceId
478
525
  }
479
526
  };
480
527
  const result = await sendRequest(state, request);
481
528
  const isolateId = result.isolateId;
529
+ const reused = result.reused ?? false;
482
530
  const wsCommandCallbacks = new Set;
483
531
  isolateWsCallbacks.set(isolateId, wsCommandCallbacks);
484
532
  const fetchHandle = {
@@ -722,6 +770,7 @@ async function createRuntime(state, options = {}) {
722
770
  return {
723
771
  id: isolateId,
724
772
  isolateId,
773
+ reused,
725
774
  fetch: fetchHandle,
726
775
  timers: timersHandle,
727
776
  console: consoleHandle,
@@ -855,7 +904,14 @@ function registerFsCallbacks(state, callbacks) {
855
904
  function registerModuleLoaderCallback(state, callback) {
856
905
  const callbackId = state.nextCallbackId++;
857
906
  state.callbacks.set(callbackId, async (moduleName) => {
858
- return callback(moduleName);
907
+ const specifier = moduleName;
908
+ const cached = state.moduleSourceCache.get(specifier);
909
+ if (cached !== undefined) {
910
+ return cached;
911
+ }
912
+ const source = await callback(specifier);
913
+ state.moduleSourceCache.set(specifier, source);
914
+ return source;
859
915
  });
860
916
  return { callbackId, name: "moduleLoader", type: "async" };
861
917
  }
@@ -1182,4 +1238,4 @@ async function sendBodyStream(state, streamId, body) {
1182
1238
  }
1183
1239
  })
1184
1240
 
1185
- //# debugId=5325684CDAEFBA9A64756E2164756E21
1241
+ //# debugId=6FA96113BD7C15F464756E2164756E21