@modelprofile.com/browser-runtime 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/.smartconfig.json +34 -0
- package/changelog.md +11 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/actions.d.ts +3 -0
- package/dist_ts/actions.js +215 -0
- package/dist_ts/classes.artifactstore.d.ts +38 -0
- package/dist_ts/classes.artifactstore.js +344 -0
- package/dist_ts/classes.egressproxy.d.ts +67 -0
- package/dist_ts/classes.egressproxy.js +830 -0
- package/dist_ts/classes.flexprovider.d.ts +9 -0
- package/dist_ts/classes.flexprovider.js +117 -0
- package/dist_ts/classes.framed.d.ts +52 -0
- package/dist_ts/classes.framed.js +557 -0
- package/dist_ts/classes.runtime.d.ts +202 -0
- package/dist_ts/classes.runtime.js +1667 -0
- package/dist_ts/confinement.d.ts +2 -0
- package/dist_ts/confinement.js +63 -0
- package/dist_ts/errors.d.ts +7 -0
- package/dist_ts/errors.js +40 -0
- package/dist_ts/index.d.ts +11 -0
- package/dist_ts/index.js +9 -0
- package/dist_ts/interfaces.d.ts +287 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/internal.testing.d.ts +9 -0
- package/dist_ts/internal.testing.js +2 -0
- package/dist_ts/mcp.d.ts +4 -0
- package/dist_ts/mcp.js +196 -0
- package/dist_ts/plugins.d.ts +20 -0
- package/dist_ts/plugins.js +24 -0
- package/dist_ts/utils.d.ts +25 -0
- package/dist_ts/utils.js +143 -0
- package/license.md +21 -0
- package/package.json +59 -0
- package/readme.hints.md +35 -0
- package/readme.md +181 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/actions.ts +241 -0
- package/ts/classes.artifactstore.ts +432 -0
- package/ts/classes.egressproxy.ts +1005 -0
- package/ts/classes.flexprovider.ts +134 -0
- package/ts/classes.framed.ts +649 -0
- package/ts/classes.runtime.ts +2135 -0
- package/ts/confinement.ts +90 -0
- package/ts/errors.ts +63 -0
- package/ts/index.ts +52 -0
- package/ts/interfaces.ts +375 -0
- package/ts/internal.testing.ts +18 -0
- package/ts/mcp.ts +230 -0
- package/ts/plugins.ts +28 -0
- package/ts/utils.ts +188 -0
package/ts/mcp.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { BrowserRuntime, BrowserRuntimeLease } from './classes.runtime.js';
|
|
3
|
+
import { BrowserRuntimeError } from './errors.js';
|
|
4
|
+
import { commitinfo } from './00_commitinfo_data.js';
|
|
5
|
+
import type { IBrowserRuntimeMcpOptions } from './interfaces.js';
|
|
6
|
+
import { validateAgentActionResult } from './actions.js';
|
|
7
|
+
import {
|
|
8
|
+
validateBoundedString,
|
|
9
|
+
validateInteger,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
const parseBearerToken = (request: Request): string => {
|
|
13
|
+
const authorization = request.headers.get('authorization');
|
|
14
|
+
if (!authorization?.startsWith('Bearer ') || authorization.includes(',')) {
|
|
15
|
+
throw new BrowserRuntimeError('CAPABILITY_INVALID');
|
|
16
|
+
}
|
|
17
|
+
return validateBoundedString(
|
|
18
|
+
authorization.slice('Bearer '.length),
|
|
19
|
+
'bearer capability',
|
|
20
|
+
16,
|
|
21
|
+
512,
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const validateStringList = (value: unknown, name: string): string[] | undefined => {
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
if (!Array.isArray(value) || value.length > 64) throw new BrowserRuntimeError('INVALID_INPUT');
|
|
28
|
+
return value.map((entry) => validateBoundedString(entry, name, 1, 2048));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const createBrowserRuntimeMcpHttpHandler = (
|
|
32
|
+
runtime: BrowserRuntime,
|
|
33
|
+
options: IBrowserRuntimeMcpOptions,
|
|
34
|
+
): plugins.smartmcp.ISmartMcpHttpHandler => {
|
|
35
|
+
if (!runtime || !options || typeof options.authenticateMcpRequest !== 'function') {
|
|
36
|
+
throw new BrowserRuntimeError('INVALID_INPUT');
|
|
37
|
+
}
|
|
38
|
+
const trustedOrigins = validateStringList(options.trustedOrigins, 'trustedOrigin');
|
|
39
|
+
const allowedHosts = validateStringList(options.allowedHosts, 'allowedHost');
|
|
40
|
+
if (options.allowMissingOrigin !== undefined && typeof options.allowMissingOrigin !== 'boolean') {
|
|
41
|
+
throw new BrowserRuntimeError('INVALID_INPUT');
|
|
42
|
+
}
|
|
43
|
+
const maxRequestBytes = options.maxRequestBytes === undefined
|
|
44
|
+
? 256 * 1024
|
|
45
|
+
: validateInteger(options.maxRequestBytes, 'maxRequestBytes', 1024, 1024 * 1024);
|
|
46
|
+
const maxToolResultBytes = options.maxToolResultBytes === undefined
|
|
47
|
+
? 256 * 1024
|
|
48
|
+
: validateInteger(options.maxToolResultBytes, 'maxToolResultBytes', 1024, 256 * 1024);
|
|
49
|
+
|
|
50
|
+
const withLease = async <T>(
|
|
51
|
+
context: plugins.smartmcp.ISmartMcpToolContext,
|
|
52
|
+
action: (lease: BrowserRuntimeLease, signal: AbortSignal) => Promise<T>,
|
|
53
|
+
): Promise<T> => {
|
|
54
|
+
const peerId = validateBoundedString(context.authInfo?.clientId, 'peerId', 1, 128);
|
|
55
|
+
const capabilityToken = parseBearerToken(context.request);
|
|
56
|
+
const signal = AbortSignal.any([context.extra.signal, context.request.signal]);
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
const descriptor = runtime.authorizeCapability(capabilityToken, peerId, 'agent', 'mcp');
|
|
59
|
+
if (descriptor.capabilityId !== context.authInfo?.token) {
|
|
60
|
+
throw new BrowserRuntimeError('CAPABILITY_INVALID');
|
|
61
|
+
}
|
|
62
|
+
const lease = await runtime.acquireLease({
|
|
63
|
+
capabilityToken,
|
|
64
|
+
peerId,
|
|
65
|
+
expectedRole: 'agent',
|
|
66
|
+
expectedSource: 'mcp',
|
|
67
|
+
signal,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
return await action(lease, signal);
|
|
72
|
+
} finally {
|
|
73
|
+
await lease.release();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const tabIdSchema = plugins.smartmcp.z.string().min(1).max(128).optional();
|
|
78
|
+
const timeoutSchema = plugins.smartmcp.z.number().int().min(100).max(120_000).optional();
|
|
79
|
+
const tools: plugins.smartmcp.ISmartMcpToolDefinition[] = [
|
|
80
|
+
{
|
|
81
|
+
name: 'browser_snapshot',
|
|
82
|
+
description: 'Read bounded accessibility text from a browser tab.',
|
|
83
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
84
|
+
tabId: tabIdSchema,
|
|
85
|
+
maxCharacters: plugins.smartmcp.z.number().int().min(256).max(50_000).optional(),
|
|
86
|
+
}).strict(),
|
|
87
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
88
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
89
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
90
|
+
action: 'snapshot',
|
|
91
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
92
|
+
...(typeof args.maxCharacters === 'number'
|
|
93
|
+
? { maxCharacters: args.maxCharacters }
|
|
94
|
+
: {}),
|
|
95
|
+
}, { signal }))
|
|
96
|
+
)),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'browser_navigate',
|
|
100
|
+
description: 'Navigate a browser tab to an HTTP or HTTPS URL.',
|
|
101
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
102
|
+
url: plugins.smartmcp.z.string().min(1).max(8192),
|
|
103
|
+
tabId: tabIdSchema,
|
|
104
|
+
timeoutMs: timeoutSchema,
|
|
105
|
+
}).strict(),
|
|
106
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
107
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
108
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
109
|
+
action: 'navigate',
|
|
110
|
+
url: String(args.url),
|
|
111
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
112
|
+
...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
|
|
113
|
+
}, { signal }))
|
|
114
|
+
)),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'browser_click',
|
|
118
|
+
description: 'Click an element selected from the observed page.',
|
|
119
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
120
|
+
selector: plugins.smartmcp.z.string().min(1).max(4096),
|
|
121
|
+
tabId: tabIdSchema,
|
|
122
|
+
timeoutMs: timeoutSchema,
|
|
123
|
+
}).strict(),
|
|
124
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
125
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
126
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
127
|
+
action: 'click',
|
|
128
|
+
selector: String(args.selector),
|
|
129
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
130
|
+
...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
|
|
131
|
+
}, { signal }))
|
|
132
|
+
)),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'browser_fill',
|
|
136
|
+
description: 'Fill an element selected from the observed page.',
|
|
137
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
138
|
+
selector: plugins.smartmcp.z.string().min(1).max(4096),
|
|
139
|
+
text: plugins.smartmcp.z.string().max(100_000),
|
|
140
|
+
tabId: tabIdSchema,
|
|
141
|
+
timeoutMs: timeoutSchema,
|
|
142
|
+
}).strict(),
|
|
143
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
144
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
145
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
146
|
+
action: 'fill',
|
|
147
|
+
selector: String(args.selector),
|
|
148
|
+
text: String(args.text),
|
|
149
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
150
|
+
...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
|
|
151
|
+
}, { signal }))
|
|
152
|
+
)),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'browser_press',
|
|
156
|
+
description: 'Press a key on an element selected from the observed page.',
|
|
157
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
158
|
+
selector: plugins.smartmcp.z.string().min(1).max(4096),
|
|
159
|
+
key: plugins.smartmcp.z.string().min(1).max(64),
|
|
160
|
+
tabId: tabIdSchema,
|
|
161
|
+
timeoutMs: timeoutSchema,
|
|
162
|
+
}).strict(),
|
|
163
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
164
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
165
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
166
|
+
action: 'press',
|
|
167
|
+
selector: String(args.selector),
|
|
168
|
+
key: String(args.key),
|
|
169
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
170
|
+
...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
|
|
171
|
+
}, { signal }))
|
|
172
|
+
)),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'browser_screenshot',
|
|
176
|
+
description: 'Capture a screenshot as bounded artifact metadata.',
|
|
177
|
+
inputSchema: plugins.smartmcp.z.object({
|
|
178
|
+
tabId: tabIdSchema,
|
|
179
|
+
format: plugins.smartmcp.z.enum(['jpeg', 'png']).optional(),
|
|
180
|
+
quality: plugins.smartmcp.z.number().int().min(0).max(100).optional(),
|
|
181
|
+
}).strict(),
|
|
182
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
183
|
+
handler: async (args, context) => withLease(context, async (lease, signal) => (
|
|
184
|
+
validateAgentActionResult(await lease.executeAgentAction({
|
|
185
|
+
action: 'screenshot',
|
|
186
|
+
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
|
|
187
|
+
...(args.format === 'jpeg' || args.format === 'png' ? { format: args.format } : {}),
|
|
188
|
+
...(typeof args.quality === 'number' ? { quality: args.quality } : {}),
|
|
189
|
+
}, { signal }))
|
|
190
|
+
)),
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
return plugins.smartmcp.createSmartMcpHttpHandler({
|
|
195
|
+
name: '@modelprofile.com/browser-runtime',
|
|
196
|
+
version: commitinfo.version,
|
|
197
|
+
...(trustedOrigins ? { trustedOrigins } : {}),
|
|
198
|
+
...(allowedHosts ? { allowedHosts } : {}),
|
|
199
|
+
...(options.allowMissingOrigin !== undefined
|
|
200
|
+
? { allowMissingOrigin: options.allowMissingOrigin }
|
|
201
|
+
: {}),
|
|
202
|
+
maxRequestBytes,
|
|
203
|
+
maxToolResultBytes,
|
|
204
|
+
redactError: () => 'Browser tool execution failed.',
|
|
205
|
+
authorize: async ({ request }) => {
|
|
206
|
+
let peerId: string;
|
|
207
|
+
let capabilityToken: string;
|
|
208
|
+
try {
|
|
209
|
+
const authentication = await options.authenticateMcpRequest(request);
|
|
210
|
+
peerId = validateBoundedString(authentication?.peerId, 'peerId', 1, 128);
|
|
211
|
+
capabilityToken = parseBearerToken(request);
|
|
212
|
+
const descriptor = runtime.authorizeCapability(
|
|
213
|
+
capabilityToken,
|
|
214
|
+
peerId,
|
|
215
|
+
'agent',
|
|
216
|
+
'mcp',
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
token: descriptor.capabilityId,
|
|
220
|
+
clientId: peerId,
|
|
221
|
+
scopes: ['browser'],
|
|
222
|
+
expiresAt: descriptor.expiresAt,
|
|
223
|
+
};
|
|
224
|
+
} catch {
|
|
225
|
+
return new Response('Unauthorized', { status: 401 });
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
tools,
|
|
229
|
+
});
|
|
230
|
+
};
|
package/ts/plugins.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// node native scope
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import * as dns from 'node:dns';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as fsPromises from 'node:fs/promises';
|
|
7
|
+
import * as http from 'node:http';
|
|
8
|
+
import * as net from 'node:net';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as stream from 'node:stream';
|
|
12
|
+
import * as tls from 'node:tls';
|
|
13
|
+
import * as url from 'node:url';
|
|
14
|
+
|
|
15
|
+
export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls, url };
|
|
16
|
+
|
|
17
|
+
// foss.global scopes
|
|
18
|
+
import * as flexharness from '@modelprofile.com/flexharness';
|
|
19
|
+
import * as smartagent from '@push.rocks/smartagent';
|
|
20
|
+
import * as smartmcp from '@push.rocks/smartmcp';
|
|
21
|
+
import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
|
|
22
|
+
|
|
23
|
+
export { flexharness, smartagent, smartmcp, smartpuppeteer };
|
|
24
|
+
|
|
25
|
+
// third-party scope
|
|
26
|
+
import ipaddr from 'ipaddr.js';
|
|
27
|
+
|
|
28
|
+
export { ipaddr };
|
package/ts/utils.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { BrowserRuntimeError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export const validateBoundedString = (
|
|
5
|
+
value: unknown,
|
|
6
|
+
name: string,
|
|
7
|
+
minimum: number,
|
|
8
|
+
maximum: number,
|
|
9
|
+
): string => {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
throw new BrowserRuntimeError('INVALID_INPUT', `${name} must be a string`);
|
|
12
|
+
}
|
|
13
|
+
const normalized = value.trim();
|
|
14
|
+
if (normalized.length < minimum || normalized.length > maximum) {
|
|
15
|
+
throw new BrowserRuntimeError(
|
|
16
|
+
'INVALID_INPUT',
|
|
17
|
+
`${name} must contain between ${minimum} and ${maximum} characters`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return normalized;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const validateInteger = (
|
|
24
|
+
value: unknown,
|
|
25
|
+
name: string,
|
|
26
|
+
minimum: number,
|
|
27
|
+
maximum: number,
|
|
28
|
+
): number => {
|
|
29
|
+
if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
30
|
+
throw new BrowserRuntimeError(
|
|
31
|
+
'INVALID_INPUT',
|
|
32
|
+
`${name} must be an integer between ${minimum} and ${maximum}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return value as number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const validateOptionalInteger = (
|
|
39
|
+
value: unknown,
|
|
40
|
+
name: string,
|
|
41
|
+
minimum: number,
|
|
42
|
+
maximum: number,
|
|
43
|
+
defaultValue: number,
|
|
44
|
+
): number => value === undefined
|
|
45
|
+
? defaultValue
|
|
46
|
+
: validateInteger(value, name, minimum, maximum);
|
|
47
|
+
|
|
48
|
+
export const validateExactKeys = (
|
|
49
|
+
value: unknown,
|
|
50
|
+
allowedKeys: readonly string[],
|
|
51
|
+
name: string,
|
|
52
|
+
): Record<string, unknown> => {
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
54
|
+
throw new BrowserRuntimeError('INVALID_INPUT', `${name} must be an object`);
|
|
55
|
+
}
|
|
56
|
+
const record = value as Record<string, unknown>;
|
|
57
|
+
const allowed = new Set(allowedKeys);
|
|
58
|
+
for (const key of Object.keys(record)) {
|
|
59
|
+
if (!allowed.has(key)) {
|
|
60
|
+
throw new BrowserRuntimeError('INVALID_INPUT', `${name} contains an unsupported field`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return record;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const randomId = (bytes = 24): string => plugins.crypto.randomBytes(bytes).toString('base64url');
|
|
67
|
+
|
|
68
|
+
export const digestToken = (token: string): plugins.Buffer => plugins.crypto
|
|
69
|
+
.createHash('sha256')
|
|
70
|
+
.update(token, 'utf8')
|
|
71
|
+
.digest();
|
|
72
|
+
|
|
73
|
+
export const digestsEqual = (left: plugins.Buffer, right: plugins.Buffer): boolean => (
|
|
74
|
+
left.byteLength === right.byteLength && plugins.crypto.timingSafeEqual(left, right)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const delay = (milliseconds: number): Promise<void> => new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
79
|
+
timer.unref();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const waitBounded = async <T>(
|
|
83
|
+
promise: Promise<T>,
|
|
84
|
+
timeoutMs: number,
|
|
85
|
+
): Promise<{ settled: true; value: T } | { settled: false }> => {
|
|
86
|
+
const timeout = Symbol('timeout');
|
|
87
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
+
const timeoutPromise = new Promise<typeof timeout>((resolve) => {
|
|
89
|
+
timer = setTimeout(() => resolve(timeout), timeoutMs);
|
|
90
|
+
timer.unref();
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
const result = await Promise.race([promise, timeoutPromise]);
|
|
94
|
+
return result === timeout
|
|
95
|
+
? { settled: false }
|
|
96
|
+
: { settled: true, value: result as T };
|
|
97
|
+
} finally {
|
|
98
|
+
if (timer) clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const truncateString = (value: string, maximum = 2048): string => (
|
|
103
|
+
value.length <= maximum ? value : value.slice(0, maximum)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
export const assertJsonWithoutBytes = (value: unknown, maxBytes = 256 * 1024): void => {
|
|
107
|
+
const seen = new WeakSet<object>();
|
|
108
|
+
const visit = (current: unknown, depth: number): void => {
|
|
109
|
+
if (depth > 24) {
|
|
110
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
current instanceof Uint8Array
|
|
114
|
+
|| current instanceof ArrayBuffer
|
|
115
|
+
|| ArrayBuffer.isView(current)
|
|
116
|
+
) {
|
|
117
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
118
|
+
}
|
|
119
|
+
if (!current || typeof current !== 'object') {
|
|
120
|
+
if (
|
|
121
|
+
typeof current === 'bigint'
|
|
122
|
+
|| typeof current === 'function'
|
|
123
|
+
|| typeof current === 'symbol'
|
|
124
|
+
|| typeof current === 'undefined'
|
|
125
|
+
|| (typeof current === 'number' && !Number.isFinite(current))
|
|
126
|
+
) {
|
|
127
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (seen.has(current)) {
|
|
132
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
133
|
+
}
|
|
134
|
+
seen.add(current);
|
|
135
|
+
if (Array.isArray(current)) {
|
|
136
|
+
for (const entry of current) visit(entry, depth + 1);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const prototype = Object.getPrototypeOf(current);
|
|
140
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
141
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
142
|
+
}
|
|
143
|
+
for (const entry of Object.values(current as Record<string, unknown>)) {
|
|
144
|
+
visit(entry, depth + 1);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
visit(value, 0);
|
|
148
|
+
const serialized = JSON.stringify(value);
|
|
149
|
+
if (plugins.Buffer.byteLength(serialized, 'utf8') > maxBytes) {
|
|
150
|
+
throw new BrowserRuntimeError('PROTOCOL_ERROR');
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export class TransitionMutex {
|
|
155
|
+
private locked = false;
|
|
156
|
+
private waiters: Array<() => void> = [];
|
|
157
|
+
|
|
158
|
+
public tryAcquire(): (() => void) | undefined {
|
|
159
|
+
if (this.locked) return undefined;
|
|
160
|
+
this.locked = true;
|
|
161
|
+
return this.createRelease();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public async acquire(): Promise<() => void> {
|
|
165
|
+
const immediate = this.tryAcquire();
|
|
166
|
+
if (immediate) return immediate;
|
|
167
|
+
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
168
|
+
return this.createRelease();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public get busy(): boolean {
|
|
172
|
+
return this.locked;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private createRelease(): () => void {
|
|
176
|
+
let released = false;
|
|
177
|
+
return () => {
|
|
178
|
+
if (released) return;
|
|
179
|
+
released = true;
|
|
180
|
+
const next = this.waiters.shift();
|
|
181
|
+
if (next) {
|
|
182
|
+
next();
|
|
183
|
+
} else {
|
|
184
|
+
this.locked = false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|