@ricsam/isolate-client 0.1.14 → 0.1.16
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 +238 -6
- package/dist/cjs/connection.cjs +377 -165
- package/dist/cjs/connection.cjs.map +3 -3
- package/dist/cjs/index.cjs +2 -1
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/connection.mjs +383 -163
- package/dist/mjs/connection.mjs.map +3 -3
- package/dist/mjs/index.mjs +3 -2
- package/dist/mjs/index.mjs.map +2 -2
- package/dist/mjs/package.json +1 -1
- package/dist/types/connection.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/types.d.ts +17 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ const runtime = await client.createRuntime({
|
|
|
37
37
|
console: {
|
|
38
38
|
onEntry: (entry) => console.log("[isolate]", entry),
|
|
39
39
|
},
|
|
40
|
-
fetch: async (
|
|
40
|
+
fetch: async (url, init) => fetch(url, init),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
// Execute code (always ES module mode)
|
|
@@ -145,6 +145,7 @@ interface Namespace {
|
|
|
145
145
|
- Namespaced runtimes are cached on dispose and evicted via LRU when `maxIsolates` limit is reached
|
|
146
146
|
- Cross-client reuse is allowed - any connection can reuse a namespace by ID
|
|
147
147
|
- 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
|
|
148
|
+
- Concurrent createRuntime calls for the same namespace are rejected with `Namespace "<id>" creation already in progress`
|
|
148
149
|
|
|
149
150
|
## Module Loader
|
|
150
151
|
|
|
@@ -270,6 +271,93 @@ const runtime = await client.createRuntime({
|
|
|
270
271
|
});
|
|
271
272
|
```
|
|
272
273
|
|
|
274
|
+
## WebSocket Client Callback
|
|
275
|
+
|
|
276
|
+
Control outbound WebSocket connections from isolate code. The callback lets you allow, block, or proxy WebSocket connections:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const runtime = await client.createRuntime({
|
|
280
|
+
webSocket: async (url: string, protocols: string[]) => {
|
|
281
|
+
// Block connections to certain hosts
|
|
282
|
+
if (url.includes("blocked.com")) {
|
|
283
|
+
return null; // Connection blocked
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Proxy to a different server
|
|
287
|
+
if (url.includes("internal")) {
|
|
288
|
+
return new WebSocket("wss://proxy.example.com" + new URL(url).pathname);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Allow connection normally
|
|
292
|
+
return new WebSocket(url, protocols.length > 0 ? protocols : undefined);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Isolate code can now use WHATWG WebSocket API
|
|
297
|
+
await runtime.eval(`
|
|
298
|
+
const ws = new WebSocket("wss://api.example.com/stream");
|
|
299
|
+
|
|
300
|
+
ws.onopen = () => {
|
|
301
|
+
console.log("Connected!");
|
|
302
|
+
ws.send("Hello server");
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
ws.onmessage = (event) => {
|
|
306
|
+
console.log("Received:", event.data);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
ws.onclose = (event) => {
|
|
310
|
+
console.log("Closed:", event.code, event.reason);
|
|
311
|
+
};
|
|
312
|
+
`);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### WebSocket Callback Behavior
|
|
316
|
+
|
|
317
|
+
| Return Value | Behavior |
|
|
318
|
+
|--------------|----------|
|
|
319
|
+
| `WebSocket` instance | Use this WebSocket for the connection |
|
|
320
|
+
| `null` | Block the connection (isolate receives error + close events) |
|
|
321
|
+
| `Promise<WebSocket>` | Async - wait for WebSocket |
|
|
322
|
+
| `Promise<null>` | Async - block the connection |
|
|
323
|
+
| Throws/rejects | Block the connection with error |
|
|
324
|
+
|
|
325
|
+
### What "Blocked" Looks Like in the Isolate
|
|
326
|
+
|
|
327
|
+
When a connection is blocked, the isolate sees it as a failed connection (similar to server unreachable):
|
|
328
|
+
|
|
329
|
+
```javascript
|
|
330
|
+
const ws = new WebSocket("wss://blocked.com");
|
|
331
|
+
|
|
332
|
+
ws.onerror = (event) => {
|
|
333
|
+
// Fires first
|
|
334
|
+
console.log("Connection failed");
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
ws.onclose = (event) => {
|
|
338
|
+
// Then fires with:
|
|
339
|
+
console.log(event.code); // 1006 (Abnormal Closure)
|
|
340
|
+
console.log(event.reason); // "Connection blocked"
|
|
341
|
+
console.log(event.wasClean); // false
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ws.onopen never fires
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Default Behavior
|
|
348
|
+
|
|
349
|
+
If no `webSocket` callback is provided, connections are allowed automatically:
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
// No callback - all WebSocket connections are auto-allowed
|
|
353
|
+
const runtime = await client.createRuntime({});
|
|
354
|
+
|
|
355
|
+
await runtime.eval(`
|
|
356
|
+
// This will connect directly
|
|
357
|
+
const ws = new WebSocket("wss://echo.websocket.org");
|
|
358
|
+
`);
|
|
359
|
+
```
|
|
360
|
+
|
|
273
361
|
## Test Environment
|
|
274
362
|
|
|
275
363
|
Enable test environment to run tests inside the sandbox:
|
|
@@ -327,19 +415,27 @@ type TestEvent =
|
|
|
327
415
|
|
|
328
416
|
## Playwright Integration
|
|
329
417
|
|
|
330
|
-
Run browser automation with untrusted code.
|
|
418
|
+
Run browser automation with untrusted code. Public API is handler-first:
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
import { defaultPlaywrightHandler } from "@ricsam/isolate-playwright/client";
|
|
422
|
+
|
|
423
|
+
playwright: { handler: defaultPlaywrightHandler(page) }
|
|
424
|
+
```
|
|
331
425
|
|
|
332
426
|
### Script Mode (No Tests)
|
|
333
427
|
|
|
334
428
|
```typescript
|
|
335
429
|
import { chromium } from "playwright";
|
|
430
|
+
import { defaultPlaywrightHandler } from "@ricsam/isolate-playwright/client";
|
|
336
431
|
|
|
337
432
|
const browser = await chromium.launch({ headless: true });
|
|
338
433
|
const page = await browser.newPage();
|
|
339
434
|
|
|
340
435
|
const runtime = await client.createRuntime({
|
|
341
436
|
playwright: {
|
|
342
|
-
page,
|
|
437
|
+
handler: defaultPlaywrightHandler(page),
|
|
438
|
+
timeout: 30000, // Default timeout for operations
|
|
343
439
|
onEvent: (event) => {
|
|
344
440
|
// Unified event handler for all playwright events
|
|
345
441
|
if (event.type === "browserConsoleLog") {
|
|
@@ -374,6 +470,7 @@ Combine `testEnvironment` and `playwright` for browser testing. Playwright exten
|
|
|
374
470
|
|
|
375
471
|
```typescript
|
|
376
472
|
import { chromium } from "playwright";
|
|
473
|
+
import { defaultPlaywrightHandler } from "@ricsam/isolate-playwright/client";
|
|
377
474
|
|
|
378
475
|
const browser = await chromium.launch({ headless: true });
|
|
379
476
|
const page = await browser.newPage();
|
|
@@ -391,7 +488,7 @@ const runtime = await client.createRuntime({
|
|
|
391
488
|
},
|
|
392
489
|
testEnvironment: true, // Provides describe, it, expect
|
|
393
490
|
playwright: {
|
|
394
|
-
page,
|
|
491
|
+
handler: defaultPlaywrightHandler(page),
|
|
395
492
|
console: true, // Routes browser logs through the console handler above
|
|
396
493
|
},
|
|
397
494
|
});
|
|
@@ -418,6 +515,141 @@ await runtime.dispose();
|
|
|
418
515
|
await browser.close();
|
|
419
516
|
```
|
|
420
517
|
|
|
518
|
+
### File Operations (Screenshots, PDFs, File Uploads)
|
|
519
|
+
|
|
520
|
+
For security, file system access requires explicit callbacks. Without these callbacks, operations with file paths will throw errors:
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { chromium } from "playwright";
|
|
524
|
+
import * as fs from "node:fs/promises";
|
|
525
|
+
import * as path from "node:path";
|
|
526
|
+
import { defaultPlaywrightHandler } from "@ricsam/isolate-playwright/client";
|
|
527
|
+
|
|
528
|
+
const browser = await chromium.launch({ headless: true });
|
|
529
|
+
const page = await browser.newPage();
|
|
530
|
+
|
|
531
|
+
const runtime = await client.createRuntime({
|
|
532
|
+
testEnvironment: true,
|
|
533
|
+
playwright: {
|
|
534
|
+
handler: defaultPlaywrightHandler(page, {
|
|
535
|
+
// Callback for writing screenshots and PDFs to disk
|
|
536
|
+
writeFile: async (filePath: string, data: Buffer) => {
|
|
537
|
+
// Validate path, then write
|
|
538
|
+
if (!filePath.startsWith("/allowed/output/")) {
|
|
539
|
+
throw new Error("Write not allowed to this path");
|
|
540
|
+
}
|
|
541
|
+
await fs.writeFile(filePath, data);
|
|
542
|
+
},
|
|
543
|
+
// Callback for reading files for setInputFiles()
|
|
544
|
+
readFile: async (filePath: string) => {
|
|
545
|
+
// Validate path, then read
|
|
546
|
+
if (!filePath.startsWith("/allowed/uploads/")) {
|
|
547
|
+
throw new Error("Read not allowed from this path");
|
|
548
|
+
}
|
|
549
|
+
const buffer = await fs.readFile(filePath);
|
|
550
|
+
return {
|
|
551
|
+
name: path.basename(filePath),
|
|
552
|
+
mimeType: "application/octet-stream", // Determine from extension
|
|
553
|
+
buffer,
|
|
554
|
+
};
|
|
555
|
+
},
|
|
556
|
+
}),
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await runtime.eval(`
|
|
561
|
+
test('file operations', async () => {
|
|
562
|
+
await page.goto('data:text/html,<input type="file" id="upload" />');
|
|
563
|
+
|
|
564
|
+
// Screenshot with path - calls writeFile callback
|
|
565
|
+
const base64 = await page.screenshot({ path: '/allowed/output/screenshot.png' });
|
|
566
|
+
// base64 is always returned, writeFile is called additionally
|
|
567
|
+
|
|
568
|
+
// PDF with path - calls writeFile callback
|
|
569
|
+
await page.pdf({ path: '/allowed/output/document.pdf' });
|
|
570
|
+
|
|
571
|
+
// File upload with path - calls readFile callback
|
|
572
|
+
await page.locator('#upload').setInputFiles('/allowed/uploads/test.txt');
|
|
573
|
+
|
|
574
|
+
// File upload with buffer data - no callback needed
|
|
575
|
+
await page.locator('#upload').setInputFiles([{
|
|
576
|
+
name: 'inline.txt',
|
|
577
|
+
mimeType: 'text/plain',
|
|
578
|
+
buffer: new TextEncoder().encode('Hello, World!'),
|
|
579
|
+
}]);
|
|
580
|
+
});
|
|
581
|
+
`);
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
**Behavior without callbacks:**
|
|
585
|
+
- `screenshot()` / `pdf()` without path: Returns base64 string (works without callback)
|
|
586
|
+
- `screenshot({ path })` / `pdf({ path })` without `writeFile`: Throws error
|
|
587
|
+
- `setInputFiles('/path')` without `readFile`: Throws error
|
|
588
|
+
- `setInputFiles([{ name, mimeType, buffer }])`: Works without callback (inline data)
|
|
589
|
+
|
|
590
|
+
### Multi-Page Testing
|
|
591
|
+
|
|
592
|
+
For tests that need multiple pages or browser contexts, provide `createPage` and/or `createContext` callbacks:
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
import { chromium } from "playwright";
|
|
596
|
+
import { defaultPlaywrightHandler } from "@ricsam/isolate-playwright/client";
|
|
597
|
+
|
|
598
|
+
const browser = await chromium.launch({ headless: true });
|
|
599
|
+
const browserContext = await browser.newContext();
|
|
600
|
+
const page = await browserContext.newPage();
|
|
601
|
+
|
|
602
|
+
const runtime = await client.createRuntime({
|
|
603
|
+
testEnvironment: true,
|
|
604
|
+
playwright: {
|
|
605
|
+
handler: defaultPlaywrightHandler(page, {
|
|
606
|
+
// Called when isolate code calls context.newPage(); receive the BrowserContext and call context.newPage()
|
|
607
|
+
createPage: async (context) => context.newPage(),
|
|
608
|
+
// Called when isolate code calls browser.newContext()
|
|
609
|
+
createContext: async (options) => browser.newContext(options),
|
|
610
|
+
}),
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
await runtime.eval(`
|
|
615
|
+
test('multi-page test', async () => {
|
|
616
|
+
// Create additional pages
|
|
617
|
+
const page2 = await context.newPage();
|
|
618
|
+
|
|
619
|
+
// Navigate independently
|
|
620
|
+
await page.goto('https://example.com/page1');
|
|
621
|
+
await page2.goto('https://example.com/page2');
|
|
622
|
+
|
|
623
|
+
// Work with multiple pages
|
|
624
|
+
await page.locator('#button').click();
|
|
625
|
+
await page2.locator('#input').fill('text');
|
|
626
|
+
|
|
627
|
+
await page2.close();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test('multi-context test', async () => {
|
|
631
|
+
// Create isolated context (separate cookies, storage)
|
|
632
|
+
const ctx2 = await browser.newContext();
|
|
633
|
+
const page2 = await ctx2.newPage();
|
|
634
|
+
|
|
635
|
+
// Cookies are isolated between contexts
|
|
636
|
+
await context.addCookies([{ name: 'test', value: '1', domain: 'example.com', path: '/' }]);
|
|
637
|
+
const ctx1Cookies = await context.cookies();
|
|
638
|
+
const ctx2Cookies = await ctx2.cookies();
|
|
639
|
+
|
|
640
|
+
expect(ctx1Cookies.some(c => c.name === 'test')).toBe(true);
|
|
641
|
+
expect(ctx2Cookies.some(c => c.name === 'test')).toBe(false);
|
|
642
|
+
|
|
643
|
+
await ctx2.close();
|
|
644
|
+
});
|
|
645
|
+
`);
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Behavior without lifecycle callbacks:**
|
|
649
|
+
- `context.newPage()` without `createPage`: Throws error
|
|
650
|
+
- `browser.newContext()` without `createContext`: Throws error
|
|
651
|
+
- `context.cookies()`, `context.addCookies()`, `context.clearCookies()`: Work without callbacks
|
|
652
|
+
|
|
421
653
|
## Runtime Interface
|
|
422
654
|
|
|
423
655
|
```typescript
|
|
@@ -476,8 +708,8 @@ interface RemoteTestEnvironmentHandle {
|
|
|
476
708
|
}
|
|
477
709
|
|
|
478
710
|
interface RemotePlaywrightHandle {
|
|
479
|
-
getCollectedData():
|
|
480
|
-
clearCollectedData():
|
|
711
|
+
getCollectedData(): CollectedData;
|
|
712
|
+
clearCollectedData(): void;
|
|
481
713
|
}
|
|
482
714
|
```
|
|
483
715
|
|