@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,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReadableStream bridge - Bridges runtime's ReadableStream for response.body
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
6
|
+
import type { BridgeContext } from './types.js';
|
|
7
|
+
import { unwrapResult } from './shared/index.js';
|
|
8
|
+
|
|
9
|
+
// Internal symbol name for cached Uint8Array wrapper on context global
|
|
10
|
+
const UINT8_WRAPPER_CACHE_KEY = '__cachedUint8ArrayWrapper';
|
|
11
|
+
|
|
12
|
+
// Minimum chunk size before passing to QuickJS (16KB)
|
|
13
|
+
// This reduces boundary crossing overhead for streams with many small chunks
|
|
14
|
+
const MIN_CHUNK_SIZE = 16 * 1024;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get or create a cached Uint8Array wrapper function for the context.
|
|
18
|
+
* This avoids the expensive evalCode() call on every read().
|
|
19
|
+
*
|
|
20
|
+
* The cached wrapper is stored on the context's global object, so it's
|
|
21
|
+
* automatically cleaned up when the context is disposed.
|
|
22
|
+
*/
|
|
23
|
+
function getUint8ArrayWrapper(ctx: BridgeContext): QuickJSHandle {
|
|
24
|
+
const { context } = ctx;
|
|
25
|
+
|
|
26
|
+
// Check if we already cached the wrapper on the global
|
|
27
|
+
const cachedHandle = context.getProp(context.global, UINT8_WRAPPER_CACHE_KEY);
|
|
28
|
+
const cachedType = context.typeof(cachedHandle);
|
|
29
|
+
|
|
30
|
+
if (cachedType === 'function') {
|
|
31
|
+
// Already cached - return it (caller must dispose)
|
|
32
|
+
return cachedHandle;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Not cached yet - dispose the undefined handle we got
|
|
36
|
+
cachedHandle.dispose();
|
|
37
|
+
|
|
38
|
+
// Create the wrapper function once - this evalCode is amortized over all reads
|
|
39
|
+
const wrapper = unwrapResult(
|
|
40
|
+
context.evalCode('(function(ab) { return new Uint8Array(ab); })'),
|
|
41
|
+
context
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Store on global for future calls — non-enumerable to hide from sandbox code
|
|
45
|
+
context.setProp(context.global, UINT8_WRAPPER_CACHE_KEY, wrapper);
|
|
46
|
+
{
|
|
47
|
+
const hideResult = context.evalCode(`
|
|
48
|
+
try {
|
|
49
|
+
Object.defineProperty(globalThis, '${UINT8_WRAPPER_CACHE_KEY}', {
|
|
50
|
+
enumerable: false, writable: false, configurable: false
|
|
51
|
+
});
|
|
52
|
+
} catch(e) {}
|
|
53
|
+
undefined;
|
|
54
|
+
`);
|
|
55
|
+
if (hideResult.error) {
|
|
56
|
+
hideResult.error.dispose();
|
|
57
|
+
} else {
|
|
58
|
+
(hideResult as { value: QuickJSHandle }).value.dispose();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Return the handle - caller must dispose their reference
|
|
63
|
+
return wrapper;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Coalescing reader that buffers small chunks before passing to QuickJS.
|
|
68
|
+
* Reduces boundary crossing overhead for streams with many small chunks.
|
|
69
|
+
*/
|
|
70
|
+
class CoalescingReader {
|
|
71
|
+
private buffer: Uint8Array[] = [];
|
|
72
|
+
private bufferSize = 0;
|
|
73
|
+
private done = false;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private hostReader: ReadableStreamDefaultReader<Uint8Array>,
|
|
77
|
+
private minChunkSize: number = MIN_CHUNK_SIZE
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read and coalesce chunks until we have at least minChunkSize bytes or stream ends
|
|
82
|
+
*/
|
|
83
|
+
async read(): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
84
|
+
// If we already know the stream is done, return done
|
|
85
|
+
if (this.done && this.bufferSize === 0) {
|
|
86
|
+
return { done: true, value: undefined };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Keep reading until we have enough data or stream ends
|
|
91
|
+
while (!this.done && this.bufferSize < this.minChunkSize) {
|
|
92
|
+
const result = await this.hostReader.read();
|
|
93
|
+
|
|
94
|
+
if (result.done) {
|
|
95
|
+
this.done = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result.value && result.value.length > 0) {
|
|
100
|
+
this.buffer.push(result.value);
|
|
101
|
+
this.bufferSize += result.value.length;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If we have no data and stream is done, return done
|
|
106
|
+
if (this.bufferSize === 0) {
|
|
107
|
+
return { done: true, value: undefined };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Coalesce buffered chunks into a single Uint8Array
|
|
111
|
+
let value: Uint8Array;
|
|
112
|
+
if (this.buffer.length === 1) {
|
|
113
|
+
// Optimization: no copy needed for single chunk
|
|
114
|
+
value = this.buffer[0]!;
|
|
115
|
+
} else {
|
|
116
|
+
// Merge multiple chunks
|
|
117
|
+
value = new Uint8Array(this.bufferSize);
|
|
118
|
+
let offset = 0;
|
|
119
|
+
for (const chunk of this.buffer) {
|
|
120
|
+
value.set(chunk, offset);
|
|
121
|
+
offset += chunk.length;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Clear buffer
|
|
126
|
+
this.buffer = [];
|
|
127
|
+
this.bufferSize = 0;
|
|
128
|
+
|
|
129
|
+
return { done: false, value };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// Reset state on error to avoid returning stale data on retry
|
|
132
|
+
this.buffer = [];
|
|
133
|
+
this.bufferSize = 0;
|
|
134
|
+
this.done = true;
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
releaseLock(): void {
|
|
140
|
+
this.hostReader.releaseLock();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a bridged ReadableStreamReader object
|
|
146
|
+
*/
|
|
147
|
+
export function createReaderObject(
|
|
148
|
+
ctx: BridgeContext,
|
|
149
|
+
hostReader: ReadableStreamDefaultReader<Uint8Array>
|
|
150
|
+
): QuickJSHandle {
|
|
151
|
+
const { context, runtime, tracker } = ctx;
|
|
152
|
+
const readerHandle = context.newObject();
|
|
153
|
+
|
|
154
|
+
// Wrap in coalescing reader to reduce boundary crossings
|
|
155
|
+
const coalescingReader = new CoalescingReader(hostReader);
|
|
156
|
+
|
|
157
|
+
// Create read() method
|
|
158
|
+
{
|
|
159
|
+
using readMethod = context.newFunction('read', () => {
|
|
160
|
+
const deferred = context.newPromise();
|
|
161
|
+
tracker.deferredPromises.add(deferred);
|
|
162
|
+
|
|
163
|
+
const hostPromise = coalescingReader
|
|
164
|
+
.read()
|
|
165
|
+
.then((result: ReadableStreamReadResult<Uint8Array>) => {
|
|
166
|
+
try {
|
|
167
|
+
// Create result object: { done: boolean, value?: Uint8Array }
|
|
168
|
+
const resultHandle = context.newObject();
|
|
169
|
+
|
|
170
|
+
context.setProp(resultHandle, 'done', result.done ? context.true : context.false);
|
|
171
|
+
|
|
172
|
+
if (result.value) {
|
|
173
|
+
// Create a Uint8Array in QuickJS from the ArrayBuffer
|
|
174
|
+
// Use slice to handle Uint8Array views into larger ArrayBuffers (e.g. Node.js Buffer)
|
|
175
|
+
const buf =
|
|
176
|
+
result.value.byteOffset === 0 &&
|
|
177
|
+
result.value.byteLength === result.value.buffer.byteLength
|
|
178
|
+
? result.value.buffer
|
|
179
|
+
: result.value.buffer.slice(
|
|
180
|
+
result.value.byteOffset,
|
|
181
|
+
result.value.byteOffset + result.value.byteLength
|
|
182
|
+
);
|
|
183
|
+
using arrayBufferHandle = context.newArrayBuffer(buf);
|
|
184
|
+
|
|
185
|
+
// Use cached wrapper function instead of evalCode on every read
|
|
186
|
+
using wrapperFn = getUint8ArrayWrapper(ctx);
|
|
187
|
+
|
|
188
|
+
using uint8ArrayHandle = unwrapResult(
|
|
189
|
+
context.callFunction(wrapperFn, context.undefined, arrayBufferHandle),
|
|
190
|
+
context
|
|
191
|
+
);
|
|
192
|
+
context.setProp(resultHandle, 'value', uint8ArrayHandle);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
deferred.resolve(resultHandle);
|
|
196
|
+
resultHandle.dispose();
|
|
197
|
+
runtime.executePendingJobs();
|
|
198
|
+
} catch (e) {
|
|
199
|
+
using errHandle = context.newString(e instanceof Error ? e.message : String(e));
|
|
200
|
+
deferred.reject(errHandle);
|
|
201
|
+
runtime.executePendingJobs();
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
.catch((err: Error) => {
|
|
205
|
+
try {
|
|
206
|
+
using errHandle = context.newString(err.message || String(err));
|
|
207
|
+
deferred.reject(errHandle);
|
|
208
|
+
runtime.executePendingJobs();
|
|
209
|
+
} catch {
|
|
210
|
+
// Context may have been disposed
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
.finally(() => {
|
|
214
|
+
tracker.pendingPromises.delete(hostPromise);
|
|
215
|
+
tracker.deferredPromises.delete(deferred);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Track for cleanup and race optimization
|
|
219
|
+
tracker.pendingPromises.add(hostPromise);
|
|
220
|
+
tracker.notifyNewPromise();
|
|
221
|
+
|
|
222
|
+
return deferred.handle;
|
|
223
|
+
});
|
|
224
|
+
context.setProp(readerHandle, 'read', readMethod);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Create releaseLock() method
|
|
228
|
+
{
|
|
229
|
+
using releaseLockMethod = context.newFunction('releaseLock', () => {
|
|
230
|
+
try {
|
|
231
|
+
coalescingReader.releaseLock();
|
|
232
|
+
return context.undefined;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return context.newError(e instanceof Error ? e.message : String(e));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
context.setProp(readerHandle, 'releaseLock', releaseLockMethod);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return readerHandle;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Create a bridged ReadableStream object
|
|
245
|
+
*/
|
|
246
|
+
export function createStreamObject(
|
|
247
|
+
ctx: BridgeContext,
|
|
248
|
+
hostStream: ReadableStream,
|
|
249
|
+
streamId: number,
|
|
250
|
+
hostReaders: Map<number, ReadableStreamDefaultReader>,
|
|
251
|
+
readerIdCounter: { value: number }
|
|
252
|
+
): QuickJSHandle {
|
|
253
|
+
const { context } = ctx;
|
|
254
|
+
const streamHandle = context.newObject();
|
|
255
|
+
|
|
256
|
+
// Create getReader() method
|
|
257
|
+
{
|
|
258
|
+
using getReaderMethod = context.newFunction('getReader', () => {
|
|
259
|
+
try {
|
|
260
|
+
const hostReader = hostStream.getReader();
|
|
261
|
+
const readerId = ++readerIdCounter.value;
|
|
262
|
+
hostReaders.set(readerId, hostReader);
|
|
263
|
+
|
|
264
|
+
return createReaderObject(ctx, hostReader);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return context.newError(e instanceof Error ? e.message : String(e));
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
context.setProp(streamHandle, 'getReader', getReaderMethod);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Create a host function to get the locked state dynamically
|
|
273
|
+
{
|
|
274
|
+
using getLockedFn = context.newFunction('__getLockedFn', () => {
|
|
275
|
+
return hostStream.locked ? context.true : context.false;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Store the function on the stream object itself (scoped to this stream)
|
|
279
|
+
context.setProp(streamHandle, '__getLockedFn', getLockedFn);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Define the 'locked' property as a getter using Object.defineProperty
|
|
283
|
+
const defineCode = `
|
|
284
|
+
(function(stream) {
|
|
285
|
+
var fn = stream.__getLockedFn;
|
|
286
|
+
Object.defineProperty(stream, 'locked', {
|
|
287
|
+
get: function() { return fn(); },
|
|
288
|
+
enumerable: true,
|
|
289
|
+
configurable: true
|
|
290
|
+
});
|
|
291
|
+
delete stream.__getLockedFn; // Clean up the visible property
|
|
292
|
+
return stream;
|
|
293
|
+
})
|
|
294
|
+
`;
|
|
295
|
+
|
|
296
|
+
const defineResult = context.evalCode(defineCode);
|
|
297
|
+
|
|
298
|
+
if (defineResult.error) {
|
|
299
|
+
// If we can't define the getter, fall back to static property
|
|
300
|
+
const error = context.dump(defineResult.error);
|
|
301
|
+
defineResult.error.dispose();
|
|
302
|
+
ctx.logger?.warn?.(`[ReadableStream] Could not define locked getter: ${JSON.stringify(error)}`);
|
|
303
|
+
context.setProp(streamHandle, 'locked', hostStream.locked ? context.true : context.false);
|
|
304
|
+
} else {
|
|
305
|
+
// Apply the function to our stream handle
|
|
306
|
+
using defineFunc = defineResult.value;
|
|
307
|
+
const applyResult = context.callFunction(defineFunc, context.undefined, streamHandle);
|
|
308
|
+
|
|
309
|
+
if (applyResult.error) {
|
|
310
|
+
const error = context.dump(applyResult.error);
|
|
311
|
+
applyResult.error.dispose();
|
|
312
|
+
ctx.logger?.warn?.(
|
|
313
|
+
`[ReadableStream] Could not apply locked getter: ${JSON.stringify(error)}`
|
|
314
|
+
);
|
|
315
|
+
// Fallback to static property
|
|
316
|
+
context.setProp(streamHandle, 'locked', hostStream.locked ? context.true : context.false);
|
|
317
|
+
} else {
|
|
318
|
+
(applyResult as { value: QuickJSHandle }).value.dispose();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return streamHandle;
|
|
323
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { executeWithAsyncHost } from '../test-helper.js';
|
|
3
|
+
|
|
4
|
+
describe('Response Bridge', () => {
|
|
5
|
+
it('should provide Response constructor', async () => {
|
|
6
|
+
const result = await executeWithAsyncHost(`return typeof Response`, {});
|
|
7
|
+
|
|
8
|
+
expect(result.success).toBe(true);
|
|
9
|
+
expect(result.result).toBe('function');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should create Response from string', async () => {
|
|
13
|
+
const code = `
|
|
14
|
+
const res = new Response('Hello World');
|
|
15
|
+
return {
|
|
16
|
+
status: res.status,
|
|
17
|
+
ok: res.ok,
|
|
18
|
+
statusText: res.statusText
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const result = await executeWithAsyncHost(code, {});
|
|
23
|
+
|
|
24
|
+
if (!result.success) {
|
|
25
|
+
console.error('Error:', result.error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
expect(result.result.status).toBe(200);
|
|
30
|
+
expect(result.result.ok).toBe(true);
|
|
31
|
+
expect(result.result.statusText).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should create Response with custom status', async () => {
|
|
35
|
+
const code = `
|
|
36
|
+
const res = new Response('Not found', { status: 404 });
|
|
37
|
+
return res.status
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const result = await executeWithAsyncHost(code, {});
|
|
41
|
+
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.result).toBe(404);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should consume response body as text', async () => {
|
|
47
|
+
const code = `
|
|
48
|
+
const res = new Response('Hello World');
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
return text
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const result = await executeWithAsyncHost(code, {});
|
|
54
|
+
|
|
55
|
+
expect(result.success).toBe(true);
|
|
56
|
+
expect(result.result).toBe('Hello World');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should consume response body as JSON', async () => {
|
|
60
|
+
const code = `
|
|
61
|
+
const res = new Response('{"message":"Hello"}');
|
|
62
|
+
const json = await res.json();
|
|
63
|
+
return json
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const result = await executeWithAsyncHost(code, {});
|
|
67
|
+
|
|
68
|
+
expect(result.success).toBe(true);
|
|
69
|
+
expect(result.result).toEqual({ message: 'Hello' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should create Response with custom headers', async () => {
|
|
73
|
+
const code = `
|
|
74
|
+
const res = new Response('data', {
|
|
75
|
+
headers: { 'Content-Type': 'text/plain', 'X-Custom': 'value' }
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
contentType: res.headers.get('Content-Type'),
|
|
79
|
+
custom: res.headers.get('X-Custom')
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const result = await executeWithAsyncHost(code, {});
|
|
84
|
+
|
|
85
|
+
expect(result.success).toBe(true);
|
|
86
|
+
expect(result.result.contentType).toBe('text/plain');
|
|
87
|
+
expect(result.result.custom).toBe('value');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should support has() method on headers', async () => {
|
|
91
|
+
const code = `
|
|
92
|
+
const res = new Response('data', {
|
|
93
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
94
|
+
});
|
|
95
|
+
return res.headers.has('Content-Type')
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const result = await executeWithAsyncHost(code, {});
|
|
99
|
+
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
expect(result.result).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should support entries() method on headers', async () => {
|
|
105
|
+
const code = `
|
|
106
|
+
const res = new Response('data', {
|
|
107
|
+
headers: { 'content-type': 'text/plain', 'x-custom': 'value' }
|
|
108
|
+
});
|
|
109
|
+
const entries = res.headers.entries();
|
|
110
|
+
return entries
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const result = await executeWithAsyncHost(code, {});
|
|
114
|
+
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
expect(result.result.length).toBeGreaterThanOrEqual(2);
|
|
117
|
+
// Check that our headers are present (Headers normalizes to lowercase)
|
|
118
|
+
const entries = result.result;
|
|
119
|
+
const hasContentType = entries.some(
|
|
120
|
+
(e: string[]) => e[0].toLowerCase() === 'content-type' && e[1] === 'text/plain'
|
|
121
|
+
);
|
|
122
|
+
const hasCustom = entries.some(
|
|
123
|
+
(e: string[]) => e[0].toLowerCase() === 'x-custom' && e[1] === 'value'
|
|
124
|
+
);
|
|
125
|
+
expect(hasContentType).toBe(true);
|
|
126
|
+
expect(hasCustom).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should support ok property based on status', async () => {
|
|
130
|
+
const code = `
|
|
131
|
+
const okRes = new Response('good', { status: 200 });
|
|
132
|
+
const notOkRes = new Response('bad', { status: 404 });
|
|
133
|
+
return { ok: okRes.ok, notOk: notOkRes.ok }
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
const result = await executeWithAsyncHost(code, {});
|
|
137
|
+
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
expect(result.result.ok).toBe(true);
|
|
140
|
+
expect(result.result.notOk).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should support custom statusText', async () => {
|
|
144
|
+
const code = `
|
|
145
|
+
const res = new Response('Error', { status: 500, statusText: 'Internal Server Error' });
|
|
146
|
+
return res.statusText
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const result = await executeWithAsyncHost(code, {});
|
|
150
|
+
|
|
151
|
+
expect(result.success).toBe(true);
|
|
152
|
+
expect(result.result).toBe('Internal Server Error');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response API bridge - Provides Response constructor for sandbox
|
|
3
|
+
*
|
|
4
|
+
* Allows creating Response objects manually, useful for:
|
|
5
|
+
* - Consuming ReadableStreams with .text(), .json(), .arrayBuffer() methods
|
|
6
|
+
* - Creating mock responses for testing
|
|
7
|
+
* - Handlers that need to return Response objects
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
11
|
+
import type { BridgeContext } from './types.js';
|
|
12
|
+
import {
|
|
13
|
+
createResponseObject,
|
|
14
|
+
setupStreamRegistry,
|
|
15
|
+
unwrapResult
|
|
16
|
+
} from './shared/index.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Install Response constructor in the sandbox
|
|
20
|
+
*/
|
|
21
|
+
export function installResponse(ctx: BridgeContext): void {
|
|
22
|
+
const { context, logger } = ctx;
|
|
23
|
+
|
|
24
|
+
// Create response object context for the factory (includes cleanup handler)
|
|
25
|
+
const objCtx = setupStreamRegistry(ctx, 'Response');
|
|
26
|
+
|
|
27
|
+
// Create host function to create Response
|
|
28
|
+
{
|
|
29
|
+
using hostCreateResponse = context.newFunction(
|
|
30
|
+
'__hostCreateResponse',
|
|
31
|
+
(bodyHandle?: QuickJSHandle, initHandle?: QuickJSHandle) => {
|
|
32
|
+
// Parse body argument
|
|
33
|
+
let body: BodyInit | null = null;
|
|
34
|
+
if (bodyHandle) {
|
|
35
|
+
const bodyValue = context.dump(bodyHandle);
|
|
36
|
+
// Check if it's a ReadableStream (has a _streamId property)
|
|
37
|
+
if (bodyValue && typeof bodyValue === 'object' && typeof bodyValue._streamId === 'number') {
|
|
38
|
+
const stream = objCtx.registry.hostStreams.get(bodyValue._streamId);
|
|
39
|
+
if (!stream) {
|
|
40
|
+
throw new Error(`Invalid stream ID: ${bodyValue._streamId}. Stream may have been consumed or cleaned up.`);
|
|
41
|
+
}
|
|
42
|
+
body = stream;
|
|
43
|
+
} else if (
|
|
44
|
+
bodyValue &&
|
|
45
|
+
typeof bodyValue === 'object' &&
|
|
46
|
+
'byteLength' in bodyValue &&
|
|
47
|
+
'buffer' in bodyValue &&
|
|
48
|
+
typeof (bodyValue as any).byteLength === 'number' &&
|
|
49
|
+
(bodyValue as any).byteLength >= 0
|
|
50
|
+
) {
|
|
51
|
+
// It's a Uint8Array or ArrayBufferView - cast to BodyInit
|
|
52
|
+
body = bodyValue as BodyInit;
|
|
53
|
+
} else {
|
|
54
|
+
body = bodyValue as BodyInit;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse init argument
|
|
59
|
+
let init: ResponseInit = {};
|
|
60
|
+
if (initHandle) {
|
|
61
|
+
try {
|
|
62
|
+
init = context.dump(initHandle) || {};
|
|
63
|
+
} catch {
|
|
64
|
+
// Invalid init object, use defaults
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Create host Response
|
|
69
|
+
const hostResponse = new Response(body, init);
|
|
70
|
+
|
|
71
|
+
// Create QuickJS Response object using the unified factory
|
|
72
|
+
return createResponseObject(ctx, { response: hostResponse }, objCtx);
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
context.setProp(context.global, '__hostCreateResponse', hostCreateResponse);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Define Response class in QuickJS
|
|
80
|
+
const classCode = `
|
|
81
|
+
class Response {
|
|
82
|
+
constructor(body, init) {
|
|
83
|
+
const response = __hostCreateResponse(body, init);
|
|
84
|
+
// Copy all properties from the response to this
|
|
85
|
+
this.status = response.status;
|
|
86
|
+
this.statusText = response.statusText;
|
|
87
|
+
this.ok = response.ok;
|
|
88
|
+
this.url = response.url;
|
|
89
|
+
this.type = response.type;
|
|
90
|
+
this.redirected = response.redirected;
|
|
91
|
+
this.body = response.body;
|
|
92
|
+
this.headers = response.headers;
|
|
93
|
+
this.json = response.json.bind(response);
|
|
94
|
+
this.text = response.text.bind(response);
|
|
95
|
+
this.arrayBuffer = response.arrayBuffer.bind(response);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
using classHandle = unwrapResult(context.evalCode(classCode), context);
|
|
102
|
+
context.setProp(context.global, 'Response', classHandle);
|
|
103
|
+
|
|
104
|
+
// Hide internal helper from sandbox code
|
|
105
|
+
// NOTE: The sandbox is not a hard security boundary. Internal helpers are hidden via
|
|
106
|
+
// enumerable: false to prevent accidental access, but can still be called by name if
|
|
107
|
+
// the sandbox code knows them. This is acceptable because:
|
|
108
|
+
// 1. The bridges are the primary security boundary, not QuickJS containment
|
|
109
|
+
// 2. Internal helpers don't expose dangerous capabilities beyond what bridges already provide
|
|
110
|
+
// 3. For the use case of safely executing LLM-generated code, this is sufficient
|
|
111
|
+
// See Sandbox.ts for the full security model documentation.
|
|
112
|
+
const hideResult = context.evalCode(`(function() {
|
|
113
|
+
try { Object.defineProperty(globalThis, '__hostCreateResponse', { enumerable: false, writable: false, configurable: false }); } catch(e) {}
|
|
114
|
+
})()`);
|
|
115
|
+
if (hideResult.error) {
|
|
116
|
+
hideResult.error.dispose();
|
|
117
|
+
} else {
|
|
118
|
+
(hideResult as { value: QuickJSHandle }).value.dispose();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
logger?.info?.('[Bridge] Response constructor installed');
|
|
123
|
+
}
|