From 4d593a769cbc83442f45eb3dd72cca77e7f3ccdf Mon Sep 17 00:00:00 2001 From: katereznykova Date: Sun, 19 Oct 2025 19:00:08 +0100 Subject: [PATCH 1/5] websockets lightweight wrapper --- examples/websocket/.gitignore | 7 + examples/websocket/README.md | 564 +++++++ examples/websocket/package-lock.json | 1589 ++++++++++++++++++ examples/websocket/package.json | 22 + examples/websocket/src/index-with-helpers.ts | 254 +++ examples/websocket/src/index.ts | 767 +++++++++ examples/websocket/tsconfig.json | 18 + examples/websocket/wrangler.jsonc | 36 + packages/sandbox/src/index.ts | 13 + packages/sandbox/src/websocket.ts | 417 +++++ 10 files changed, 3687 insertions(+) create mode 100644 examples/websocket/.gitignore create mode 100644 examples/websocket/README.md create mode 100644 examples/websocket/package-lock.json create mode 100644 examples/websocket/package.json create mode 100644 examples/websocket/src/index-with-helpers.ts create mode 100644 examples/websocket/src/index.ts create mode 100644 examples/websocket/tsconfig.json create mode 100644 examples/websocket/wrangler.jsonc create mode 100644 packages/sandbox/src/websocket.ts diff --git a/examples/websocket/.gitignore b/examples/websocket/.gitignore new file mode 100644 index 00000000..dd7799e6 --- /dev/null +++ b/examples/websocket/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.wrangler/ +.dev.vars +.env +*.log +.DS_Store diff --git a/examples/websocket/README.md b/examples/websocket/README.md new file mode 100644 index 00000000..ad2003f5 --- /dev/null +++ b/examples/websocket/README.md @@ -0,0 +1,564 @@ +# WebSocket Examples for Sandbox SDK + +This example demonstrates how to use WebSockets with the Cloudflare Sandbox SDK for real-time, bidirectional communication between clients and sandboxed containers. + +## Setup + +```bash +# Install dependencies +npm install + +# Run locally +npm run dev + +# Deploy to Cloudflare +npm run deploy +``` + +## Usage + +### Local Development + +1. Start the development server: + ```bash + npm run dev + ``` + +2. Open your browser to `http://localhost:8787` + + +### WebSocket Endpoints + +#### `/ws/echo` +Basic echo server with sandbox command execution. + +**Client Message Format:** +```json +{ + "type": "echo", + "data": "Hello, sandbox!" +} +``` + +```json +{ + "type": "execute", + "command": "python --version" +} +``` + +**Server Response Format:** +```json +{ + "type": "echo", + "data": "Hello, sandbox!", + "timestamp": 1234567890 +} +``` + +```json +{ + "type": "result", + "stdout": "Python 3.11.0\n", + "stderr": "", + "exitCode": 0 +} +``` + +#### `/ws/code` +Real-time code execution with streaming output. + +**Client Message Format:** +```json +{ + "type": "execute", + "code": "print('Hello from Python')", + "language": "python", + "sessionId": "session-123" +} +``` + +**Server Response Format:** +```json +{ + "type": "stdout", + "data": "Hello from Python\n", + "sessionId": "session-123" +} +``` + +```json +{ + "type": "result", + "sessionId": "session-123", + "results": [...], + "error": null, + "logs": { "stdout": [...], "stderr": [...] } +} +``` + +#### `/ws/process` +Stream output from long-running processes. + +**Client Message Format:** +```json +{ + "type": "start", + "command": "ping", + "args": ["-c", "5", "cloudflare.com"] +} +``` + +```json +{ + "type": "kill" +} +``` + +**Server Response Format:** +```json +{ + "type": "started", + "pid": 12345 +} +``` + +```json +{ + "type": "stdout", + "data": "PING cloudflare.com...\n", + "pid": 12345 +} +``` + +```json +{ + "type": "completed", + "pid": 12345 +} +``` + +#### `/ws/terminal` +Interactive terminal session. + +**Client Message Format:** +``` +ls -la\n +``` + +```json +{ + "type": "resize", + "rows": 24, + "cols": 80 +} +``` + +**Server Response:** +Raw terminal output or JSON status messages. + +## Code Examples + +### JavaScript Client + +```javascript +// Connect to echo server +const ws = new WebSocket('wss://your-worker.workers.dev/ws/echo?id=my-session'); + +ws.onopen = () => { + console.log('Connected'); + + // Send echo message + ws.send(JSON.stringify({ + type: 'echo', + data: 'Hello!' + })); + + // Execute command + ws.send(JSON.stringify({ + type: 'execute', + command: 'python -c "print(2+2)"' + })); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log('Received:', data); +}; + +ws.onclose = () => { + console.log('Disconnected'); +}; +``` + +### Python Client + +```python +import asyncio +import websockets +import json + +async def execute_code(): + uri = "wss://your-worker.workers.dev/ws/code?id=my-session" + + async with websockets.connect(uri) as websocket: + # Send code to execute + await websocket.send(json.dumps({ + "type": "execute", + "code": """ +import time +for i in range(5): + print(f'Count: {i}') + time.sleep(0.5) + """, + "sessionId": "test-1" + })) + + # Receive streaming output + while True: + try: + message = await websocket.recv() + data = json.loads(message) + + if data['type'] == 'stdout': + print(data['data'], end='') + elif data['type'] == 'result': + print('Execution complete') + break + except websockets.exceptions.ConnectionClosed: + break + +asyncio.run(execute_code()) +``` + +### Node.js Client + +```javascript +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://localhost:8787/ws/process?id=node-client'); + +ws.on('open', () => { + console.log('Connected to process streamer'); + + // Start a long-running process + ws.send(JSON.stringify({ + type: 'start', + command: 'python', + args: ['-u', 'long_script.py'] + })); +}); + +ws.on('message', (data) => { + const message = JSON.parse(data); + + if (message.type === 'stdout' || message.type === 'stderr') { + process.stdout.write(message.data); + } else if (message.type === 'completed') { + console.log('Process completed'); + ws.close(); + } +}); + +// Kill process after 10 seconds +setTimeout(() => { + ws.send(JSON.stringify({ type: 'kill' })); +}, 10000); +``` + +## Use Cases + +### 1. Real-Time Data Analysis + +Stream data processing results as they're computed: + +```javascript +const ws = new WebSocket('wss://your-worker.workers.dev/ws/code'); + +ws.send(JSON.stringify({ + type: 'execute', + code: ` +import pandas as pd +import time + +for chunk in pd.read_csv('large_file.csv', chunksize=1000): + result = chunk.describe() + print(result) + time.sleep(0.1) + `, + sessionId: 'analysis-1' +})); +``` + +### 2. AI Code Agent + +AI agent that generates and executes code with real-time feedback: + +```javascript +// Generate code with AI +const code = await generateWithAI(userPrompt); + +// Execute and stream results +ws.send(JSON.stringify({ + type: 'execute', + code: code, + sessionId: 'ai-agent-1' +})); + +// User sees live output as code runs +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'stdout') { + updateUI(data.data); + } +}; +``` + +### 3. Collaborative IDE + +Multiple users share a sandbox: + +```javascript +// User A's connection +const wsA = new WebSocket('wss://your-worker.workers.dev/ws/code?id=shared-123'); + +// User B's connection (same sandbox) +const wsB = new WebSocket('wss://your-worker.workers.dev/ws/code?id=shared-123'); + +// Both see the same execution results +wsA.send(JSON.stringify({ + type: 'execute', + code: 'x = 42' +})); + +wsB.send(JSON.stringify({ + type: 'execute', + code: 'print(x)' // Prints 42 from shared context +})); +``` + +### 4. Live Monitoring + +Monitor sandbox metrics and logs: + +```javascript +const ws = new WebSocket('wss://your-worker.workers.dev/ws/process'); + +ws.send(JSON.stringify({ + type: 'start', + command: 'top', + args: ['-b', '-d', '1'] // Update every second +})); + +// Real-time system monitoring +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'stdout') { + updateMonitoringDashboard(data.data); + } +}; +``` + +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully: + +```javascript +ws.onerror = (error) => { + console.error('WebSocket error:', error); + // Attempt reconnection + setTimeout(() => reconnect(), 1000); +}; + +ws.onclose = (event) => { + if (event.code !== 1000) { // Not a normal closure + console.log('Unexpected close, reconnecting...'); + reconnect(); + } +}; +``` + +### 2. Heartbeat/Ping-Pong + +Keep connections alive: + +```javascript +let pingInterval; + +ws.onopen = () => { + // Send ping every 30 seconds + pingInterval = setInterval(() => { + ws.send(JSON.stringify({ type: 'ping' })); + }, 30000); +}; + +ws.onclose = () => { + clearInterval(pingInterval); +}; +``` + +### 3. Message Buffering + +Buffer messages when disconnected: + +```javascript +const messageQueue = []; +let isConnected = false; + +function sendMessage(msg) { + if (isConnected) { + ws.send(JSON.stringify(msg)); + } else { + messageQueue.push(msg); + } +} + +ws.onopen = () => { + isConnected = true; + // Flush queue + while (messageQueue.length > 0) { + ws.send(JSON.stringify(messageQueue.shift())); + } +}; +``` + +### 4. Rate Limiting + +Prevent overwhelming the sandbox: + +```javascript +class RateLimitedWebSocket { + constructor(url, messagesPerSecond = 10) { + this.ws = new WebSocket(url); + this.queue = []; + this.limit = messagesPerSecond; + this.interval = 1000 / messagesPerSecond; + + setInterval(() => this.processQueue(), this.interval); + } + + send(message) { + this.queue.push(message); + } + + processQueue() { + if (this.queue.length > 0 && this.ws.readyState === 1) { + this.ws.send(this.queue.shift()); + } + } +} +``` + +## Security Considerations + +### Authentication + +Add authentication to WebSocket connections: + +```typescript +// Server side +const token = new URL(request.url).searchParams.get('token'); +if (!token || !(await verifyToken(token))) { + return new Response('Unauthorized', { status: 401 }); +} +``` + +### Input Validation + +Always validate and sanitize inputs: + +```typescript +server.addEventListener('message', async (event) => { + const message = JSON.parse(event.data); + + // Validate message structure + if (!message.type || typeof message.type !== 'string') { + server.send(JSON.stringify({ type: 'error', message: 'Invalid format' })); + return; + } + + // Prevent command injection + if (message.command && /[;&|`$]/.test(message.command)) { + server.send(JSON.stringify({ type: 'error', message: 'Invalid characters' })); + return; + } +}); +``` + +### Resource Limits + +Set limits on connections and execution time: + +```typescript +const MAX_CONNECTIONS = 100; +const MAX_EXECUTION_TIME = 60000; // 60 seconds + +if (activeConnections.size >= MAX_CONNECTIONS) { + server.close(1008, 'Connection limit reached'); + return; +} + +// Set execution timeout +const timeout = setTimeout(() => { + server.close(1000, 'Execution timeout'); +}, MAX_EXECUTION_TIME); +``` + +## Troubleshooting + +### Connection Refused + +Check that WebSocket upgrades are properly handled: +- Verify `Upgrade: websocket` header is present +- Ensure status code 101 is returned +- Check that WebSocketPair is created correctly + +### Messages Not Received + +Ensure proper message formatting: +- Use `JSON.stringify()` for structured data +- Check for serialization errors +- Verify server is calling `server.accept()` + +### Connection Drops + +Implement reconnection logic: +```javascript +let reconnectAttempts = 0; +const MAX_RECONNECT_ATTEMPTS = 5; + +function connect() { + const ws = new WebSocket(url); + + ws.onclose = () => { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + setTimeout(connect, 1000 * reconnectAttempts); + } + }; + + ws.onopen = () => { + reconnectAttempts = 0; + }; +} +``` + +## Performance Tips + +1. **Use Binary Frames** for large data transfers +2. **Buffer Small Messages** to reduce syscalls +3. **Implement Backpressure** to handle slow clients +4. **Use Compression** for text-heavy streams +5. **Batch Updates** when sending frequent small updates + +## Further Reading + +- [Cloudflare Workers WebSocket Documentation](https://developers.cloudflare.com/workers/runtime-apis/websockets/) +- [Sandbox SDK Documentation](https://developers.cloudflare.com/sandbox/) +- [WebSocket RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455) + +## License + +MIT diff --git a/examples/websocket/package-lock.json b/examples/websocket/package-lock.json new file mode 100644 index 00000000..3c89098a --- /dev/null +++ b/examples/websocket/package-lock.json @@ -0,0 +1,1589 @@ +{ + "name": "@cloudflare/sandbox-websocket-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cloudflare/sandbox-websocket-example", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@cloudflare/sandbox": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250111.0", + "typescript": "^5.3.3", + "wrangler": "^4.43.0" + } + }, + "node_modules/@cloudflare/containers": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.0.28.tgz", + "integrity": "sha512-wzR9UWcGvZ9znd4elkXklilPcHX6srncsjSkx696SZRZyTygNbWsLlHegvc1C+e9gn28HRZU3dLiAzXiC9IY1w==", + "license": "ISC" + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/sandbox": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@cloudflare/sandbox/-/sandbox-0.4.3.tgz", + "integrity": "sha512-ODynUVKLID9viGLDDnFpWXNToy4vvN2WCQliJiWvtgBee8K1WiRDI3SYzUHFt3onV8kBHKjkoCbADQHOPBAllA==", + "license": "ISC", + "dependencies": { + "@cloudflare/containers": "^0.0.28" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.7.7", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.7.tgz", + "integrity": "sha512-HtZuh166y0Olbj9bqqySckz0Rw9uHjggJeoGbDx5x+sgezBXlxO6tQSig2RZw5tgObF8mWI8zaPvQMkQZtAODw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.21", + "workerd": "^1.20250927.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20251008.0.tgz", + "integrity": "sha512-yph0H+8mMOK5Z9oDwjb8rI96oTVt4no5lZ43aorcbzsWG9VUIaXSXlBBoB3von6p4YCRW+J3n36fBM9XZ6TLaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20251008.0.tgz", + "integrity": "sha512-Yc4lMGSbM4AEtYRpyDpmk77MsHb6X2BSwJgMgGsLVPmckM7ZHivZkJChfcNQjZ/MGR6nkhYc4iF6TcVS+UMEVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20251008.0.tgz", + "integrity": "sha512-AjoQnylw4/5G6SmfhZRsli7EuIK7ZMhmbxtU0jkpciTlVV8H01OsFOgS1d8zaTXMfkWamEfMouy8oH/L7B9YcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20251008.0.tgz", + "integrity": "sha512-hRy9yyvzVq1HsqHZUmFkAr0C8JGjAD/PeeVEGCKL3jln3M9sNCKIrbDXiL+efe+EwajJNNlDxpO+s30uVWVaRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20251008.0.tgz", + "integrity": "sha512-Gm0RR+ehfNMsScn2pUcn3N9PDUpy7FyvV9ecHEyclKttvztyFOcmsF14bxEaSVv7iM4TxWEBn1rclmYHxDM4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251011.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251011.0.tgz", + "integrity": "sha512-gQpih+pbq3sP4uXltUeCSbPgZxTNp2gQd8639SaIbQMwgA6oJNHLhIART1fWy6DQACngiRzDVULA2x0ohmkGTQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.4.tgz", + "integrity": "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz", + "integrity": "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.7.tgz", + "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "4.20251008.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20251008.0.tgz", + "integrity": "sha512-sKCNYNzXG6l8qg0Oo7y8WcDKcpbgw0qwZsxNpdZilFTR4EavRow2TlcwuPSVN99jqAjhz0M4VXvTdSGdtJ2VfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "sharp": "^0.33.5", + "stoppable": "1.1.0", + "undici": "7.14.0", + "workerd": "1.20251008.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.14.0.tgz", + "integrity": "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.21", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz", + "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.7", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "ufo": "^1.6.1" + } + }, + "node_modules/workerd": { + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20251008.0.tgz", + "integrity": "sha512-HwaJmXO3M1r4S8x2ea2vy8Rw/y/38HRQuK/gNDRQ7w9cJXn6xSl1sIIqKCffULSUjul3wV3I3Nd/GfbmsRReEA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20251008.0", + "@cloudflare/workerd-darwin-arm64": "1.20251008.0", + "@cloudflare/workerd-linux-64": "1.20251008.0", + "@cloudflare/workerd-linux-arm64": "1.20251008.0", + "@cloudflare/workerd-windows-64": "1.20251008.0" + } + }, + "node_modules/wrangler": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.43.0.tgz", + "integrity": "sha512-IBNqXlYHSUSCNNWj/tQN4hFiQy94l7fTxEnJWETXyW69+cjUyjQ7MfeoId3vIV9KBgY8y5M5uf2XulU95OikJg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.0", + "@cloudflare/unenv-preset": "2.7.7", + "blake3-wasm": "2.1.5", + "esbuild": "0.25.4", + "miniflare": "4.20251008.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.21", + "workerd": "1.20251008.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20251008.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/examples/websocket/package.json b/examples/websocket/package.json new file mode 100644 index 00000000..75c12cb3 --- /dev/null +++ b/examples/websocket/package.json @@ -0,0 +1,22 @@ +{ + "name": "@cloudflare/sandbox-websocket-example", + "version": "1.0.0", + "type": "module", + "private": true, + "description": "WebSocket example for Cloudflare Sandbox SDK", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "build": "tsc --noEmit" + }, + "author": "", + "license": "MIT", + "dependencies": { + "@cloudflare/sandbox": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250111.0", + "typescript": "^5.3.3", + "wrangler": "^4.43.0" + } +} diff --git a/examples/websocket/src/index-with-helpers.ts b/examples/websocket/src/index-with-helpers.ts new file mode 100644 index 00000000..371085cc --- /dev/null +++ b/examples/websocket/src/index-with-helpers.ts @@ -0,0 +1,254 @@ +/** + * WebSocket Example Using SDK Helpers + * + * This demonstrates the simplified approach using the new WebSocket helpers + * from @cloudflare/sandbox. Compare with index.ts to see the difference! + */ + +import { createWebSocketHandler, Sandbox } from "@cloudflare/sandbox"; + +interface Env { + Sandbox: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const pathname = url.pathname; + + // Check for WebSocket upgrade + const upgradeHeader = request.headers.get("Upgrade"); + if (upgradeHeader !== "websocket") { + // Serve a simple HTML page for testing + if (pathname === "/") { + return new Response(getTestHTML(), { + headers: { "Content-Type": "text/html" }, + }); + } + return new Response("Expected WebSocket", { status: 426 }); + } + + // Route to different WebSocket handlers + switch (pathname) { + case "/ws/echo": + return handleEchoWebSocket(request, env); + case "/ws/code": + return handleCodeExecutionWebSocket(request, env); + case "/ws/process": + return handleProcessStreamWebSocket(request, env); + case "/ws/terminal": + return handleTerminalWebSocket(request, env); + default: + return new Response("Unknown WebSocket endpoint", { status: 404 }); + } + }, +}; + +/** + * Example 1: Echo Server (Simplified) + */ +async function handleEchoWebSocket( + request: Request, + env: Env +): Promise { + const { response, websocket } = await createWebSocketHandler(request, env.Sandbox, { + onReady: (ws, sandboxId) => { + ws.sendReady("Echo server connected", sandboxId); + }, + onMessage: async (ws, message) => { + switch (message.type) { + case "echo": + ws.send({ + type: "echo", + data: message.data, + timestamp: Date.now(), + }); + break; + + case "execute": + try { + const result = await ws.raw.sandbox.exec(message.command); + ws.sendResult({ + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } catch (error) { + ws.sendError(error as Error); + } + break; + } + }, + }); + + return response; +} + +/** + * Example 2: Code Execution (Simplified) + */ +async function handleCodeExecutionWebSocket( + request: Request, + env: Env +): Promise { + let context: any = null; + + const { response, websocket, sandbox } = await createWebSocketHandler( + request, + env.Sandbox, + { + onReady: (ws) => { + ws.sendReady("Code interpreter ready"); + }, + onMessage: async (ws, message) => { + switch (message.type) { + case "execute": + try { + // Create context if needed + if (!context) { + context = await sandbox.createCodeContext({ + language: message.language || "python", + }); + } + + // Use the helper method for streaming execution! + await ws.runCodeWithStreaming(message.code, { + language: message.language || "python", + context, + }); + } catch (error) { + ws.sendError(error as Error); + } + break; + + case "reset": + if (context) { + await sandbox.deleteCodeContext(context.id); + context = null; + } + ws.sendStatus("reset", "Context cleared"); + break; + } + }, + } + ); + + return response; +} + +/** + * Example 3: Process Streaming (Simplified) + */ +async function handleProcessStreamWebSocket( + request: Request, + env: Env +): Promise { + const { response, websocket } = await createWebSocketHandler(request, env.Sandbox, { + onReady: (ws) => { + ws.sendReady("Process streaming ready"); + }, + onMessage: async (ws, message) => { + switch (message.type) { + case "start": + try { + // Use the helper method that auto-streams logs! + await ws.startProcessWithStreaming(message.command); + } catch (error) { + ws.sendError(error as Error); + } + break; + } + }, + }); + + return response; +} + +/** + * Example 4: Interactive Terminal (Simplified) + */ +async function handleTerminalWebSocket( + request: Request, + env: Env +): Promise { + let shellProcess: any = null; + + const { response, websocket, sandbox } = await createWebSocketHandler( + request, + env.Sandbox, + { + onReady: async (ws) => { + // Start shell process with streaming + shellProcess = await ws.startProcessWithStreaming("/bin/bash"); + ws.sendReady("Terminal ready"); + }, + onMessage: async (ws, message) => { + if (message.type === "input" && shellProcess) { + try { + // Write to stdin (this would need to be added to the SDK) + await sandbox.exec(`echo "${message.data}" | /proc/${shellProcess.pid}/fd/0`); + } catch (error) { + ws.sendError(error as Error); + } + } + }, + onClose: async (ws) => { + // Cleanup + if (shellProcess) { + await sandbox.killProcess(shellProcess.id); + } + }, + } + ); + + return response; +} + +// HTML test interface (same as before) +function getTestHTML(): string { + return ` + + + Sandbox WebSocket Examples (With Helpers) + + + +

