Skip to content

feat(runner): Create new @wxt-dev/runner package #1566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ stats.html
.tool-versions
.cache
*-stats.txt
data
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use-node-version=22.14.0
56 changes: 56 additions & 0 deletions packages/runner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `@wxt-dev/runner`

> [!NOTE]
> This package is still in development and is not ready for production use.

Programmatically open a browser and install a web extension from a local directory.

## TODO

- [x] Launch Chrome with extension installed
- [x] Launch Firefox with extension installed
- [x] Provide install functions to allow hooking into already running instances of Chrome/Firefox
- [ ] Try to setup E2E tests on Firefox with Puppeteer using this approach
- [ ] Try to setup E2E tests on Chrome with Puppeteer using this approach
- [ ] Implement the `chromiumDataPersistence` option
- [ ] Implement some form of data persistence for Firefox
- [ ] Close browsers when process exits
- [ ] Don't error out if the browser is already open
- [ ] Validate list of known browser paths on Mac
- [ ] Validate list of known browser paths on Windows
- [ ] Validate list of known browser paths on Linux
- [ ] Support more than just "chromium" or "firefox" for targets

## Features

- Supports all Chromium and Firefox based browsers
- Zero dependencies
- Persist data between launches on Chrome

## Requirements

| Runtime | Version |
| ------- | --------- |
| NodeJS | >= 22.4.0 |
| Bun | >= 1.2.0 |

| Browser | Version |
| ------- | ------- |
| Chrome | Unknown |
| Firefox | >= 139 |

## Usage

```ts
import { run } from '@wxt-dev/runner';

await run({
extensionDir: '/path/to/extension',
});
```

## Implementation Details

On Chrome, this uses the [CDP](https://chromedevtools.github.io/devtools-protocol/) to install the extension. On Firefox, it uses the new [WebDriver BiDi protocol](https://www.w3.org/TR/webdriver-bidi).

BiDi was not used on Chrome because it requires use of the Chrome driver, which would require a post-install step to download it, plus additional startup time to spin it up.
1 change: 1 addition & 0 deletions packages/runner/demo-extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('Hello background!');
9 changes: 9 additions & 0 deletions packages/runner/demo-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "Test",
"version": "1.0.0",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"scripts": ["background.js"]
}
}
9 changes: 9 additions & 0 deletions packages/runner/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { run } from './src';

// Uncomment to enable debug logs
process.env.DEBUG = '@wxt-dev/runner';

await run({
extensionDir: 'demo-extension',
target: process.argv[2] as 'chromium' | 'firefox' | undefined,
});
52 changes: 52 additions & 0 deletions packages/runner/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@wxt-dev/runner",
"description": "Launch Chrome and Firefox with a web extension installed",
"version": "0.1.0",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/wxt-dev/wxt.git",
"directory": "packages/runner"
},
"homepage": "https://github.com/wxt-dev/wxt/tree/main/packages/runner#readme",
"keywords": [
"web-extension",
"chrome-extension",
"wxt"
],
"author": {
"name": "Aaron Klinker",
"email": "[email protected]"
},
"license": "MIT",
"funding": "https://github.com/sponsors/wxt-dev",
"scripts": {
"check": "pnpm build && check",
"test": "buildc --deps-only -- vitest",
"dev": "tsx dev.ts",
"dev:firefox": "tsx dev.ts firefox",
"build": "buildc -- unbuild",
"prepublishOnly": "pnpm build"
},
"dependencies": {},
"devDependencies": {
"@aklinker1/check": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"vitest": "catalog:"
},
"main": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
]
}
75 changes: 75 additions & 0 deletions packages/runner/src/bidi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { openWebSocket } from './web-socket';
import { debug } from './debug';

const debugBidi = debug.scoped('bidi');

export interface BidiConnection extends Disposable {
send<T>(method: string, params: any, timeout?: number): Promise<T>;
close(): void;
}

export async function createBidiConnection(
baseUrl: string,
): Promise<BidiConnection> {
const url = new URL('/session', baseUrl);
debugBidi('Connecting to BiDi server @', url.href);

const webSocket = await openWebSocket(url.href);
debugBidi('Connected');

let requestId = 0;

return {
send(method, params, timeout = 10e3) {
const id = ++requestId;
const command = { id, method, params };
debugBidi('Sending command:', command);

return new Promise((resolve, reject) => {
const cleanup = () => {
webSocket.removeEventListener('message', onMessage);
webSocket.removeEventListener('error', onError);
};

setTimeout(() => {
cleanup();
reject(
new Error(
`Timed out after ${timeout}ms waiting for ${method} response`,
),
);
}, timeout);

const onMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.id === id) {
debugBidi('Received response:', data);
cleanup();
if (data.type === 'success') resolve(data.result);
else reject(Error(data.message, { cause: data }));
}
};
const onError = (error: any) => {
cleanup();
reject(new Error('Error sending request', { cause: error }));
};

webSocket.addEventListener('message', onMessage);
webSocket.addEventListener('error', onError);

webSocket.send(JSON.stringify(command));
});
},

