@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.
Files changed (51) hide show
  1. package/.smartconfig.json +34 -0
  2. package/changelog.md +11 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/actions.d.ts +3 -0
  6. package/dist_ts/actions.js +215 -0
  7. package/dist_ts/classes.artifactstore.d.ts +38 -0
  8. package/dist_ts/classes.artifactstore.js +344 -0
  9. package/dist_ts/classes.egressproxy.d.ts +67 -0
  10. package/dist_ts/classes.egressproxy.js +830 -0
  11. package/dist_ts/classes.flexprovider.d.ts +9 -0
  12. package/dist_ts/classes.flexprovider.js +117 -0
  13. package/dist_ts/classes.framed.d.ts +52 -0
  14. package/dist_ts/classes.framed.js +557 -0
  15. package/dist_ts/classes.runtime.d.ts +202 -0
  16. package/dist_ts/classes.runtime.js +1667 -0
  17. package/dist_ts/confinement.d.ts +2 -0
  18. package/dist_ts/confinement.js +63 -0
  19. package/dist_ts/errors.d.ts +7 -0
  20. package/dist_ts/errors.js +40 -0
  21. package/dist_ts/index.d.ts +11 -0
  22. package/dist_ts/index.js +9 -0
  23. package/dist_ts/interfaces.d.ts +287 -0
  24. package/dist_ts/interfaces.js +2 -0
  25. package/dist_ts/internal.testing.d.ts +9 -0
  26. package/dist_ts/internal.testing.js +2 -0
  27. package/dist_ts/mcp.d.ts +4 -0
  28. package/dist_ts/mcp.js +196 -0
  29. package/dist_ts/plugins.d.ts +20 -0
  30. package/dist_ts/plugins.js +24 -0
  31. package/dist_ts/utils.d.ts +25 -0
  32. package/dist_ts/utils.js +143 -0
  33. package/license.md +21 -0
  34. package/package.json +59 -0
  35. package/readme.hints.md +35 -0
  36. package/readme.md +181 -0
  37. package/ts/00_commitinfo_data.ts +8 -0
  38. package/ts/actions.ts +241 -0
  39. package/ts/classes.artifactstore.ts +432 -0
  40. package/ts/classes.egressproxy.ts +1005 -0
  41. package/ts/classes.flexprovider.ts +134 -0
  42. package/ts/classes.framed.ts +649 -0
  43. package/ts/classes.runtime.ts +2135 -0
  44. package/ts/confinement.ts +90 -0
  45. package/ts/errors.ts +63 -0
  46. package/ts/index.ts +52 -0
  47. package/ts/interfaces.ts +375 -0
  48. package/ts/internal.testing.ts +18 -0
  49. package/ts/mcp.ts +230 -0
  50. package/ts/plugins.ts +28 -0
  51. package/ts/utils.ts +188 -0
