@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search bridge - Provides search/chunkSearch with pagination support
|
|
3
|
+
*
|
|
4
|
+
* Returns result objects with:
|
|
5
|
+
* - .results - current page results
|
|
6
|
+
* - .hasMore - boolean
|
|
7
|
+
* - .nextPage() - returns next page (same shape)
|
|
8
|
+
* - async iterator - for await (const r of results)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
12
|
+
import { isFail } from 'quickjs-emscripten-core';
|
|
13
|
+
import type { BridgeContext } from './types.js';
|
|
14
|
+
import type { SmartBucket } from '@liquidmetal-ai/raindrop-framework';
|
|
15
|
+
import { withTrackedPromiseResult, parseMountPath } from './shared/index.js';
|
|
16
|
+
|
|
17
|
+
export interface SearchMountInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
smartbucket: SmartBucket;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a single search result item handle. Uses Scope for per-item cleanup.
|
|
24
|
+
*/
|
|
25
|
+
function createSearchResultItem(
|
|
26
|
+
context: BridgeContext['context'],
|
|
27
|
+
r: any
|
|
28
|
+
): QuickJSHandle {
|
|
29
|
+
const itemHandle = context.newObject();
|
|
30
|
+
|
|
31
|
+
if (r.text) {
|
|
32
|
+
using textHandle = context.newString(r.text);
|
|
33
|
+
context.setProp(itemHandle, 'text', textHandle);
|
|
34
|
+
}
|
|
35
|
+
if (r.source) {
|
|
36
|
+
using sourceHandle = context.newString(r.source);
|
|
37
|
+
context.setProp(itemHandle, 'source', sourceHandle);
|
|
38
|
+
}
|
|
39
|
+
if (r.score !== undefined) {
|
|
40
|
+
using scoreHandle = context.newNumber(r.score);
|
|
41
|
+
context.setProp(itemHandle, 'score', scoreHandle);
|
|
42
|
+
}
|
|
43
|
+
if (r.chunkSignature) {
|
|
44
|
+
using chunkHandle = context.newString(r.chunkSignature);
|
|
45
|
+
context.setProp(itemHandle, 'chunkSignature', chunkHandle);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return itemHandle;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a results array handle from search results.
|
|
53
|
+
*/
|
|
54
|
+
function createResultsArray(
|
|
55
|
+
context: BridgeContext['context'],
|
|
56
|
+
results: any[]
|
|
57
|
+
): QuickJSHandle {
|
|
58
|
+
const resultsArray = context.newArray();
|
|
59
|
+
results.forEach((r, index) => {
|
|
60
|
+
using itemHandle = createSearchResultItem(context, r);
|
|
61
|
+
context.setProp(resultsArray, index, itemHandle);
|
|
62
|
+
});
|
|
63
|
+
return resultsArray;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Install search API in the sandbox
|
|
68
|
+
*/
|
|
69
|
+
export function installSearch(
|
|
70
|
+
ctx: BridgeContext,
|
|
71
|
+
searchMounts: Map<string, SearchMountInfo>
|
|
72
|
+
): void {
|
|
73
|
+
const { context, logger } = ctx;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a search results object with pagination support
|
|
77
|
+
*/
|
|
78
|
+
function createSearchResultsHandle(
|
|
79
|
+
results: any[],
|
|
80
|
+
hasMore: boolean,
|
|
81
|
+
pagination: { total: number; page: number; pageSize: number },
|
|
82
|
+
smartbucket: SmartBucket,
|
|
83
|
+
requestId: string
|
|
84
|
+
): QuickJSHandle {
|
|
85
|
+
const resultHandle = context.newObject();
|
|
86
|
+
|
|
87
|
+
// .results array
|
|
88
|
+
{
|
|
89
|
+
using resultsArray = createResultsArray(context, results);
|
|
90
|
+
context.setProp(resultHandle, 'results', resultsArray);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// .hasMore boolean
|
|
94
|
+
context.setProp(resultHandle, 'hasMore', hasMore ? context.true : context.false);
|
|
95
|
+
|
|
96
|
+
// .total, .page, .pageSize for info
|
|
97
|
+
{
|
|
98
|
+
using totalHandle = context.newNumber(pagination.total);
|
|
99
|
+
context.setProp(resultHandle, 'total', totalHandle);
|
|
100
|
+
}
|
|
101
|
+
{
|
|
102
|
+
using pageHandle = context.newNumber(pagination.page);
|
|
103
|
+
context.setProp(resultHandle, 'page', pageHandle);
|
|
104
|
+
}
|
|
105
|
+
{
|
|
106
|
+
using pageSizeHandle = context.newNumber(pagination.pageSize);
|
|
107
|
+
context.setProp(resultHandle, 'pageSize', pageSizeHandle);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// .nextPage() method - captures requestId in closure
|
|
111
|
+
{
|
|
112
|
+
using nextPageMethod = context.newFunction('nextPage', () => {
|
|
113
|
+
if (!hasMore) {
|
|
114
|
+
// No more pages - return empty results
|
|
115
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
116
|
+
return createSearchResultsHandle(
|
|
117
|
+
[],
|
|
118
|
+
false,
|
|
119
|
+
{ total: pagination.total, page: pagination.page + 1, pageSize: pagination.pageSize },
|
|
120
|
+
smartbucket,
|
|
121
|
+
requestId
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
127
|
+
const nextPage = pagination.page + 1;
|
|
128
|
+
const result = await smartbucket.getPaginatedResults({
|
|
129
|
+
requestId,
|
|
130
|
+
page: nextPage
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return createSearchResultsHandle(
|
|
134
|
+
result.results,
|
|
135
|
+
result.pagination.hasMore,
|
|
136
|
+
{
|
|
137
|
+
total: result.pagination.total,
|
|
138
|
+
page: result.pagination.page,
|
|
139
|
+
pageSize: result.pagination.pageSize
|
|
140
|
+
},
|
|
141
|
+
smartbucket,
|
|
142
|
+
requestId
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
context.setProp(resultHandle, 'nextPage', nextPageMethod);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Symbol.asyncIterator for "for await" support
|
|
150
|
+
// We define the iterator entirely in QuickJS so there are no captured host handles
|
|
151
|
+
// that could be disposed before the iterator is used.
|
|
152
|
+
const asyncIteratorSetupCode = `
|
|
153
|
+
(function(resultsObj) {
|
|
154
|
+
resultsObj[Symbol.asyncIterator] = function() {
|
|
155
|
+
return {
|
|
156
|
+
currentResults: resultsObj.results.slice(),
|
|
157
|
+
currentIndex: 0,
|
|
158
|
+
resultsObj: resultsObj,
|
|
159
|
+
async next() {
|
|
160
|
+
// If we have results in current page, yield them
|
|
161
|
+
if (this.currentIndex < this.currentResults.length) {
|
|
162
|
+
return { value: this.currentResults[this.currentIndex++], done: false };
|
|
163
|
+
}
|
|
164
|
+
// Try to get next page
|
|
165
|
+
if (this.resultsObj.hasMore) {
|
|
166
|
+
this.resultsObj = await this.resultsObj.nextPage();
|
|
167
|
+
this.currentResults = this.resultsObj.results.slice();
|
|
168
|
+
this.currentIndex = 0;
|
|
169
|
+
if (this.currentResults.length > 0) {
|
|
170
|
+
return { value: this.currentResults[this.currentIndex++], done: false };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// No more results
|
|
174
|
+
return { done: true };
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
})
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
const setupFnResult = context.evalCode(asyncIteratorSetupCode);
|
|
182
|
+
if (isFail(setupFnResult)) {
|
|
183
|
+
logger?.warn?.('[Search Bridge] Failed to create async iterator setup');
|
|
184
|
+
setupFnResult.error.dispose();
|
|
185
|
+
} else {
|
|
186
|
+
using setupFn = setupFnResult.value;
|
|
187
|
+
const callResult = context.callFunction(setupFn, context.undefined, resultHandle);
|
|
188
|
+
if (isFail(callResult)) {
|
|
189
|
+
logger?.warn?.('[Search Bridge] Failed to apply async iterator');
|
|
190
|
+
callResult.error.dispose();
|
|
191
|
+
} else {
|
|
192
|
+
callResult.value.dispose();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return resultHandle;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// search(path, query) - Semantic search with pagination
|
|
200
|
+
{
|
|
201
|
+
using searchMethod = context.newFunction(
|
|
202
|
+
'search',
|
|
203
|
+
(pathHandle: QuickJSHandle, queryHandle: QuickJSHandle) => {
|
|
204
|
+
const path = context.dump(pathHandle) as string;
|
|
205
|
+
const query = context.dump(queryHandle) as string;
|
|
206
|
+
|
|
207
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
208
|
+
const { mount } = parseMountPath(path, searchMounts, 'Search');
|
|
209
|
+
const requestId = `search-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
210
|
+
|
|
211
|
+
const result = await mount.smartbucket.search({
|
|
212
|
+
input: query,
|
|
213
|
+
requestId
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return createSearchResultsHandle(
|
|
217
|
+
result.results,
|
|
218
|
+
result.pagination.hasMore,
|
|
219
|
+
{
|
|
220
|
+
total: result.pagination.total,
|
|
221
|
+
page: result.pagination.page,
|
|
222
|
+
pageSize: result.pagination.pageSize
|
|
223
|
+
},
|
|
224
|
+
mount.smartbucket,
|
|
225
|
+
requestId
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
context.setProp(context.global, 'search', searchMethod);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// chunkSearch(path, query) - RAG chunk search (no pagination)
|
|
234
|
+
{
|
|
235
|
+
using chunkSearchMethod = context.newFunction(
|
|
236
|
+
'chunkSearch',
|
|
237
|
+
(pathHandle: QuickJSHandle, queryHandle: QuickJSHandle) => {
|
|
238
|
+
const path = context.dump(pathHandle) as string;
|
|
239
|
+
const query = context.dump(queryHandle) as string;
|
|
240
|
+
|
|
241
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
242
|
+
const { mount } = parseMountPath(path, searchMounts, 'Search');
|
|
243
|
+
const requestId = `chunk-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
244
|
+
|
|
245
|
+
const result = await mount.smartbucket.chunkSearch({
|
|
246
|
+
input: query,
|
|
247
|
+
requestId
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Build results using the shared helper
|
|
251
|
+
const resultHandle = context.newObject();
|
|
252
|
+
{
|
|
253
|
+
using resultsArray = createResultsArray(context, result.results);
|
|
254
|
+
context.setProp(resultHandle, 'results', resultsArray);
|
|
255
|
+
}
|
|
256
|
+
return resultHandle;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
context.setProp(context.global, 'chunkSearch', chunkSearchMethod);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
logger?.info?.(`[Search Bridge] Installed with ${searchMounts.size} SmartBucket mount(s)`);
|
|
264
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared body consumption methods (json, text, arrayBuffer) for Response-like objects
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { QuickJSHandle } from 'quickjs-emscripten-core';
|
|
6
|
+
import type { BridgeContext } from '../types.js';
|
|
7
|
+
import { withTrackedPromiseResult, withTrackedPromiseValue } from './promise-helper.js';
|
|
8
|
+
import { parseJsonInContext } from './json-helpers.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a body consumption method (json, text, or arrayBuffer)
|
|
12
|
+
*
|
|
13
|
+
* @param ctx - Bridge context
|
|
14
|
+
* @param methodName - 'json', 'text', or 'arrayBuffer'
|
|
15
|
+
* @param responseId - ID of the response in the registry
|
|
16
|
+
* @param responseRegistry - Map of response IDs to Response objects
|
|
17
|
+
*/
|
|
18
|
+
export function createBodyMethod(
|
|
19
|
+
ctx: BridgeContext,
|
|
20
|
+
methodName: 'json' | 'text' | 'arrayBuffer',
|
|
21
|
+
responseId: number,
|
|
22
|
+
responseRegistry: Map<number, Response>
|
|
23
|
+
): QuickJSHandle {
|
|
24
|
+
const { context, tracker } = ctx;
|
|
25
|
+
|
|
26
|
+
return context.newFunction(methodName, () => {
|
|
27
|
+
const response = responseRegistry.get(responseId);
|
|
28
|
+
|
|
29
|
+
if (!response) {
|
|
30
|
+
const deferred = context.newPromise();
|
|
31
|
+
tracker.deferredPromises.add(deferred);
|
|
32
|
+
using errHandle = context.newString('Response not found or already consumed');
|
|
33
|
+
deferred.reject(errHandle);
|
|
34
|
+
tracker.deferredPromises.delete(deferred);
|
|
35
|
+
return deferred.handle;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if body was already used (host Response tracks this)
|
|
39
|
+
if (response.bodyUsed) {
|
|
40
|
+
const deferred = context.newPromise();
|
|
41
|
+
tracker.deferredPromises.add(deferred);
|
|
42
|
+
using errHandle = context.newString('Body has already been consumed');
|
|
43
|
+
deferred.reject(errHandle);
|
|
44
|
+
tracker.deferredPromises.delete(deferred);
|
|
45
|
+
return deferred.handle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (methodName === 'text') {
|
|
49
|
+
return withTrackedPromiseValue(ctx, async () => {
|
|
50
|
+
return await response.text();
|
|
51
|
+
});
|
|
52
|
+
} else if (methodName === 'arrayBuffer') {
|
|
53
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
54
|
+
const buffer = await response.arrayBuffer();
|
|
55
|
+
return ctx.context.newArrayBuffer(buffer);
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
// json
|
|
59
|
+
return withTrackedPromiseResult(ctx, async () => {
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
const jsonStr = JSON.stringify(data);
|
|
62
|
+
return parseJsonInContext(context, jsonStr);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Attach all body methods (json, text, arrayBuffer) to a response handle
|
|
70
|
+
*/
|
|
71
|
+
export function attachBodyMethods(
|
|
72
|
+
ctx: BridgeContext,
|
|
73
|
+
responseHandle: QuickJSHandle,
|
|
74
|
+
responseId: number,
|
|
75
|
+
responseRegistry: Map<number, Response>
|
|
76
|
+
): void {
|
|
77
|
+
const { context } = ctx;
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
using jsonMethod = createBodyMethod(ctx, 'json', responseId, responseRegistry);
|
|
81
|
+
context.setProp(responseHandle, 'json', jsonMethod);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
using textMethod = createBodyMethod(ctx, 'text', responseId, responseRegistry);
|
|
86
|
+
context.setProp(responseHandle, 'text', textMethod);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
using arrayBufferMethod = createBodyMethod(ctx, 'arrayBuffer', responseId, responseRegistry);
|
|
91
|
+
context.setProp(responseHandle, 'arrayBuffer', arrayBufferMethod);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cleanup utilities for stream-based bridges
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Logger } from '../../../types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registry of host objects that need cleanup
|
|
9
|
+
*/
|
|
10
|
+
export interface StreamRegistry {
|
|
11
|
+
hostResponses?: Map<number, Response>;
|
|
12
|
+
hostStreams: Map<number, ReadableStream>;
|
|
13
|
+
hostReaders: Map<number, ReadableStreamDefaultReader>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a cleanup handler for stream-based bridges.
|
|
18
|
+
*
|
|
19
|
+
* The cleanup order is important:
|
|
20
|
+
* 1. Release readers FIRST - streams can't be cancelled while locked
|
|
21
|
+
* 2. Cancel streams - this settles any pending read promises
|
|
22
|
+
* 3. Wait for cancellation to complete (with timeout)
|
|
23
|
+
* 4. Clear all maps
|
|
24
|
+
*
|
|
25
|
+
* @param registry - Registry containing Maps of host objects
|
|
26
|
+
* @param bridgeName - Name for logging (e.g., 'Fetch', 'Response', 'Bucket')
|
|
27
|
+
* @param logger - Optional logger
|
|
28
|
+
*/
|
|
29
|
+
export function createStreamCleanupHandler(
|
|
30
|
+
registry: StreamRegistry,
|
|
31
|
+
bridgeName: string,
|
|
32
|
+
logger?: Logger,
|
|
33
|
+
cleanupTimeoutMs: number = 5000
|
|
34
|
+
): () => Promise<void> {
|
|
35
|
+
return async () => {
|
|
36
|
+
logger?.info?.(`[${bridgeName}] Cleanup: aborting all active streams and readers`);
|
|
37
|
+
|
|
38
|
+
const pendingOperations: Promise<unknown>[] = [];
|
|
39
|
+
|
|
40
|
+
// STEP 1: Release readers FIRST before cancelling streams
|
|
41
|
+
// Streams cannot be cancelled while a reader has the lock
|
|
42
|
+
const readersToRelease = Array.from(registry.hostReaders.entries());
|
|
43
|
+
for (const [id, reader] of readersToRelease) {
|
|
44
|
+
try {
|
|
45
|
+
reader.releaseLock();
|
|
46
|
+
logger?.info?.(`[${bridgeName}] Released reader lock for reader ${id}`);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Reader might already be released, ignore error
|
|
49
|
+
logger?.info?.(`[${bridgeName}] Reader ${id} already released or not locked: ${e}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// STEP 2: Cancel all streams (after readers are released)
|
|
54
|
+
const streamsToCancel = Array.from(registry.hostStreams.entries());
|
|
55
|
+
for (const [id, stream] of streamsToCancel) {
|
|
56
|
+
try {
|
|
57
|
+
const cancelPromise = stream.cancel('Sandbox cleanup').catch(() => {
|
|
58
|
+
// Ignore errors from cancellation
|
|
59
|
+
});
|
|
60
|
+
pendingOperations.push(cancelPromise);
|
|
61
|
+
logger?.info?.(`[${bridgeName}] Cancelled stream ${id}`);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// Stream might be cancelled or closed already
|
|
64
|
+
logger?.info?.(`[${bridgeName}] Stream ${id} already cancelled or closed: ${e}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// STEP 3: Wait for all stream cancellation promises to settle WITH TIMEOUT
|
|
69
|
+
// This prevents stream cleanup from blocking disposal indefinitely
|
|
70
|
+
if (pendingOperations.length > 0) {
|
|
71
|
+
logger?.info?.(
|
|
72
|
+
`[${bridgeName}] Waiting for ${pendingOperations.length} stream operations to settle (with timeout)`
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
try {
|
|
77
|
+
const timeout = new Promise(resolve => {
|
|
78
|
+
timer = setTimeout(resolve, cleanupTimeoutMs);
|
|
79
|
+
});
|
|
80
|
+
await Promise.race([Promise.allSettled(pendingOperations), timeout]);
|
|
81
|
+
logger?.info?.(`[${bridgeName}] Stream cleanup completed`);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
logger?.warn?.(`[${bridgeName}] Stream cleanup timeout or error: ${e}`);
|
|
84
|
+
} finally {
|
|
85
|
+
if (timer !== undefined) {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// STEP 4: Clear all registries AFTER operations have settled
|
|
92
|
+
registry.hostReaders.clear();
|
|
93
|
+
registry.hostStreams.clear();
|
|
94
|
+
if (registry.hostResponses) {
|
|
95
|
+
registry.hostResponses.clear();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Counter factory for generating unique IDs
|
|
102
|
+
*/
|
|
103
|
+
export function createIdCounter(): { value: number } {
|
|
104
|
+
return { value: 0 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get next ID from counter
|
|
109
|
+
*/
|
|
110
|
+
export function nextId(counter: { value: number }): number {
|
|
111
|
+
return ++counter.value;
|
|
112
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared value conversion utility for QuickJS bridges
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for converting JavaScript values to QuickJS handles.
|
|
5
|
+
* Used by both utils.ts (createSyncBridge/createAsyncBridge) and promise-helper.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten-core';
|
|
9
|
+
import { getJsonParse } from './json-helpers.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Safely extract the relevant portion of a Uint8Array's backing ArrayBuffer.
|
|
13
|
+
* Handles the case where a Uint8Array is a view into a larger ArrayBuffer
|
|
14
|
+
* (common with Node.js Buffer).
|
|
15
|
+
*/
|
|
16
|
+
function safeArrayBuffer(uint8Array: Uint8Array): ArrayBuffer {
|
|
17
|
+
if (uint8Array.byteOffset === 0 && uint8Array.byteLength === uint8Array.buffer.byteLength) {
|
|
18
|
+
return uint8Array.buffer as ArrayBuffer;
|
|
19
|
+
}
|
|
20
|
+
return uint8Array.buffer.slice(
|
|
21
|
+
uint8Array.byteOffset,
|
|
22
|
+
uint8Array.byteOffset + uint8Array.byteLength
|
|
23
|
+
) as ArrayBuffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert JavaScript values to QuickJS handles.
|
|
28
|
+
*
|
|
29
|
+
* IMPORTANT: Returns duplicated handles for singleton values (undefined, null, true, false)
|
|
30
|
+
* so that callers can always safely call .dispose() on the returned handle.
|
|
31
|
+
*/
|
|
32
|
+
export function convertToHandle(context: QuickJSContext, value: any): QuickJSHandle {
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
return context.undefined.dup();
|
|
35
|
+
} else if (value === null) {
|
|
36
|
+
return context.null.dup();
|
|
37
|
+
} else if (typeof value === 'number') {
|
|
38
|
+
return context.newNumber(value);
|
|
39
|
+
} else if (typeof value === 'string') {
|
|
40
|
+
return context.newString(value);
|
|
41
|
+
} else if (typeof value === 'boolean') {
|
|
42
|
+
return value ? context.true.dup() : context.false.dup();
|
|
43
|
+
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
44
|
+
const uint8Array = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
45
|
+
return context.newArrayBuffer(safeArrayBuffer(uint8Array));
|
|
46
|
+
} else if (Array.isArray(value)) {
|
|
47
|
+
const arr = context.newArray();
|
|
48
|
+
for (let i = 0; i < value.length; i++) {
|
|
49
|
+
const elemHandle = convertToHandle(context, value[i]);
|
|
50
|
+
context.setProp(arr, i, elemHandle);
|
|
51
|
+
elemHandle.dispose();
|
|
52
|
+
}
|
|
53
|
+
return arr;
|
|
54
|
+
} else if (typeof value === 'object') {
|
|
55
|
+
try {
|
|
56
|
+
const jsonStr = JSON.stringify(value);
|
|
57
|
+
const jsonParse = getJsonParse(context);
|
|
58
|
+
const jsonStrHandle = context.newString(jsonStr);
|
|
59
|
+
const result = context.callFunction(jsonParse, context.undefined, jsonStrHandle);
|
|
60
|
+
jsonStrHandle.dispose();
|
|
61
|
+
jsonParse.dispose();
|
|
62
|
+
|
|
63
|
+
if (result.error) {
|
|
64
|
+
result.error.dispose();
|
|
65
|
+
throw new Error('Failed to convert object via JSON.parse');
|
|
66
|
+
}
|
|
67
|
+
return (result as { value: QuickJSHandle }).value;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'Failed to convert object: ' + (e instanceof Error ? e.message : String(e))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
return context.newString(String(value));
|
|
75
|
+
}
|
|
76
|
+
}
|