@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 +89 -0
- package/dist/cjs/connection.cjs +80 -26
- package/dist/cjs/connection.cjs.map +3 -3
- package/dist/cjs/index.cjs +2 -4
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/types.cjs +2 -4
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/mjs/connection.mjs +79 -24
- package/dist/mjs/connection.mjs.map +3 -3
- package/dist/mjs/index.mjs +1 -2
- package/dist/mjs/index.mjs.map +3 -3
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/types.mjs +1 -2
- package/dist/mjs/types.mjs.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types.d.ts +14 -0
- package/package.json +1 -1
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>;
|
package/dist/cjs/connection.cjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
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.
|
|
191
|
-
|
|
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
|
-
|
|
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.
|
|
237
|
+
receiver.pullResolvers.push(resolve);
|
|
203
238
|
});
|
|
204
239
|
},
|
|
205
240
|
cancel(_reason) {
|
|
206
|
-
receiver.state = "
|
|
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.
|
|
280
|
+
if (receiver.pullResolvers.length > 0) {
|
|
240
281
|
receiver.controller.enqueue(msg.chunk);
|
|
241
|
-
const resolver = receiver.
|
|
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.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
receiver.
|
|
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
|
-
|
|
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=
|
|
1239
|
+
//# debugId=31B62D8DD4910A6464756E2164756E21
|