Skip to content
This repository has been archived by the owner on Sep 16, 2024. It is now read-only.

Feat(browser-emulator): Migrating away from proxies, lots of extra improvements and enabling TS strict mode #104

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,30 @@ jobs:
os: [macos-13, windows-latest, ubuntu-latest]
node-version: [18, 20]
include:
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-123-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-122-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-121-0
- node-version: 20.x
os: ubuntu-latest
browser: chrome-120-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-119-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-118-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-117-0
# - node-version: 20.x
# os: ubuntu-latest
# browser: chrome-116-0
- node-version: 20.x
os: ubuntu-latest
browser: chrome-115-0
Expand Down
4 changes: 2 additions & 2 deletions agent/main/lib/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { IHooksProvider } from '@ulixee/unblocked-specification/agent/hooks/IHoo
import IEmulationProfile, {
IEmulationOptions,
} from '@ulixee/unblocked-specification/plugin/IEmulationProfile';
import { IUnblockedPluginClass, UnblockedPluginConfig } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin';
import { IUnblockedPluginClass, PluginConfigs } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin';
import { nanoid } from 'nanoid';
import env from '../env';
import ICommandMarker from '../interfaces/ICommandMarker';
Expand All @@ -26,7 +26,7 @@ const { log } = Log(module);
export interface IAgentCreateOptions extends Omit<IEmulationProfile, keyof IEmulationOptions> {
id?: string;
plugins?: IUnblockedPluginClass[];
pluginConfigs?: UnblockedPluginConfig;
pluginConfigs?: PluginConfigs;
commandMarker?: ICommandMarker;
}

