@liquidmetal-ai/precip 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +33 -0
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
# Precip Sandbox
|
|
2
|
+
|
|
3
|
+
A secure JavaScript sandbox system using QuickJS WASM that enables async/await execution with bridged runtime APIs.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Precip sandbox provides isolated JavaScript execution environments using QuickJS WebAssembly. It enables running untrusted code safely while providing controlled access to runtime APIs through a bridge system. The key innovation is enabling `async/await` in QuickJS (which is synchronous) using a deferred promise pattern with Promise.race optimization.
|
|
8
|
+
|
|
9
|
+
**Key Features:**
|
|
10
|
+
|
|
11
|
+
- Isolated execution in QuickJS WASM
|
|
12
|
+
- Full async/await support via host promise bridging
|
|
13
|
+
- Bridge system for runtime APIs (fetch, console, storage, search, etc.)
|
|
14
|
+
- Proper resource cleanup and handle management
|
|
15
|
+
- Timeout and memory limit enforcement
|
|
16
|
+
- Console output capture
|
|
17
|
+
- 5.81x faster than polling for simple async operations
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
sandbox/
|
|
23
|
+
├── sandbox.ts # Singleton sandbox with queued execution
|
|
24
|
+
├── index.ts # Sandbox exports
|
|
25
|
+
├── test-helper.ts # Test compatibility helper
|
|
26
|
+
├── quickjs-wasm.d.ts # TypeScript definitions
|
|
27
|
+
└── bridges/ # API bridge implementations
|
|
28
|
+
├── types.ts # Shared bridge types
|
|
29
|
+
├── utils.ts # Bridge utility functions
|
|
30
|
+
├── index.ts # Bridge installation and cleanup
|
|
31
|
+
├── console.ts # Console API bridge
|
|
32
|
+
├── fetch.ts # Fetch API bridge
|
|
33
|
+
├── response.ts # Response constructor bridge
|
|
34
|
+
├── readable-stream.ts # ReadableStream bridge
|
|
35
|
+
├── text-encoder.ts # TextEncoder bridge
|
|
36
|
+
├── text-decoder.ts # TextDecoder bridge
|
|
37
|
+
├── storage.ts # KV storage bridge
|
|
38
|
+
├── search.ts # Vector search bridge
|
|
39
|
+
├── bucket.ts # Bucket object bridge
|
|
40
|
+
├── actor.ts # Actor/Durable Object bridge
|
|
41
|
+
└── shared/ # Shared bridge utilities
|
|
42
|
+
├── index.ts
|
|
43
|
+
├── headers.ts # Headers object factory
|
|
44
|
+
├── response-object.ts # Unified Response factory
|
|
45
|
+
├── body-methods.ts # Body consumption methods
|
|
46
|
+
├── cleanup.ts # Stream cleanup utilities
|
|
47
|
+
└── json-helpers.ts # JSON parsing utilities
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Core Concepts
|
|
51
|
+
|
|
52
|
+
### QuickJS Context
|
|
53
|
+
|
|
54
|
+
QuickJS is a lightweight JavaScript engine compiled to WebAssembly. The `QuickJSContext` represents an isolated JavaScript execution environment with its own global object, heap, and runtime state.
|
|
55
|
+
|
|
56
|
+
**Handles**: All QuickJS values (objects, strings, functions) are represented as `QuickJSHandle` objects. Handles must be manually disposed to prevent memory leaks.
|
|
57
|
+
|
|
58
|
+
### Async/Await in QuickJS
|
|
59
|
+
|
|
60
|
+
QuickJS is synchronous by design, so it doesn't support native async/await. The sandbox enables async functionality through a **deferred promise pattern**:
|
|
61
|
+
|
|
62
|
+
1. When sandbox code calls an async host function, the function returns a QuickJS Promise immediately (created via `context.newPromise()`)
|
|
63
|
+
2. The host function kicks off the actual async work and stores the promise in a tracker
|
|
64
|
+
3. When the async work completes, the deferred promise is resolved/rejected using the result
|
|
65
|
+
4. The main execution waits for the top-level promise using a race loop that:
|
|
66
|
+
- Checks if the main promise is settled
|
|
67
|
+
- Races all pending host promises
|
|
68
|
+
- Wakes immediately when any promise settles (no polling)
|
|
69
|
+
- Re-races until the main promise settles
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Host function returns deferred promise immediately
|
|
73
|
+
const deferred = context.newPromise();
|
|
74
|
+
|
|
75
|
+
// Kick off async work
|
|
76
|
+
const hostPromise = fetch(url)
|
|
77
|
+
.then(result => {
|
|
78
|
+
const handle = convertToHandle(context, result);
|
|
79
|
+
deferred.resolve(handle);
|
|
80
|
+
handle.dispose();
|
|
81
|
+
})
|
|
82
|
+
.catch(err => {
|
|
83
|
+
const errHandle = context.newString(err.message);
|
|
84
|
+
deferred.reject(errHandle);
|
|
85
|
+
errHandle.dispose();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Track for cleanup and race optimization
|
|
89
|
+
tracker.pendingPromises.add(hostPromise);
|
|
90
|
+
tracker.notifyNewPromise();
|
|
91
|
+
|
|
92
|
+
return deferred.handle;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Bridge System
|
|
96
|
+
|
|
97
|
+
Bridges expose runtime APIs to the sandbox code by creating QuickJS objects and methods that call into host functions. Each bridge is responsible for:
|
|
98
|
+
|
|
99
|
+
1. Creating QuickJS objects/functions
|
|
100
|
+
2. Converting between host and sandbox values
|
|
101
|
+
3. Tracking host resources (streams, responses, readers)
|
|
102
|
+
4. Managing handle lifetimes
|
|
103
|
+
5. Registering cleanup handlers
|
|
104
|
+
|
|
105
|
+
### Handle Management
|
|
106
|
+
|
|
107
|
+
Handles represent QuickJS values and must be disposed when no longer needed. The pattern is:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// Create handle
|
|
111
|
+
const handle = context.newString('hello');
|
|
112
|
+
|
|
113
|
+
// Use handle
|
|
114
|
+
context.setProp(objHandle, 'message', handle);
|
|
115
|
+
|
|
116
|
+
// Dispose handle
|
|
117
|
+
handle.dispose();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Critical Rules:**
|
|
121
|
+
|
|
122
|
+
- Every `new*` call must be matched with a `dispose()` call
|
|
123
|
+
- `callFunction` returns a handle that must be disposed
|
|
124
|
+
- `evalCode` returns a value/error handle that must be disposed
|
|
125
|
+
- Use `context.dump()` to convert handles to JS values (doesn't dispose the handle)
|
|
126
|
+
|
|
127
|
+
### Promise Tracking
|
|
128
|
+
|
|
129
|
+
The `PromiseTracker` manages all async operations in the sandbox:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
interface PromiseTracker {
|
|
133
|
+
pendingPromises: Set<Promise<void>>; // Host promises being tracked
|
|
134
|
+
notifyNewPromise: () => void; // Signal to wake race loop
|
|
135
|
+
newPromiseSignal: Promise<void>; // Signal promise for racing
|
|
136
|
+
deferredPromises: Set<any>; // QuickJS deferred promises
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The tracker is used for:
|
|
141
|
+
|
|
142
|
+
- Race loop optimization (wake when any promise settles)
|
|
143
|
+
- Cleanup (wait for all promises to settle before disposal)
|
|
144
|
+
- Proper handle disposal timing
|
|
145
|
+
|
|
146
|
+
## Main Components
|
|
147
|
+
|
|
148
|
+
### sandbox.ts
|
|
149
|
+
|
|
150
|
+
Singleton QuickJS sandbox with queued execution. Ensures only one sandbox (64MB memory limit) exists per worker by using a singleton pattern and execution queue.
|
|
151
|
+
|
|
152
|
+
#### `Sandbox` Class
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
class Sandbox {
|
|
156
|
+
static async getInstance(options?: SandboxOptions): Promise<Sandbox>;
|
|
157
|
+
async execute(
|
|
158
|
+
code: string,
|
|
159
|
+
globals: SandboxGlobals,
|
|
160
|
+
options?: SandboxOptions
|
|
161
|
+
): Promise<SandboxResult>;
|
|
162
|
+
async dispose(): Promise<void>;
|
|
163
|
+
static reset(): void;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Usage:**
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// Get or create singleton (same for services and actors)
|
|
171
|
+
const sandbox = await Sandbox.getInstance();
|
|
172
|
+
|
|
173
|
+
// Execute code (queues if another execution is running)
|
|
174
|
+
const result = await sandbox.execute(
|
|
175
|
+
'const x = await fetch(url); return x;',
|
|
176
|
+
{},
|
|
177
|
+
{
|
|
178
|
+
timeoutMs: 30000,
|
|
179
|
+
memoryLimitBytes: 64 * 1024 * 1024,
|
|
180
|
+
logger: console
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Dispose when shutting down (e.g., actor eviction)
|
|
185
|
+
await sandbox.dispose();
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Key Features:**
|
|
189
|
+
|
|
190
|
+
- **Singleton pattern**: One sandbox per worker, created on first `getInstance()`
|
|
191
|
+
- **Execution queue**: Concurrent `execute()` calls wait their turn
|
|
192
|
+
- **Memory limit**: Enforces 64MB limit (or custom via options)
|
|
193
|
+
- **Reusable context**: Created once, reused across executions (recreated after cleanup)
|
|
194
|
+
- **Proper cleanup**: Full cleanup after each execution including context recreation
|
|
195
|
+
- **Queue management**: Pending executions are rejected on `dispose()`
|
|
196
|
+
|
|
197
|
+
**Why Singleton?**
|
|
198
|
+
|
|
199
|
+
In Raindrop Actors, multiple requests can arrive concurrently. Without a singleton, each request would create its own sandbox (64MB each), exceeding worker memory limits (128MB) and causing crashes. The singleton ensures maximum one sandbox per worker.
|
|
200
|
+
|
|
201
|
+
**Execution Flow:**
|
|
202
|
+
|
|
203
|
+
1. **First `getInstance()`**: Creates QuickJS runtime and context, sets memory limit
|
|
204
|
+
2. **`execute(code, globals, options)`**:
|
|
205
|
+
- Adds execution to queue
|
|
206
|
+
- If queue is idle, starts processing immediately
|
|
207
|
+
- If another execution is running, waits in queue
|
|
208
|
+
- When turn comes:
|
|
209
|
+
- Set interrupt handler for timeout
|
|
210
|
+
- Create promise tracker and cleanup handlers
|
|
211
|
+
- Install bridges (standard + custom)
|
|
212
|
+
- Inject globals with promise tracking
|
|
213
|
+
- Wrap code in async IIFE
|
|
214
|
+
- Run race loop to wait for promise
|
|
215
|
+
- Return result
|
|
216
|
+
- Full cleanup
|
|
217
|
+
- Recreate context for next execution
|
|
218
|
+
3. **`dispose()`**:
|
|
219
|
+
- Rejects all queued executions
|
|
220
|
+
- Disposes context and runtime
|
|
221
|
+
- Clears singleton (next `getInstance()` creates new instance)
|
|
222
|
+
|
|
223
|
+
### Bridge System
|
|
224
|
+
|
|
225
|
+
The bridge system enables sandbox code to interact with host APIs. Bridges are registered functions or objects that expose host capabilities to the sandbox.
|
|
226
|
+
|
|
227
|
+
**Key Types:**
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
interface BridgeContext {
|
|
231
|
+
context: QuickJSContext;
|
|
232
|
+
runtime: any;
|
|
233
|
+
tracker: PromiseTracker;
|
|
234
|
+
logger?: Logger;
|
|
235
|
+
cleanupHandlers: Array<() => void | Promise<void>>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type BridgeInstaller = (ctx: BridgeContext) => void;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Bridge Installation:**
|
|
242
|
+
|
|
243
|
+
Bridges are installed in two phases:
|
|
244
|
+
|
|
245
|
+
1. **Standard bridges**: Always installed (fetch, console, TextEncoder, TextDecoder, ReadableStream, Response)
|
|
246
|
+
2. **Custom bridges**: Optional, provided via options
|
|
247
|
+
|
|
248
|
+
**Execution Flow:**
|
|
249
|
+
|
|
250
|
+
1. **Initialization**
|
|
251
|
+
- Load QuickJS WASM module (singleton, cached across executions)
|
|
252
|
+
- Create runtime and context
|
|
253
|
+
- Set memory limit and interrupt handler for timeout
|
|
254
|
+
- Create promise tracker and cleanup handlers array
|
|
255
|
+
|
|
256
|
+
2. **Bridge Installation**
|
|
257
|
+
- Install standard bridges (fetch, console, TextEncoder, TextDecoder)
|
|
258
|
+
- Install custom bridges (if provided)
|
|
259
|
+
|
|
260
|
+
3. **Global Injection**
|
|
261
|
+
- For each global value:
|
|
262
|
+
- Primitive → convert to handle, set on global
|
|
263
|
+
- Function → create QuickJS function that tracks async returns
|
|
264
|
+
- Object → create object, inject properties and methods
|
|
265
|
+
|
|
266
|
+
4. **Code Execution**
|
|
267
|
+
- Wrap code in async IIFE: `(async () => { ${code} })()`
|
|
268
|
+
- Evaluate code (returns promise handle immediately)
|
|
269
|
+
- Run race loop to wait for promise settlement
|
|
270
|
+
|
|
271
|
+
5. **Race Loop**
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
while (true) {
|
|
275
|
+
// Check timeout
|
|
276
|
+
// Execute pending QuickJS jobs
|
|
277
|
+
// Check main promise state
|
|
278
|
+
// If settled → resolve/reject with result
|
|
279
|
+
// Race: pending promises | new promise signal | timeout
|
|
280
|
+
// Wait for first to complete
|
|
281
|
+
// Loop
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
6. **Cleanup** (in finally block)
|
|
286
|
+
- Run bridge cleanup handlers (abort streams, release readers)
|
|
287
|
+
- Flush microtasks
|
|
288
|
+
- Wait for all pending promises to settle (3s timeout)
|
|
289
|
+
- Settle remaining deferred promises
|
|
290
|
+
- Drain job queue (max 1000 iterations, 1s timeout)
|
|
291
|
+
- Dispose deferred promise handles
|
|
292
|
+
- Final job drain (100 iterations, 500ms timeout)
|
|
293
|
+
- Check for lingering GC objects
|
|
294
|
+
- Dispose context
|
|
295
|
+
- Dispose runtime (only if safe)
|
|
296
|
+
|
|
297
|
+
**Cleanup Complexity:**
|
|
298
|
+
|
|
299
|
+
The cleanup sequence is critical to prevent memory leaks and crashes:
|
|
300
|
+
|
|
301
|
+
1. **Bridge cleanup first**: Aborts streams, releases readers. This prevents new promises from being created.
|
|
302
|
+
2. **Flush microtasks**: Lets abort handlers propagate and settle deferred promises.
|
|
303
|
+
3. **Wait for promises**: All pending host promises must settle before disposal.
|
|
304
|
+
4. **Settle deferred promises**: Reject pending deferred promises with cleanup error.
|
|
305
|
+
5. **Drain job queue**: Execute QuickJS jobs repeatedly until queue is empty.
|
|
306
|
+
6. **Dispose handles**: All QuickJS handles must be disposed.
|
|
307
|
+
7. **Check GC objects**: Skip runtime disposal if lingering promise objects detected (QuickJS limitation).
|
|
308
|
+
8. **Dispose context and runtime**: Final cleanup.
|
|
309
|
+
|
|
310
|
+
### Bridge System
|
|
311
|
+
|
|
312
|
+
#### types.ts
|
|
313
|
+
|
|
314
|
+
Defines shared types for bridges:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
interface BridgeContext {
|
|
318
|
+
context: QuickJSContext;
|
|
319
|
+
runtime: any;
|
|
320
|
+
tracker: PromiseTracker;
|
|
321
|
+
logger?: Logger;
|
|
322
|
+
cleanupHandlers: Array<() => void | Promise<void>>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface PromiseTracker {
|
|
326
|
+
pendingPromises: Set<Promise<void>>;
|
|
327
|
+
notifyNewPromise: () => void;
|
|
328
|
+
newPromiseSignal: Promise<void>;
|
|
329
|
+
deferredPromises: Set<any>;
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
#### utils.ts
|
|
334
|
+
|
|
335
|
+
Core bridge utility functions:
|
|
336
|
+
|
|
337
|
+
- `convertToHandle(context, value)`: Convert JS values to QuickJS handles
|
|
338
|
+
- `createAsyncBridge(ctx, name, hostFn)`: Create an async bridge function
|
|
339
|
+
- `createSyncBridge(ctx, name, hostFn)`: Create a sync bridge function
|
|
340
|
+
- `defineClass(ctx, classCode, className)`: Define a QuickJS class from code
|
|
341
|
+
- `setProperties(context, handle, props)`: Set multiple properties on an object
|
|
342
|
+
- `addMethod(context, objHandle, methodName, methodHandle)`: Add a method to an object
|
|
343
|
+
- `executeTrackedAsync(ctx, deferred, hostPromise)`: Execute async work with tracking
|
|
344
|
+
|
|
345
|
+
#### index.ts
|
|
346
|
+
|
|
347
|
+
Bridge installation and cleanup orchestration:
|
|
348
|
+
|
|
349
|
+
- `installAllBridges(ctx)`: Install all standard bridges
|
|
350
|
+
- `createPromiseTracker()`: Create a new promise tracker
|
|
351
|
+
- `runCleanupHandlers(handlers, logger)`: Execute all cleanup handlers
|
|
352
|
+
|
|
353
|
+
### Bridge Implementations
|
|
354
|
+
|
|
355
|
+
#### console.ts
|
|
356
|
+
|
|
357
|
+
Bridges the `console` API with output capture.
|
|
358
|
+
|
|
359
|
+
**Features:**
|
|
360
|
+
|
|
361
|
+
- `console.log`, `console.error`, `console.warn` methods
|
|
362
|
+
- Argument formatting (objects, strings, primitives)
|
|
363
|
+
- Output capture (accessible via `getConsoleOutput(context)`)
|
|
364
|
+
- Cleanup via `clearConsoleOutput(context)`
|
|
365
|
+
|
|
366
|
+
**Pattern:**
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
export function installConsole(ctx: BridgeContext): void {
|
|
370
|
+
const consoleHandle = context.newObject();
|
|
371
|
+
const output: string[] = [];
|
|
372
|
+
|
|
373
|
+
const logMethod = context.newFunction('log', (...args: QuickJSHandle[]) => {
|
|
374
|
+
const jsArgs = args.map(arg => context.dump(arg));
|
|
375
|
+
const outputLine = formatArgs(jsArgs);
|
|
376
|
+
output.push(`[LOG] ${outputLine}`);
|
|
377
|
+
return context.undefined;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
context.setProp(consoleHandle, 'log', logMethod);
|
|
381
|
+
logHandle.dispose();
|
|
382
|
+
context.setProp(context.global, 'console', consoleHandle);
|
|
383
|
+
consoleHandle.dispose();
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
#### fetch.ts
|
|
388
|
+
|
|
389
|
+
Bridges the `fetch` API.
|
|
390
|
+
|
|
391
|
+
**Features:**
|
|
392
|
+
|
|
393
|
+
- Full fetch API support (GET, POST, etc.)
|
|
394
|
+
- Request/Response streaming
|
|
395
|
+
- AbortController support
|
|
396
|
+
- Stream cleanup on abort/timeout
|
|
397
|
+
- Headers support
|
|
398
|
+
|
|
399
|
+
**Resource Tracking:**
|
|
400
|
+
|
|
401
|
+
- Tracks fetch abort controllers for cleanup
|
|
402
|
+
- Registers cleanup handler to abort in-flight requests
|
|
403
|
+
|
|
404
|
+
**Key Implementation:**
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
export function installFetch(ctx: BridgeContext): void {
|
|
408
|
+
const { context, tracker, cleanupHandlers } = ctx;
|
|
409
|
+
const pendingAbortControllers = new Set<AbortController>();
|
|
410
|
+
|
|
411
|
+
const fetchHandle = context.newFunction(
|
|
412
|
+
'fetch',
|
|
413
|
+
(urlHandle: QuickJSHandle, initHandle?: QuickJSHandle) => {
|
|
414
|
+
const url = context.dump(urlHandle);
|
|
415
|
+
const init = initHandle ? context.dump(initHandle) : undefined;
|
|
416
|
+
|
|
417
|
+
const deferred = context.newPromise();
|
|
418
|
+
tracker.deferredPromises.add(deferred);
|
|
419
|
+
|
|
420
|
+
// Create AbortController for this request
|
|
421
|
+
const abortController = new AbortController();
|
|
422
|
+
pendingAbortControllers.add(abortController);
|
|
423
|
+
|
|
424
|
+
const hostPromise = fetch(url, { ...init, signal: abortController.signal })
|
|
425
|
+
.then(response => {
|
|
426
|
+
const responseHandle = createResponseObject(ctx, response, objCtx);
|
|
427
|
+
deferred.resolve(responseHandle);
|
|
428
|
+
responseHandle.dispose();
|
|
429
|
+
})
|
|
430
|
+
.catch(err => {
|
|
431
|
+
const errHandle = context.newString(err.message);
|
|
432
|
+
deferred.reject(errHandle);
|
|
433
|
+
errHandle.dispose();
|
|
434
|
+
})
|
|
435
|
+
.finally(() => {
|
|
436
|
+
pendingAbortControllers.delete(abortController);
|
|
437
|
+
tracker.pendingPromises.delete(hostPromise);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
tracker.pendingPromises.add(hostPromise);
|
|
441
|
+
tracker.notifyNewPromise();
|
|
442
|
+
|
|
443
|
+
return deferred.handle;
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Register cleanup handler
|
|
448
|
+
cleanupHandlers.push(() => {
|
|
449
|
+
for (const controller of pendingAbortControllers) {
|
|
450
|
+
controller.abort('Sandbox cleanup');
|
|
451
|
+
}
|
|
452
|
+
pendingAbortControllers.clear();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
#### response.ts
|
|
458
|
+
|
|
459
|
+
Bridges the `Response` constructor.
|
|
460
|
+
|
|
461
|
+
**Features:**
|
|
462
|
+
|
|
463
|
+
- `new Response(body, init)` support
|
|
464
|
+
- Stream body bridging
|
|
465
|
+
- Headers support
|
|
466
|
+
- Body consumption methods (text, json, arrayBuffer)
|
|
467
|
+
|
|
468
|
+
**Implementation:**
|
|
469
|
+
Uses `createResponseObject` from shared utilities for unified Response creation.
|
|
470
|
+
|
|
471
|
+
#### readable-stream.ts
|
|
472
|
+
|
|
473
|
+
Bridges the `ReadableStream` API.
|
|
474
|
+
|
|
475
|
+
**Features:**
|
|
476
|
+
|
|
477
|
+
- `getReader()` method
|
|
478
|
+
- `read()` method (async)
|
|
479
|
+
- `releaseLock()` method
|
|
480
|
+
- `locked` property (getter)
|
|
481
|
+
- Coalescing reader for performance (buffers small chunks)
|
|
482
|
+
|
|
483
|
+
**Coalescing Reader:**
|
|
484
|
+
Reduces boundary crossing overhead by buffering small chunks before passing to QuickJS. Minimum chunk size: 16KB.
|
|
485
|
+
|
|
486
|
+
**Key Implementation:**
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
export function createStreamObject(
|
|
490
|
+
ctx: BridgeContext,
|
|
491
|
+
hostStream: ReadableStream,
|
|
492
|
+
streamId: number,
|
|
493
|
+
hostReaders: Map<number, ReadableStreamDefaultReader>,
|
|
494
|
+
readerIdCounter: { value: number }
|
|
495
|
+
): QuickJSHandle {
|
|
496
|
+
const { context } = ctx;
|
|
497
|
+
const streamHandle = context.newObject();
|
|
498
|
+
|
|
499
|
+
const getReaderMethod = context.newFunction('getReader', () => {
|
|
500
|
+
const hostReader = hostStream.getReader();
|
|
501
|
+
const readerId = ++readerIdCounter.value;
|
|
502
|
+
hostReaders.set(readerId, hostReader);
|
|
503
|
+
return createReaderObject(ctx, hostReader, readerId);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
context.setProp(streamHandle, 'getReader', getReaderMethod);
|
|
507
|
+
getReaderMethod.dispose();
|
|
508
|
+
|
|
509
|
+
return streamHandle;
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
#### text-encoder.ts & text-decoder.ts
|
|
514
|
+
|
|
515
|
+
Bridges `TextEncoder` and `TextDecoder` classes.
|
|
516
|
+
|
|
517
|
+
**Features:**
|
|
518
|
+
|
|
519
|
+
- Standard Web API compatibility
|
|
520
|
+
- UTF-8 encoding/decoding
|
|
521
|
+
- TextDecoder label support
|
|
522
|
+
- Stream mode for TextDecoder
|
|
523
|
+
|
|
524
|
+
#### storage.ts
|
|
525
|
+
|
|
526
|
+
Bridges KV storage API.
|
|
527
|
+
|
|
528
|
+
**Features:**
|
|
529
|
+
|
|
530
|
+
- `await storage.get(path)` - Read values
|
|
531
|
+
- `await storage.write(path, content)` - Write values
|
|
532
|
+
- `await storage.list(prefix, limit)` - List keys
|
|
533
|
+
- `await storage.remove(path)` - Delete keys
|
|
534
|
+
- Response body with metadata (key, size, uploaded)
|
|
535
|
+
- Proper error handling
|
|
536
|
+
|
|
537
|
+
**Mount Info:**
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
interface StorageMountInfo {
|
|
541
|
+
kv: KVNamespace;
|
|
542
|
+
bucket: R2Bucket;
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
**Usage:**
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
const bridgeCtx: BridgeContext = { ... };
|
|
550
|
+
bridgeInstallers.push((ctx) => installStorage(ctx, { kv, bucket }));
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
#### search.ts
|
|
554
|
+
|
|
555
|
+
Bridges vector search API.
|
|
556
|
+
|
|
557
|
+
**Features:**
|
|
558
|
+
|
|
559
|
+
- `await search(path, query)` - Search documents
|
|
560
|
+
- `await search.chunk(path, query)` - Chunked search
|
|
561
|
+
- Async iteration for results (for await...of)
|
|
562
|
+
- Pagination support
|
|
563
|
+
- Response metadata (text, source, score, chunk)
|
|
564
|
+
|
|
565
|
+
**Mount Info:**
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
interface SearchMountInfo {
|
|
569
|
+
search: VectorSearch;
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
#### bucket.ts
|
|
574
|
+
|
|
575
|
+
Bridges R2 bucket API.
|
|
576
|
+
|
|
577
|
+
**Features:**
|
|
578
|
+
|
|
579
|
+
- `await bucket.get(path)` - Get object
|
|
580
|
+
- `await bucket.put(path, content)` - Put object
|
|
581
|
+
- `await bucket.list(prefix, limit)` - List objects
|
|
582
|
+
- `await bucket.delete(path)` - Delete object
|
|
583
|
+
- Response streaming
|
|
584
|
+
- Object metadata
|
|
585
|
+
|
|
586
|
+
#### actor.ts
|
|
587
|
+
|
|
588
|
+
Bridges Durable Object actor API.
|
|
589
|
+
|
|
590
|
+
**Features:**
|
|
591
|
+
|
|
592
|
+
- `await actor(path, instanceName)` - Get actor stub
|
|
593
|
+
- Host stub caching
|
|
594
|
+
- Error handling
|
|
595
|
+
|
|
596
|
+
### Shared Utilities
|
|
597
|
+
|
|
598
|
+
The `shared/` directory contains reusable components used across multiple bridges.
|
|
599
|
+
|
|
600
|
+
#### headers.ts
|
|
601
|
+
|
|
602
|
+
Creates Headers objects with full Web API support.
|
|
603
|
+
|
|
604
|
+
**Features:**
|
|
605
|
+
|
|
606
|
+
- `get()`, `has()`, `set()` methods
|
|
607
|
+
- `entries()`, `keys()`, `values()` iterators
|
|
608
|
+
- `forEach(callback)` method
|
|
609
|
+
- Direct property access (e.g., `headers['content-type']`)
|
|
610
|
+
|
|
611
|
+
**Usage:**
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
const headersHandle = createHeadersObject(ctx, {
|
|
615
|
+
'content-type': 'application/json',
|
|
616
|
+
'x-custom': 'value'
|
|
617
|
+
});
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**Handle Management:**
|
|
621
|
+
|
|
622
|
+
- Disposes all temporary handles created during iteration
|
|
623
|
+
- Returns Headers handle that caller must dispose
|
|
624
|
+
|
|
625
|
+
#### response-object.ts
|
|
626
|
+
|
|
627
|
+
Unified Response object factory.
|
|
628
|
+
|
|
629
|
+
**Features:**
|
|
630
|
+
|
|
631
|
+
- Works with real Response objects (fetch, Response constructor)
|
|
632
|
+
- Works with bare ReadableStream (bucket.get)
|
|
633
|
+
- Sets all standard Response properties (status, ok, headers, etc.)
|
|
634
|
+
- Bridges body stream
|
|
635
|
+
- Attaches body consumption methods (text, json, arrayBuffer)
|
|
636
|
+
- Exposes `bodyUsed` as getter
|
|
637
|
+
- Supports extra metadata (bucket: key, size, uploaded)
|
|
638
|
+
|
|
639
|
+
**Usage:**
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
// From real Response
|
|
643
|
+
const responseHandle = createResponseObject(ctx, { response: hostResponse }, objCtx);
|
|
644
|
+
|
|
645
|
+
// From bare stream (bucket)
|
|
646
|
+
const responseHandle = createResponseObject(ctx, { body: stream, metadata }, objCtx);
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
#### body-methods.ts
|
|
650
|
+
|
|
651
|
+
Body consumption methods for Response objects.
|
|
652
|
+
|
|
653
|
+
**Features:**
|
|
654
|
+
|
|
655
|
+
- `createBodyMethod(ctx, methodName, responseId, registry)` - Creates text/json/arrayBuffer method
|
|
656
|
+
- `attachBodyMethods(ctx, responseHandle, responseId, registry)` - Attaches all three
|
|
657
|
+
- Checks `bodyUsed` before consumption
|
|
658
|
+
- Uses cached JSON.parse for efficiency
|
|
659
|
+
- Proper promise tracking
|
|
660
|
+
|
|
661
|
+
**Pattern:**
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
export function createBodyMethod(
|
|
665
|
+
ctx: BridgeContext,
|
|
666
|
+
methodName: 'json' | 'text' | 'arrayBuffer',
|
|
667
|
+
responseId: number,
|
|
668
|
+
responseRegistry: Map<number, Response>
|
|
669
|
+
): QuickJSHandle {
|
|
670
|
+
return context.newFunction(methodName, () => {
|
|
671
|
+
const response = responseRegistry.get(responseId);
|
|
672
|
+
if (!response || response.bodyUsed) {
|
|
673
|
+
// Return rejected promise
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const deferred = context.newPromise();
|
|
677
|
+
const hostPromise = response[methodName]().then(result => {
|
|
678
|
+
// Convert and resolve
|
|
679
|
+
});
|
|
680
|
+
tracker.pendingPromises.add(hostPromise);
|
|
681
|
+
return deferred.handle;
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
#### cleanup.ts
|
|
687
|
+
|
|
688
|
+
Stream cleanup utilities.
|
|
689
|
+
|
|
690
|
+
**Features:**
|
|
691
|
+
|
|
692
|
+
- `createStreamCleanupHandler(registry, bridgeName, logger)` - Creates cleanup function
|
|
693
|
+
- Proper cleanup order: readers → streams → wait → clear maps
|
|
694
|
+
- Timeout for cleanup operations (2s)
|
|
695
|
+
- Error handling and logging
|
|
696
|
+
|
|
697
|
+
**Critical Pattern:**
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
export function createStreamCleanupHandler(
|
|
701
|
+
registry: StreamRegistry,
|
|
702
|
+
bridgeName: string,
|
|
703
|
+
logger?: Logger
|
|
704
|
+
): () => Promise<void> {
|
|
705
|
+
return async () => {
|
|
706
|
+
// STEP 1: Release readers FIRST
|
|
707
|
+
for (const [id, reader] of registry.hostReaders) {
|
|
708
|
+
reader.releaseLock();
|
|
709
|
+
}
|
|
710
|
+
registry.hostReaders.clear();
|
|
711
|
+
|
|
712
|
+
// STEP 2: Cancel streams
|
|
713
|
+
const pendingOperations: Promise<unknown>[] = [];
|
|
714
|
+
for (const [id, stream] of registry.hostStreams) {
|
|
715
|
+
pendingOperations.push(stream.cancel('Sandbox cleanup'));
|
|
716
|
+
}
|
|
717
|
+
registry.hostStreams.clear();
|
|
718
|
+
|
|
719
|
+
// STEP 3: Wait for cancellation (with timeout)
|
|
720
|
+
await Promise.race([
|
|
721
|
+
Promise.allSettled(pendingOperations),
|
|
722
|
+
new Promise(resolve => setTimeout(resolve, 2000))
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
// STEP 4: Clear response references
|
|
726
|
+
registry.hostResponses?.clear();
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
**Why this order?**
|
|
732
|
+
|
|
733
|
+
- Readers must be released before streams can be cancelled
|
|
734
|
+
- Cancellation settles pending read promises
|
|
735
|
+
- Timeout prevents cleanup from blocking disposal indefinitely
|
|
736
|
+
|
|
737
|
+
#### json-helpers.ts
|
|
738
|
+
|
|
739
|
+
JSON parsing utilities with caching.
|
|
740
|
+
|
|
741
|
+
**Features:**
|
|
742
|
+
|
|
743
|
+
- `getJsonParse(context)` - Get or create cached JSON.parse handle
|
|
744
|
+
- `parseJsonInContext(context, jsonString)` - Parse JSON string
|
|
745
|
+
- Caches JSON.parse on context global to avoid evalCode overhead
|
|
746
|
+
- Caller must dispose returned handle
|
|
747
|
+
|
|
748
|
+
**Performance:**
|
|
749
|
+
Caching JSON.parse avoids expensive `evalCode()` calls on every parse operation.
|
|
750
|
+
|
|
751
|
+
**Usage:**
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
const jsonHandle = parseJsonInContext(context, jsonString);
|
|
755
|
+
// Use handle
|
|
756
|
+
jsonHandle.dispose();
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
## Creating a New Bridge
|
|
760
|
+
|
|
761
|
+
### Step 1: Define the Bridge Function
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
// packages/precip/src/sandbox/bridges/my-api.ts
|
|
765
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
766
|
+
import type { BridgeContext } from './types.js';
|
|
767
|
+
import { createAsyncBridge, convertToHandle } from './utils.js';
|
|
768
|
+
|
|
769
|
+
export function installMyApi(ctx: BridgeContext): void {
|
|
770
|
+
const { context, tracker, cleanupHandlers, logger } = ctx;
|
|
771
|
+
|
|
772
|
+
// Create the main API object
|
|
773
|
+
const apiHandle = context.newObject();
|
|
774
|
+
|
|
775
|
+
// Add a method
|
|
776
|
+
const myMethod = context.newFunction('myMethod', (...args: QuickJSHandle[]) => {
|
|
777
|
+
const jsArgs = args.map(arg => context.dump(arg));
|
|
778
|
+
|
|
779
|
+
// Create deferred promise
|
|
780
|
+
const deferred = context.newPromise();
|
|
781
|
+
tracker.deferredPromises.add(deferred);
|
|
782
|
+
|
|
783
|
+
// Kick off async work
|
|
784
|
+
const hostPromise = doAsyncWork(jsArgs)
|
|
785
|
+
.then(result => {
|
|
786
|
+
const handle = convertToHandle(context, result);
|
|
787
|
+
deferred.resolve(handle);
|
|
788
|
+
handle.dispose();
|
|
789
|
+
runtime.executePendingJobs();
|
|
790
|
+
})
|
|
791
|
+
.catch(err => {
|
|
792
|
+
const errHandle = context.newString(err.message);
|
|
793
|
+
deferred.reject(errHandle);
|
|
794
|
+
errHandle.dispose();
|
|
795
|
+
runtime.executePendingJobs();
|
|
796
|
+
})
|
|
797
|
+
.finally(() => {
|
|
798
|
+
tracker.pendingPromises.delete(hostPromise);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
tracker.pendingPromises.add(hostPromise);
|
|
802
|
+
tracker.notifyNewPromise();
|
|
803
|
+
|
|
804
|
+
return deferred.handle;
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
context.setProp(apiHandle, 'myMethod', myMethod);
|
|
808
|
+
myMethod.dispose();
|
|
809
|
+
|
|
810
|
+
// Register on global
|
|
811
|
+
context.setProp(context.global, 'myApi', apiHandle);
|
|
812
|
+
apiHandle.dispose();
|
|
813
|
+
|
|
814
|
+
logger?.info?.('[MyApi] Bridge installed');
|
|
815
|
+
}
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### Step 2: Register the Bridge
|
|
819
|
+
|
|
820
|
+
```typescript
|
|
821
|
+
// In packages/precip/src/sandbox/bridges/index.ts
|
|
822
|
+
export { installMyApi } from './my-api.js';
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Step 3: Use the Bridge
|
|
826
|
+
|
|
827
|
+
```typescript
|
|
828
|
+
// When executing sandbox code
|
|
829
|
+
const sandbox = await Sandbox.getInstance();
|
|
830
|
+
await sandbox.execute(code, globals, {
|
|
831
|
+
bridgeInstallers: [installMyApi]
|
|
832
|
+
});
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
## Common Patterns
|
|
836
|
+
|
|
837
|
+
### Sync Bridge Function
|
|
838
|
+
|
|
839
|
+
```typescript
|
|
840
|
+
const syncMethod = context.newFunction('syncMethod', (argHandle: QuickJSHandle) => {
|
|
841
|
+
const arg = context.dump(argHandle);
|
|
842
|
+
const result = doSyncWork(arg);
|
|
843
|
+
return convertToHandle(context, result);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
context.setProp(objHandle, 'syncMethod', syncMethod);
|
|
847
|
+
syncMethod.dispose();
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
### Async Bridge Function
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
const asyncMethod = context.newFunction('asyncMethod', (argHandle: QuickJSHandle) => {
|
|
854
|
+
const arg = context.dump(argHandle);
|
|
855
|
+
const deferred = context.newPromise();
|
|
856
|
+
tracker.deferredPromises.add(deferred);
|
|
857
|
+
|
|
858
|
+
const hostPromise = doAsyncWork(arg)
|
|
859
|
+
.then(result => {
|
|
860
|
+
const handle = convertToHandle(context, result);
|
|
861
|
+
deferred.resolve(handle);
|
|
862
|
+
handle.dispose();
|
|
863
|
+
runtime.executePendingJobs();
|
|
864
|
+
})
|
|
865
|
+
.catch(err => {
|
|
866
|
+
const errHandle = context.newString(err.message);
|
|
867
|
+
deferred.reject(errHandle);
|
|
868
|
+
errHandle.dispose();
|
|
869
|
+
runtime.executePendingJobs();
|
|
870
|
+
})
|
|
871
|
+
.finally(() => {
|
|
872
|
+
tracker.pendingPromises.delete(hostPromise);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
tracker.pendingPromises.add(hostPromise);
|
|
876
|
+
tracker.notifyNewPromise();
|
|
877
|
+
|
|
878
|
+
return deferred.handle;
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
context.setProp(objHandle, 'asyncMethod', asyncMethod);
|
|
882
|
+
asyncMethod.dispose();
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
### Class Definition
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
const classCode = `
|
|
889
|
+
class MyClass {
|
|
890
|
+
constructor(value) {
|
|
891
|
+
this.value = value;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
method() {
|
|
895
|
+
return this.value * 2;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
`;
|
|
899
|
+
|
|
900
|
+
const { value: classHandle, error } = context.evalCode(classCode);
|
|
901
|
+
if (error) {
|
|
902
|
+
error.dispose();
|
|
903
|
+
throw new Error('Failed to define class');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
context.setProp(context.global, 'MyClass', classHandle);
|
|
907
|
+
classHandle.dispose();
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Property Getter
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
// Create getter function
|
|
914
|
+
const getProp = context.newFunction('__getProp', () => {
|
|
915
|
+
return hostProp ? context.true : context.false;
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
context.setProp(objHandle, '__getProp', getProp);
|
|
919
|
+
getProp.dispose();
|
|
920
|
+
|
|
921
|
+
// Define as getter
|
|
922
|
+
const defineCode = `
|
|
923
|
+
(function(obj) {
|
|
924
|
+
var fn = obj.__getProp;
|
|
925
|
+
Object.defineProperty(obj, 'myProp', {
|
|
926
|
+
get: function() { return fn(); },
|
|
927
|
+
enumerable: true,
|
|
928
|
+
configurable: true
|
|
929
|
+
});
|
|
930
|
+
delete obj.__getProp;
|
|
931
|
+
return obj;
|
|
932
|
+
})
|
|
933
|
+
`;
|
|
934
|
+
|
|
935
|
+
const { value: defineFn, error } = context.evalCode(defineCode);
|
|
936
|
+
if (!error) {
|
|
937
|
+
const result = context.callFunction(defineFn, context.undefined, objHandle);
|
|
938
|
+
defineFn.dispose();
|
|
939
|
+
if (!('error' in result)) {
|
|
940
|
+
result.value.dispose();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
## Resource Management
|
|
946
|
+
|
|
947
|
+
### Handle Disposal
|
|
948
|
+
|
|
949
|
+
**Golden Rule:** Every handle created must be disposed.
|
|
950
|
+
|
|
951
|
+
```typescript
|
|
952
|
+
// Create handle
|
|
953
|
+
const handle = context.newString('value');
|
|
954
|
+
|
|
955
|
+
// Use it
|
|
956
|
+
context.setProp(obj, 'key', handle);
|
|
957
|
+
|
|
958
|
+
// Dispose it
|
|
959
|
+
handle.dispose();
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
**Handle Lifecycle:**
|
|
963
|
+
|
|
964
|
+
1. **Create**: `context.newString()`, `context.newObject()`, etc.
|
|
965
|
+
2. **Use**: Pass to other functions or set as properties
|
|
966
|
+
3. **Dispose**: `handle.dispose()`
|
|
967
|
+
|
|
968
|
+
**Common Pitfalls:**
|
|
969
|
+
|
|
970
|
+
```typescript
|
|
971
|
+
// ❌ Forgot to dispose
|
|
972
|
+
const handle = context.newString('value');
|
|
973
|
+
context.setProp(obj, 'key', handle);
|
|
974
|
+
// handle.dispose() missing!
|
|
975
|
+
|
|
976
|
+
// ❌ Disposed too early
|
|
977
|
+
const handle = context.newString('value');
|
|
978
|
+
handle.dispose();
|
|
979
|
+
context.setProp(obj, 'key', handle); // Error: handle already disposed
|
|
980
|
+
|
|
981
|
+
// ✅ Correct
|
|
982
|
+
const handle = context.newString('value');
|
|
983
|
+
context.setProp(obj, 'key', handle);
|
|
984
|
+
handle.dispose();
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### Stream Cleanup
|
|
988
|
+
|
|
989
|
+
Streams must be cleaned up in a specific order:
|
|
990
|
+
|
|
991
|
+
1. Release readers (streams can't be cancelled while locked)
|
|
992
|
+
2. Cancel streams (settles pending promises)
|
|
993
|
+
3. Wait for cancellation (with timeout)
|
|
994
|
+
4. Clear all maps
|
|
995
|
+
|
|
996
|
+
```typescript
|
|
997
|
+
export function cleanupStreams(registry: StreamRegistry): () => Promise<void> {
|
|
998
|
+
return async () => {
|
|
999
|
+
// Release readers
|
|
1000
|
+
for (const reader of registry.hostReaders.values()) {
|
|
1001
|
+
reader.releaseLock();
|
|
1002
|
+
}
|
|
1003
|
+
registry.hostReaders.clear();
|
|
1004
|
+
|
|
1005
|
+
// Cancel streams
|
|
1006
|
+
const pending = [];
|
|
1007
|
+
for (const stream of registry.hostStreams.values()) {
|
|
1008
|
+
pending.push(stream.cancel('Sandbox cleanup'));
|
|
1009
|
+
}
|
|
1010
|
+
registry.hostStreams.clear();
|
|
1011
|
+
|
|
1012
|
+
// Wait (with timeout)
|
|
1013
|
+
await Promise.race([
|
|
1014
|
+
Promise.allSettled(pending),
|
|
1015
|
+
new Promise(resolve => setTimeout(resolve, 2000))
|
|
1016
|
+
]);
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Registry Pattern
|
|
1022
|
+
|
|
1023
|
+
Host objects are tracked in registries for cleanup:
|
|
1024
|
+
|
|
1025
|
+
```typescript
|
|
1026
|
+
interface ObjectContext {
|
|
1027
|
+
registry: {
|
|
1028
|
+
hostResponses: Map<number, Response>;
|
|
1029
|
+
hostStreams: Map<number, ReadableStream>;
|
|
1030
|
+
hostReaders: Map<number, ReadableStreamDefaultReader>;
|
|
1031
|
+
};
|
|
1032
|
+
responseIdCounter: { value: number };
|
|
1033
|
+
streamIdCounter: { value: number };
|
|
1034
|
+
readerIdCounter: { value: number };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// When creating a response
|
|
1038
|
+
const responseId = ++responseIdCounter.value;
|
|
1039
|
+
registry.hostResponses.set(responseId, response);
|
|
1040
|
+
|
|
1041
|
+
// During cleanup
|
|
1042
|
+
registry.hostResponses.clear();
|
|
1043
|
+
registry.hostStreams.clear();
|
|
1044
|
+
registry.hostReaders.clear();
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
## Performance Optimizations
|
|
1048
|
+
|
|
1049
|
+
### 1. Cached Function Handles
|
|
1050
|
+
|
|
1051
|
+
Expensive operations like `evalCode()` are cached:
|
|
1052
|
+
|
|
1053
|
+
```typescript
|
|
1054
|
+
// JSON parsing
|
|
1055
|
+
const JSON_PARSE_CACHE_KEY = '__cachedJsonParse';
|
|
1056
|
+
|
|
1057
|
+
function getJsonParse(context: QuickJSContext): QuickJSHandle {
|
|
1058
|
+
const cachedHandle = context.getProp(context.global, JSON_PARSE_CACHE_KEY);
|
|
1059
|
+
if (context.typeof(cachedHandle) === 'function') {
|
|
1060
|
+
return cachedHandle;
|
|
1061
|
+
}
|
|
1062
|
+
cachedHandle.dispose();
|
|
1063
|
+
|
|
1064
|
+
const jsonParse = context.getProp(context.global, 'JSON').parse;
|
|
1065
|
+
context.setProp(context.global, JSON_PARSE_CACHE_KEY, jsonParse);
|
|
1066
|
+
return jsonParse;
|
|
1067
|
+
}
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
### 2. Coalescing Reader
|
|
1071
|
+
|
|
1072
|
+
Small stream chunks are buffered before crossing the boundary:
|
|
1073
|
+
|
|
1074
|
+
```typescript
|
|
1075
|
+
class CoalescingReader {
|
|
1076
|
+
private buffer: Uint8Array[] = [];
|
|
1077
|
+
private bufferSize = 0;
|
|
1078
|
+
private minChunkSize = 16 * 1024;
|
|
1079
|
+
|
|
1080
|
+
async read(): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
1081
|
+
// Buffer until minChunkSize or stream ends
|
|
1082
|
+
while (!this.done && this.bufferSize < this.minChunkSize) {
|
|
1083
|
+
const result = await this.hostReader.read();
|
|
1084
|
+
if (result.done) {
|
|
1085
|
+
this.done = true;
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
this.buffer.push(result.value);
|
|
1089
|
+
this.bufferSize += result.value.length;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Coalesce and return
|
|
1093
|
+
// ...
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### 3. Promise.race Optimization
|
|
1099
|
+
|
|
1100
|
+
Instead of polling, race all pending promises:
|
|
1101
|
+
|
|
1102
|
+
```typescript
|
|
1103
|
+
const raceLoop = async () => {
|
|
1104
|
+
while (true) {
|
|
1105
|
+
// Check promise state
|
|
1106
|
+
const state = context.getPromiseState(promiseHandle);
|
|
1107
|
+
if (state.type !== 'pending') {
|
|
1108
|
+
// Return result
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Create fresh signal BEFORE racing
|
|
1112
|
+
tracker.newPromiseSignal = new Promise<void>(resolve => {
|
|
1113
|
+
tracker.notifyNewPromise = resolve;
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// Race: pending promises | signal | timeout
|
|
1117
|
+
await Promise.race([
|
|
1118
|
+
...Array.from(tracker.pendingPromises),
|
|
1119
|
+
tracker.newPromiseSignal,
|
|
1120
|
+
new Promise(r => setTimeout(r, 100))
|
|
1121
|
+
]);
|
|
1122
|
+
|
|
1123
|
+
// Loop
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
This wakes immediately when any promise settles, achieving 5.81x speedup over polling.
|
|
1129
|
+
|
|
1130
|
+
## Error Handling
|
|
1131
|
+
|
|
1132
|
+
### Context Errors
|
|
1133
|
+
|
|
1134
|
+
QuickJS can throw errors during operations. Always handle them:
|
|
1135
|
+
|
|
1136
|
+
```typescript
|
|
1137
|
+
try {
|
|
1138
|
+
const result = context.callFunction(fn, context.undefined, argHandle);
|
|
1139
|
+
if (result.error) {
|
|
1140
|
+
const error = context.dump(result.error);
|
|
1141
|
+
result.error.dispose();
|
|
1142
|
+
throw new Error(`Call failed: ${error}`);
|
|
1143
|
+
}
|
|
1144
|
+
// Use result.value
|
|
1145
|
+
result.value.dispose();
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
// Handle error
|
|
1148
|
+
}
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
### Disposal Errors
|
|
1152
|
+
|
|
1153
|
+
Disposal can fail if the handle is already disposed or invalid:
|
|
1154
|
+
|
|
1155
|
+
```typescript
|
|
1156
|
+
try {
|
|
1157
|
+
handle.dispose();
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
logger?.warn?.(`Failed to dispose handle: ${e}`);
|
|
1160
|
+
}
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
### Promise Rejection
|
|
1164
|
+
|
|
1165
|
+
Host promises can reject. Always handle rejections:
|
|
1166
|
+
|
|
1167
|
+
```typescript
|
|
1168
|
+
const hostPromise = fetch(url)
|
|
1169
|
+
.then(result => {
|
|
1170
|
+
// Handle success
|
|
1171
|
+
})
|
|
1172
|
+
.catch(err => {
|
|
1173
|
+
try {
|
|
1174
|
+
const errHandle = context.newString(err.message);
|
|
1175
|
+
deferred.reject(errHandle);
|
|
1176
|
+
errHandle.dispose();
|
|
1177
|
+
runtime.executePendingJobs();
|
|
1178
|
+
} catch (e) {
|
|
1179
|
+
// Context may be disposed
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1182
|
+
.finally(() => {
|
|
1183
|
+
tracker.pendingPromises.delete(hostPromise);
|
|
1184
|
+
});
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
## Testing
|
|
1188
|
+
|
|
1189
|
+
Bridges should be tested for:
|
|
1190
|
+
|
|
1191
|
+
1. **Functionality**: Correct behavior of bridged APIs
|
|
1192
|
+
2. **Error handling**: Proper error propagation
|
|
1193
|
+
3. **Resource cleanup**: No handle leaks or memory leaks
|
|
1194
|
+
4. **Async patterns**: Correct promise tracking and settlement
|
|
1195
|
+
5. **Edge cases**: Empty values, large payloads, timeouts
|
|
1196
|
+
|
|
1197
|
+
### Example Test
|
|
1198
|
+
|
|
1199
|
+
```typescript
|
|
1200
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1201
|
+
import { Sandbox } from './index.js';
|
|
1202
|
+
|
|
1203
|
+
describe('Console Bridge', () => {
|
|
1204
|
+
let sandbox: Sandbox;
|
|
1205
|
+
|
|
1206
|
+
beforeEach(async () => {
|
|
1207
|
+
sandbox = await Sandbox.getInstance();
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('should capture console output', async () => {
|
|
1211
|
+
const result = await sandbox.execute(
|
|
1212
|
+
`
|
|
1213
|
+
console.log("hello");
|
|
1214
|
+
console.error("oops");
|
|
1215
|
+
console.warn("careful");
|
|
1216
|
+
return "done"
|
|
1217
|
+
`,
|
|
1218
|
+
{}
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
expect(result.success).toBe(true);
|
|
1222
|
+
expect(result.result).toBe('done');
|
|
1223
|
+
expect(result.consoleOutput).toContain('hello');
|
|
1224
|
+
expect(result.consoleOutput).toContain('[ERROR] oops');
|
|
1225
|
+
expect(result.consoleOutput).toContain('[WARN] careful');
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
## Debugging
|
|
1231
|
+
|
|
1232
|
+
### Logging
|
|
1233
|
+
|
|
1234
|
+
Use the logger parameter to trace execution:
|
|
1235
|
+
|
|
1236
|
+
```typescript
|
|
1237
|
+
const sandbox = await Sandbox.getInstance();
|
|
1238
|
+
await sandbox.execute(code, globals, {
|
|
1239
|
+
logger: console
|
|
1240
|
+
});
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
### Handle Leak Detection
|
|
1244
|
+
|
|
1245
|
+
QuickJS doesn't track handles for you. Watch for:
|
|
1246
|
+
|
|
1247
|
+
- Increasing memory usage
|
|
1248
|
+
- QuickJS assertion errors on disposal
|
|
1249
|
+
- Lingering GC objects in memory usage dump
|
|
1250
|
+
|
|
1251
|
+
### Promise Leak Detection
|
|
1252
|
+
|
|
1253
|
+
Check promise tracker size:
|
|
1254
|
+
|
|
1255
|
+
```typescript
|
|
1256
|
+
logger?.info(`Pending promises: ${tracker.pendingPromises.size}`);
|
|
1257
|
+
logger?.info(`Deferred promises: ${tracker.deferredPromises.size}`);
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
### Cleanup Verification
|
|
1261
|
+
|
|
1262
|
+
Verify cleanup handlers run:
|
|
1263
|
+
|
|
1264
|
+
```typescript
|
|
1265
|
+
logger?.info(`Running ${cleanupHandlers.length} cleanup handlers`);
|
|
1266
|
+
await runCleanupHandlers(cleanupHandlers, logger);
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
## Known Limitations
|
|
1270
|
+
|
|
1271
|
+
### QuickJS GC Objects
|
|
1272
|
+
|
|
1273
|
+
When sandbox code holds references to unresolved promises (e.g., timeout during fetch), QuickJS may have lingering GC objects. Disposing the runtime in this state causes assertion errors.
|
|
1274
|
+
|
|
1275
|
+
**Workaround:** Skip runtime disposal if lingering objects detected:
|
|
1276
|
+
|
|
1277
|
+
```typescript
|
|
1278
|
+
const memUsage = runtime.dumpMemoryUsage();
|
|
1279
|
+
if (memUsage.includes(' resolve') || memUsage.includes(' promise')) {
|
|
1280
|
+
logger?.warn?.('Detected lingering promise objects - skipping runtime disposal');
|
|
1281
|
+
// Memory will leak, but process won't crash
|
|
1282
|
+
} else {
|
|
1283
|
+
runtime.dispose();
|
|
1284
|
+
}
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
### Context Disposal
|
|
1288
|
+
|
|
1289
|
+
Context must be disposed before runtime. If context disposal fails, runtime should not be disposed.
|
|
1290
|
+
|
|
1291
|
+
### Promise Settlement
|
|
1292
|
+
|
|
1293
|
+
Not all promises can be settled during cleanup (e.g., network timeout). After a timeout, remaining promises are abandoned.
|
|
1294
|
+
|
|
1295
|
+
### Web API Gaps (Accepted)
|
|
1296
|
+
|
|
1297
|
+
The following Web API features are intentionally not implemented. They are known gaps accepted as current limitations:
|
|
1298
|
+
|
|
1299
|
+
- **`Response.clone()`** — Not supported. Body can only be consumed once; sandbox code cannot both stream and read text from the same response.
|
|
1300
|
+
- **`Request` constructor** — Not available. The `fetch` bridge accepts `(url, init)` directly. Sandbox code cannot create `Request` objects for more complex fetch patterns.
|
|
1301
|
+
- **`AbortController` / `AbortSignal`** — Not exposed to sandbox code. The fetch bridge creates its own `AbortController` internally for cleanup, but sandbox code cannot create or use `AbortController` to cancel its own requests. This may be implemented in the future.
|
|
1302
|
+
- **`ReadableStream.tee()`, `pipeTo()`, `pipeThrough()`** — Only `getReader()` is supported on ReadableStream. Advanced stream combinators are not bridged.
|
|
1303
|
+
|
|
1304
|
+
## Best Practices
|
|
1305
|
+
|
|
1306
|
+
1. **Always dispose handles**: Use try/finally to ensure cleanup
|
|
1307
|
+
2. **Track host objects**: Use registries for streams, responses, readers
|
|
1308
|
+
3. **Register cleanup handlers**: Abort streams, release readers
|
|
1309
|
+
4. **Check handle validity**: Use `handle.alive` before disposal
|
|
1310
|
+
5. **Log operations**: Use logger for debugging
|
|
1311
|
+
6. **Handle errors gracefully**: Context may be disposed during async operations
|
|
1312
|
+
7. **Use shared utilities**: Don't reinvent headers, response objects, etc.
|
|
1313
|
+
8. **Test cleanup**: Verify no handle leaks
|
|
1314
|
+
9. **Time-limit cleanup**: Prevent indefinite blocking
|
|
1315
|
+
10. **Follow patterns**: Use existing bridges as templates
|
|
1316
|
+
|
|
1317
|
+
## See Also
|
|
1318
|
+
|
|
1319
|
+
- [QuickJS Documentation](https://bellard.org/quickjs/)
|
|
1320
|
+
- [quickjs-emscripten](https://github.com/justjake/quickjs-emscripten)
|
|
1321
|
+
- [Bridges README](./bridges/README.md) - Detailed bridge development guide
|