@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.
Files changed (78) hide show
  1. package/.prettierrc +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/eslint.config.mjs +28 -0
  4. package/package.json +53 -0
  5. package/src/engine/agent.ts +478 -0
  6. package/src/engine/llm-provider.test.ts +275 -0
  7. package/src/engine/llm-provider.ts +330 -0
  8. package/src/engine/stream-parser.ts +170 -0
  9. package/src/index.ts +142 -0
  10. package/src/mounts/mount-manager.test.ts +516 -0
  11. package/src/mounts/mount-manager.ts +327 -0
  12. package/src/mounts/mount-registry.ts +196 -0
  13. package/src/mounts/zod-to-string.test.ts +154 -0
  14. package/src/mounts/zod-to-string.ts +213 -0
  15. package/src/presets/agent-tools.ts +57 -0
  16. package/src/presets/index.ts +5 -0
  17. package/src/sandbox/README.md +1321 -0
  18. package/src/sandbox/bridges/README.md +571 -0
  19. package/src/sandbox/bridges/actor.test.ts +229 -0
  20. package/src/sandbox/bridges/actor.ts +195 -0
  21. package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
  22. package/src/sandbox/bridges/bucket.test.ts +300 -0
  23. package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
  24. package/src/sandbox/bridges/console-multiple.test.ts +187 -0
  25. package/src/sandbox/bridges/console.test.ts +157 -0
  26. package/src/sandbox/bridges/console.ts +122 -0
  27. package/src/sandbox/bridges/fetch.ts +93 -0
  28. package/src/sandbox/bridges/index.ts +78 -0
  29. package/src/sandbox/bridges/readable-stream.ts +323 -0
  30. package/src/sandbox/bridges/response.test.ts +154 -0
  31. package/src/sandbox/bridges/response.ts +123 -0
  32. package/src/sandbox/bridges/review-fixes.test.ts +331 -0
  33. package/src/sandbox/bridges/search.test.ts +475 -0
  34. package/src/sandbox/bridges/search.ts +264 -0
  35. package/src/sandbox/bridges/shared/body-methods.ts +93 -0
  36. package/src/sandbox/bridges/shared/cleanup.ts +112 -0
  37. package/src/sandbox/bridges/shared/convert.ts +76 -0
  38. package/src/sandbox/bridges/shared/headers.ts +181 -0
  39. package/src/sandbox/bridges/shared/index.ts +36 -0
  40. package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
  41. package/src/sandbox/bridges/shared/path-parser.ts +109 -0
  42. package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
  43. package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
  44. package/src/sandbox/bridges/shared/response-object.ts +280 -0
  45. package/src/sandbox/bridges/shared/result-builder.ts +130 -0
  46. package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
  47. package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
  48. package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
  49. package/src/sandbox/bridges/storage.ts +421 -0
  50. package/src/sandbox/bridges/text-decoder.ts +190 -0
  51. package/src/sandbox/bridges/text-encoder.ts +102 -0
  52. package/src/sandbox/bridges/types.ts +39 -0
  53. package/src/sandbox/bridges/utils.ts +123 -0
  54. package/src/sandbox/index.ts +6 -0
  55. package/src/sandbox/quickjs-wasm.d.ts +9 -0
  56. package/src/sandbox/sandbox.test.ts +191 -0
  57. package/src/sandbox/sandbox.ts +831 -0
  58. package/src/sandbox/test-helper.ts +43 -0
  59. package/src/sandbox/test-mocks.ts +154 -0
  60. package/src/sandbox/user-stream.test.ts +77 -0
  61. package/src/skills/frontmatter.test.ts +305 -0
  62. package/src/skills/frontmatter.ts +200 -0
  63. package/src/skills/index.ts +9 -0
  64. package/src/skills/skills-loader.test.ts +237 -0
  65. package/src/skills/skills-loader.ts +200 -0
  66. package/src/tools/actor-storage-tools.ts +250 -0
  67. package/src/tools/code-tools.test.ts +199 -0
  68. package/src/tools/code-tools.ts +444 -0
  69. package/src/tools/file-tools.ts +206 -0
  70. package/src/tools/registry.ts +125 -0
  71. package/src/tools/script-tools.ts +145 -0
  72. package/src/tools/smartbucket-tools.ts +203 -0
  73. package/src/tools/sql-tools.ts +213 -0
  74. package/src/tools/tool-factory.ts +119 -0
  75. package/src/types.ts +512 -0
  76. package/tsconfig.eslint.json +5 -0
  77. package/tsconfig.json +15 -0
  78. 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
+ }