@modelprofile.com/browser-runtime 5.0.0 → 5.2.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/changelog.md +23 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.artifactstore.d.ts +4 -3
- package/dist_ts/classes.artifactstore.js +19 -5
- package/dist_ts/classes.runtime.d.ts +26 -4
- package/dist_ts/classes.runtime.js +183 -38
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/interfaces.d.ts +14 -1
- package/package.json +4 -5
- package/readme.md +12 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.artifactstore.ts +19 -1
- package/ts/classes.runtime.ts +191 -36
- package/ts/index.ts +2 -0
- package/ts/interfaces.ts +17 -0
- package/readme.hints.md +0 -60
package/ts/classes.runtime.ts
CHANGED
|
@@ -29,6 +29,8 @@ import type {
|
|
|
29
29
|
IBrowserResourceRegistration,
|
|
30
30
|
IBrowserRuntimeState,
|
|
31
31
|
IBrowserRuntimeViewportResult,
|
|
32
|
+
IBrowserRuntimeEventSubscriptionOptions,
|
|
33
|
+
TBrowserRuntimeVideoOffer,
|
|
32
34
|
IBrowserScreenshotResult,
|
|
33
35
|
ICreateBrowserResourceRequest,
|
|
34
36
|
TQualifiedBrowserSessionId,
|
|
@@ -102,6 +104,7 @@ interface INormalizedRuntimeOptions {
|
|
|
102
104
|
maxOutstandingFrames: number;
|
|
103
105
|
frameAcknowledgementTimeoutMs: number;
|
|
104
106
|
screencast: INormalizedScreencastOptions;
|
|
107
|
+
video: Required<plugins.smartpuppeteer.ILiveVideoOptions>;
|
|
105
108
|
egress: NonNullable<IBrowserRuntimeOptions['egress']>;
|
|
106
109
|
ownership?: IBrowserRuntimeTestingOptions['ownership'];
|
|
107
110
|
beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
|
|
@@ -138,6 +141,9 @@ interface ILeaseRecord {
|
|
|
138
141
|
released: boolean;
|
|
139
142
|
releasePromise?: Promise<void>;
|
|
140
143
|
preferredViewport?: plugins.smartpuppeteer.ILiveBrowserViewport;
|
|
144
|
+
videoPeerId: string;
|
|
145
|
+
videoPeerMayExist: boolean;
|
|
146
|
+
frameCaptureUsed: boolean;
|
|
141
147
|
heldKeys: Map<string, plugins.smartpuppeteer.ILiveBrowserKeyInput>;
|
|
142
148
|
heldButtons: Map<string, plugins.smartpuppeteer.ILiveBrowserMouseInput>;
|
|
143
149
|
}
|
|
@@ -175,7 +181,7 @@ interface IQueuedOperationRecord {
|
|
|
175
181
|
state: 'queued' | 'starting' | 'active' | 'settled';
|
|
176
182
|
onQueuedAbort?: () => void;
|
|
177
183
|
/** Resource-owned cleanup, admitted only for this exact released participant. */
|
|
178
|
-
participantCleanup?:
|
|
184
|
+
participantCleanup?: 'participant' | 'subscription' | false;
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
interface IOutstandingFrameRecord {
|
|
@@ -194,6 +200,10 @@ type TFrameAcknowledgementOutcome =
|
|
|
194
200
|
interface IFrameSubscriptionRecord {
|
|
195
201
|
lease: ILeaseRecord;
|
|
196
202
|
listener: (event: TBrowserRuntimeEvent) => void;
|
|
203
|
+
includeFrames: boolean;
|
|
204
|
+
session: ILiveBrowserSessionLike;
|
|
205
|
+
closePromise?: Promise<void>;
|
|
206
|
+
closeComplete?: boolean;
|
|
197
207
|
outstanding: Map<string, IOutstandingFrameRecord>;
|
|
198
208
|
highestSequence: number;
|
|
199
209
|
closed: boolean;
|
|
@@ -240,6 +250,7 @@ interface IResourceSlot {
|
|
|
240
250
|
operationQueue: IQueuedOperationRecord[];
|
|
241
251
|
operationSchedulerRunning: boolean;
|
|
242
252
|
frameSubscriptions: Map<string, IFrameSubscriptionRecord>;
|
|
253
|
+
frameCaptureEnabled?: boolean;
|
|
243
254
|
highestFrameSequence: number;
|
|
244
255
|
producerAcknowledgements: Set<Promise<TFrameAcknowledgementOutcome>>;
|
|
245
256
|
frameRefresh?: IFrameRefreshRecord;
|
|
@@ -558,6 +569,7 @@ export class BrowserRuntime {
|
|
|
558
569
|
10_000,
|
|
559
570
|
),
|
|
560
571
|
screencast,
|
|
572
|
+
video: plugins.smartpuppeteer.normalizeLiveVideoOptions(options.video ?? {}),
|
|
561
573
|
egress: options.egress ?? {},
|
|
562
574
|
ownership: testingOptions?.ownership,
|
|
563
575
|
beforeLeasePublication: testingOptions?.beforeLeasePublication,
|
|
@@ -1352,6 +1364,46 @@ export class BrowserRuntime {
|
|
|
1352
1364
|
});
|
|
1353
1365
|
}
|
|
1354
1366
|
|
|
1367
|
+
/** @internal */
|
|
1368
|
+
public async leaseOpenVideoPeer(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<TBrowserRuntimeVideoOffer> {
|
|
1369
|
+
this.requireHuman(lease);
|
|
1370
|
+
return this.runOperation(lease, 'openVideoPeer', 'video-peer', options, async (signal, session) => {
|
|
1371
|
+
lease.videoPeerMayExist = true;
|
|
1372
|
+
const { peerId: _peerId, ...offer } = await session.openVideoPeer(lease.videoPeerId, { signal });
|
|
1373
|
+
return offer;
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** @internal */
|
|
1378
|
+
public async leaseAnswerVideoPeer(lease: ILeaseRecord, negotiationId: string,
|
|
1379
|
+
description: plugins.smartpuppeteer.ILiveVideoDescription, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
|
|
1380
|
+
this.requireHuman(lease);
|
|
1381
|
+
const id = validateBoundedString(negotiationId, 'negotiationId', 1, 128);
|
|
1382
|
+
if (id !== negotiationId) throw new BrowserRuntimeError('INVALID_INPUT');
|
|
1383
|
+
const record = validateExactKeys(description, ['type', 'sdp'], 'video answer');
|
|
1384
|
+
const answer = { type: record.type, sdp: record.sdp } as plugins.smartpuppeteer.ILiveVideoDescription;
|
|
1385
|
+
try { plugins.smartpuppeteer.validateLiveVideoDescription(answer, 'answer'); }
|
|
1386
|
+
catch { throw new BrowserRuntimeError('INVALID_INPUT'); }
|
|
1387
|
+
return this.runOperation(lease, 'answerVideoPeer', 'video-peer', options,
|
|
1388
|
+
async (signal, session) => session.answerVideoPeer(lease.videoPeerId, id, answer, { signal }));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** @internal */
|
|
1392
|
+
public async leaseCloseVideoPeer(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
|
|
1393
|
+
this.requireHuman(lease);
|
|
1394
|
+
return this.runOperation(lease, 'closeVideoPeer', 'video-peer', options, async (_signal, session) => {
|
|
1395
|
+
await session.closeVideoPeer(lease.videoPeerId);
|
|
1396
|
+
lease.videoPeerMayExist = false;
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** @internal */
|
|
1401
|
+
public async leaseGetVideoStatistics(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<plugins.smartpuppeteer.ILiveVideoStatistics> {
|
|
1402
|
+
this.requireHuman(lease);
|
|
1403
|
+
return this.runOperation(lease, 'getVideoStatistics', 'video-peer', options,
|
|
1404
|
+
async (_signal, session) => session.getVideoStatistics(lease.videoPeerId));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1355
1407
|
private validatePreferredViewport(
|
|
1356
1408
|
value: plugins.smartpuppeteer.ILiveBrowserViewport,
|
|
1357
1409
|
): plugins.smartpuppeteer.ILiveBrowserViewport {
|
|
@@ -1402,7 +1454,7 @@ export class BrowserRuntime {
|
|
|
1402
1454
|
this.requireValidLease(lease);
|
|
1403
1455
|
const slot = lease.slot;
|
|
1404
1456
|
const subscription = slot.frameSubscriptions.get(lease.leaseId);
|
|
1405
|
-
if (!subscription || subscription.closed || subscription.lease !== lease) {
|
|
1457
|
+
if (!subscription || subscription.closed || !subscription.includeFrames || subscription.lease !== lease) {
|
|
1406
1458
|
throw new BrowserRuntimeError('BUSY');
|
|
1407
1459
|
}
|
|
1408
1460
|
let refresh: IFrameRefreshRecord | undefined;
|
|
@@ -1599,34 +1651,65 @@ export class BrowserRuntime {
|
|
|
1599
1651
|
public async subscribeLeaseEvents(
|
|
1600
1652
|
lease: ILeaseRecord,
|
|
1601
1653
|
listener: (event: TBrowserRuntimeEvent) => void,
|
|
1654
|
+
options: IBrowserRuntimeEventSubscriptionOptions = {},
|
|
1602
1655
|
): Promise<IBrowserRuntimeFrameSubscription> {
|
|
1603
1656
|
this.requireHuman(lease);
|
|
1604
1657
|
if (typeof listener !== 'function') throw new BrowserRuntimeError('INVALID_INPUT');
|
|
1658
|
+
const values = validateExactKeys(options, ['includeFrames'], 'event subscription');
|
|
1659
|
+
if (values.includeFrames !== undefined && typeof values.includeFrames !== 'boolean') {
|
|
1660
|
+
throw new BrowserRuntimeError('INVALID_INPUT');
|
|
1661
|
+
}
|
|
1605
1662
|
const slot = lease.slot;
|
|
1606
1663
|
const release = slot.mutex.tryAcquire();
|
|
1607
1664
|
if (!release) throw new BrowserRuntimeError('BUSY');
|
|
1608
|
-
let subscription: IFrameSubscriptionRecord;
|
|
1609
1665
|
try {
|
|
1610
1666
|
this.requireValidLease(lease);
|
|
1611
|
-
if (
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1667
|
+
if (values.includeFrames === false) {
|
|
1668
|
+
if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
|
|
1669
|
+
const subscription: IFrameSubscriptionRecord = {
|
|
1670
|
+
lease, listener, includeFrames: false, session: slot.session!,
|
|
1671
|
+
outstanding: new Map(), highestSequence: 0, closed: false,
|
|
1672
|
+
};
|
|
1673
|
+
slot.frameSubscriptions.set(lease.leaseId, subscription);
|
|
1674
|
+
this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(slot.session!.getState()) });
|
|
1675
|
+
return { close: () => this.closeFrameSubscription(subscription) };
|
|
1676
|
+
}
|
|
1677
|
+
} finally { release(); }
|
|
1678
|
+
let subscription: IFrameSubscriptionRecord | undefined;
|
|
1679
|
+
try {
|
|
1680
|
+
return await this.runOperation(lease, 'subscribeEvents', 'frame-stream', {}, async (signal, session) => {
|
|
1681
|
+
if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
|
|
1682
|
+
subscription = {
|
|
1683
|
+
lease, listener, includeFrames: values.includeFrames !== false, session,
|
|
1684
|
+
outstanding: new Map(), highestSequence: 0, closed: false,
|
|
1685
|
+
};
|
|
1686
|
+
slot.frameSubscriptions.set(lease.leaseId, subscription);
|
|
1687
|
+
if (subscription.includeFrames) {
|
|
1688
|
+
lease.frameCaptureUsed = true;
|
|
1689
|
+
await this.reconcileFrameCapture(slot, session, signal);
|
|
1690
|
+
}
|
|
1691
|
+
this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(session.getState()) });
|
|
1692
|
+
const ownedSubscription = subscription;
|
|
1693
|
+
return { close: () => this.closeFrameSubscription(ownedSubscription) };
|
|
1623
1694
|
});
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
if (subscription) {
|
|
1697
|
+
this.detachFrameSubscription(subscription);
|
|
1698
|
+
await this.revokeCapabilityRecord(lease.capability);
|
|
1699
|
+
}
|
|
1700
|
+
throw error;
|
|
1626
1701
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
private async reconcileFrameCapture(slot: IResourceSlot, session: ILiveBrowserSessionLike, signal: AbortSignal): Promise<void> {
|
|
1705
|
+
const enabled = [...slot.frameSubscriptions.values()].some((subscription) => (
|
|
1706
|
+
!subscription.closed && !subscription.lease.released && subscription.includeFrames
|
|
1707
|
+
));
|
|
1708
|
+
if (slot.frameCaptureEnabled === enabled) return;
|
|
1709
|
+
// A failed/aborted CDP transition may have reached the browser; cleanup must reconcile it.
|
|
1710
|
+
slot.frameCaptureEnabled = undefined;
|
|
1711
|
+
await session.setFrameCaptureEnabled(enabled, { signal });
|
|
1712
|
+
slot.frameCaptureEnabled = enabled;
|
|
1630
1713
|
}
|
|
1631
1714
|
|
|
1632
1715
|
/** @internal */
|
|
@@ -1649,23 +1732,30 @@ export class BrowserRuntime {
|
|
|
1649
1732
|
lease: ILeaseRecord,
|
|
1650
1733
|
artifactId: string,
|
|
1651
1734
|
): Promise<Uint8Array> {
|
|
1652
|
-
this.requireHuman(lease);
|
|
1653
1735
|
this.requireValidLease(lease);
|
|
1654
|
-
|
|
1736
|
+
const bytes = await this.requireArtifactStore().read(
|
|
1655
1737
|
lease.capability.projectId,
|
|
1656
1738
|
lease.capability.browserResourceId,
|
|
1657
1739
|
artifactId,
|
|
1740
|
+
lease.role === 'agent' ? lease.leaseId : undefined,
|
|
1658
1741
|
);
|
|
1742
|
+
try {
|
|
1743
|
+
this.requireValidLease(lease);
|
|
1744
|
+
return bytes;
|
|
1745
|
+
} catch (errorArg) {
|
|
1746
|
+
bytes.fill(0);
|
|
1747
|
+
throw errorArg;
|
|
1748
|
+
}
|
|
1659
1749
|
}
|
|
1660
1750
|
|
|
1661
1751
|
/** @internal */
|
|
1662
1752
|
public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
|
|
1663
|
-
this.requireHuman(lease);
|
|
1664
1753
|
this.requireValidLease(lease);
|
|
1665
1754
|
await this.requireArtifactStore().delete(
|
|
1666
1755
|
lease.capability.projectId,
|
|
1667
1756
|
lease.capability.browserResourceId,
|
|
1668
1757
|
artifactId,
|
|
1758
|
+
lease.role === 'agent' ? lease.leaseId : undefined,
|
|
1669
1759
|
);
|
|
1670
1760
|
}
|
|
1671
1761
|
|
|
@@ -1884,6 +1974,9 @@ export class BrowserRuntime {
|
|
|
1884
1974
|
controller: new AbortController(),
|
|
1885
1975
|
authorityGeneration: slot.authorityGeneration,
|
|
1886
1976
|
released: false,
|
|
1977
|
+
videoPeerId: randomId(24),
|
|
1978
|
+
videoPeerMayExist: false,
|
|
1979
|
+
frameCaptureUsed: false,
|
|
1887
1980
|
heldKeys: new Map(),
|
|
1888
1981
|
heldButtons: new Map(),
|
|
1889
1982
|
};
|
|
@@ -1919,8 +2012,9 @@ export class BrowserRuntime {
|
|
|
1919
2012
|
forceNoSandbox: false,
|
|
1920
2013
|
usePipe: true,
|
|
1921
2014
|
allowEvaluation: true,
|
|
2015
|
+
video: this.options.video,
|
|
1922
2016
|
screencast: {
|
|
1923
|
-
maxOutstandingFrames: this.options.maxOutstandingFrames,
|
|
2017
|
+
enabled: false, maxOutstandingFrames: this.options.maxOutstandingFrames,
|
|
1924
2018
|
quality: this.options.screencast.quality,
|
|
1925
2019
|
maxWidth: this.options.screencast.maxWidth,
|
|
1926
2020
|
maxHeight: this.options.screencast.maxHeight,
|
|
@@ -1938,7 +2032,6 @@ export class BrowserRuntime {
|
|
|
1938
2032
|
'--disable-quic',
|
|
1939
2033
|
'--disable-dns-prefetch',
|
|
1940
2034
|
'--disable-background-networking',
|
|
1941
|
-
'--disable-extensions',
|
|
1942
2035
|
'--webrtc-ip-handling-policy=disable_non_proxied_udp',
|
|
1943
2036
|
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
1944
2037
|
],
|
|
@@ -1991,6 +2084,7 @@ export class BrowserRuntime {
|
|
|
1991
2084
|
}
|
|
1992
2085
|
if (probeSignal.aborted) throw probeSignal.reason;
|
|
1993
2086
|
slot.session = session;
|
|
2087
|
+
slot.frameCaptureEnabled = false;
|
|
1994
2088
|
slot.proxy = proxy;
|
|
1995
2089
|
slot.profileDirectory = profileDirectory;
|
|
1996
2090
|
slot.unsubscribeSession = unsubscribe;
|
|
@@ -2052,7 +2146,7 @@ export class BrowserRuntime {
|
|
|
2052
2146
|
operationOptions: IBrowserRuntimeOperationOptions,
|
|
2053
2147
|
execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
|
|
2054
2148
|
finalize?: () => Promise<void>,
|
|
2055
|
-
participantCleanup = false,
|
|
2149
|
+
participantCleanup: 'participant' | 'subscription' | false = false,
|
|
2056
2150
|
): Promise<T> {
|
|
2057
2151
|
if (!operationOptions || typeof operationOptions !== 'object') {
|
|
2058
2152
|
throw new BrowserRuntimeError('INVALID_INPUT');
|
|
@@ -2564,6 +2658,7 @@ export class BrowserRuntime {
|
|
|
2564
2658
|
lease.capability.browserResourceId,
|
|
2565
2659
|
snapshot.mimeType,
|
|
2566
2660
|
snapshot.data,
|
|
2661
|
+
lease.leaseId,
|
|
2567
2662
|
);
|
|
2568
2663
|
return { action: 'screenshot', artifact } satisfies IBrowserScreenshotResult;
|
|
2569
2664
|
} finally {
|
|
@@ -2614,6 +2709,7 @@ export class BrowserRuntime {
|
|
|
2614
2709
|
|
|
2615
2710
|
private resourceState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
|
|
2616
2711
|
return {
|
|
2712
|
+
...(state.videoAcceleration ? { videoAcceleration: { ...state.videoAcceleration } } : {}),
|
|
2617
2713
|
status: state.status,
|
|
2618
2714
|
activeTabId: state.activeTabId ? truncateString(state.activeTabId, 128) : null,
|
|
2619
2715
|
viewportRevision: state.viewportRevision,
|
|
@@ -2657,6 +2753,7 @@ export class BrowserRuntime {
|
|
|
2657
2753
|
slot.frameRefresh
|
|
2658
2754
|
|| slot.frameSubscriptions.get(lease.leaseId) !== subscription
|
|
2659
2755
|
|| subscription.closed
|
|
2756
|
+
|| !subscription.includeFrames
|
|
2660
2757
|
|| subscription.lease !== lease
|
|
2661
2758
|
|| slot.leases.get(lease.leaseId) !== lease
|
|
2662
2759
|
|| slot.session !== session
|
|
@@ -2938,6 +3035,7 @@ export class BrowserRuntime {
|
|
|
2938
3035
|
if (event.frame.data.byteLength > this.options.maxFrameBytes) {
|
|
2939
3036
|
if (refresh) this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
|
|
2940
3037
|
for (const subscription of slot.frameSubscriptions.values()) {
|
|
3038
|
+
if (!subscription.includeFrames) continue;
|
|
2941
3039
|
this.pushToSubscription(subscription, {
|
|
2942
3040
|
type: 'error',
|
|
2943
3041
|
error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: acknowledgement.tabId },
|
|
@@ -2956,7 +3054,7 @@ export class BrowserRuntime {
|
|
|
2956
3054
|
}
|
|
2957
3055
|
const key = this.frameAcknowledgementKey(acknowledgement);
|
|
2958
3056
|
for (const subscription of [...slot.frameSubscriptions.values()]) {
|
|
2959
|
-
if (subscription.closed) continue;
|
|
3057
|
+
if (subscription.closed || !subscription.includeFrames) continue;
|
|
2960
3058
|
while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
|
|
2961
3059
|
const oldestKey = subscription.outstanding.keys().next().value!;
|
|
2962
3060
|
this.trackCleanup(this.retireOutstandingFrame(subscription, oldestKey).then(() => undefined));
|
|
@@ -3019,20 +3117,53 @@ export class BrowserRuntime {
|
|
|
3019
3117
|
return true;
|
|
3020
3118
|
} catch {
|
|
3021
3119
|
// A failed participant transport cannot revoke another participant or kill the producer.
|
|
3022
|
-
this.
|
|
3120
|
+
this.detachFrameSubscription(subscription);
|
|
3023
3121
|
this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
|
|
3024
3122
|
return false;
|
|
3025
3123
|
}
|
|
3026
3124
|
}
|
|
3027
3125
|
|
|
3028
3126
|
private async closeFrameSubscription(subscription: IFrameSubscriptionRecord): Promise<void> {
|
|
3029
|
-
if (subscription.
|
|
3127
|
+
if (subscription.closeComplete) return;
|
|
3128
|
+
if (subscription.closePromise) return subscription.closePromise;
|
|
3129
|
+
this.detachFrameSubscription(subscription, false);
|
|
3130
|
+
const lease = subscription.lease;
|
|
3131
|
+
const slot = lease.slot;
|
|
3132
|
+
const operation = (async () => {
|
|
3133
|
+
if (subscription.includeFrames && slot.session === subscription.session) {
|
|
3134
|
+
if (lease.released) await this.releaseLeaseRecord(lease);
|
|
3135
|
+
else if (this.lifecycleState === 'running' && !slot.permanentlyFenced && !slot.retirementPending
|
|
3136
|
+
&& slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined) {
|
|
3137
|
+
await this.runOperation(lease, 'unsubscribeFrames', 'frame-stream', {}, async (signal, session) => {
|
|
3138
|
+
await this.reconcileFrameCapture(slot, session, signal);
|
|
3139
|
+
this.detachFrameSubscription(subscription);
|
|
3140
|
+
subscription.closeComplete = true;
|
|
3141
|
+
}, undefined, 'subscription');
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
this.detachFrameSubscription(subscription);
|
|
3145
|
+
subscription.closeComplete = true;
|
|
3146
|
+
})();
|
|
3147
|
+
subscription.closePromise = operation;
|
|
3148
|
+
try { await operation; }
|
|
3149
|
+
catch (error) {
|
|
3150
|
+
// Producer failure may supersede a queued unsubscribe. Its exact incarnation's
|
|
3151
|
+
// confirmed termination completes capture cleanup without issuing another toggle.
|
|
3152
|
+
const failure = slot.frameFailure;
|
|
3153
|
+
if (failure?.session === subscription.session) await failure.promise;
|
|
3154
|
+
if (slot.session === subscription.session || !lease.released) throw error;
|
|
3155
|
+
this.detachFrameSubscription(subscription);
|
|
3156
|
+
subscription.closeComplete = true;
|
|
3157
|
+
} finally { subscription.closePromise = undefined; }
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
private detachFrameSubscription(subscription: IFrameSubscriptionRecord, remove = true): void {
|
|
3030
3161
|
subscription.closed = true;
|
|
3031
3162
|
const slot = subscription.lease.slot;
|
|
3032
3163
|
if (slot.frameRefresh?.subscription === subscription) {
|
|
3033
3164
|
this.abandonFrameRefresh(slot, slot.frameRefresh);
|
|
3034
3165
|
}
|
|
3035
|
-
if (slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
|
|
3166
|
+
if (remove && slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
|
|
3036
3167
|
slot.frameSubscriptions.delete(subscription.lease.leaseId);
|
|
3037
3168
|
}
|
|
3038
3169
|
for (const frame of subscription.outstanding.values()) {
|
|
@@ -3191,10 +3322,10 @@ export class BrowserRuntime {
|
|
|
3191
3322
|
this.trackCleanup(operation);
|
|
3192
3323
|
}
|
|
3193
3324
|
|
|
3194
|
-
private requireQueuedLease(lease: ILeaseRecord, participantCleanup = false): void {
|
|
3325
|
+
private requireQueuedLease(lease: ILeaseRecord, participantCleanup: 'participant' | 'subscription' | false = false): void {
|
|
3195
3326
|
if (!participantCleanup) return this.requireValidLease(lease);
|
|
3196
3327
|
if (
|
|
3197
|
-
!lease.released || lease.slot.leases.get(lease.leaseId) !== lease
|
|
3328
|
+
(participantCleanup === 'participant' && !lease.released) || lease.slot.leases.get(lease.leaseId) !== lease
|
|
3198
3329
|
|| lease.capability.lease !== lease || !lease.slot.session
|
|
3199
3330
|
) throw new BrowserRuntimeError('CAPABILITY_REVOKED');
|
|
3200
3331
|
}
|
|
@@ -3359,14 +3490,20 @@ export class BrowserRuntime {
|
|
|
3359
3490
|
if (
|
|
3360
3491
|
this.lifecycleState === 'running' && slot.session && !slot.permanentlyFenced && !slot.retirementPending
|
|
3361
3492
|
&& slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined
|
|
3362
|
-
&& (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size
|
|
3493
|
+
&& (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size
|
|
3494
|
+
|| lease.videoPeerMayExist || lease.frameCaptureUsed)
|
|
3363
3495
|
) {
|
|
3364
3496
|
await this.runOperation(lease, 'releaseParticipant', 'viewport', {},
|
|
3365
3497
|
async (signal, session) => {
|
|
3498
|
+
if (lease.videoPeerMayExist) {
|
|
3499
|
+
await session.closeVideoPeer(lease.videoPeerId);
|
|
3500
|
+
lease.videoPeerMayExist = false;
|
|
3501
|
+
}
|
|
3502
|
+
if (lease.frameCaptureUsed) await this.reconcileFrameCapture(slot, session, signal);
|
|
3366
3503
|
await this.releaseParticipantInput(lease, session);
|
|
3367
3504
|
await this.applySharedViewport(slot, session, signal);
|
|
3368
3505
|
lease.preferredViewport = undefined;
|
|
3369
|
-
}, undefined,
|
|
3506
|
+
}, undefined, 'participant');
|
|
3370
3507
|
}
|
|
3371
3508
|
if (slot.permanentlyFenced && (slot.session || slot.proxy || slot.profileDirectory)) {
|
|
3372
3509
|
await this.terminateSlotSession(slot);
|
|
@@ -3384,7 +3521,7 @@ export class BrowserRuntime {
|
|
|
3384
3521
|
|
|
3385
3522
|
private async closeFrameSubscriptionIfLease(lease: ILeaseRecord): Promise<void> {
|
|
3386
3523
|
const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
|
|
3387
|
-
if (subscription)
|
|
3524
|
+
if (subscription) this.detachFrameSubscription(subscription);
|
|
3388
3525
|
}
|
|
3389
3526
|
|
|
3390
3527
|
private async quiesceSlot(slot: IResourceSlot): Promise<void> {
|
|
@@ -4235,8 +4372,26 @@ export class BrowserRuntimeLease {
|
|
|
4235
4372
|
|
|
4236
4373
|
public subscribeEvents(
|
|
4237
4374
|
listener: (event: TBrowserRuntimeEvent) => void,
|
|
4375
|
+
options?: IBrowserRuntimeEventSubscriptionOptions,
|
|
4238
4376
|
): Promise<IBrowserRuntimeFrameSubscription> {
|
|
4239
|
-
return this.runtime.subscribeLeaseEvents(this.record, listener);
|
|
4377
|
+
return this.runtime.subscribeLeaseEvents(this.record, listener, options);
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
public openVideoPeer(options?: IBrowserRuntimeOperationOptions): Promise<TBrowserRuntimeVideoOffer> {
|
|
4381
|
+
return this.runtime.leaseOpenVideoPeer(this.record, options);
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
public answerVideoPeer(negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
|
|
4385
|
+
options?: IBrowserRuntimeOperationOptions): Promise<void> {
|
|
4386
|
+
return this.runtime.leaseAnswerVideoPeer(this.record, negotiationId, description, options);
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
public closeVideoPeer(options?: IBrowserRuntimeOperationOptions): Promise<void> {
|
|
4390
|
+
return this.runtime.leaseCloseVideoPeer(this.record, options);
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
public getVideoStatistics(options?: IBrowserRuntimeOperationOptions): Promise<plugins.smartpuppeteer.ILiveVideoStatistics> {
|
|
4394
|
+
return this.runtime.leaseGetVideoStatistics(this.record, options);
|
|
4240
4395
|
}
|
|
4241
4396
|
|
|
4242
4397
|
public acknowledgeFrame(
|
package/ts/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export type {
|
|
|
48
48
|
IBrowserRuntimeOperationOptions,
|
|
49
49
|
IBrowserRuntimeOptions,
|
|
50
50
|
IBrowserRuntimeScreencastOptions,
|
|
51
|
+
IBrowserRuntimeEventSubscriptionOptions,
|
|
52
|
+
TBrowserRuntimeVideoOffer,
|
|
51
53
|
IBrowserResourceKey,
|
|
52
54
|
IBrowserResourceRegistration,
|
|
53
55
|
IBrowserRuntimeState,
|
package/ts/interfaces.ts
CHANGED
|
@@ -133,6 +133,7 @@ export type TBrowserRuntimeOperationIdentity = TReadonlyBrowserCapabilityAuthori
|
|
|
133
133
|
export type TBrowserRuntimeOperationClassification =
|
|
134
134
|
| 'raw-input'
|
|
135
135
|
| 'frame-stream'
|
|
136
|
+
| 'video-peer'
|
|
136
137
|
| 'viewport'
|
|
137
138
|
| 'navigation'
|
|
138
139
|
| 'tab'
|
|
@@ -167,6 +168,12 @@ export interface ILiveBrowserSessionLike {
|
|
|
167
168
|
getState(): plugins.smartpuppeteer.ILiveBrowserState;
|
|
168
169
|
getProcessState(): plugins.smartpuppeteer.ILiveBrowserProcessState;
|
|
169
170
|
onEvent(listener: plugins.smartpuppeteer.TLiveBrowserEventListener): () => void;
|
|
171
|
+
setFrameCaptureEnabled(enabled: boolean, options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
|
|
172
|
+
openVideoPeer(peerId: string, options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<plugins.smartpuppeteer.ILiveVideoOffer>;
|
|
173
|
+
answerVideoPeer(peerId: string, negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
|
|
174
|
+
options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
|
|
175
|
+
closeVideoPeer(peerId: string): Promise<void>;
|
|
176
|
+
getVideoStatistics(peerId: string): Promise<plugins.smartpuppeteer.ILiveVideoStatistics>;
|
|
170
177
|
refreshScreencast(
|
|
171
178
|
options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions,
|
|
172
179
|
): Promise<plugins.smartpuppeteer.ILiveBrowserFrameIdentity>;
|
|
@@ -291,6 +298,8 @@ export interface IBrowserRuntimeOptions {
|
|
|
291
298
|
maxOutstandingFrames?: number;
|
|
292
299
|
frameAcknowledgementTimeoutMs?: number;
|
|
293
300
|
screencast?: IBrowserRuntimeScreencastOptions;
|
|
301
|
+
/** Trusted host configuration; defaults to direct ICE and automatic GPU support. */
|
|
302
|
+
video?: plugins.smartpuppeteer.ILiveVideoOptions;
|
|
294
303
|
egress?: Omit<IBrowserEgressProxyOptions, 'projectId' | 'browserResourceId'>;
|
|
295
304
|
artifacts?: Omit<IBrowserArtifactStoreOptions, 'rootDirectory'>;
|
|
296
305
|
}
|
|
@@ -306,7 +315,15 @@ export interface IBrowserRuntimeTabState {
|
|
|
306
315
|
streaming: boolean;
|
|
307
316
|
}
|
|
308
317
|
|
|
318
|
+
export interface IBrowserRuntimeEventSubscriptionOptions {
|
|
319
|
+
/** Set false for native video viewers. Defaults to true for image-frame consumers. */
|
|
320
|
+
includeFrames?: boolean;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type TBrowserRuntimeVideoOffer = Omit<plugins.smartpuppeteer.ILiveVideoOffer, 'peerId'>;
|
|
324
|
+
|
|
309
325
|
export interface IBrowserRuntimeState {
|
|
326
|
+
videoAcceleration?: plugins.smartpuppeteer.ILiveVideoAcceleration;
|
|
310
327
|
status: 'stopped' | 'starting' | 'running' | 'stopping';
|
|
311
328
|
activeTabId: string | null;
|
|
312
329
|
viewportRevision: number;
|
package/readme.hints.md
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# readme.hints.md
|
|
2
|
-
|
|
3
|
-
Durable implementation findings for `@modelprofile.com/browser-runtime`.
|
|
4
|
-
|
|
5
|
-
## Ownership and lifecycle
|
|
6
|
-
|
|
7
|
-
- Host-runtime liveness is owned by SmartIPC `NamedMutex` using a namespace bound to the UID and secured runtime-directory device, inode, and path hash. Its permanent private `runtime.mutex` anchor directory and file stay on the runtime directory's trusted local filesystem. Clean native contention is `LOCKED`; backend, anchor, lock, or release uncertainty is `FENCED`.
|
|
8
|
-
- Persistent, fsynced `runtime.lock` metadata is a downgrade fence, not the live mutex, and is never removed by normal shutdown. Every start owns one metadata-named generation containing private profile and artifact roots.
|
|
9
|
-
- Metadata-generation recovery performs no lock-descriptor scan. It removes only the exact named generation after checking every same-UID process for an absolute `--user-data-dir` equal to or below that profile root; this check is process-generic, not Chromium-specific.
|
|
10
|
-
- Process ownership comes from all four `/proc/<pid>/status` `Uid:` values, not `/proc/<pid>` directory ownership. Only an exact same-UID or definite other-UID classification is actionable; mixed, unreadable, malformed, symlinked, wrongly owned/mode, hard-linked, ambiguous, or uncontained ownership state remains fenced.
|
|
11
|
-
- An empty process command line triggers one status revalidation. A conclusively departed PID (both status and PID directory missing), definite other UID, or exact same-UID zombie is clear. A still-present PID with missing/unreadable status, ambiguous UID, or malformed/duplicate state remains fenced; exit between command-line and status reads does not block startup.
|
|
12
|
-
- Legacy zero-byte locks alone use SmartIPC's consuming kernel probe for other read/write open descriptions. Adoption additionally requires birth, change, and modification timestamps strictly before bounded `/proc/stat` `btime`, plus no same-UID process using the exact or descendant legacy profile root. After an exclusive probe, Runtime reopens and revalidates the exact original inode, ownership, mode, link count, zero size, and timestamps. Probe contention is locked; probe or reopen uncertainty, same-boot, malformed, or ambiguous state remains fenced against the 3.2 close-before-unlink race. `O_PATH` descriptors do not contend.
|
|
13
|
-
- A clean 3.2 stopped layout has no lock and may retain private empty top-level profile and artifact roots. The native-mutex winner validates, process-checks, and removes only those roots. Before metadata fsync succeeds, failure cleanup may unlink only the exact lock inode that acquisition just created; preexisting locks are never removed.
|
|
14
|
-
- Shutdown attempts artifact and ownership cleanup after peer, revocation, or resource failures, retains retryable local slot state, and blocks restart until that state clears. Automatic ownership-loss cleanup gets one bounded retry; stale active metadata remains a fail-closed recovery fence.
|
|
15
|
-
- Controller durable truth is a logical resource with immutable `projectId`, stable random `browserResourceId`, and a revisioned attachment binding. Runtime state is process-local and must be re-registered after every restart.
|
|
16
|
-
- A resource slot owns its browser incarnation, transition mutex, generations, exact participant leases, shared bounded FIFO, independent viewer frame windows, authenticated egress proxy, private profile, and idle timer. Joining or releasing a participant does not change the resource arbitration generation.
|
|
17
|
-
- `terminateResource()` and idle termination remove only the incarnation. They preserve registration, attachment, capabilities, and artifacts. `retireResource()` permanently fences, revokes, terminates, purges exact-resource artifacts, then unregisters only after all cleanup succeeds. Durable retirement remains Controller truth; Runtime tombstones are bounded and process-local.
|
|
18
|
-
- Failed retirement retains a fenced, retryable registration. Unknown profile, lock, or artifact ownership remains fail closed.
|
|
19
|
-
- Launch quota admission uses synchronous pending reservations across global and per-project running-resource limits.
|
|
20
|
-
|
|
21
|
-
## Attachment and capability boundary
|
|
22
|
-
|
|
23
|
-
- Attachment revision is independent from incarnation and arbitration generations. Equal revisions are idempotent only for an identical binding; lower or conflicting equal revisions fail.
|
|
24
|
-
- Any `sessionId: null` binding is detached; revision zero is the initial detached/no-agent-authority state. Attachment transitions fence and revoke only agent authority. Human viewers keep resource access. An agent operation that cannot quiesce still requires terminating that resource's incarnation.
|
|
25
|
-
- Agent capabilities require the exact current qualified session. Human issuance validates the attachment current at issuance, but subsequent human operations authorize the exact project/resource/actor/peer independently from agent attachment changes. Flex run channels use trusted pipes; independently authenticated MCP bindings support OpenCode, Codex, and Flex session IDs without impersonating a Flex run channel.
|
|
26
|
-
- Capability tokens are returned once. Runtime records retain only SHA-256 digests and use `timingSafeEqual`. Authorization is rechecked after asynchronous host authorization, during lease acquisition, and before and after every operation.
|
|
27
|
-
- Capability, audit, and lease identity includes immutable project, resource, attachment authority and revision, actor, peer, role, source, qualified agent session, and Flex scope/channel/run where applicable.
|
|
28
|
-
- All participant operations share a resource FIFO with per-lease and aggregate bounds. A released participant's cleanup joins that same FIFO with exact retained lease identity, bypasses user-operation authorization only for resource-owned input release/viewport recomputation, and retains lifecycle cancellation and operation timeout enforcement.
|
|
29
|
-
- Human frame refresh enters the shared FIFO, captures the initiating subscription, clears viewer windows, waits for producer acknowledgements, and validates one constant-sized new-generation boundary. A running open tab may refresh while streaming is false. New frames fan out to all participants.
|
|
30
|
-
- `beforeOperation` reserves the exact resource and lets the host persist policy/audit through an awaited fail-closed gate. Runtime then atomically revalidates lease, attachment, session, arbitration generation, and incarnation before starting the side effect. Both host hooks carry the same exact operation ID and classification; terminal audit remains best effort and runs after bounded cleanup releases or fences the exact reservation.
|
|
31
|
-
- Lease authority snapshots are immutable process-local revalidation tokens. They expose `authorityGeneration` and bind the runtime instance, incarnation, exact `capabilityId`, exact `leaseId`, and attachment, but never replace Controller durable attachment truth.
|
|
32
|
-
|
|
33
|
-
- Viewport preferences are per human lease. Effective width, height and DSF use the componentwise minimum; results carry the actual viewport revision, including unchanged-size responses. Departure expands the viewport through serialized resource-owned cleanup and retains its cleanup marker until the resize succeeds, so failed cleanup remains retryable. Input ownership is bounded to 64 keys and 5 buttons per participant, and departure preserves another participant's held input.
|
|
34
|
-
|
|
35
|
-
## Browser confinement
|
|
36
|
-
|
|
37
|
-
- Production session options are assembled only inside `BrowserRuntime`: sandbox required, random private per-resource user data directory, one authenticated proxy per running resource, fixed network-reduction arguments, denied downloads/file choosers/permissions, and HTTP(S)-only public navigation.
|
|
38
|
-
- `allowEvaluation` exists only for the private confinement probe. No runtime lease, framed message, Flex tool, or MCP tool exposes evaluation.
|
|
39
|
-
- The production probe verifies loopback denial, synthetic `.invalid` traversal, and forced WebRTC suppression through the resource's proxy.
|
|
40
|
-
|
|
41
|
-
## Egress and artifacts
|
|
42
|
-
|
|
43
|
-
- HTTP absolute-form, WebSocket Upgrade, and CONNECT use one fail-closed target resolver. Every DNS answer must be public unicast and the selected answer is dialed numerically.
|
|
44
|
-
- Egress proxies carry immutable `projectId` and `browserResourceId`; credentials are private and one proxy exists per running resource.
|
|
45
|
-
- Artifact identity is `(projectId, browserResourceId, artifactId)`. Paths use keyed project and resource digests, and quotas apply per resource, per project, and globally.
|
|
46
|
-
- Artifact writes are private, temporary, synced, and atomically renamed. Reads use no-follow handles and verify size plus SHA-256. Human leases can read or delete artifacts only from their exact resource.
|
|
47
|
-
|
|
48
|
-
## Adapter boundary
|
|
49
|
-
|
|
50
|
-
- Framed messages contain no identity selectors. The parent binds project, resource, attachment authority/revision, actor, peer, role, source, qualified session, Flex scope, channel, and exact run out of band.
|
|
51
|
-
- One qualified session can own multiple resource-specific framed channels. Capability revocation owns server-peer disconnect; peer-initiated release/close revokes only that channel's capability without recursively disconnecting itself.
|
|
52
|
-
- Framed request counts and queued write bytes are bounded. Runtime shutdown has a bounded caller-visible cleanup deadline while retaining in-flight cleanup ownership for retry.
|
|
53
|
-
- Each viewer has a bounded exact-identity frame map. Each incoming frame starts one bounded producer ACK whose outcome is shared by viewer records. Producer ACK promises have a separate hard bound; viewer lag and eviction cannot re-ACK or block Chrome. Producer failures fence the exact incarnation regardless of which participants subsequently joined it. A failure from an older incarnation cannot fence its replacement.
|
|
54
|
-
- Late viewer acknowledgements and oldest-first eviction only retire the local map entry. A throwing listener revokes its own participant. Neither event routes through resource-wide producer failure handling.
|
|
55
|
-
- A frame above `maxFrameBytes` advances the resource sequence watermark, is acknowledged once and dropped, and reports nonfatal `FRAME_TOO_LARGE` to viewers. During refresh it also fails the refresh boundary with `FRAME_TOO_LARGE`.
|
|
56
|
-
- `screencast` (`quality` 0..100 default 70, `maxWidth`/`maxHeight` 1..4096 each with `maxWidth * maxHeight <= 8_294_400` and defaults 2560/1600, `everyNthFrame` 1..60 default 1, `firstFrameTimeoutMs` 1000..60000 default left to SmartPuppeteer) is validated in the constructor with `validateExactKeys` plus the integer validators and forwarded to `LiveBrowserSession` next to `maxOutstandingFrames`. The ceilings deliberately match `@push.rocks/smartpuppeteer` 2.6's `LiveBrowserSession` validation (`maxViewportPixelArea = 8294400`, `maxTimeoutMs = 60000`); a looser runtime bound would let `new BrowserRuntime()` succeed and every incarnation launch fail instead. Without `quality`/`maxWidth`/`maxHeight`, SmartPuppeteer's defaults (quality 80, unbounded size) produced multi-megabyte frames on device-pixel-ratio 2 viewers.
|
|
57
|
-
- Refresh records track bounded producer ACK work independently of viewer windows. Producer failure cleanup is coalesced by exact session; affected operation completion waits for the fence and process cleanup. Closing an initiating subscription abandons its refresh without revoking other viewers.
|
|
58
|
-
- Runtime state/error messages are bounded to 2,048 characters. Fatal producer errors revoke every current participant synchronously before captured error delivery; released participants receive no duplicate fatal notification.
|
|
59
|
-
- Flex resolves only a capability token, and the provider's run must exactly match its trusted framed client. SmartAgent exposes exactly navigate, snapshot, screenshot, click, fill, and press.
|
|
60
|
-
- MCP independent authentication returns the complete expected binding. The binding is retained in server-owned auth context and tool schemas expose no resource, session, revision, or authority selector.
|