Sandbox WebSocket Examples

+
+ Note: This version uses the new SDK WebSocket helpers! + Check index-with-helpers.ts vs index.ts to see the difference. +
+

Open index-with-helpers.ts to see how much simpler the code is!

+ +

Key Improvements

+
    +
  • createWebSocketHandler() - Handles all boilerplate setup
  • +
  • ws.sendReady(), ws.sendError(), etc - Type-safe message sending
  • +
  • ws.runCodeWithStreaming() - Auto-streams code execution output
  • +
  • ws.startProcessWithStreaming() - Auto-streams process logs
  • +
  • Lifecycle callbacks - onReady, onMessage, onClose, onError
  • +
+ +`; +} + +export { Sandbox }; diff --git a/examples/websocket/src/index.ts b/examples/websocket/src/index.ts new file mode 100644 index 00000000..3ad06caf --- /dev/null +++ b/examples/websocket/src/index.ts @@ -0,0 +1,767 @@ +/** + * WebSocket Example for Cloudflare Sandbox SDK + * + * This example demonstrates various WebSocket patterns: + * 1. Basic echo server + * 2. Real-time code execution + * 3. Process streaming + * 4. Interactive terminal + */ + +import { getSandbox, parseSSEStream, Sandbox, type LogEvent } from "@cloudflare/sandbox"; + +interface Env { + Sandbox: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const pathname = url.pathname; + + // Check for WebSocket upgrade + const upgradeHeader = request.headers.get("Upgrade"); + if (upgradeHeader !== "websocket") { + // Serve a simple HTML page for testing + if (pathname === "/") { + return new Response(getTestHTML(), { + headers: { "Content-Type": "text/html" }, + }); + } + return new Response("Expected WebSocket", { status: 426 }); + } + + // Route to different WebSocket handlers + switch (pathname) { + case "/ws/echo": + return handleEchoWebSocket(request, env); + case "/ws/code": + return handleCodeExecutionWebSocket(request, env); + case "/ws/process": + return handleProcessStreamWebSocket(request, env); + case "/ws/terminal": + return handleTerminalWebSocket(request, env); + default: + return new Response("Unknown WebSocket endpoint", { status: 404 }); + } + }, +}; + +/** + * Example 1: Basic Echo Server + * Demonstrates basic WebSocket handling with sandbox execution + */ +async function handleEchoWebSocket( + request: Request, + env: Env +): Promise { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Get sandbox instance + const sandboxId = new URL(request.url).searchParams.get("id") || "echo-sandbox"; + const sandbox = getSandbox(env.Sandbox, sandboxId); + + // Accept the connection + server.accept(); + + // Send welcome message + server.send( + JSON.stringify({ + type: "connected", + message: "Echo server connected", + sandboxId, + }) + ); + + // Handle incoming messages + server.addEventListener("message", async (event) => { + try { + const message = JSON.parse(event.data as string); + + switch (message.type) { + case "echo": + // Simple echo back + server.send( + JSON.stringify({ + type: "echo", + data: message.data, + timestamp: Date.now(), + }) + ); + break; + + case "execute": + // Execute a command in the sandbox and echo the result + const result = await sandbox.exec(message.command); + server.send( + JSON.stringify({ + type: "result", + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }) + ); + break; + + case "ping": + server.send(JSON.stringify({ type: "pong" })); + break; + + default: + server.send( + JSON.stringify({ + type: "error", + message: "Unknown message type", + }) + ); + } + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + }); + + server.addEventListener("close", () => { + console.log("WebSocket closed"); + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} + +/** + * Example 2: Real-Time Code Execution + * Executes Python/JavaScript code and streams output + */ +async function handleCodeExecutionWebSocket( + request: Request, + env: Env +): Promise { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + const sandboxId = + new URL(request.url).searchParams.get("id") || "code-interpreter"; + const sandbox = getSandbox(env.Sandbox, sandboxId); + + server.accept(); + + // Create persistent code context + let context: any = null; + + server.send( + JSON.stringify({ + type: "ready", + message: "Code interpreter ready", + }) + ); + + server.addEventListener("message", async (event) => { + try { + const message = JSON.parse(event.data as string); + + if (message.type === "execute") { + const { code, language = "python", sessionId } = message; + + // Send acknowledgment + server.send( + JSON.stringify({ + type: "executing", + sessionId, + }) + ); + + // Create context if needed + if (!context) { + context = await sandbox.createCodeContext({ language }); + } + + // Execute with streaming callbacks + const execution = await sandbox.runCode(code, { + context, + onStdout: (output) => { + server.send( + JSON.stringify({ + type: "stdout", + data: output.text, + sessionId, + }) + ); + }, + onStderr: (output) => { + server.send( + JSON.stringify({ + type: "stderr", + data: output.text, + sessionId, + }) + ); + }, + }); + + // Send final results + server.send( + JSON.stringify({ + type: "result", + sessionId, + results: execution.results, + error: execution.error, + logs: execution.logs, + }) + ); + } else if (message.type === "reset") { + // Reset the code context + context = null; + server.send( + JSON.stringify({ + type: "reset", + message: "Context reset", + }) + ); + } + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + }); + + server.addEventListener("close", async () => { + // Clean up context + if (context && context.cleanup) { + await context.cleanup(); + } + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} + +/** + * Example 3: Process Output Streaming + * Starts a long-running process and streams its output + */ +async function handleProcessStreamWebSocket( + request: Request, + env: Env +): Promise { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + const sandboxId = + new URL(request.url).searchParams.get("id") || "process-stream"; + const sandbox = getSandbox(env.Sandbox, sandboxId); + + server.accept(); + + let currentProcess: any = null; + + server.send( + JSON.stringify({ + type: "ready", + message: "Process streamer ready", + }) + ); + + server.addEventListener("message", async (event) => { + try { + const message = JSON.parse(event.data as string); + + if (message.type === "start") { + const { command, args = [] } = message; + + // Build full command string + const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command; + + // Start the process + currentProcess = await sandbox.startProcess(fullCommand); + + server.send( + JSON.stringify({ + type: "started", + pid: currentProcess.pid, + }) + ); + + // Stream logs in the background + (async () => { + try { + const logStream = await sandbox.streamProcessLogs(currentProcess.id); + + for await (const event of parseSSEStream(logStream)) { + if (event.type === "stdout" || event.type === "stderr") { + server.send( + JSON.stringify({ + type: event.type, + data: event.data, + pid: currentProcess.pid, + }) + ); + } + } + + // Process completed + server.send( + JSON.stringify({ + type: "completed", + pid: currentProcess.pid, + }) + ); + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + })(); + } else if (message.type === "kill" && currentProcess) { + await sandbox.killProcess(currentProcess.id); + server.send( + JSON.stringify({ + type: "killed", + pid: currentProcess.pid, + }) + ); + currentProcess = null; + } + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + }); + + server.addEventListener("close", async () => { + if (currentProcess) { + try { + await sandbox.killProcess(currentProcess.id); + } catch { + // Process might already be done + } + } + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} + +/** + * Example 4: Interactive Terminal + * Provides a full interactive shell session + */ +async function handleTerminalWebSocket( + request: Request, + env: Env +): Promise { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + const sandboxId = + new URL(request.url).searchParams.get("id") || "terminal-session"; + const sandbox = getSandbox(env.Sandbox, sandboxId); + + server.accept(); + + // Start an interactive bash shell + const shell = await sandbox.startProcess("/bin/bash -i", { + env: { + TERM: "xterm-256color", + PS1: "\\[\\033[01;32m\\]sandbox\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]$ ", + }, + }); + + server.send( + JSON.stringify({ + type: "ready", + pid: shell.pid, + message: "Terminal connected", + }) + ); + + // Stream shell output to client + (async () => { + try { + const logStream = await sandbox.streamProcessLogs(shell.id); + + for await (const event of parseSSEStream(logStream)) { + if (event.type === "stdout") { + // Send raw output for terminal rendering + server.send(event.data); + } + } + + // Shell exited + server.send( + JSON.stringify({ + type: "exit", + message: "Shell process exited", + }) + ); + server.close(1000, "Shell exited"); + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + server.close(1011, "Error"); + } + })(); + + // Send client input to shell + server.addEventListener("message", async (event) => { + try { + const input = event.data as string; + + // Handle special commands + if (input.startsWith("{")) { + const command = JSON.parse(input); + + if (command.type === "resize") { + // Handle terminal resize (would need TTY support) + // For now, just acknowledge + server.send( + JSON.stringify({ + type: "resized", + rows: command.rows, + cols: command.cols, + }) + ); + } + } else { + // Regular input - send to shell stdin + // Note: This requires implementing sendToProcess in the sandbox + // await sandbox.sendToProcess(shell.pid, input); + + // For now, we'll use a workaround by executing each line + if (input.includes('\n')) { + const result = await sandbox.exec(input.trim()); + server.send(result.stdout + result.stderr); + } + } + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + }); + + server.addEventListener("close", async () => { + try { + await sandbox.killProcess(shell.id); + } catch { + // Process might already be done + } + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} + +/** + * Simple HTML test page for WebSocket examples + */ +function getTestHTML(): string { + return ` + + + Sandbox WebSocket Examples + + + +