Expand Down
5 changes: 4 additions & 1 deletion agent/main/lib/Browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,14 @@ export default class Browser extends TypedEventEmitter<IBrowserEvents> implement
private applyDefaultLaunchArgs(options: IBrowserUserConfig): void {
const launchArgs = [
...this.engine.launchArguments,
'--ignore-certificate-errors',
'--no-startup-window',
'--use-mock-keychain', // Use mock keychain on Mac to prevent blocking permissions dialogs
];

if (!options.disableMitm) {
launchArgs.push('--ignore-certificate-errors');
}

if (options.proxyPort !== undefined && !launchArgs.some(x => x.startsWith('--proxy-server'))) {
launchArgs.push(
// Use proxy for localhost URLs
Expand Down
2 changes: 1 addition & 1 deletion agent/main/lib/Page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export default class Page extends TypedEventEmitter<IPageLevelEvents> implements

evaluate<T>(
expression: string,
options?: { timeoutMs?: number, isolatedFromWebPageEnvironment?: boolean },
options?: { timeoutMs?: number; isolatedFromWebPageEnvironment?: boolean },
): Promise<T> {
return this.mainFrame.evaluate<T>(expression, options);
}
Expand Down
6 changes: 3 additions & 3 deletions agent/main/lib/Pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import IBrowserUserConfig from '@ulixee/unblocked-specification/agent/browser/IB
import { IHooksProvider } from '@ulixee/unblocked-specification/agent/hooks/IHooks';
import {
IUnblockedPluginClass,
UnblockedPluginConfig,
PluginConfigs,
} from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin';
import IEmulationProfile from '@ulixee/unblocked-specification/plugin/IEmulationProfile';
import IRegisteredEventListener from '@ulixee/commons/interfaces/IRegisteredEventListener';
Expand All @@ -31,7 +31,7 @@ interface ICreatePoolOptions {
certificateStore?: ICertificateStore;
defaultBrowserEngine?: IBrowserEngine;
plugins?: IUnblockedPluginClass[];
pluginConfigs?: UnblockedPluginConfig;
pluginConfigs?: PluginConfigs;
dataDir?: string;
logger?: IBoundLog;
}
Expand All @@ -56,7 +56,7 @@ export default class Pool extends TypedEventEmitter<{
public readonly agentsById = new Map<string, Agent>();
public sharedMitmProxy: MitmProxy;
public plugins: IUnblockedPluginClass[] = [];
public pluginConfigs: UnblockedPluginConfig = {};
public pluginConfigs: PluginConfigs = {};

#activeAgentsCount = 0;
#waitingForAvailability: {
Expand Down
24 changes: 15 additions & 9 deletions agent/main/lib/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,20 @@ export class Worker extends TypedEventEmitter<IWorkerEvents> implements IWorker
return this.devtoolsSession.send('Runtime.runIfWaitingForDebugger');
}

return Promise.all([
hooks.onNewWorker(this),
this.devtoolsSession.send('Debugger.enable'),
this.devtoolsSession.send('Debugger.setBreakpointByUrl', {
lineNumber: 0,
url: this.targetInfo.url,
}),
])
const isBlobWorker = this.targetInfo.url.startsWith('blob:');
const promises = [hooks.onNewWorker(this)];

if (!isBlobWorker) {
promises.push(
this.devtoolsSession.send('Debugger.enable'),
this.devtoolsSession.send('Debugger.setBreakpointByUrl', {
lineNumber: 0,
url: this.targetInfo.url,
}),
);
}

return Promise.all(promises)
.then(this.resumeAfterEmulation.bind(this))
.catch(async error => {
if (error instanceof CanceledPromiseError) return;
Expand All @@ -154,8 +160,8 @@ export class Worker extends TypedEventEmitter<IWorkerEvents> implements IWorker

private resumeAfterEmulation(): Promise<any> {
return Promise.all([
this.devtoolsSession.send('Runtime.runIfWaitingForDebugger'),
this.devtoolsSession.send('Debugger.disable'),
this.devtoolsSession.send('Runtime.runIfWaitingForDebugger'),
soundofspace marked this conversation as resolved.
Show resolved Hide resolved
]);
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"watch": "yarn tsc && tsc -b -w tsconfig.json",
"test": "yarn copy:build && yarn test:build",
"test:build": "cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test jest",
"test:debug": "yarn build && yarn copy:build && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand",
"test:debug": "yarn build && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand",
"test:debug:fast": "yarn tsc && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand",
"lint": "eslint --cache ./",
"version:check": "ulx-repo-version-check fix",
"version:bump": "ulx-repo-version-bump"
Expand Down
10 changes: 10 additions & 0 deletions plugins/default-browser-emulator/injected-scripts/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ module.exports = {
files: ['**/*.ts'],
rules: {
'no-restricted-globals': 'off',
'no-restricted-properties': [
'error',
...Object.getOwnPropertyNames(Object).map(key => {
return { object: 'Object', property: key };
}),
...Object.getOwnPropertyNames(Reflect).map(key => {
return { object: 'Reflect', property: key };
}),
],
'no-proto': 'off',
'no-extend-native': 'off',
'no-inner-declarations': 'off',
Expand All @@ -21,6 +30,7 @@ module.exports = {
'prefer-rest-params': 'off',
'func-names': 'off',
'no-console': 'off',
'lines-around-directive': 'off',
},
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
const triggerName = args.callbackName;
export type Args = {
callbackName: string;
};
const typedArgs = args as Args;
const triggerName = typedArgs.callbackName;

if (!self[triggerName]) throw new Error('No cookie trigger');
const cookieTrigger = ((self[triggerName] as unknown) as Function).bind(self);
const cookieTrigger = (self[triggerName] as unknown as Function).bind(self);

delete self[triggerName];

proxySetter(Document.prototype, 'cookie', (target, thisArg, cookie) => {
cookieTrigger(JSON.stringify({ cookie, origin: self.location.origin }));
return ProxyOverride.callOriginal;
replaceSetter(Document.prototype, 'cookie', (target, thisArg, argArray) => {
const cookie = argArray.at(0);
if (cookie) {
cookieTrigger(JSON.stringify({ cookie, origin: self.location.origin }));
}
return ReflectCached.apply(target, thisArg, argArray!);
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
proxyFunction(JSON, 'stringify', (target, thisArg, argArray) => {
argArray[1] = null;
argArray[2] = 2;
export type Args = never;

const result = target.apply(thisArg, argArray);
replaceFunction(JSON, 'stringify', (target, thisArg, argArray) => {
const result = ReflectCached.apply(target, thisArg, [argArray.at(0), null, 2]);
console.log(result);

return result;
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
export type Args = {
deviceId?: string;
groupId?: string
};
const typedArgs = args as Args;

if (
navigator.mediaDevices &&
navigator.mediaDevices.enumerateDevices &&
navigator.mediaDevices.enumerateDevices.name !== 'bound reportBlock'
) {
const videoDevice = {
deviceId: args.deviceId,
groupId: args.groupId,
deviceId: typedArgs.deviceId,
groupId: typedArgs.groupId,
kind: 'videoinput',
label: '',
};
proxyFunction(MediaDevices.prototype, 'enumerateDevices', (func, thisObj, ...args) => {
return func.apply(thisObj, args).then(list => {
replaceFunction(MediaDevices.prototype, 'enumerateDevices', (target, thisArg, argArray) => {
return (ReflectCached.apply(target, thisArg, argArray) as Promise<any>).then(list => {
if (list.find(x => x.kind === 'videoinput')) return list;
list.push(videoDevice);
return list;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
// @ts-ignore
const { audioCodecs, videoCodecs } = args;
export type Args = {
audioCodecs: any;
videoCodecs: any;
};
const typedArgs = args as Args;

const { audioCodecs, videoCodecs } = typedArgs;

if ('RTCRtpSender' in self && RTCRtpSender.prototype) {
proxyFunction(RTCRtpSender, 'getCapabilities', function (target, thisArg, argArray) {
replaceFunction(RTCRtpSender, 'getCapabilities', function (target, thisArg, argArray) {
const kind = argArray && argArray.length ? argArray[0] : null;
const args = kind ? [kind] : undefined;
const capabilities = target.apply(thisArg, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
export type Args = never;

if (typeof SharedWorker === 'undefined') {
// @ts-ignore
return;
}

const OriginalSharedWorker = SharedWorker;
const originalSharedWorkerProperties = ObjectCached.getOwnPropertyDescriptors(SharedWorker);

// shared workers created from blobs don't automatically pause in devtools, so we have to manipulate
proxyConstructor(self, 'SharedWorker', (target, argArray) => {
if (!argArray?.length) return ReflectCached.construct(target, argArray);
ObjectCached.defineProperty(self, 'SharedWorker', {
// eslint-disable-next-line object-shorthand
value: function SharedWorker(this, scriptURL, options) {
// eslint-disable-next-line strict
'use strict';
soundofspace marked this conversation as resolved.
Show resolved Hide resolved
if (!new.target) {
return ReflectCached.apply(OriginalSharedWorker, this, [scriptURL, options]);
}

const [url] = argArray;
if (!url?.toString().startsWith('blob:')) {
return ReflectCached.construct(target, argArray);
}
let isBlob = false;
try {
isBlob = scriptURL?.toString().startsWith('blob:');
} catch {}
if (!isBlob) {
return ReflectCached.construct(OriginalSharedWorker, [scriptURL, options], new.target);
}

// read blob contents synchronously
const xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.send();
const text = xhr.response;
// read blob contents synchronously
const xhr = new XMLHttpRequest();
xhr.open('GET', scriptURL, false);
xhr.send();
const text = xhr.response;

const script = createScript(text);

const newBlob = new Blob([script]);
return ReflectCached.construct(OriginalSharedWorker, [URL.createObjectURL(newBlob), options], new.target);
},
});

ObjectCached.defineProperties(SharedWorker, originalSharedWorkerProperties);
SharedWorker.prototype.constructor = SharedWorker;
toOriginalFn.set(SharedWorker, OriginalSharedWorker);

function createScript(originalScript: string) {
const script = `
function original() {
${text};
${originalScript};
}

(async function runWhenReady() {
Expand All @@ -34,37 +60,33 @@ proxyConstructor(self, 'SharedWorker', (target, argArray) => {
setTimeout(()=> {
removeEventListener('connect', storeEvent);
original();
events.forEach(ev => onconnect(ev));
events.forEach(ev => dispatchEvent(ev));
delete events;
}, 0);
}

function isInjectedDone() {
// See proxyUtils
function getSharedStorage() {
try {
// We can use this to check if injected logic is loaded
return Error['${sourceUrl}'];
return Function.prototype.toString('${sourceUrl}');
} catch {
return false;
return undefined;
}
}


if (isInjectedDone()) {
if (getSharedStorage()?.ready) {
originalAsSync();
return;
}

// Keep checking until we are ready
const interval = setInterval(() => {
if (!isInjectedDone()) {
return
if (getSharedStorage()?.ready) {
clearInterval(interval);
originalAsSync();
}
clearInterval(interval);
originalAsSync();
}, 20);
})()
`;

const newBlob = new Blob([script]);
return ReflectCached.construct(target, [URL.createObjectURL(newBlob)]);
});
return script;
}
Loading
Loading