close() {
debugBidi('Closing connection...');
webSocket.close();
debugBidi('Closed connection');
},
[Symbol.dispose]() {
debugBidi('Disposing connection...');
webSocket.close();
debugBidi('Disposed connection');
},
};
}
37 changes: 37 additions & 0 deletions packages/runner/src/browser-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type BrowserPlatform = 'win32' | 'darwin' | 'linux';

export const CHROMIUM_PATHS: Record<BrowserPlatform, string[]> = {
darwin: ['/Applications/Chrome.app/Contents/MacOS/Google Chrome'],
linux: [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/microsoft-edge',
'/usr/bin/microsoft-edge-dev',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Chromium\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Chromium\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge Dev\\Application\\msedge.exe',
],
};

export const FIREFOX_PATHS: Record<BrowserPlatform, string[]> = {
darwin: [
'/Applications/Firefox Nightly.app/Contents/MacOS/firefox',
'/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox',
'/Applications/Firefox.app/Contents/MacOS/firefox',
],
linux: [
'/usr/bin/firefox-nightly',
'/usr/bin/firefox-developer-edition',
'/usr/bin/firefox',
],
win32: [
'C:\\Program Files\\Firefox Nightly\\firefox.exe',
'C:\\Program Files\\Firefox Developer Edition\\firefox.exe',
'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
],
};
57 changes: 57 additions & 0 deletions packages/runner/src/cdp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ChildProcess } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { debug } from './debug';

const debugCdp = debug.scoped('cdp');

export interface CDPConnection extends Disposable {
send<T>(method: string, params: any, timeout?: number): Promise<T>;
close(): void;
}

export function createCdpConnection(
browserProcess: ChildProcess,
): CDPConnection {
const inputStream = browserProcess.stdio[3] as Writable;
const outputStream = browserProcess.stdio[4] as Readable;

let requestId = 0;

return {
send(method, params, timeout = 10e3) {
const id = ++requestId;
const command = { id, method, params };
debugCdp('Sending command:', command);

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`CDP command timed out: ${method}`));
}, timeout);

const onData = (data: Buffer) => {
// Trim the trailing null character
const text = data.toString().slice(0, -1);
const res = JSON.parse(text);

if (res.id !== id) return;

debugCdp('Received response:', res);
clearTimeout(timer);
outputStream.removeListener('data', onData);

if ('error' in res) {
reject(new Error(res.error.message, { cause: res.error }));
} else {
resolve(res.result);
}
};

outputStream.addListener('data', onData);

inputStream.write(JSON.stringify(command) + '\0');
});
},
close() {},
[Symbol.dispose]() {},
};
}
24 changes: 24 additions & 0 deletions packages/runner/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface Debug {
(...args: any[]): void;
scoped: (scope: string) => Debug;
}

function createDebug(scopes: string[]): Debug {
const debug = (...args: any[]) => {
const scope = scopes.join(':');
if (
process.env.DEBUG === '1' ||
process.env.DEBUG === 'true' ||
scope.startsWith(process.env.DEBUG ?? '@NOT')
) {
const params = scope ? [`\x1b[31m${scope}\x1b[0m`, ...args] : args;
console.log(...params);
}
};

debug.scoped = (scope: string) => createDebug([...scopes, scope]);

return debug;
}

export const debug = createDebug(['@wxt-dev/runner']);
3 changes: 3 additions & 0 deletions packages/runner/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './run';
export * from './options';
export * from './install';
59 changes: 59 additions & 0 deletions packages/runner/src/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ChildProcess } from 'node:child_process';
import { createBidiConnection } from './bidi';
import { createCdpConnection } from './cdp';

/**
* Install an extension to an already running instance of Firefox.
* @param debuggerUrl The URL of the Firefox BiDi server (ex: `ws://127.0.0.1:45912`).
* @param extensionDir Absolute path to the directory containing the extension to be installed.
*/
export async function installFirefox(
debuggerUrl: string,
extensionDir: string,
): Promise<BidiWebExtensionInstallResponse> {
using bidi = await createBidiConnection(debuggerUrl);

// Start a session
await bidi.send<unknown>('session.new', { capabilities: {} });

// Install the extension
return await bidi.send<BidiWebExtensionInstallResponse>(
'webExtension.install',
{
extensionData: {
type: 'path',
path: extensionDir,
},
},
);
}

export type BidiWebExtensionInstallResponse = {
extension: string;
};

/**
* Given a child process of Chrome, install an extension. The process must be started with the following flags:
*
* - `--remote-debugging-pipe`
* - `--user-data-dir=...`
* - `--enable-unsafe-extension-debugging`
*
* Otherwise it the CDP doesn't have permission to install extensions.
*/
export async function installChrome(
browserProcess: ChildProcess,
extensionDir: string,
): Promise<CdpExtensionsLoadUnpackedResponse> {
using cdp = createCdpConnection(browserProcess);
return await cdp.send<CdpExtensionsLoadUnpackedResponse>(
'Extensions.loadUnpacked',
{
path: extensionDir,
},
);
}

export type CdpExtensionsLoadUnpackedResponse = {
id: string;
};
Loading
Loading