@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 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 (request) => fetch(request),
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. **The client owns the browser** - you provide the Playwright page object:
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(): Promise<CollectedData>;
480
- clearCollectedData(): Promise<void>;
711
+ getCollectedData(): CollectedData;
712
+ clearCollectedData(): void;
481
713
  }
482
714
  ```
483
715