Sandbox SDK WebSocket Examples

+ +
+

1. Echo Server

+
Disconnected
+ + + + + +
+
+ +
+

2. Code Execution

+
Disconnected
+ + + + + +
+
+ +
+

3. Process Streaming

+
Disconnected
+ + + + + +
+
+ + + +`; +} + +export { Sandbox }; diff --git a/examples/websocket/tsconfig.json b/examples/websocket/tsconfig.json new file mode 100644 index 00000000..1747331b --- /dev/null +++ b/examples/websocket/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/websocket/wrangler.jsonc b/examples/websocket/wrangler.jsonc new file mode 100644 index 00000000..a1a1f126 --- /dev/null +++ b/examples/websocket/wrangler.jsonc @@ -0,0 +1,36 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "sandbox-websocket-example", + "main": "src/index.ts", + "compatibility_date": "2025-05-06", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + + "containers": [ + { + "class_name": "Sandbox", + "image": "../basic/Dockerfile", + "name": "sandbox", + "max_instances": 10, + "instance_type": "standard-2" + } + ], + + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox" + } + ] + }, + + "migrations": [ + { + "new_sqlite_classes": ["Sandbox"], + "tag": "v1" + } + ] +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 0db9a17d..a3a313d3 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -91,3 +91,16 @@ export { } from './request-handler'; // Export SSE parser for converting ReadableStream to AsyncIterable export { asyncIterableToSSEStream, parseSSEStream, responseToAsyncIterable } from "./sse-parser"; + +export { + createWebSocketHandler, + SandboxWebSocket, + type ErrorMessage, + type OutputMessage, + type ReadyMessage, + type ResultMessage, + type StatusMessage, + type WebSocketHandlerOptions, + type WebSocketHandlerResult, + type WebSocketMessage +} from './websocket'; diff --git a/packages/sandbox/src/websocket.ts b/packages/sandbox/src/websocket.ts new file mode 100644 index 00000000..b8d32ae5 --- /dev/null +++ b/packages/sandbox/src/websocket.ts @@ -0,0 +1,417 @@ +/** + * WebSocket helpers for Cloudflare Sandbox SDK + */ + +import type { ISandbox, LogEvent, Process } from '@repo/shared'; +import { parseSSEStream } from './sse-parser'; + +/** + * Standard message types for sandbox WebSocket communication + */ +export interface WebSocketMessage { + type: string; + [key: string]: any; +} + +export interface ReadyMessage extends WebSocketMessage { + type: 'ready'; + message: string; + sandboxId?: string; +} + +export interface OutputMessage extends WebSocketMessage { + type: 'stdout' | 'stderr'; + data: string; + pid?: number; +} + +export interface ErrorMessage extends WebSocketMessage { + type: 'error'; + message: string; + code?: string; + stack?: string; +} + +export interface StatusMessage extends WebSocketMessage { + type: 'status'; + status: string; + message?: string; +} + +export interface ResultMessage extends WebSocketMessage { + type: 'result'; + data: any; + executionTime?: number; +} + +/** + * Type-safe wrapper around WebSocket for sandbox communication + * + * Provides helpers for: + * - Sending structured messages with automatic JSON serialization + * - Streaming process logs to the client + * - Running code with real-time output streaming + * - Error handling and connection management + */ +export class SandboxWebSocket { + constructor( + private readonly server: WebSocket, + private readonly sandbox: ISandbox + ) {} + + /** + * Send a type-safe message to the client + */ + send(message: WebSocketMessage): void { + try { + this.server.send(JSON.stringify(message)); + } catch (error) { + console.error('Failed to send WebSocket message:', error); + } + } + + /** + * Send a ready notification + */ + sendReady(message: string, sandboxId?: string): void { + this.send({ + type: 'ready', + message, + sandboxId, + } as ReadyMessage); + } + + /** + * Send output (stdout or stderr) + */ + sendOutput(type: 'stdout' | 'stderr', data: string, pid?: number): void { + this.send({ + type, + data, + pid, + } as OutputMessage); + } + + /** + * Send an error message + */ + sendError(error: Error | string, code?: string): void { + const message = typeof error === 'string' ? error : error.message; + const stack = typeof error === 'string' ? undefined : error.stack; + + this.send({ + type: 'error', + message, + code, + stack, + } as ErrorMessage); + } + + /** + * Send a status update + */ + sendStatus(status: string, message?: string): void { + this.send({ + type: 'status', + status, + message, + } as StatusMessage); + } + + /** + * Send a result + */ + sendResult(data: any, executionTime?: number): void { + this.send({ + type: 'result', + data, + executionTime, + } as ResultMessage); + } + + /** + * Stream process logs to the WebSocket client + * + * This bridges the sandbox's SSE log stream to WebSocket, + * automatically parsing and forwarding log events. + * + * @param process - The process to stream logs from + * @returns Promise that resolves when streaming completes + */ + async streamProcessLogs(process: Process): Promise { + try { + const logStream = await this.sandbox.streamProcessLogs(process.id); + + for await (const event of parseSSEStream(logStream)) { + if (event.type === 'stdout' || event.type === 'stderr') { + this.sendOutput(event.type, event.data, process.pid); + } else if (event.type === 'exit') { + this.send({ + type: 'exit', + pid: process.pid, + exitCode: event.exitCode, + }); + } + } + } catch (error) { + this.sendError(error as Error); + } + } + + /** + * Execute code with real-time output streaming + * + * @param code - Code to execute + * @param options - Execution options including language and context + * @returns Promise that resolves with the execution result + */ + async runCodeWithStreaming( + code: string, + options?: { language?: 'python' | 'javascript'; context?: any; } + ): Promise { + const startTime = Date.now(); + + try { + this.sendStatus('executing'); + + // Use runCodeStream for streaming support + const stream = await this.sandbox.runCodeStream(code, { + language: options?.language || 'python', + context: options?.context, + }); + + // Stream results back to client + for await (const chunk of parseSSEStream(stream)) { + if (chunk.type === 'stdout' || chunk.type === 'stderr') { + this.sendOutput(chunk.type, chunk.data); + } else if (chunk.type === 'result') { + const executionTime = Date.now() - startTime; + this.sendResult(chunk.data, executionTime); + return chunk.data; + } else if (chunk.type === 'error') { + this.sendError(chunk.data); + throw new Error(chunk.data); + } + } + } catch (error) { + this.sendError(error as Error); + throw error; + } + } + + /** + * Start a process and stream its output + * + * @param command - Command to execute (including arguments) + * @returns Promise that resolves with the process + */ + async startProcessWithStreaming(command: string): Promise { + try { + const process = await this.sandbox.startProcess(command); + + this.send({ + type: 'process_started', + pid: process.pid, + id: process.id, + command, + }); + + // Stream logs in the background + this.streamProcessLogs(process).catch(error => { + console.error('Error streaming process logs:', error); + }); + + return process; + } catch (error) { + this.sendError(error as Error); + throw error; + } + } + + /** + * Close the WebSocket connection + */ + close(code?: number, reason?: string): void { + try { + this.server.close(code, reason); + } catch (error) { + console.error('Failed to close WebSocket:', error); + } + } + + /** + * Get the underlying WebSocket server instance + */ + get raw(): WebSocket { + return this.server; + } +} + +/** + * Options for creating a WebSocket handler + */ +export interface WebSocketHandlerOptions { + /** + * Custom sandbox ID (defaults to URL param or random UUID) + */ + sandboxId?: string; + + /** + * Callback when WebSocket is ready + */ + onReady?: (ws: SandboxWebSocket, sandboxId: string) => void | Promise; + + /** + * Callback for incoming messages + */ + onMessage?: (ws: SandboxWebSocket, message: any, event: MessageEvent) => void | Promise; + + /** + * Callback when WebSocket closes + */ + onClose?: (ws: SandboxWebSocket, event: CloseEvent) => void | Promise; + + /** + * Callback for WebSocket errors + */ + onError?: (ws: SandboxWebSocket, event: Event | ErrorEvent) => void | Promise; +} + +/** + * Result of creating a WebSocket handler + */ +export interface WebSocketHandlerResult { + /** + * The Response object to return from fetch() + */ + response: Response; + + /** + * The SandboxWebSocket wrapper + */ + websocket: SandboxWebSocket; + + /** + * The sandbox instance + */ + sandbox: ISandbox; + + /** + * The sandbox ID used + */ + sandboxId: string; +} + +/** + * Create a WebSocket handler with automatic setup and lifecycle management + * + * This is the main entry point for using WebSocket helpers. It: + * - Creates a WebSocket pair + * - Gets or creates a sandbox instance + * - Wraps everything in a SandboxWebSocket + * - Sets up event handlers + * - Returns both the Response and the wrapper for further customization + * + * @example + * ```typescript + * async function handleWebSocket(request: Request, env: Env) { + * const { response, websocket, sandbox } = await createWebSocketHandler( + * request, + * env.Sandbox, + * { + * onReady: (ws, sandboxId) => { + * ws.sendReady('Connected to sandbox', sandboxId); + * }, + * onMessage: async (ws, message) => { + * if (message.type === 'execute') { + * await ws.runPythonWithStreaming(message.code); + * } + * } + * } + * ); + * + * return response; + * } + * ``` + */ +export async function createWebSocketHandler( + request: Request, + sandboxNamespace: DurableObjectNamespace, + options: WebSocketHandlerOptions = {} +): Promise { + // Create WebSocket pair + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Get sandbox ID + const url = new URL(request.url); + const sandboxId = + options.sandboxId || + url.searchParams.get('id') || + crypto.randomUUID(); + + // Get sandbox instance + const id = sandboxNamespace.idFromName(sandboxId); + const stub = sandboxNamespace.get(id); + const sandbox = stub as unknown as ISandbox; + + // Accept connection + server.accept(); + + // Create wrapper + const ws = new SandboxWebSocket(server, sandbox); + + // Set up event handlers + if (options.onMessage) { + server.addEventListener('message', async (event) => { + try { + const message = JSON.parse(event.data as string); + await options.onMessage!(ws, message, event); + } catch (error) { + console.error('Error handling message:', error); + ws.sendError(error as Error); + } + }); + } + + if (options.onClose) { + server.addEventListener('close', async (event) => { + try { + await options.onClose!(ws, event); + } catch (error) { + console.error('Error handling close:', error); + } + }); + } + + if (options.onError) { + server.addEventListener('error', async (event) => { + try { + await options.onError!(ws, event); + } catch (error) { + console.error('Error handling error:', error); + } + }); + } + + // Call onReady callback + if (options.onReady) { + try { + await options.onReady(ws, sandboxId); + } catch (error) { + console.error('Error in onReady callback:', error); + ws.sendError(error as Error); + } + } + + // Return response and wrapper + return { + response: new Response(null, { + status: 101, + webSocket: client, + }), + websocket: ws, + sandbox, + sandboxId, + }; +} From 7e09ebe7c99accdf3f321a1ce17862332b7379a0 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Sun, 19 Oct 2025 19:02:44 +0100 Subject: [PATCH 2/5] fix formatting --- packages/sandbox/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index a3a313d3..6bd83040 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -94,11 +94,11 @@ export { asyncIterableToSSEStream, parseSSEStream, responseToAsyncIterable } fro export { createWebSocketHandler, - SandboxWebSocket, type ErrorMessage, type OutputMessage, type ReadyMessage, type ResultMessage, + SandboxWebSocket, type StatusMessage, type WebSocketHandlerOptions, type WebSocketHandlerResult, From 34b2e228ed503ffbfdd7aaf8d3221b9ac954b361 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 23 Oct 2025 18:36:48 +0100 Subject: [PATCH 3/5] fix ws --- examples/basic/Dockerfile | 4 +- examples/websocket/src/index-with-helpers.ts | 254 ---------------- examples/websocket/src/index.ts | 226 ++++++++++++++ packages/sandbox/src/sandbox.ts | 37 +++ packages/sandbox/src/websocket.ts | 295 +++++++++++++++++++ packages/shared/src/types.ts | 1 + 6 files changed, 561 insertions(+), 256 deletions(-) delete mode 100644 examples/websocket/src/index-with-helpers.ts diff --git a/examples/basic/Dockerfile b/examples/basic/Dockerfile index adde8145..b2d843a8 100644 --- a/examples/basic/Dockerfile +++ b/examples/basic/Dockerfile @@ -1,8 +1,8 @@ # This image is unique to this repo, and you'll never need it. # Whenever you're integrating with sandbox SDK in your own project, # you should use the official image instead: -# FROM docker.io/cloudflare/sandbox:0.4.3 -FROM cloudflare/sandbox-test:0.4.3 + FROM docker.io/cloudflare/sandbox:0.4.3 +#FROM cloudflare/sandbox-test:0.4.3 # On a mac, you might need to actively pick up the # arm64 build of the image. diff --git a/examples/websocket/src/index-with-helpers.ts b/examples/websocket/src/index-with-helpers.ts deleted file mode 100644 index 371085cc..00000000 --- a/examples/websocket/src/index-with-helpers.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * WebSocket Example Using SDK Helpers - * - * This demonstrates the simplified approach using the new WebSocket helpers - * from @cloudflare/sandbox. Compare with index.ts to see the difference! - */ - -import { createWebSocketHandler, Sandbox } from "@cloudflare/sandbox"; - -interface Env { - Sandbox: DurableObjectNamespace; -} - -export default { - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - // Check for WebSocket upgrade - const upgradeHeader = request.headers.get("Upgrade"); - if (upgradeHeader !== "websocket") { - // Serve a simple HTML page for testing - if (pathname === "/") { - return new Response(getTestHTML(), { - headers: { "Content-Type": "text/html" }, - }); - } - return new Response("Expected WebSocket", { status: 426 }); - } - - // Route to different WebSocket handlers - switch (pathname) { - case "/ws/echo": - return handleEchoWebSocket(request, env); - case "/ws/code": - return handleCodeExecutionWebSocket(request, env); - case "/ws/process": - return handleProcessStreamWebSocket(request, env); - case "/ws/terminal": - return handleTerminalWebSocket(request, env); - default: - return new Response("Unknown WebSocket endpoint", { status: 404 }); - } - }, -}; - -/** - * Example 1: Echo Server (Simplified) - */ -async function handleEchoWebSocket( - request: Request, - env: Env -): Promise { - const { response, websocket } = await createWebSocketHandler(request, env.Sandbox, { - onReady: (ws, sandboxId) => { - ws.sendReady("Echo server connected", sandboxId); - }, - onMessage: async (ws, message) => { - switch (message.type) { - case "echo": - ws.send({ - type: "echo", - data: message.data, - timestamp: Date.now(), - }); - break; - - case "execute": - try { - const result = await ws.raw.sandbox.exec(message.command); - ws.sendResult({ - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } catch (error) { - ws.sendError(error as Error); - } - break; - } - }, - }); - - return response; -} - -/** - * Example 2: Code Execution (Simplified) - */ -async function handleCodeExecutionWebSocket( - request: Request, - env: Env -): Promise { - let context: any = null; - - const { response, websocket, sandbox } = await createWebSocketHandler( - request, - env.Sandbox, - { - onReady: (ws) => { - ws.sendReady("Code interpreter ready"); - }, - onMessage: async (ws, message) => { - switch (message.type) { - case "execute": - try { - // Create context if needed - if (!context) { - context = await sandbox.createCodeContext({ - language: message.language || "python", - }); - } - - // Use the helper method for streaming execution! - await ws.runCodeWithStreaming(message.code, { - language: message.language || "python", - context, - }); - } catch (error) { - ws.sendError(error as Error); - } - break; - - case "reset": - if (context) { - await sandbox.deleteCodeContext(context.id); - context = null; - } - ws.sendStatus("reset", "Context cleared"); - break; - } - }, - } - ); - - return response; -} - -/** - * Example 3: Process Streaming (Simplified) - */ -async function handleProcessStreamWebSocket( - request: Request, - env: Env -): Promise { - const { response, websocket } = await createWebSocketHandler(request, env.Sandbox, { - onReady: (ws) => { - ws.sendReady("Process streaming ready"); - }, - onMessage: async (ws, message) => { - switch (message.type) { - case "start": - try { - // Use the helper method that auto-streams logs! - await ws.startProcessWithStreaming(message.command); - } catch (error) { - ws.sendError(error as Error); - } - break; - } - }, - }); - - return response; -} - -/** - * Example 4: Interactive Terminal (Simplified) - */ -async function handleTerminalWebSocket( - request: Request, - env: Env -): Promise { - let shellProcess: any = null; - - const { response, websocket, sandbox } = await createWebSocketHandler( - request, - env.Sandbox, - { - onReady: async (ws) => { - // Start shell process with streaming - shellProcess = await ws.startProcessWithStreaming("/bin/bash"); - ws.sendReady("Terminal ready"); - }, - onMessage: async (ws, message) => { - if (message.type === "input" && shellProcess) { - try { - // Write to stdin (this would need to be added to the SDK) - await sandbox.exec(`echo "${message.data}" | /proc/${shellProcess.pid}/fd/0`); - } catch (error) { - ws.sendError(error as Error); - } - } - }, - onClose: async (ws) => { - // Cleanup - if (shellProcess) { - await sandbox.killProcess(shellProcess.id); - } - }, - } - ); - - return response; -} - -// HTML test interface (same as before) -function getTestHTML(): string { - return ` - - - Sandbox WebSocket Examples (With Helpers) - - - -