@@ -0,0 +1,432 @@
1
+ import * as plugins from './plugins.js';
2
+ import type {
3
+ IBrowserArtifactMetadata,
4
+ IBrowserArtifactStoreOptions,
5
+ } from './interfaces.js';
6
+ import { BrowserRuntimeError } from './errors.js';
7
+ import {
8
+ TransitionMutex,
9
+ randomId,
10
+ validateBoundedString,
11
+ validateOptionalInteger,
12
+ } from './utils.js';
13
+
14
+ interface IStoredArtifact {
15
+ metadata: IBrowserArtifactMetadata;
16
+ digest: string;
17
+ }
18
+
19
+ interface IProjectArtifacts {
20
+ directoryName: string;
21
+ totalBytes: number;
22
+ artifacts: Map<string, IStoredArtifact>;
23
+ }
24
+
25
+ export class BrowserArtifactStore {
26
+ private readonly rootDirectory: string;
27
+ private readonly maxFileBytes: number;
28
+ private readonly maxArtifactsPerProject: number;
29
+ private readonly maxProjectBytes: number;
30
+ private readonly maxProjects: number;
31
+ private readonly maxTotalArtifacts: number;
32
+ private readonly maxTotalBytes: number;
33
+ private readonly artifactTtlMs: number;
34
+ private readonly now: () => number;
35
+ private readonly directoryKey = plugins.crypto.randomBytes(32);
36
+ private readonly projects = new Map<string, IProjectArtifacts>();
37
+ private readonly mutex = new TransitionMutex();
38
+ private totalArtifacts = 0;
39
+ private totalBytes = 0;
40
+ private started = false;
41
+ private startPromise?: Promise<void>;
42
+ private stopPromise?: Promise<void>;
43
+ private sweepTimer?: ReturnType<typeof setInterval>;
44
+ private sweepRunning = false;
45
+
46
+ constructor(options: IBrowserArtifactStoreOptions) {
47
+ if (!options || typeof options !== 'object' || !plugins.path.isAbsolute(options.rootDirectory)) {
48
+ throw new BrowserRuntimeError('INVALID_INPUT', 'rootDirectory must be absolute');
49
+ }
50
+ this.rootDirectory = options.rootDirectory;
51
+ this.maxFileBytes = validateOptionalInteger(
52
+ options.maxFileBytes,
53
+ 'maxFileBytes',
54
+ 1024,
55
+ 64 * 1024 * 1024,
56
+ 8 * 1024 * 1024,
57
+ );
58
+ this.maxArtifactsPerProject = validateOptionalInteger(
59
+ options.maxArtifactsPerProject,
60
+ 'maxArtifactsPerProject',
61
+ 1,
62
+ 4096,
63
+ 256,
64
+ );
65
+ this.maxProjectBytes = validateOptionalInteger(
66
+ options.maxProjectBytes,
67
+ 'maxProjectBytes',
68
+ this.maxFileBytes,
69
+ 1024 * 1024 * 1024,
70
+ 64 * 1024 * 1024,
71
+ );
72
+ this.maxProjects = validateOptionalInteger(
73
+ options.maxProjects,
74
+ 'maxProjects',
75
+ 1,
76
+ 256,
77
+ 32,
78
+ );
79
+ this.maxTotalArtifacts = validateOptionalInteger(
80
+ options.maxTotalArtifacts,
81
+ 'maxTotalArtifacts',
82
+ this.maxArtifactsPerProject,
83
+ 65_536,
84
+ Math.max(4096, this.maxArtifactsPerProject),
85
+ );
86
+ this.maxTotalBytes = validateOptionalInteger(
87
+ options.maxTotalBytes,
88
+ 'maxTotalBytes',
89
+ this.maxProjectBytes,
90
+ 8 * 1024 * 1024 * 1024,
91
+ Math.max(512 * 1024 * 1024, this.maxProjectBytes),
92
+ );
93
+ this.artifactTtlMs = validateOptionalInteger(
94
+ options.artifactTtlMs,
95
+ 'artifactTtlMs',
96
+ 1000,
97
+ 24 * 60 * 60 * 1000,
98
+ 60 * 60 * 1000,
99
+ );
100
+ this.now = options.now ?? Date.now;
101
+ if (typeof this.now !== 'function') throw new BrowserRuntimeError('INVALID_INPUT');
102
+ }
103
+
104
+ public start(): Promise<void> {
105
+ if (this.started) return Promise.resolve();
106
+ if (this.startPromise) return this.startPromise;
107
+ this.startPromise = this.startInternal().finally(() => {
108
+ this.startPromise = undefined;
109
+ });
110
+ return this.startPromise;
111
+ }
112
+
113
+ public stop(): Promise<void> {
114
+ if (this.stopPromise) return this.stopPromise;
115
+ if (!this.started && !this.startPromise) return Promise.resolve();
116
+ this.stopPromise = (async () => {
117
+ await this.startPromise?.catch(() => undefined);
118
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
119
+ this.sweepTimer = undefined;
120
+ const release = await this.mutex.acquire();
121
+ try {
122
+ await plugins.fsPromises.rm(this.rootDirectory, { recursive: true, force: true });
123
+ this.projects.clear();
124
+ this.totalArtifacts = 0;
125
+ this.totalBytes = 0;
126
+ this.started = false;
127
+ } finally {
128
+ release();
129
+ }
130
+ })().finally(() => {
131
+ this.stopPromise = undefined;
132
+ });
133
+ return this.stopPromise;
134
+ }
135
+
136
+ public async store(
137
+ projectIdArg: string,
138
+ mimeTypeArg: string,
139
+ data: Uint8Array,
140
+ ): Promise<IBrowserArtifactMetadata> {
141
+ this.requireStarted();
142
+ const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
143
+ const mimeType = validateBoundedString(mimeTypeArg, 'mimeType', 1, 128);
144
+ if (!(data instanceof Uint8Array)) {
145
+ throw new BrowserRuntimeError('INVALID_INPUT', 'data must be Uint8Array');
146
+ }
147
+ if (data.byteLength > this.maxFileBytes) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
148
+ const bytes = plugins.Buffer.from(data);
149
+ const release = await this.mutex.acquire();
150
+ try {
151
+ this.requireStarted();
152
+ await this.purgeExpiredInternal(projectId);
153
+ const existingProject = this.projects.get(projectId);
154
+ if (!existingProject && this.projects.size >= this.maxProjects) {
155
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
156
+ }
157
+ if (
158
+ this.totalArtifacts >= this.maxTotalArtifacts
159
+ || this.totalBytes + bytes.byteLength > this.maxTotalBytes
160
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
161
+ const project = existingProject ?? await this.createProject(projectId);
162
+ if (
163
+ project.artifacts.size >= this.maxArtifactsPerProject
164
+ || project.totalBytes + bytes.byteLength > this.maxProjectBytes
165
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
166
+
167
+ const artifactId = randomId();
168
+ const createdAt = this.now();
169
+ const metadata: IBrowserArtifactMetadata = {
170
+ artifactId,
171
+ projectId,
172
+ mimeType,
173
+ size: bytes.byteLength,
174
+ createdAt,
175
+ expiresAt: createdAt + this.artifactTtlMs,
176
+ };
177
+ const stored: IStoredArtifact = {
178
+ metadata,
179
+ digest: plugins.crypto.createHash('sha256').update(bytes).digest('hex'),
180
+ };
181
+ const projectDirectory = plugins.path.join(this.rootDirectory, project.directoryName);
182
+ const finalPath = plugins.path.join(projectDirectory, artifactId);
183
+ const temporaryPath = plugins.path.join(projectDirectory, `.${randomId(12)}.tmp`);
184
+ let handle: plugins.fsPromises.FileHandle | undefined;
185
+ let renamed = false;
186
+ try {
187
+ handle = await plugins.fsPromises.open(temporaryPath, 'wx', 0o600);
188
+ await handle.writeFile(bytes);
189
+ await handle.sync();
190
+ await handle.close();
191
+ handle = undefined;
192
+ await plugins.fsPromises.rename(temporaryPath, finalPath);
193
+ renamed = true;
194
+ await plugins.fsPromises.chmod(finalPath, 0o600);
195
+ project.artifacts.set(artifactId, stored);
196
+ project.totalBytes += bytes.byteLength;
197
+ this.totalArtifacts += 1;
198
+ this.totalBytes += bytes.byteLength;
199
+ return { ...metadata };
200
+ } catch (error) {
201
+ await handle?.close().catch(() => undefined);
202
+ await plugins.fsPromises.rm(temporaryPath, { force: true }).catch(() => undefined);
203
+ if (renamed) await plugins.fsPromises.rm(finalPath, { force: true }).catch(() => undefined);
204
+ if (project.artifacts.size === 0) await this.removeEmptyProject(projectId, project);
205
+ throw error;
206
+ }
207
+ } finally {
208
+ release();
209
+ }
210
+ }
211
+
212
+ public async read(projectIdArg: string, artifactIdArg: string): Promise<Uint8Array> {
213
+ this.requireStarted();
214
+ const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
215
+ const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
216
+ const release = await this.mutex.acquire();
217
+ let handle: plugins.fsPromises.FileHandle | undefined;
218
+ try {
219
+ const { project, stored, filePath } = this.resolveArtifact(projectId, artifactId);
220
+ if (stored.metadata.expiresAt <= this.now()) {
221
+ await this.deleteArtifact(projectId, project, stored, filePath);
222
+ throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
223
+ }
224
+ handle = await plugins.fsPromises.open(
225
+ filePath,
226
+ plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW,
227
+ );
228
+ const stat = await handle.stat();
229
+ if (!stat.isFile() || stat.size !== stored.metadata.size || stat.size > this.maxFileBytes) {
230
+ await handle.close();
231
+ handle = undefined;
232
+ await this.deleteArtifact(projectId, project, stored, filePath);
233
+ throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
234
+ }
235
+ const data = plugins.Buffer.allocUnsafe(stat.size);
236
+ const { bytesRead } = await handle.read(data, 0, stat.size, 0);
237
+ await handle.close();
238
+ handle = undefined;
239
+ const digest = plugins.crypto.createHash('sha256').update(data).digest('hex');
240
+ if (bytesRead !== stat.size || digest !== stored.digest) {
241
+ await this.deleteArtifact(projectId, project, stored, filePath);
242
+ throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
243
+ }
244
+ return new Uint8Array(data);
245
+ } finally {
246
+ await handle?.close().catch(() => undefined);
247
+ release();
248
+ }
249
+ }
250
+
251
+ public async delete(projectIdArg: string, artifactIdArg: string): Promise<void> {
252
+ this.requireStarted();
253
+ const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
254
+ const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
255
+ const release = await this.mutex.acquire();
256
+ try {
257
+ const { project, stored, filePath } = this.resolveArtifact(projectId, artifactId);
258
+ await this.deleteArtifact(projectId, project, stored, filePath);
259
+ } finally {
260
+ release();
261
+ }
262
+ }
263
+
264
+ public async purgeExpired(projectIdArg?: string): Promise<number> {
265
+ this.requireStarted();
266
+ const projectId = projectIdArg === undefined
267
+ ? undefined
268
+ : validateBoundedString(projectIdArg, 'projectId', 1, 128);
269
+ const release = await this.mutex.acquire();
270
+ try {
271
+ return await this.purgeExpiredInternal(projectId);
272
+ } finally {
273
+ release();
274
+ }
275
+ }
276
+
277
+ public async purgeProject(projectIdArg: string): Promise<void> {
278
+ this.requireStarted();
279
+ const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
280
+ const release = await this.mutex.acquire();
281
+ try {
282
+ const project = this.projects.get(projectId);
283
+ if (!project) return;
284
+ await plugins.fsPromises.rm(
285
+ plugins.path.join(this.rootDirectory, project.directoryName),
286
+ { recursive: true, force: true },
287
+ );
288
+ this.totalArtifacts -= project.artifacts.size;
289
+ this.totalBytes -= project.totalBytes;
290
+ this.projects.delete(projectId);
291
+ } finally {
292
+ release();
293
+ }
294
+ }
295
+
296
+ public getMetadata(projectIdArg: string, artifactIdArg: string): IBrowserArtifactMetadata {
297
+ this.requireStarted();
298
+ const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
299
+ const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
300
+ const project = this.projects.get(projectId);
301
+ const stored = project?.artifacts.get(artifactId);
302
+ if (!stored || stored.metadata.expiresAt <= this.now()) {
303
+ throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
304
+ }
305
+ try {
306
+ const stat = plugins.fs.lstatSync(
307
+ plugins.path.join(this.rootDirectory, project!.directoryName, artifactId),
308
+ );
309
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== stored.metadata.size) {
310
+ throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
311
+ }
312
+ } catch (error) {
313
+ if (error instanceof BrowserRuntimeError) throw error;
314
+ throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
315
+ }
316
+ return { ...stored.metadata };
317
+ }
318
+
319
+ private async startInternal(): Promise<void> {
320
+ try {
321
+ await plugins.fsPromises.mkdir(this.rootDirectory, { recursive: false, mode: 0o700 });
322
+ } catch (error) {
323
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
324
+ const entries = await plugins.fsPromises.readdir(this.rootDirectory).catch(() => []);
325
+ throw new BrowserRuntimeError(
326
+ 'FENCED',
327
+ entries.length > 0 ? 'artifact root is not empty' : 'artifact root must not exist',
328
+ );
329
+ }
330
+ throw error;
331
+ }
332
+ const stat = await plugins.fsPromises.lstat(this.rootDirectory);
333
+ const uid = process.getuid?.();
334
+ if (
335
+ !stat.isDirectory()
336
+ || stat.isSymbolicLink()
337
+ || (uid !== undefined && stat.uid !== uid)
338
+ || (stat.mode & 0o777) !== 0o700
339
+ ) throw new BrowserRuntimeError('FENCED', 'artifact root is not private');
340
+ const entries = await plugins.fsPromises.readdir(this.rootDirectory);
341
+ if (entries.length > 0) throw new BrowserRuntimeError('FENCED', 'artifact root is not empty');
342
+ await plugins.fsPromises.chmod(this.rootDirectory, 0o700);
343
+ this.started = true;
344
+ const sweepIntervalMs = Math.min(60_000, Math.max(1000, Math.floor(this.artifactTtlMs / 2)));
345
+ this.sweepTimer = setInterval(() => {
346
+ if (this.sweepRunning) return;
347
+ this.sweepRunning = true;
348
+ void this.purgeExpired().catch(() => undefined).finally(() => {
349
+ this.sweepRunning = false;
350
+ });
351
+ }, sweepIntervalMs);
352
+ this.sweepTimer.unref();
353
+ }
354
+
355
+ private requireStarted(): void {
356
+ if (!this.started) throw new BrowserRuntimeError('NOT_RUNNING');
357
+ }
358
+
359
+ private async createProject(projectId: string): Promise<IProjectArtifacts> {
360
+ const directoryName = plugins.crypto
361
+ .createHmac('sha256', this.directoryKey)
362
+ .update(projectId, 'utf8')
363
+ .digest('hex');
364
+ const projectDirectory = plugins.path.join(this.rootDirectory, directoryName);
365
+ await plugins.fsPromises.mkdir(projectDirectory, { recursive: false, mode: 0o700 });
366
+ const project = { directoryName, totalBytes: 0, artifacts: new Map() };
367
+ this.projects.set(projectId, project);
368
+ return project;
369
+ }
370
+
371
+ private async purgeExpiredInternal(projectId?: string): Promise<number> {
372
+ const projectIds = projectId === undefined ? [...this.projects.keys()] : [projectId];
373
+ let purged = 0;
374
+ for (const currentProjectId of projectIds) {
375
+ const project = this.projects.get(currentProjectId);
376
+ if (!project) continue;
377
+ for (const stored of [...project.artifacts.values()]) {
378
+ if (stored.metadata.expiresAt <= this.now()) {
379
+ const filePath = plugins.path.join(
380
+ this.rootDirectory,
381
+ project.directoryName,
382
+ stored.metadata.artifactId,
383
+ );
384
+ await this.deleteArtifact(currentProjectId, project, stored, filePath);
385
+ purged += 1;
386
+ }
387
+ }
388
+ }
389
+ return purged;
390
+ }
391
+
392
+ private async deleteArtifact(
393
+ projectId: string,
394
+ project: IProjectArtifacts,
395
+ stored: IStoredArtifact,
396
+ filePath: string,
397
+ ): Promise<void> {
398
+ await plugins.fsPromises.rm(filePath, { force: true });
399
+ if (project.artifacts.delete(stored.metadata.artifactId)) {
400
+ project.totalBytes = Math.max(0, project.totalBytes - stored.metadata.size);
401
+ this.totalArtifacts = Math.max(0, this.totalArtifacts - 1);
402
+ this.totalBytes = Math.max(0, this.totalBytes - stored.metadata.size);
403
+ }
404
+ await this.removeEmptyProject(projectId, project);
405
+ }
406
+
407
+ private async removeEmptyProject(projectId: string, project: IProjectArtifacts): Promise<void> {
408
+ if (project.artifacts.size > 0 || this.projects.get(projectId) !== project) return;
409
+ await plugins.fsPromises.rm(
410
+ plugins.path.join(this.rootDirectory, project.directoryName),
411
+ { recursive: true, force: true },
412
+ );
413
+ this.projects.delete(projectId);
414
+ }
415
+
416
+ private resolveArtifact(projectId: string, artifactId: string): {
417
+ project: IProjectArtifacts;
418
+ stored: IStoredArtifact;
419
+ filePath: string;
420
+ } {
421
+ const project = this.projects.get(projectId);
422
+ const stored = project?.artifacts.get(artifactId);
423
+ if (!project || !stored) {
424
+ throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
425
+ }
426
+ return {
427
+ project,
428
+ stored,
429
+ filePath: plugins.path.join(this.rootDirectory, project.directoryName, artifactId),
430
+ };
431
+ }
432
+ }