@ricsam/isolate-client 0.1.5 → 0.1.7

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>;
@@ -1,5 +1,4 @@
1
- // @bun @bun-cjs
2
- (function(exports, require, module, __filename, __dirname) {var __defProp = Object.defineProperty;
1
+ var __defProp = Object.defineProperty;
3
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __hasOwnProp = Object.prototype.hasOwnProperty;
@@ -33,7 +32,7 @@ __export(exports_connection, {
33
32
  connect: () => connect
34
33
  });
35
34
  module.exports = __toCommonJS(exports_connection);
36
- var import_node_net = require("net");
35
+ var import_node_net = require("node:net");
37
36
  var import_isolate_protocol = require("@ricsam/isolate-protocol");
38
37
  var import_client = require("@ricsam/isolate-playwright/client");
39
38
  var DEFAULT_TIMEOUT = 30000;
@@ -49,7 +48,8 @@ async function connect(options = {}) {
49
48
  nextStreamId: 1,
50
49
  connected: true,
51
50
  streamResponses: new Map,
52
- uploadStreams: new Map
51
+ uploadStreams: new Map,
52
+ moduleSourceCache: new Map
53
53
  };
54
54
  const parser = import_isolate_protocol.createFrameParser();
55
55
  socket.on("data", (data) => {
@@ -64,15 +64,38 @@ async function connect(options = {}) {
64
64
  socket.on("close", () => {
65
65
  state.connected = false;
66
66
  for (const [, pending] of state.pendingRequests) {
67
+ if (pending.timeoutId) {
68
+ clearTimeout(pending.timeoutId);
69
+ }
67
70
  pending.reject(new Error("Connection closed"));
68
71
  }
69
72
  state.pendingRequests.clear();
73
+ for (const [, receiver] of state.streamResponses) {
74
+ receiver.state = "errored";
75
+ receiver.error = new Error("Connection closed");
76
+ const resolvers = receiver.pullResolvers.splice(0);
77
+ for (const resolver of resolvers) {
78
+ resolver();
79
+ }
80
+ }
81
+ state.streamResponses.clear();
82
+ for (const [, session] of state.uploadStreams) {
83
+ session.state = "closed";
84
+ if (session.creditResolver) {
85
+ session.creditResolver();
86
+ }
87
+ }
88
+ state.uploadStreams.clear();
70
89
  });
71
90
  socket.on("error", (err) => {
72
91
  console.error("Socket error:", err);
73
92
  });
74
93
  return {
75
94
  createRuntime: (runtimeOptions) => createRuntime(state, runtimeOptions),
95
+ createNamespace: (id) => ({
96
+ id,
97
+ createRuntime: (runtimeOptions) => createRuntime(state, runtimeOptions, id)
98
+ }),
76
99
  close: async () => {
77
100
  state.connected = false;
78
101
  socket.destroy();
@@ -175,23 +198,35 @@ function handleMessage(message, state) {
175
198
  metadata: msg.metadata,
176
199
  controller: null,
177
200
  state: "active",
178
- pendingChunks: []
201
+ pendingChunks: [],
202
+ pullResolvers: [],
203
+ controllerFinalized: false
179
204
  };
180
205
  const readableStream = new ReadableStream({
181
206
  start(controller) {
182
207
  receiver.controller = controller;
183
208
  },
184
209
  pull(_controller) {
210
+ if (receiver.controllerFinalized) {
211
+ return;
212
+ }
185
213
  while (receiver.pendingChunks.length > 0) {
186
214
  const chunk = receiver.pendingChunks.shift();
187
215
  receiver.controller.enqueue(chunk);
188
216
  }
189
217
  if (receiver.state === "closed") {
190
- receiver.controller.close();
191
- return;
218
+ if (!receiver.controllerFinalized) {
219
+ receiver.controllerFinalized = true;
220
+ receiver.controller.close();
221
+ }
222
+ return Promise.resolve();
192
223
  }
193
224
  if (receiver.state === "errored") {
194
- return;
225
+ if (!receiver.controllerFinalized && receiver.error) {
226
+ receiver.controllerFinalized = true;
227
+ receiver.controller.error(receiver.error);
228
+ }
229
+ return Promise.resolve();
195
230
  }
196
231
  sendMessage(state.socket, {
197
232
  type: import_isolate_protocol.MessageType.STREAM_PULL,
@@ -199,17 +234,23 @@ function handleMessage(message, state) {
199
234
  maxBytes: import_isolate_protocol.STREAM_DEFAULT_CREDIT
200
235
  });
201
236
  return new Promise((resolve) => {
202
- receiver.pullResolver = resolve;
237
+ receiver.pullResolvers.push(resolve);
203
238
  });
204
239
  },
205
240
  cancel(_reason) {
206
- receiver.state = "errored";
241
+ receiver.state = "closed";
242
+ receiver.controllerFinalized = true;
243
+ const resolvers = receiver.pullResolvers.splice(0);
244
+ for (const resolver of resolvers) {
245
+ resolver();
246
+ }
207
247
  sendMessage(state.socket, {
208
248
  type: import_isolate_protocol.MessageType.STREAM_ERROR,
209
249
  streamId: msg.streamId,
210
250
  error: "Stream cancelled by consumer"
211
251
  });
212
252
  state.streamResponses.delete(msg.streamId);
253
+ return new Promise((resolve) => setTimeout(resolve, 0));
213
254
  }
214
255
  });
215
256
  state.streamResponses.set(msg.streamId, receiver);
@@ -236,10 +277,9 @@ function handleMessage(message, state) {
236
277
  const msg = message;
237
278
  const receiver = state.streamResponses.get(msg.streamId);
238
279
  if (receiver && receiver.state === "active") {
239
- if (receiver.pullResolver) {
280
+ if (receiver.pullResolvers.length > 0) {
240
281
  receiver.controller.enqueue(msg.chunk);
241
- const resolver = receiver.pullResolver;
242
- receiver.pullResolver = undefined;
282
+ const resolver = receiver.pullResolvers.shift();
243
283
  resolver();
244
284
  } else {
245
285
  receiver.pendingChunks.push(msg.chunk);
@@ -256,10 +296,12 @@ function handleMessage(message, state) {
256
296
  const chunk = receiver.pendingChunks.shift();
257
297
  receiver.controller.enqueue(chunk);
258
298
  }
259
- receiver.controller.close();
260
- if (receiver.pullResolver) {
261
- const resolver = receiver.pullResolver;
262
- receiver.pullResolver = undefined;
299
+ if (!receiver.controllerFinalized) {
300
+ receiver.controllerFinalized = true;
301
+ receiver.controller.close();
302
+ }
303
+ const resolvers = receiver.pullResolvers.splice(0);
304
+ for (const resolver of resolvers) {
263
305
  resolver();
264
306
  }
265
307
  state.streamResponses.delete(msg.streamId);
@@ -288,10 +330,13 @@ function handleMessage(message, state) {
288
330
  const receiver = state.streamResponses.get(msg.streamId);
289
331
  if (receiver) {
290
332
  receiver.state = "errored";
291
- receiver.controller.error(new Error(msg.error));
292
- if (receiver.pullResolver) {
293
- const resolver = receiver.pullResolver;
294
- receiver.pullResolver = undefined;
333
+ receiver.error = new Error(msg.error);
334
+ while (receiver.pendingChunks.length > 0) {
335
+ const chunk = receiver.pendingChunks.shift();
336
+ receiver.controller.enqueue(chunk);
337
+ }
338
+ const resolvers = receiver.pullResolvers.splice(0);
339
+ for (const resolver of resolvers) {
295
340
  resolver();
296
341
  }
297
342
  state.streamResponses.delete(msg.streamId);
@@ -351,7 +396,7 @@ function sendRequest(state, message, timeout = DEFAULT_TIMEOUT) {
351
396
  sendMessage(state.socket, message);
352
397
  });
353
398
  }
354
- async function createRuntime(state, options = {}) {
399
+ async function createRuntime(state, options = {}, namespaceId) {
355
400
  const callbacks = {};
356
401
  if (options.console) {
357
402
  callbacks.console = registerConsoleCallbacks(state, options.console);
@@ -474,11 +519,13 @@ async function createRuntime(state, options = {}) {
474
519
  memoryLimitMB: options.memoryLimitMB,
475
520
  cwd: options.cwd,
476
521
  callbacks,
477
- testEnvironment: testEnvironmentOption
522
+ testEnvironment: testEnvironmentOption,
523
+ namespaceId
478
524
  }
479
525
  };
480
526
  const result = await sendRequest(state, request);
481
527
  const isolateId = result.isolateId;
528
+ const reused = result.reused ?? false;
482
529
  const wsCommandCallbacks = new Set;
483
530
  isolateWsCallbacks.set(isolateId, wsCommandCallbacks);
484
531
  const fetchHandle = {
@@ -722,6 +769,7 @@ async function createRuntime(state, options = {}) {
722
769
  return {
723
770
  id: isolateId,
724
771
  isolateId,
772
+ reused,
725
773
  fetch: fetchHandle,
726
774
  timers: timersHandle,
727
775
  console: consoleHandle,
@@ -855,7 +903,14 @@ function registerFsCallbacks(state, callbacks) {
855
903
  function registerModuleLoaderCallback(state, callback) {
856
904
  const callbackId = state.nextCallbackId++;
857
905
  state.callbacks.set(callbackId, async (moduleName) => {
858
- return callback(moduleName);
906
+ const specifier = moduleName;
907
+ const cached = state.moduleSourceCache.get(specifier);
908
+ if (cached !== undefined) {
909
+ return cached;
910
+ }
911
+ const source = await callback(specifier);
912
+ state.moduleSourceCache.set(specifier, source);
913
+ return source;
859
914
  });
860
915
  return { callbackId, name: "moduleLoader", type: "async" };
861
916
  }
@@ -1180,6 +1235,5 @@ async function sendBodyStream(state, streamId, body) {
1180
1235
  state.uploadStreams.delete(streamId);
1181
1236
  }
1182
1237
  }
1183
- })
1184
1238
 
1185
- //# debugId=5325684CDAEFBA9A64756E2164756E21
1239
+ //# debugId=31B62D8DD4910A6464756E2164756E21