Sandbox WebSocket Examples

-
- Note: This version uses the new SDK WebSocket helpers! - Check index-with-helpers.ts vs index.ts to see the difference. -
-

Open index-with-helpers.ts to see how much simpler the code is!

- -

Key Improvements

-
    -
  • createWebSocketHandler() - Handles all boilerplate setup
  • -
  • ws.sendReady(), ws.sendError(), etc - Type-safe message sending
  • -
  • ws.runCodeWithStreaming() - Auto-streams code execution output
  • -
  • ws.startProcessWithStreaming() - Auto-streams process logs
  • -
  • Lifecycle callbacks - onReady, onMessage, onClose, onError
  • -
- -`; -} - -export { Sandbox }; diff --git a/examples/websocket/src/index.ts b/examples/websocket/src/index.ts index 3ad06caf..5b38780a 100644 --- a/examples/websocket/src/index.ts +++ b/examples/websocket/src/index.ts @@ -6,6 +6,13 @@ * 2. Real-time code execution * 3. Process streaming * 4. Interactive terminal + * + * IMPORTANT: This example shows Worker → DO → Container WebSocket communication. + * For connecting to WebSocket servers INSIDE the container, use sandbox.connect() + * instead of containerFetch. See WEBSOCKET_FIX.md for details. + * + * These examples include optional rate limiting and timeout management. + * Uncomment the rate limiting sections to enable protection. */ import { getSandbox, parseSSEStream, Sandbox, type LogEvent } from "@cloudflare/sandbox"; @@ -41,6 +48,8 @@ export default { return handleProcessStreamWebSocket(request, env); case "/ws/terminal": return handleTerminalWebSocket(request, env); + case "/ws/protected": + return handleProtectedWebSocket(request, env); default: return new Response("Unknown WebSocket endpoint", { status: 404 }); } @@ -483,6 +492,223 @@ async function handleTerminalWebSocket( }); } +/** + * Example 5: Protected WebSocket with Rate Limiting & Timeouts + * Demonstrates production-ready WebSocket with security features + */ +async function handleProtectedWebSocket( + request: Request, + env: Env +): Promise { + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + const sandboxId = + new URL(request.url).searchParams.get("id") || "protected-session"; + const sandbox = getSandbox(env.Sandbox, sandboxId); + + server.accept(); + + // Rate limiting state (in production, store this in Durable Object storage) + const rateLimitState = { + messages: [] as number[], + maxMessages: 100, + windowMs: 60000, // 1 minute + maxMessageSize: 1024 * 1024, // 1MB + }; + + // Timeout state + let idleTimeout: number; + let maxConnectionTimeout: number; + let heartbeatInterval: number; + let lastActivity = Date.now(); + + // Heartbeat mechanism + heartbeatInterval = setInterval(() => { + const timeSinceActivity = Date.now() - lastActivity; + if (timeSinceActivity > 300000) { + // 5 minutes idle + server.close(1000, "Idle timeout"); + clearAllTimers(); + return; + } + try { + server.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); + } catch (error) { + console.error("Failed to send heartbeat:", error); + } + }, 30000); // Ping every 30 seconds + + // Max connection time + maxConnectionTimeout = setTimeout(() => { + server.send( + JSON.stringify({ type: "info", message: "Maximum connection time reached" }) + ); + server.close(1000, "Maximum connection time reached"); + clearAllTimers(); + }, 1800000); // 30 minutes + + // Idle timeout reset function + const resetIdleTimeout = () => { + lastActivity = Date.now(); + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + server.close(1000, "Idle timeout"); + clearAllTimers(); + }, 300000); // 5 minutes + }; + + // Initial idle timeout + resetIdleTimeout(); + + // Send welcome with limits info + server.send( + JSON.stringify({ + type: "connected", + message: "Protected WebSocket connected", + sandboxId, + limits: { + maxMessages: rateLimitState.maxMessages, + windowMs: rateLimitState.windowMs, + maxMessageSize: rateLimitState.maxMessageSize, + idleTimeout: 300000, + maxConnectionTime: 1800000, + }, + }) + ); + + // Handle incoming messages + server.addEventListener("message", async (event) => { + try { + // Reset idle timer on activity + resetIdleTimeout(); + + // Rate limiting check + const now = Date.now(); + const messageSize = new Blob([event.data as string]).size; + + // Check message size + if (messageSize > rateLimitState.maxMessageSize) { + server.send( + JSON.stringify({ + type: "error", + code: "RATE_LIMIT_EXCEEDED", + message: `Message size ${messageSize} exceeds limit of ${rateLimitState.maxMessageSize} bytes`, + }) + ); + return; + } + + // Clean old timestamps + rateLimitState.messages = rateLimitState.messages.filter( + (timestamp) => now - timestamp < rateLimitState.windowMs + ); + + // Check rate limit + if (rateLimitState.messages.length >= rateLimitState.maxMessages) { + server.send( + JSON.stringify({ + type: "error", + code: "RATE_LIMIT_EXCEEDED", + message: `Rate limit exceeded: ${rateLimitState.maxMessages} messages per ${rateLimitState.windowMs}ms`, + remaining: 0, + }) + ); + return; + } + + // Record this message + rateLimitState.messages.push(now); + + const message = JSON.parse(event.data as string); + + // Handle pong for heartbeat + if (message.type === "pong") { + return; // Just acknowledge, don't process + } + + // Send rate limit info with response + const remaining = rateLimitState.maxMessages - rateLimitState.messages.length; + + switch (message.type) { + case "echo": + server.send( + JSON.stringify({ + type: "echo", + data: message.data, + rateLimit: { remaining }, + timestamp: Date.now(), + }) + ); + break; + + case "execute": + const result = await sandbox.exec(message.command); + server.send( + JSON.stringify({ + type: "result", + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + rateLimit: { remaining }, + }) + ); + break; + + case "status": + server.send( + JSON.stringify({ + type: "status", + connected: true, + sandboxId, + rateLimit: { + messagesInWindow: rateLimitState.messages.length, + maxMessages: rateLimitState.maxMessages, + remaining, + }, + timeout: { + timeSinceActivity: Date.now() - lastActivity, + idleTimeoutRemaining: Math.max(0, 300000 - (Date.now() - lastActivity)), + }, + }) + ); + break; + + default: + server.send( + JSON.stringify({ + type: "error", + message: "Unknown message type", + }) + ); + } + } catch (error: any) { + server.send( + JSON.stringify({ + type: "error", + message: error.message, + }) + ); + } + }); + + function clearAllTimers() { + if (idleTimeout) clearTimeout(idleTimeout); + if (maxConnectionTimeout) clearTimeout(maxConnectionTimeout); + if (heartbeatInterval) clearInterval(heartbeatInterval); + } + + server.addEventListener("close", () => { + console.log("Protected WebSocket closed"); + clearAllTimers(); + }); + + return new Response(null, { + status: 101, + webSocket: client, + }); +} + /** * Simple HTML test page for WebSocket examples */ diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 8b2318bf..b8c2ea97 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -889,6 +889,43 @@ export class Sandbox extends Container implements ISandbox { }; } + /** + * Connect to a WebSocket endpoint running inside the container. + * + * This method bypasses containerFetch and uses the underlying container.fetch() + * directly + * + * @param portOrUrl - Port number (e.g., 3001) or full path (e.g., "/ws/endpoint") + * If a number, connects to `http://localhost:` + * If a string starting with "/", connects to `http://localhost:3000` + * @param options - Optional request init options (headers, etc.) + * @returns WebSocket upgrade response + */ + async connect(portOrUrl: number | string, options?: RequestInit): Promise { + let url: string; + + if (typeof portOrUrl === 'number') { + // Connect to specific port + url = `http://localhost:${portOrUrl}`; + } else if (portOrUrl.startsWith('/')) { + // Connect to path on default port (3000) + url = `http://localhost:${this.defaultPort}${portOrUrl}`; + } else { + throw new Error('Invalid portOrUrl: must be a port number or path starting with "/"'); + } + + // Create WebSocket upgrade request + const upgradeRequest = new Request(url, { + ...options, + headers: { + 'Upgrade': 'websocket', + 'Connection': 'Upgrade', + ...options?.headers, + }, + }); + return super.fetch(upgradeRequest); + } + // ============================================================================ // Code interpreter methods - delegate to CodeInterpreter wrapper // ============================================================================ diff --git a/packages/sandbox/src/websocket.ts b/packages/sandbox/src/websocket.ts index b8d32ae5..43cacc39 100644 --- a/packages/sandbox/src/websocket.ts +++ b/packages/sandbox/src/websocket.ts @@ -247,6 +247,46 @@ export class SandboxWebSocket { } } +/** + * Rate limiting configuration + */ +export interface RateLimitConfig { + /** + * Maximum messages per window (default: 100) + */ + maxMessages?: number; + + /** + * Time window in milliseconds (default: 60000 = 1 minute) + */ + windowMs?: number; + + /** + * Maximum message size in bytes (default: 1MB) + */ + maxMessageSize?: number; +} + +/** + * Connection timeout configuration + */ +export interface TimeoutConfig { + /** + * Idle timeout - close connection if no messages received (default: 5 minutes) + */ + idleTimeout?: number; + + /** + * Maximum connection duration (default: 30 minutes) + */ + maxConnectionTime?: number; + + /** + * Heartbeat interval - send ping to keep connection alive (default: 30 seconds) + */ + heartbeatInterval?: number; +} + /** * Options for creating a WebSocket handler */ @@ -256,6 +296,16 @@ export interface WebSocketHandlerOptions { */ sandboxId?: string; + /** + * Rate limiting configuration + */ + rateLimit?: RateLimitConfig; + + /** + * Connection timeout configuration + */ + timeout?: TimeoutConfig; + /** * Callback when WebSocket is ready */ @@ -275,6 +325,11 @@ export interface WebSocketHandlerOptions { * Callback for WebSocket errors */ onError?: (ws: SandboxWebSocket, event: Event | ErrorEvent) => void | Promise; + + /** + * Callback when rate limit is exceeded + */ + onRateLimitExceeded?: (ws: SandboxWebSocket) => void | Promise; } /** @@ -302,6 +357,194 @@ export interface WebSocketHandlerResult { sandboxId: string; } +/** + * Rate limiter using sliding window algorithm + */ +class RateLimiter { + private timestamps: number[] = []; + private readonly maxMessages: number; + private readonly windowMs: number; + private readonly maxMessageSize: number; + + constructor(config: RateLimitConfig = {}) { + this.maxMessages = config.maxMessages ?? 100; + this.windowMs = config.windowMs ?? 60000; // 1 minute + this.maxMessageSize = config.maxMessageSize ?? 1024 * 1024; // 1MB + } + + /** + * Check if a message is allowed under rate limits + */ + checkMessage(messageSize: number): { allowed: boolean; reason?: string } { + // Check message size + if (messageSize > this.maxMessageSize) { + return { + allowed: false, + reason: `Message size ${messageSize} exceeds limit of ${this.maxMessageSize} bytes`, + }; + } + + const now = Date.now(); + + // Remove timestamps outside the current window + this.timestamps = this.timestamps.filter( + (timestamp) => now - timestamp < this.windowMs + ); + + // Check if we're at the limit + if (this.timestamps.length >= this.maxMessages) { + return { + allowed: false, + reason: `Rate limit exceeded: ${this.maxMessages} messages per ${this.windowMs}ms`, + }; + } + + // Record this message + this.timestamps.push(now); + return { allowed: true }; + } + + /** + * Get current usage statistics + */ + getStats() { + const now = Date.now(); + this.timestamps = this.timestamps.filter( + (timestamp) => now - timestamp < this.windowMs + ); + + return { + messagesInWindow: this.timestamps.length, + maxMessages: this.maxMessages, + windowMs: this.windowMs, + remaining: Math.max(0, this.maxMessages - this.timestamps.length), + }; + } + + /** + * Reset the rate limiter + */ + reset(): void { + this.timestamps = []; + } +} + +/** + * Connection timeout manager + */ +class TimeoutManager { + private idleTimer?: number; + private maxConnectionTimer?: number; + private heartbeatTimer?: number; + private lastActivity: number; + private readonly config: Required; + + constructor( + private readonly ws: SandboxWebSocket, + config: TimeoutConfig = {} + ) { + this.config = { + idleTimeout: config.idleTimeout ?? 300000, // 5 minutes + maxConnectionTime: config.maxConnectionTime ?? 1800000, // 30 minutes + heartbeatInterval: config.heartbeatInterval ?? 30000, // 30 seconds + }; + this.lastActivity = Date.now(); + } + + /** + * Start all timeout timers + */ + start(): void { + // Start idle timeout + this.resetIdleTimer(); + + // Start max connection timer + this.maxConnectionTimer = setTimeout(() => { + this.ws.close(1000, 'Maximum connection time reached'); + }, this.config.maxConnectionTime) as unknown as number; + + // Start heartbeat + if (this.config.heartbeatInterval > 0) { + this.startHeartbeat(); + } + } + + /** + * Reset idle timer (call this on activity) + */ + resetIdleTimer(): void { + this.lastActivity = Date.now(); + + if (this.idleTimer) { + clearTimeout(this.idleTimer); + } + + this.idleTimer = setTimeout(() => { + this.ws.close(1000, 'Idle timeout'); + }, this.config.idleTimeout) as unknown as number; + } + + /** + * Start heartbeat ping/pong mechanism + */ + private startHeartbeat(): void { + this.heartbeatTimer = setInterval(() => { + // Check if we're past idle timeout + const timeSinceActivity = Date.now() - this.lastActivity; + if (timeSinceActivity > this.config.idleTimeout) { + this.ws.close(1000, 'Heartbeat timeout'); + this.stop(); + return; + } + + // Send heartbeat + try { + this.ws.send({ type: 'ping', timestamp: Date.now() }); + } catch (error) { + console.error('Failed to send heartbeat:', error); + } + }, this.config.heartbeatInterval) as unknown as number; + } + + /** + * Stop all timers + */ + stop(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + + if (this.maxConnectionTimer) { + clearTimeout(this.maxConnectionTimer); + this.maxConnectionTimer = undefined; + } + + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + } + + /** + * Get timeout statistics + */ + getStats() { + const now = Date.now(); + const timeSinceActivity = now - this.lastActivity; + + return { + lastActivity: this.lastActivity, + timeSinceActivity, + idleTimeoutRemaining: Math.max( + 0, + this.config.idleTimeout - timeSinceActivity + ), + config: this.config, + }; + } +} + /** * Create a WebSocket handler with automatic setup and lifecycle management * @@ -361,11 +604,58 @@ export async function createWebSocketHandler( // Create wrapper const ws = new SandboxWebSocket(server, sandbox); + // Initialize rate limiter if configured + const rateLimiter = options.rateLimit + ? new RateLimiter(options.rateLimit) + : null; + + // Initialize timeout manager if configured + const timeoutManager = options.timeout + ? new TimeoutManager(ws, options.timeout) + : null; + + // Start timeout tracking + if (timeoutManager) { + timeoutManager.start(); + } + // Set up event handlers if (options.onMessage) { server.addEventListener('message', async (event) => { try { + // Reset idle timer on activity + if (timeoutManager) { + timeoutManager.resetIdleTimer(); + } + + // Check rate limit + if (rateLimiter) { + const messageSize = new Blob([event.data as string]).size; + const rateLimitResult = rateLimiter.checkMessage(messageSize); + + if (!rateLimitResult.allowed) { + ws.sendError( + `Rate limit exceeded: ${rateLimitResult.reason}`, + 'RATE_LIMIT_EXCEEDED' + ); + + // Call rate limit callback if provided + if (options.onRateLimitExceeded) { + await options.onRateLimitExceeded(ws); + } + + // Don't process the message + return; + } + } + const message = JSON.parse(event.data as string); + + // Handle pong messages for heartbeat + if (message.type === 'pong') { + return; // Just acknowledge, don't process + } + await options.onMessage!(ws, message, event); } catch (error) { console.error('Error handling message:', error); @@ -377,6 +667,11 @@ export async function createWebSocketHandler( if (options.onClose) { server.addEventListener('close', async (event) => { try { + // Stop timeout manager + if (timeoutManager) { + timeoutManager.stop(); + } + await options.onClose!(ws, event); } catch (error) { console.error('Error handling close:', error); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 732fbb87..b3c8b3e6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -643,6 +643,7 @@ export interface ISandbox { runCodeStream(code: string, options?: RunCodeOptions): Promise; listCodeContexts(): Promise; deleteCodeContext(contextId: string): Promise; + connect(portOrUrl: number | string, options?: RequestInit): Promise; } // Type guards for runtime validation From 7867acdf459113566a30a22b1a4b731b329b8cc8 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Thu, 23 Oct 2025 18:37:51 +0100 Subject: [PATCH 4/5] minor fixes --- examples/websocket/src/index.ts | 13 ------------- packages/sandbox/src/websocket.ts | 29 ----------------------------- 2 files changed, 42 deletions(-) diff --git a/examples/websocket/src/index.ts b/examples/websocket/src/index.ts index 5b38780a..89699806 100644 --- a/examples/websocket/src/index.ts +++ b/examples/websocket/src/index.ts @@ -1,18 +1,5 @@ /** * WebSocket Example for Cloudflare Sandbox SDK - * - * This example demonstrates various WebSocket patterns: - * 1. Basic echo server - * 2. Real-time code execution - * 3. Process streaming - * 4. Interactive terminal - * - * IMPORTANT: This example shows Worker → DO → Container WebSocket communication. - * For connecting to WebSocket servers INSIDE the container, use sandbox.connect() - * instead of containerFetch. See WEBSOCKET_FIX.md for details. - * - * These examples include optional rate limiting and timeout management. - * Uncomment the rate limiting sections to enable protection. */ import { getSandbox, parseSSEStream, Sandbox, type LogEvent } from "@cloudflare/sandbox"; diff --git a/packages/sandbox/src/websocket.ts b/packages/sandbox/src/websocket.ts index 43cacc39..7bfb2090 100644 --- a/packages/sandbox/src/websocket.ts +++ b/packages/sandbox/src/websocket.ts @@ -547,35 +547,6 @@ class TimeoutManager { /** * Create a WebSocket handler with automatic setup and lifecycle management - * - * This is the main entry point for using WebSocket helpers. It: - * - Creates a WebSocket pair - * - Gets or creates a sandbox instance - * - Wraps everything in a SandboxWebSocket - * - Sets up event handlers - * - Returns both the Response and the wrapper for further customization - * - * @example - * ```typescript - * async function handleWebSocket(request: Request, env: Env) { - * const { response, websocket, sandbox } = await createWebSocketHandler( - * request, - * env.Sandbox, - * { - * onReady: (ws, sandboxId) => { - * ws.sendReady('Connected to sandbox', sandboxId); - * }, - * onMessage: async (ws, message) => { - * if (message.type === 'execute') { - * await ws.runPythonWithStreaming(message.code); - * } - * } - * } - * ); - * - * return response; - * } - * ``` */ export async function createWebSocketHandler( request: Request, From cfc2dd158f66da1eb97bb128552a572e5dfe3eeb Mon Sep 17 00:00:00 2001 From: katereznykova Date: Mon, 27 Oct 2025 12:38:56 +0000 Subject: [PATCH 5/5] WIP --- packages/sandbox/tests/connect.test.ts | 240 +++++++++++++++++++++++++ packages/sandbox/tests/sandbox.test.ts | 3 + 2 files changed, 243 insertions(+) create mode 100644 packages/sandbox/tests/connect.test.ts diff --git a/packages/sandbox/tests/connect.test.ts b/packages/sandbox/tests/connect.test.ts new file mode 100644 index 00000000..eeb2bd1c --- /dev/null +++ b/packages/sandbox/tests/connect.test.ts @@ -0,0 +1,240 @@ +import type { DurableObjectState } from '@cloudflare/workers-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Sandbox } from '../src/sandbox'; + +// Mock Container before imports - same pattern as sandbox.test.ts +vi.mock('@cloudflare/containers', () => ({ + Container: class Container { + ctx: any; + env: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + // Minimal fetch implementation that will be spied on + async fetch(_request: Request): Promise { + return new Response('Not implemented'); + } + }, + getContainer: vi.fn(), +})); + +/** + * Tests for Sandbox.connect() method + * + * This test suite validates that sandbox.connect() properly calls super.fetch() + * (Container.fetch) with the correct WebSocket upgrade request. + */ +describe('Sandbox.connect() - WebSocket connection method', () => { + let sandbox: Sandbox; + let capturedRequest: Request | null = null; + let fetchSpy: any; + + beforeEach(async () => { + // Create minimal mock DurableObjectState - same pattern as sandbox.test.ts + const mockCtx: Partial = { + storage: { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(new Map()), + } as any, + blockConcurrencyWhile: vi.fn((fn: () => Promise) => fn()), + id: { + toString: () => 'test-connect-sandbox', + equals: vi.fn(), + name: 'test-connect', + } as any, + }; + + const mockEnv = {}; + + // Create real Sandbox instance + sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv); + + // Wait for initialization + await new Promise(resolve => setTimeout(resolve, 10)); + + // Spy on the sandbox's parent fetch to verify connect() calls super.fetch() + fetchSpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(sandbox)), 'fetch') + .mockImplementation(async function(request: RequestInfo | Request) { + if (request instanceof Request) { + capturedRequest = request; + } + // Return mock response + return new Response(null, { + status: 200, + headers: { + 'Upgrade': 'websocket', + 'Connection': 'Upgrade', + }, + }); + }); + }); + + afterEach(() => { + capturedRequest = null; + if (fetchSpy) { + fetchSpy.mockRestore(); + } + }); + + describe('URL construction', () => { + it('should construct correct URL for port number and call super.fetch()', async () => { + await sandbox.connect(3001); + + // Verify super.fetch() was called + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe('http://localhost:3001/'); + expect(capturedRequest!.headers.get('Upgrade')).toBe('websocket'); + expect(capturedRequest!.headers.get('Connection')).toBe('Upgrade'); + }); + + it('should construct correct URL for path string and call super.fetch()', async () => { + await sandbox.connect('/ws/chat'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe('http://localhost:3000/ws/chat'); + expect(capturedRequest!.headers.get('Upgrade')).toBe('websocket'); + expect(capturedRequest!.headers.get('Connection')).toBe('Upgrade'); + }); + + it('should throw error for invalid portOrUrl (not a number or path)', async () => { + await expect(sandbox.connect('invalid-url' as any)).rejects.toThrow( + 'Invalid portOrUrl: must be a port number or path starting with "/"' + ); + + // Should not call super.fetch() if validation fails + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should throw error for empty string', async () => { + await expect(sandbox.connect('')).rejects.toThrow( + 'Invalid portOrUrl: must be a port number or path starting with "/"' + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should throw error for relative path without leading slash', async () => { + await expect(sandbox.connect('ws/chat')).rejects.toThrow( + 'Invalid portOrUrl: must be a port number or path starting with "/"' + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Request headers', () => { + it('should include WebSocket upgrade headers', async () => { + await sandbox.connect(3001); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.headers.get('Upgrade')).toBe('websocket'); + expect(capturedRequest!.headers.get('Connection')).toBe('Upgrade'); + }); + + it('should include custom headers alongside WebSocket headers', async () => { + await sandbox.connect(3001, { + headers: { + 'Authorization': 'Bearer token123', + 'X-Custom-Header': 'value', + }, + }); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.headers.get('Upgrade')).toBe('websocket'); + expect(capturedRequest!.headers.get('Connection')).toBe('Upgrade'); + expect(capturedRequest!.headers.get('Authorization')).toBe('Bearer token123'); + expect(capturedRequest!.headers.get('X-Custom-Header')).toBe('value'); + }); + + it('should allow custom headers to override defaults (spread operator behavior)', async () => { + await sandbox.connect(3001, { + headers: { + 'Upgrade': 'custom-protocol', + }, + }); + + expect(capturedRequest).not.toBeNull(); + // Since options?.headers comes after the defaults in spread, + // custom header overrides the default + expect(capturedRequest!.headers.get('Upgrade')).toBe('custom-protocol'); + }); + }); + + describe('RequestInit options', () => { + it('should pass through method option', async () => { + await sandbox.connect(3001, { + method: 'GET', + }); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.method).toBe('GET'); + }); + + it('should pass through multiple options', async () => { + await sandbox.connect(3001, { + method: 'GET', + headers: { + 'User-Agent': 'test-client', + }, + }); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.method).toBe('GET'); + expect(capturedRequest!.headers.get('User-Agent')).toBe('test-client'); + }); + + it('should work with no options provided', async () => { + await sandbox.connect(3001); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe('http://localhost:3001/'); + expect(capturedRequest!.headers.get('Upgrade')).toBe('websocket'); + }); + }); + + describe('Port and path variations', () => { + it('should handle different port numbers correctly', async () => { + const testCases = [ + { port: 3000, expected: 'http://localhost:3000/' }, + { port: 3001, expected: 'http://localhost:3001/' }, + { port: 8080, expected: 'http://localhost:8080/' }, + { port: 8888, expected: 'http://localhost:8888/' }, + ]; + + for (const { port, expected } of testCases) { + capturedRequest = null; + await sandbox.connect(port); + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe(expected); + } + }); + + it('should handle different path formats correctly', async () => { + const testCases = [ + { path: '/ws', expected: 'http://localhost:3000/ws' }, + { path: '/ws/chat', expected: 'http://localhost:3000/ws/chat' }, + { path: '/api/v1/websocket', expected: 'http://localhost:3000/api/v1/websocket' }, + { path: '/socket.io', expected: 'http://localhost:3000/socket.io' }, + ]; + + for (const { path, expected } of testCases) { + capturedRequest = null; + await sandbox.connect(path); + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe(expected); + } + }); + + it('should use defaultPort (3000) for path-based connections', async () => { + await sandbox.connect('/ws/test'); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toBe('http://localhost:3000/ws/test'); + }); + }); +}); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index 479a40ec..325f2b2d 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -15,6 +15,9 @@ vi.mock('@cloudflare/containers', () => ({ this.ctx = ctx; this.env = env; } + async fetch(_request: Request): Promise { + return new Response('Not implemented'); + } }, getContainer: vi.fn(), }));