@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,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage bridge - Provides unified read/write/list/remove with Response-like objects
|
|
3
|
+
*
|
|
4
|
+
* Works with Bucket, SmartBucket (extends Bucket), KvCache, and ActorStorage.
|
|
5
|
+
* All storage types share the same four operations: read, write, list, remove.
|
|
6
|
+
*
|
|
7
|
+
* read() returns a Response-like object:
|
|
8
|
+
* - .text() / .json() / .arrayBuffer() for convenience
|
|
9
|
+
* - .body.getReader() for streaming (buckets only)
|
|
10
|
+
* - .size, .key, .uploaded metadata (buckets only)
|
|
11
|
+
*
|
|
12
|
+
* write() accepts optional type-specific options:
|
|
13
|
+
* - KV: { ttl, expiration, metadata }
|
|
14
|
+
* - Bucket: { contentType, customMetadata }
|
|
15
|
+
* - ActorStorage: (none — values stored as-is)
|
|
16
|
+
*
|
|
17
|
+
* list() accepts optional type-specific options:
|
|
18
|
+
* - Common: { prefix, limit }
|
|
19
|
+
* - Bucket: { delimiter, startAfter }
|
|
20
|
+
* - ActorStorage: { start, end, startAfter, reverse, limit }
|
|
21
|
+
*
|
|
22
|
+
* remove() deletes a key or file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
26
|
+
import type { BridgeContext } from './types.js';
|
|
27
|
+
import type { Bucket, KvCache, ActorStorage } from '@liquidmetal-ai/raindrop-framework';
|
|
28
|
+
import {
|
|
29
|
+
withTrackedPromiseResult,
|
|
30
|
+
withTrackedPromiseValue,
|
|
31
|
+
createResponseObject,
|
|
32
|
+
setupStreamRegistry,
|
|
33
|
+
parseMountPath,
|
|
34
|
+
createListResult
|
|
35
|
+
} from './shared/index.js';
|
|
36
|
+
|
|
37
|
+
export interface StorageMountInfo {
|
|
38
|
+
name: string;
|
|
39
|
+
type: 'bucket' | 'smartbucket' | 'kv' | 'actor-storage';
|
|
40
|
+
resource: Bucket | KvCache | ActorStorage;
|
|
41
|
+
mode?: 'ro' | 'rw'; // Default: 'ro' (read-only)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert a string to a ReadableStream<Uint8Array> so KV values
|
|
46
|
+
* go through the same createResponseObject path as bucket objects.
|
|
47
|
+
*/
|
|
48
|
+
function stringToStream(value: string): ReadableStream<Uint8Array> {
|
|
49
|
+
const encoded = new TextEncoder().encode(value);
|
|
50
|
+
return bytesToStream(encoded);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a Uint8Array (or ArrayBuffer-backed value) in a single-chunk ReadableStream.
|
|
55
|
+
* Used for actor-storage values which are stored as raw bytes.
|
|
56
|
+
*/
|
|
57
|
+
function bytesToStream(data: Uint8Array | ArrayBuffer): ReadableStream<Uint8Array> {
|
|
58
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
59
|
+
return new ReadableStream<Uint8Array>({
|
|
60
|
+
start(controller) {
|
|
61
|
+
controller.enqueue(bytes);
|
|
62
|
+
controller.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Install storage API in the sandbox (read, write, list, remove)
|
|
69
|
+
*/
|
|
70
|
+
export function installStorage(
|
|
71
|
+
ctx: BridgeContext,
|
|
72
|
+
storageMounts: Map<string, StorageMountInfo>
|
|
73
|
+
): void {
|
|
74
|
+
const { context, logger } = ctx;
|
|
75
|
+
|
|
76
|
+
// Create response object context for the factory (includes cleanup handler)
|
|
77
|
+
const objCtx = setupStreamRegistry(ctx, 'Storage');
|
|
78
|
+
|
|
79
|
+
// ================================================================
|
|
80
|
+
// read(path) - Returns Response-like object
|
|
81
|
+
//
|
|
82
|
+
// All types return the same shape via createResponseObject:
|
|
83
|
+
// .body (ReadableStream), .text(), .json(), .arrayBuffer()
|
|
84
|
+
// .status, .ok, .headers, .key
|
|
85
|
+
// Buckets additionally have: .size, .uploaded
|
|
86
|
+
// KV additionally has: .metadata (if set)
|
|
87
|
+
// ================================================================
|
|
88
|
+
const readMethod = context.newFunction('read', (pathHandle: QuickJSHandle) => {
|
|
89
|
+
const path = context.dump(pathHandle) as string;
|
|
90
|
+
|
|
91
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
92
|
+
const { mount, resourcePath } = parseMountPath(path, storageMounts, 'Storage');
|
|
93
|
+
|
|
94
|
+
if (mount.type === 'kv') {
|
|
95
|
+
// KV read - use getWithMetadata for expiration/metadata
|
|
96
|
+
const kv = mount.resource as KvCache;
|
|
97
|
+
const result = await kv.getWithMetadata(resourcePath);
|
|
98
|
+
|
|
99
|
+
if (result.value === null) {
|
|
100
|
+
throw new Error(`Resource not found: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const valueStr = result.value as string;
|
|
104
|
+
const body = stringToStream(valueStr);
|
|
105
|
+
const metadata: Record<string, string | number> = { key: path };
|
|
106
|
+
if (result.metadata !== null && result.metadata !== undefined) {
|
|
107
|
+
metadata.metadata =
|
|
108
|
+
typeof result.metadata === 'string'
|
|
109
|
+
? result.metadata
|
|
110
|
+
: JSON.stringify(result.metadata);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return createResponseObject(
|
|
114
|
+
ctx,
|
|
115
|
+
{
|
|
116
|
+
responseInit: {
|
|
117
|
+
status: 200,
|
|
118
|
+
statusText: 'OK',
|
|
119
|
+
ok: true,
|
|
120
|
+
url: path,
|
|
121
|
+
type: 'default',
|
|
122
|
+
redirected: false,
|
|
123
|
+
headers: new Headers()
|
|
124
|
+
},
|
|
125
|
+
body,
|
|
126
|
+
metadata
|
|
127
|
+
},
|
|
128
|
+
objCtx
|
|
129
|
+
);
|
|
130
|
+
} else if (mount.type === 'actor-storage') {
|
|
131
|
+
// ActorStorage read - stored as Uint8Array bytes
|
|
132
|
+
const storage = mount.resource as ActorStorage;
|
|
133
|
+
const value = await storage.get<Uint8Array>(resourcePath);
|
|
134
|
+
|
|
135
|
+
if (value === undefined) {
|
|
136
|
+
throw new Error(`Resource not found: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body = bytesToStream(value);
|
|
140
|
+
|
|
141
|
+
return createResponseObject(
|
|
142
|
+
ctx,
|
|
143
|
+
{
|
|
144
|
+
responseInit: {
|
|
145
|
+
status: 200,
|
|
146
|
+
statusText: 'OK',
|
|
147
|
+
ok: true,
|
|
148
|
+
url: path,
|
|
149
|
+
type: 'default',
|
|
150
|
+
redirected: false,
|
|
151
|
+
headers: new Headers()
|
|
152
|
+
},
|
|
153
|
+
body,
|
|
154
|
+
metadata: { key: path }
|
|
155
|
+
},
|
|
156
|
+
objCtx
|
|
157
|
+
);
|
|
158
|
+
} else {
|
|
159
|
+
// Bucket read - with streaming support
|
|
160
|
+
const bucket = mount.resource as Bucket;
|
|
161
|
+
const obj = await bucket.get(resourcePath);
|
|
162
|
+
|
|
163
|
+
if (!obj) {
|
|
164
|
+
throw new Error(`Resource not found: ${path}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Create headers object with content-type
|
|
168
|
+
const headers = new Headers();
|
|
169
|
+
if (obj.httpMetadata?.contentType) {
|
|
170
|
+
headers.set('content-type', obj.httpMetadata.contentType);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Create Response-like object with bridged stream
|
|
174
|
+
return createResponseObject(
|
|
175
|
+
ctx,
|
|
176
|
+
{
|
|
177
|
+
responseInit: {
|
|
178
|
+
status: 200,
|
|
179
|
+
statusText: 'OK',
|
|
180
|
+
ok: true,
|
|
181
|
+
url: path,
|
|
182
|
+
type: 'default',
|
|
183
|
+
redirected: false,
|
|
184
|
+
headers
|
|
185
|
+
},
|
|
186
|
+
body: obj.body,
|
|
187
|
+
metadata: {
|
|
188
|
+
key: path,
|
|
189
|
+
size: obj.size,
|
|
190
|
+
uploaded: obj.uploaded.toISOString()
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
objCtx
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
context.setProp(context.global, 'read', readMethod);
|
|
199
|
+
readMethod.dispose();
|
|
200
|
+
|
|
201
|
+
// ================================================================
|
|
202
|
+
// write(path, content, options?) - Write file, KV entry, or actor state
|
|
203
|
+
//
|
|
204
|
+
// Options (type-specific):
|
|
205
|
+
// KV: { ttl: number, expiration: number, metadata: any }
|
|
206
|
+
// Bucket: { contentType: string, customMetadata: Record<string, string> }
|
|
207
|
+
// ActorStorage: (none)
|
|
208
|
+
// ================================================================
|
|
209
|
+
const writeMethod = context.newFunction(
|
|
210
|
+
'write',
|
|
211
|
+
(pathHandle: QuickJSHandle, contentHandle: QuickJSHandle, optionsHandle?: QuickJSHandle) => {
|
|
212
|
+
const path = context.dump(pathHandle) as string;
|
|
213
|
+
const content = context.dump(contentHandle);
|
|
214
|
+
const options = optionsHandle
|
|
215
|
+
? (context.dump(optionsHandle) as Record<string, any> | null)
|
|
216
|
+
: null;
|
|
217
|
+
|
|
218
|
+
return withTrackedPromiseValue(ctx, async () => {
|
|
219
|
+
const { mount, resourcePath } = parseMountPath(path, storageMounts, 'Storage');
|
|
220
|
+
|
|
221
|
+
// Check if mount allows writes (default is read-only)
|
|
222
|
+
if (mount.mode !== 'rw') {
|
|
223
|
+
throw new Error(`Cannot write to read-only mount: /${mount.name}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (mount.type === 'kv') {
|
|
227
|
+
const kv = mount.resource as KvCache;
|
|
228
|
+
const kvOptions: Record<string, any> = {};
|
|
229
|
+
if (options?.ttl !== undefined) {
|
|
230
|
+
kvOptions.expirationTtl = options.ttl;
|
|
231
|
+
}
|
|
232
|
+
if (options?.expiration !== undefined) {
|
|
233
|
+
kvOptions.expiration = options.expiration;
|
|
234
|
+
}
|
|
235
|
+
if (options?.metadata !== undefined) {
|
|
236
|
+
kvOptions.metadata = options.metadata;
|
|
237
|
+
}
|
|
238
|
+
const value = typeof content === 'string' ? content : JSON.stringify(content);
|
|
239
|
+
await kv.put(
|
|
240
|
+
resourcePath,
|
|
241
|
+
value,
|
|
242
|
+
Object.keys(kvOptions).length > 0 ? kvOptions : undefined
|
|
243
|
+
);
|
|
244
|
+
} else if (mount.type === 'actor-storage') {
|
|
245
|
+
const storage = mount.resource as ActorStorage;
|
|
246
|
+
// Store as Uint8Array bytes, same as what a bucket stores
|
|
247
|
+
const value = typeof content === 'string' ? content : JSON.stringify(content);
|
|
248
|
+
const bytes = new TextEncoder().encode(value);
|
|
249
|
+
await storage.put(resourcePath, bytes);
|
|
250
|
+
} else {
|
|
251
|
+
const bucket = mount.resource as Bucket;
|
|
252
|
+
const bucketOptions: Record<string, any> = {};
|
|
253
|
+
if (options?.contentType) {
|
|
254
|
+
bucketOptions.httpMetadata = { contentType: options.contentType };
|
|
255
|
+
}
|
|
256
|
+
if (options?.customMetadata) {
|
|
257
|
+
bucketOptions.customMetadata = options.customMetadata;
|
|
258
|
+
}
|
|
259
|
+
const value = typeof content === 'string' ? content : JSON.stringify(content);
|
|
260
|
+
await bucket.put(
|
|
261
|
+
resourcePath,
|
|
262
|
+
value,
|
|
263
|
+
Object.keys(bucketOptions).length > 0 ? bucketOptions : undefined
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Return success as a plain value
|
|
268
|
+
return { success: true };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
context.setProp(context.global, 'write', writeMethod);
|
|
273
|
+
writeMethod.dispose();
|
|
274
|
+
|
|
275
|
+
// ================================================================
|
|
276
|
+
// list(path, options?) - List files or keys
|
|
277
|
+
//
|
|
278
|
+
// Options (type-specific):
|
|
279
|
+
// Common: { prefix: string, limit: number }
|
|
280
|
+
// Bucket: { delimiter: string, startAfter: string }
|
|
281
|
+
// ActorStorage: { start: string, end: string, startAfter: string, reverse: boolean }
|
|
282
|
+
//
|
|
283
|
+
// Note: prefix is also extracted from the path itself (everything after the mount name).
|
|
284
|
+
// If options.prefix is also provided, it is appended to the path-based prefix.
|
|
285
|
+
// ================================================================
|
|
286
|
+
const listMethod = context.newFunction(
|
|
287
|
+
'list',
|
|
288
|
+
(pathHandle: QuickJSHandle, optionsHandle?: QuickJSHandle) => {
|
|
289
|
+
const path = context.dump(pathHandle) as string;
|
|
290
|
+
const options = optionsHandle
|
|
291
|
+
? (context.dump(optionsHandle) as Record<string, any> | null)
|
|
292
|
+
: null;
|
|
293
|
+
|
|
294
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
295
|
+
const { mount, resourcePath } = parseMountPath(path, storageMounts, 'Storage');
|
|
296
|
+
|
|
297
|
+
if (mount.type === 'kv') {
|
|
298
|
+
const kv = mount.resource as KvCache;
|
|
299
|
+
// Build prefix: use resourcePath as base, append options.prefix if provided
|
|
300
|
+
let prefix: string | undefined = resourcePath || undefined;
|
|
301
|
+
if (options?.prefix !== undefined) {
|
|
302
|
+
prefix = (prefix || '') + options.prefix;
|
|
303
|
+
}
|
|
304
|
+
const listed = await kv.list({
|
|
305
|
+
prefix,
|
|
306
|
+
limit: options?.limit,
|
|
307
|
+
cursor: options?.cursor
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Build files array - include cursor/complete for pagination
|
|
311
|
+
const files = listed.keys.map((key: any) => ({
|
|
312
|
+
key: key.name,
|
|
313
|
+
expiration: key.expiration
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
// Build additional properties for pagination
|
|
317
|
+
const additionalProps: Record<string, QuickJSHandle> = {};
|
|
318
|
+
if (!listed.list_complete && listed.cursor) {
|
|
319
|
+
additionalProps.cursor = context.newString(listed.cursor);
|
|
320
|
+
}
|
|
321
|
+
additionalProps.list_complete = listed.list_complete ? context.true.dup() : context.false.dup();
|
|
322
|
+
|
|
323
|
+
return createListResult(context, mount.name, files, additionalProps);
|
|
324
|
+
} else if (mount.type === 'actor-storage') {
|
|
325
|
+
const storage = mount.resource as ActorStorage;
|
|
326
|
+
const listOptions: Record<string, any> = {};
|
|
327
|
+
|
|
328
|
+
// Use resourcePath as prefix if present
|
|
329
|
+
if (resourcePath) {
|
|
330
|
+
listOptions.prefix = resourcePath;
|
|
331
|
+
}
|
|
332
|
+
// Options can override/supplement
|
|
333
|
+
if (options?.prefix !== undefined) {
|
|
334
|
+
listOptions.prefix = (listOptions.prefix || '') + options.prefix;
|
|
335
|
+
}
|
|
336
|
+
if (options?.start !== undefined) listOptions.start = options.start;
|
|
337
|
+
if (options?.end !== undefined) listOptions.end = options.end;
|
|
338
|
+
if (options?.startAfter !== undefined) listOptions.startAfter = options.startAfter;
|
|
339
|
+
if (options?.reverse !== undefined) listOptions.reverse = options.reverse;
|
|
340
|
+
if (options?.limit !== undefined) listOptions.limit = options.limit;
|
|
341
|
+
|
|
342
|
+
const entries = await storage.list(
|
|
343
|
+
Object.keys(listOptions).length > 0 ? listOptions : undefined
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Build files array
|
|
347
|
+
const files = Array.from(entries).map(([key]) => ({ key }));
|
|
348
|
+
|
|
349
|
+
return createListResult(context, mount.name, files);
|
|
350
|
+
} else {
|
|
351
|
+
const bucket = mount.resource as Bucket;
|
|
352
|
+
const bucketOptions: Record<string, any> = {
|
|
353
|
+
limit: options?.limit || 1000
|
|
354
|
+
};
|
|
355
|
+
if (resourcePath) {
|
|
356
|
+
bucketOptions.prefix = resourcePath;
|
|
357
|
+
}
|
|
358
|
+
if (options?.prefix !== undefined) {
|
|
359
|
+
bucketOptions.prefix = (bucketOptions.prefix || '') + options.prefix;
|
|
360
|
+
}
|
|
361
|
+
if (options?.delimiter !== undefined) bucketOptions.delimiter = options.delimiter;
|
|
362
|
+
if (options?.startAfter !== undefined) bucketOptions.startAfter = options.startAfter;
|
|
363
|
+
|
|
364
|
+
const listed = await bucket.list(bucketOptions);
|
|
365
|
+
|
|
366
|
+
// Build files array
|
|
367
|
+
const files = listed.objects.map(obj => ({
|
|
368
|
+
key: obj.key,
|
|
369
|
+
size: obj.size,
|
|
370
|
+
uploaded: obj.uploaded
|
|
371
|
+
}));
|
|
372
|
+
|
|
373
|
+
// Build additional properties for pagination
|
|
374
|
+
const additionalProps: Record<string, QuickJSHandle> = {};
|
|
375
|
+
if (listed.truncated && listed.cursor) {
|
|
376
|
+
additionalProps.cursor = context.newString(listed.cursor);
|
|
377
|
+
}
|
|
378
|
+
additionalProps.truncated = listed.truncated ? context.true.dup() : context.false.dup();
|
|
379
|
+
|
|
380
|
+
return createListResult(context, mount.name, files, additionalProps);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
context.setProp(context.global, 'list', listMethod);
|
|
386
|
+
listMethod.dispose();
|
|
387
|
+
|
|
388
|
+
// ================================================================
|
|
389
|
+
// remove(path) - Delete file or key
|
|
390
|
+
// ================================================================
|
|
391
|
+
const removeMethod = context.newFunction('remove', (pathHandle: QuickJSHandle) => {
|
|
392
|
+
const path = context.dump(pathHandle) as string;
|
|
393
|
+
|
|
394
|
+
return withTrackedPromiseValue(ctx, async () => {
|
|
395
|
+
const { mount, resourcePath } = parseMountPath(path, storageMounts, 'Storage');
|
|
396
|
+
|
|
397
|
+
// Check if mount allows writes (default is read-only)
|
|
398
|
+
if (mount.mode !== 'rw') {
|
|
399
|
+
throw new Error(`Cannot delete from read-only mount: /${mount.name}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (mount.type === 'kv') {
|
|
403
|
+
const kv = mount.resource as KvCache;
|
|
404
|
+
await kv.delete(resourcePath);
|
|
405
|
+
} else if (mount.type === 'actor-storage') {
|
|
406
|
+
const storage = mount.resource as ActorStorage;
|
|
407
|
+
await storage.delete(resourcePath);
|
|
408
|
+
} else {
|
|
409
|
+
const bucket = mount.resource as Bucket;
|
|
410
|
+
await bucket.delete(resourcePath);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Return success as a plain value
|
|
414
|
+
return { success: true };
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
context.setProp(context.global, 'remove', removeMethod);
|
|
418
|
+
removeMethod.dispose();
|
|
419
|
+
|
|
420
|
+
logger?.info?.('[Bridge] storage installed (read, write, list, remove)');
|
|
421
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextDecoder bridge - Decodes Uint8Array to strings with streaming support
|
|
3
|
+
*
|
|
4
|
+
* OPTIMIZATION: Uses context.getArrayBuffer() for efficient binary transfer
|
|
5
|
+
* instead of context.dump() which is extremely slow for large arrays.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
9
|
+
import type { BridgeContext } from './types.js';
|
|
10
|
+
import { defineClass } from './utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Install TextDecoder in the sandbox
|
|
14
|
+
* Bridges to runtime's native TextDecoder with stateful instance tracking
|
|
15
|
+
*/
|
|
16
|
+
export function installTextDecoder(ctx: BridgeContext): void {
|
|
17
|
+
const { context, logger, cleanupHandlers } = ctx;
|
|
18
|
+
|
|
19
|
+
// Store decoder instances by ID to maintain state across calls
|
|
20
|
+
const decoderInstances = new Map<number, TextDecoder>();
|
|
21
|
+
let decoderIdCounter = 0;
|
|
22
|
+
|
|
23
|
+
// Register cleanup handler to prevent memory leak
|
|
24
|
+
cleanupHandlers.push(() => {
|
|
25
|
+
const count = decoderInstances.size;
|
|
26
|
+
decoderInstances.clear();
|
|
27
|
+
if (count > 0) {
|
|
28
|
+
logger?.info?.(`[TextDecoder] Cleaned up ${count} decoder instances`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Host function to create a decoder and return its ID
|
|
33
|
+
{
|
|
34
|
+
using hostCreateDecoder = context.newFunction(
|
|
35
|
+
'__hostCreateDecoder',
|
|
36
|
+
(labelHandle?, optionsHandle?) => {
|
|
37
|
+
try {
|
|
38
|
+
const label = labelHandle ? context.dump(labelHandle) : 'utf-8';
|
|
39
|
+
const options = optionsHandle ? context.dump(optionsHandle) : {};
|
|
40
|
+
|
|
41
|
+
// Create the runtime's TextDecoder instance and store it
|
|
42
|
+
const decoder = new TextDecoder(typeof label === 'string' ? label : 'utf-8', options);
|
|
43
|
+
const decoderId = ++decoderIdCounter;
|
|
44
|
+
decoderInstances.set(decoderId, decoder);
|
|
45
|
+
|
|
46
|
+
// Return an object with the decoder ID and encoding
|
|
47
|
+
const resultHandle = context.newObject();
|
|
48
|
+
{
|
|
49
|
+
using idHandle = context.newNumber(decoderId);
|
|
50
|
+
context.setProp(resultHandle, 'id', idHandle);
|
|
51
|
+
}
|
|
52
|
+
{
|
|
53
|
+
using encodingHandle = context.newString(decoder.encoding);
|
|
54
|
+
context.setProp(resultHandle, 'encoding', encodingHandle);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return resultHandle;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return context.newError(e instanceof Error ? e.message : String(e));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
context.setProp(context.global, '__hostCreateDecoder', hostCreateDecoder);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Host function to decode - uses getArrayBuffer for efficient transfer
|
|
68
|
+
{
|
|
69
|
+
using hostDecode = context.newFunction(
|
|
70
|
+
'__hostDecode',
|
|
71
|
+
(
|
|
72
|
+
decoderIdHandle: QuickJSHandle,
|
|
73
|
+
bufferHandle?: QuickJSHandle,
|
|
74
|
+
byteOffsetHandle?: QuickJSHandle,
|
|
75
|
+
byteLengthHandle?: QuickJSHandle,
|
|
76
|
+
streamHandle?: QuickJSHandle
|
|
77
|
+
) => {
|
|
78
|
+
try {
|
|
79
|
+
const decoderId = context.dump(decoderIdHandle) as number;
|
|
80
|
+
|
|
81
|
+
const decoder = decoderInstances.get(decoderId);
|
|
82
|
+
if (!decoder) {
|
|
83
|
+
return context.newError('TextDecoder instance not found');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If no buffer provided, this is a final flush call
|
|
87
|
+
if (!bufferHandle) {
|
|
88
|
+
const text = decoder.decode();
|
|
89
|
+
return context.newString(text);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get stream option
|
|
93
|
+
const stream = streamHandle ? context.dump(streamHandle) === true : false;
|
|
94
|
+
|
|
95
|
+
// Use getArrayBuffer for efficient binary transfer (avoids slow dump())
|
|
96
|
+
const arrayBufferResult = context.getArrayBuffer(bufferHandle);
|
|
97
|
+
if (!arrayBufferResult) {
|
|
98
|
+
return context.newError(
|
|
99
|
+
'Failed to extract ArrayBuffer from input. Input must be an ArrayBuffer or TypedArray.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// arrayBufferResult.value is a Uint8Array view of the ArrayBuffer
|
|
105
|
+
let uint8Array = arrayBufferResult.value;
|
|
106
|
+
|
|
107
|
+
// Apply byte offset and length if this was a typed array view
|
|
108
|
+
const byteOffset = byteOffsetHandle ? (context.dump(byteOffsetHandle) as number) : 0;
|
|
109
|
+
const byteLength = byteLengthHandle
|
|
110
|
+
? (context.dump(byteLengthHandle) as number)
|
|
111
|
+
: uint8Array.length;
|
|
112
|
+
|
|
113
|
+
// If we have offset/length that differ from the full buffer, create a view
|
|
114
|
+
if (byteOffset !== 0 || byteLength !== uint8Array.length) {
|
|
115
|
+
uint8Array = new Uint8Array(uint8Array.buffer, byteOffset, byteLength);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Decode synchronously, then dispose the WASM-backed buffer
|
|
119
|
+
// (safe because decode() is synchronous and completes before dispose)
|
|
120
|
+
const text = decoder.decode(uint8Array, { stream });
|
|
121
|
+
return context.newString(text);
|
|
122
|
+
} finally {
|
|
123
|
+
arrayBufferResult.dispose();
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
logger?.error?.('TextDecoder.decode error', { error: e });
|
|
127
|
+
return context.newError(e instanceof Error ? e.message : String(e));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
context.setProp(context.global, '__hostDecode', hostDecode);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Define TextDecoder class in QuickJS
|
|
136
|
+
const classCode = `
|
|
137
|
+
class TextDecoder {
|
|
138
|
+
constructor(label, options) {
|
|
139
|
+
const result = __hostCreateDecoder(label, options);
|
|
140
|
+
this.__decoderId = result.id;
|
|
141
|
+
this.encoding = result.encoding;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
decode(input, options) {
|
|
145
|
+
if (input === undefined || input === null) {
|
|
146
|
+
return __hostDecode(this.__decoderId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let buffer, byteOffset, byteLength;
|
|
150
|
+
|
|
151
|
+
if (input instanceof ArrayBuffer) {
|
|
152
|
+
buffer = input;
|
|
153
|
+
byteOffset = 0;
|
|
154
|
+
byteLength = input.byteLength;
|
|
155
|
+
} else if (ArrayBuffer.isView(input)) {
|
|
156
|
+
buffer = input.buffer;
|
|
157
|
+
byteOffset = input.byteOffset;
|
|
158
|
+
byteLength = input.byteLength;
|
|
159
|
+
} else {
|
|
160
|
+
throw new TypeError('input must be a BufferSource');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stream = options && options.stream === true;
|
|
164
|
+
return __hostDecode(this.__decoderId, buffer, byteOffset, byteLength, stream);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const classHandle = defineClass(ctx, classCode, 'TextDecoder');
|
|
170
|
+
if (classHandle) {
|
|
171
|
+
using handle = classHandle;
|
|
172
|
+
context.setProp(context.global, 'TextDecoder', handle);
|
|
173
|
+
|
|
174
|
+
// Clean up internal globals — make non-enumerable/writable to hide from sandbox
|
|
175
|
+
// NOTE: The sandbox is not a hard security boundary. Internal helpers are hidden via
|
|
176
|
+
// enumerable: false but can still be called by name. This is acceptable because the bridges
|
|
177
|
+
// are the primary security boundary, not QuickJS containment. See Sandbox.ts for details.
|
|
178
|
+
const hideResult = context.evalCode(`(function() {
|
|
179
|
+
try { Object.defineProperty(globalThis, '__hostCreateDecoder', { enumerable: false, writable: false, configurable: false }); } catch(e) {}
|
|
180
|
+
try { Object.defineProperty(globalThis, '__hostDecode', { enumerable: false, writable: false, configurable: false }); } catch(e) {}
|
|
181
|
+
})()`);
|
|
182
|
+
if (hideResult.error) {
|
|
183
|
+
hideResult.error.dispose();
|
|
184
|
+
} else {
|
|
185
|
+
(hideResult as { value: QuickJSHandle }).value.dispose();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logger?.info?.('[Bridge] TextDecoder installed (optimized with getArrayBuffer)');
|
|
189
|
+
}
|
|
190
|
+
}
|