@ricsam/isolate-client 0.1.13 → 0.1.15

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