@ricsam/isolate-client 0.1.4 → 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>;
@@ -35,7 +35,7 @@ __export(exports_connection, {
35
35
  module.exports = __toCommonJS(exports_connection);
36
36
  var import_node_net = require("net");
37
37
  var import_isolate_protocol = require("@ricsam/isolate-protocol");
38
- var import_isolate_playwright = require("@ricsam/isolate-playwright");
38
+ var import_client = require("@ricsam/isolate-playwright/client");
39
39
  var DEFAULT_TIMEOUT = 30000;
40
40
  var isolateWsCallbacks = new Map;
41
41
  async function connect(options = {}) {
@@ -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();
@@ -173,10 +197,76 @@ function handleMessage(message, state) {
173
197
  streamId: msg.streamId,
174
198
  requestId: msg.requestId,
175
199
  metadata: msg.metadata,
176
- chunks: [],
177
- totalBytes: 0
200
+ controller: null,
201
+ state: "active",
202
+ pendingChunks: [],
203
+ pullResolvers: [],
204
+ controllerFinalized: false
178
205
  };
206
+ const readableStream = new ReadableStream({
207
+ start(controller) {
208
+ receiver.controller = controller;
209
+ },
210
+ pull(_controller) {
211
+ if (receiver.controllerFinalized) {
212
+ return;
213
+ }
214
+ while (receiver.pendingChunks.length > 0) {
215
+ const chunk = receiver.pendingChunks.shift();
216
+ receiver.controller.enqueue(chunk);
217
+ }
218
+ if (receiver.state === "closed") {
219
+ if (!receiver.controllerFinalized) {
220
+ receiver.controllerFinalized = true;
221
+ receiver.controller.close();
222
+ }
223
+ return Promise.resolve();
224
+ }
225
+ if (receiver.state === "errored") {
226
+ if (!receiver.controllerFinalized && receiver.error) {
227
+ receiver.controllerFinalized = true;
228
+ receiver.controller.error(receiver.error);
229
+ }
230
+ return Promise.resolve();
231
+ }
232
+ sendMessage(state.socket, {
233
+ type: import_isolate_protocol.MessageType.STREAM_PULL,
234
+ streamId: msg.streamId,
235
+ maxBytes: import_isolate_protocol.STREAM_DEFAULT_CREDIT
236
+ });
237
+ return new Promise((resolve) => {
238
+ receiver.pullResolvers.push(resolve);
239
+ });
240
+ },
241
+ cancel(_reason) {
242
+ receiver.state = "closed";
243
+ receiver.controllerFinalized = true;
244
+ const resolvers = receiver.pullResolvers.splice(0);
245
+ for (const resolver of resolvers) {
246
+ resolver();
247
+ }
248
+ sendMessage(state.socket, {
249
+ type: import_isolate_protocol.MessageType.STREAM_ERROR,
250
+ streamId: msg.streamId,
251
+ error: "Stream cancelled by consumer"
252
+ });
253
+ state.streamResponses.delete(msg.streamId);
254
+ return new Promise((resolve) => setTimeout(resolve, 0));
255
+ }
256
+ });
179
257
  state.streamResponses.set(msg.streamId, receiver);
258
+ const pending = state.pendingRequests.get(msg.requestId);
259
+ if (pending) {
260
+ state.pendingRequests.delete(msg.requestId);
261
+ if (pending.timeoutId)
262
+ clearTimeout(pending.timeoutId);
263
+ const response = new Response(readableStream, {
264
+ status: msg.metadata?.status ?? 200,
265
+ statusText: msg.metadata?.statusText ?? "OK",
266
+ headers: msg.metadata?.headers
267
+ });
268
+ pending.resolve({ response, __streaming: true });
269
+ }
180
270
  sendMessage(state.socket, {
181
271
  type: import_isolate_protocol.MessageType.STREAM_PULL,
182
272
  streamId: msg.streamId,
@@ -187,14 +277,14 @@ function handleMessage(message, state) {
187
277
  case import_isolate_protocol.MessageType.RESPONSE_STREAM_CHUNK: {
188
278
  const msg = message;
189
279
  const receiver = state.streamResponses.get(msg.streamId);
190
- if (receiver) {
191
- receiver.chunks.push(msg.chunk);
192
- receiver.totalBytes += msg.chunk.length;
193
- sendMessage(state.socket, {
194
- type: import_isolate_protocol.MessageType.STREAM_PULL,
195
- streamId: msg.streamId,
196
- maxBytes: import_isolate_protocol.STREAM_DEFAULT_CREDIT
197
- });
280
+ if (receiver && receiver.state === "active") {
281
+ if (receiver.pullResolvers.length > 0) {
282
+ receiver.controller.enqueue(msg.chunk);
283
+ const resolver = receiver.pullResolvers.shift();
284
+ resolver();
285
+ } else {
286
+ receiver.pendingChunks.push(msg.chunk);
287
+ }
198
288
  }
199
289
  break;
200
290
  }
@@ -202,20 +292,18 @@ function handleMessage(message, state) {
202
292
  const msg = message;
203
293
  const receiver = state.streamResponses.get(msg.streamId);
204
294
  if (receiver) {
205
- const body = concatUint8Arrays(receiver.chunks);
206
- const pending = state.pendingRequests.get(receiver.requestId);
207
- if (pending) {
208
- state.pendingRequests.delete(receiver.requestId);
209
- if (pending.timeoutId)
210
- clearTimeout(pending.timeoutId);
211
- pending.resolve({
212
- response: {
213
- status: receiver.metadata?.status ?? 200,
214
- statusText: receiver.metadata?.statusText ?? "OK",
215
- headers: receiver.metadata?.headers ?? [],
216
- body
217
- }
218
- });
295
+ receiver.state = "closed";
296
+ while (receiver.pendingChunks.length > 0) {
297
+ const chunk = receiver.pendingChunks.shift();
298
+ receiver.controller.enqueue(chunk);
299
+ }
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) {
306
+ resolver();
219
307
  }
220
308
  state.streamResponses.delete(msg.streamId);
221
309
  }
@@ -242,12 +330,15 @@ function handleMessage(message, state) {
242
330
  }
243
331
  const receiver = state.streamResponses.get(msg.streamId);
244
332
  if (receiver) {
245
- const pending = state.pendingRequests.get(receiver.requestId);
246
- if (pending) {
247
- state.pendingRequests.delete(receiver.requestId);
248
- if (pending.timeoutId)
249
- clearTimeout(pending.timeoutId);
250
- pending.reject(new Error(msg.error));
333
+ receiver.state = "errored";
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) {
341
+ resolver();
251
342
  }
252
343
  state.streamResponses.delete(msg.streamId);
253
344
  }
@@ -257,16 +348,6 @@ function handleMessage(message, state) {
257
348
  console.warn(`Unexpected message type: ${message.type}`);
258
349
  }
259
350
  }
260
- function concatUint8Arrays(arrays) {
261
- const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
262
- const result = new Uint8Array(totalLength);
263
- let offset = 0;
264
- for (const arr of arrays) {
265
- result.set(arr, offset);
266
- offset += arr.length;
267
- }
268
- return result;
269
- }
270
351
  async function handleCallbackInvoke(invoke, state) {
271
352
  const callback = state.callbacks.get(invoke.callbackId);
272
353
  const response = {
@@ -316,7 +397,7 @@ function sendRequest(state, message, timeout = DEFAULT_TIMEOUT) {
316
397
  sendMessage(state.socket, message);
317
398
  });
318
399
  }
319
- async function createRuntime(state, options = {}) {
400
+ async function createRuntime(state, options = {}, namespaceId) {
320
401
  const callbacks = {};
321
402
  if (options.console) {
322
403
  callbacks.console = registerConsoleCallbacks(state, options.console);
@@ -335,7 +416,7 @@ async function createRuntime(state, options = {}) {
335
416
  }
336
417
  let playwrightHandler;
337
418
  if (options.playwright) {
338
- playwrightHandler = import_isolate_playwright.createPlaywrightHandler(options.playwright.page, {
419
+ playwrightHandler = import_client.createPlaywrightHandler(options.playwright.page, {
339
420
  timeout: options.playwright.timeout,
340
421
  baseUrl: options.playwright.baseUrl
341
422
  });
@@ -439,11 +520,13 @@ async function createRuntime(state, options = {}) {
439
520
  memoryLimitMB: options.memoryLimitMB,
440
521
  cwd: options.cwd,
441
522
  callbacks,
442
- testEnvironment: testEnvironmentOption
523
+ testEnvironment: testEnvironmentOption,
524
+ namespaceId
443
525
  }
444
526
  };
445
527
  const result = await sendRequest(state, request);
446
528
  const isolateId = result.isolateId;
529
+ const reused = result.reused ?? false;
447
530
  const wsCommandCallbacks = new Set;
448
531
  isolateWsCallbacks.set(isolateId, wsCommandCallbacks);
449
532
  const fetchHandle = {
@@ -458,15 +541,21 @@ async function createRuntime(state, options = {}) {
458
541
  request: serializableRequest,
459
542
  options: opts
460
543
  };
544
+ const handleResponse = (res) => {
545
+ if (res.__streaming && res.response instanceof Response) {
546
+ return res.response;
547
+ }
548
+ return deserializeResponse(res.response);
549
+ };
461
550
  if (serialized.bodyStreamId !== undefined && bodyStream) {
462
551
  const streamId = serialized.bodyStreamId;
463
552
  const responsePromise = sendRequest(state, request2, opts?.timeout ?? DEFAULT_TIMEOUT);
464
553
  await sendBodyStream(state, streamId, bodyStream);
465
554
  const res = await responsePromise;
466
- return deserializeResponse(res.response);
555
+ return handleResponse(res);
467
556
  } else {
468
557
  const res = await sendRequest(state, request2, opts?.timeout ?? DEFAULT_TIMEOUT);
469
- return deserializeResponse(res.response);
558
+ return handleResponse(res);
470
559
  }
471
560
  },
472
561
  async getUpgradeRequest() {
@@ -681,6 +770,7 @@ async function createRuntime(state, options = {}) {
681
770
  return {
682
771
  id: isolateId,
683
772
  isolateId,
773
+ reused,
684
774
  fetch: fetchHandle,
685
775
  timers: timersHandle,
686
776
  console: consoleHandle,
@@ -814,7 +904,14 @@ function registerFsCallbacks(state, callbacks) {
814
904
  function registerModuleLoaderCallback(state, callback) {
815
905
  const callbackId = state.nextCallbackId++;
816
906
  state.callbacks.set(callbackId, async (moduleName) => {
817
- 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;
818
915
  });
819
916
  return { callbackId, name: "moduleLoader", type: "async" };
820
917
  }
@@ -1141,4 +1238,4 @@ async function sendBodyStream(state, streamId, body) {
1141
1238
  }
1142
1239
  })
1143
1240
 
1144
- //# debugId=7380B90E2B96832A64756E2164756E21
1241
+ //# debugId=6FA96113BD7C15F464756E2164756E21