chore: experimental agent mode (#516)
This commit is contained in:
197
src/browserAgent.ts
Normal file
197
src/browserAgent.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import net from 'net';
|
||||
|
||||
import { program } from 'commander';
|
||||
import playwright from 'playwright';
|
||||
|
||||
import { HttpServer } from './httpServer.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
import type http from 'http';
|
||||
|
||||
export type LaunchBrowserRequest = {
|
||||
browserType: string;
|
||||
userDataDir: string;
|
||||
launchOptions: playwright.LaunchOptions;
|
||||
contextOptions: playwright.BrowserContextOptions;
|
||||
};
|
||||
|
||||
export type BrowserInfo = {
|
||||
browserType: string;
|
||||
userDataDir: string;
|
||||
cdpPort: number;
|
||||
launchOptions: playwright.LaunchOptions;
|
||||
contextOptions: playwright.BrowserContextOptions;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type BrowserEntry = {
|
||||
browser?: playwright.Browser;
|
||||
info: BrowserInfo;
|
||||
};
|
||||
|
||||
class Agent {
|
||||
private _server = new HttpServer();
|
||||
private _entries: BrowserEntry[] = [];
|
||||
|
||||
constructor() {
|
||||
this._setupExitHandler();
|
||||
}
|
||||
|
||||
async start(port: number) {
|
||||
await this._server.start({ port });
|
||||
this._server.routePath('/json/list', (req, res) => {
|
||||
this._handleJsonList(res);
|
||||
});
|
||||
this._server.routePath('/json/launch', async (req, res) => {
|
||||
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
||||
});
|
||||
this._setEntries([]);
|
||||
}
|
||||
|
||||
private _handleJsonList(res: http.ServerResponse) {
|
||||
const list = this._entries.map(browser => browser.info);
|
||||
res.end(JSON.stringify(list));
|
||||
}
|
||||
|
||||
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const request = await readBody<LaunchBrowserRequest>(req);
|
||||
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
||||
if (!info || info.error)
|
||||
info = await this._newBrowser(request);
|
||||
res.end(JSON.stringify(info));
|
||||
}
|
||||
|
||||
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
||||
const cdpPort = await findFreePort();
|
||||
(request.launchOptions as any).cdpPort = cdpPort;
|
||||
const info: BrowserInfo = {
|
||||
browserType: request.browserType,
|
||||
userDataDir: request.userDataDir,
|
||||
cdpPort,
|
||||
launchOptions: request.launchOptions,
|
||||
contextOptions: request.contextOptions,
|
||||
};
|
||||
|
||||
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
||||
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
||||
...request.launchOptions,
|
||||
...request.contextOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
}).then(context => {
|
||||
return { browser: context.browser()!, error: undefined };
|
||||
}).catch(error => {
|
||||
return { browser: undefined, error: error.message };
|
||||
});
|
||||
this._setEntries([...this._entries, {
|
||||
browser,
|
||||
info: {
|
||||
browserType: request.browserType,
|
||||
userDataDir: request.userDataDir,
|
||||
cdpPort,
|
||||
launchOptions: request.launchOptions,
|
||||
contextOptions: request.contextOptions,
|
||||
error,
|
||||
},
|
||||
}]);
|
||||
browser?.on('disconnected', () => {
|
||||
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
||||
private _updateReport() {
|
||||
// Clear the current line and move cursor to top of screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
process.stdout.write(`Playwright Browser agent v${packageJSON.version}\n`);
|
||||
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
||||
|
||||
if (this._entries.length === 0) {
|
||||
process.stdout.write('No browsers currently running\n');
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('Running browsers:\n');
|
||||
for (const entry of this._entries) {
|
||||
const status = entry.browser ? 'running' : 'error';
|
||||
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
||||
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
||||
if (entry.info.error)
|
||||
process.stdout.write(` Error: ${entry.info.error}\n`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private _setEntries(entries: BrowserEntry[]) {
|
||||
this._entries = entries;
|
||||
this._updateReport();
|
||||
}
|
||||
|
||||
private _setupExitHandler() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
for (const entry of this._entries)
|
||||
await entry.browser?.close().catch(() => {});
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.name('browser-agent')
|
||||
.option('-p, --port <port>', 'Port to listen on', '9224')
|
||||
.action(async options => {
|
||||
await main(options);
|
||||
});
|
||||
|
||||
void program.parseAsync(process.argv);
|
||||
|
||||
async function main(options: { port: string }) {
|
||||
const agent = new Agent();
|
||||
await agent.start(+options.port);
|
||||
}
|
||||
|
||||
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
||||
});
|
||||
}
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -15,13 +15,16 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
import { userDataDir } from './fileUtils.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
@@ -32,6 +35,8 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
if (browserConfig.browserAgent)
|
||||
return new AgentContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
@@ -97,6 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch({
|
||||
...this.browserConfig.launchOptions,
|
||||
@@ -155,6 +161,7 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
@@ -209,3 +216,51 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('persistent', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
browserType: this.browserConfig.browserName,
|
||||
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
||||
launchOptions: this.browserConfig.launchOptions,
|
||||
contextOptions: this.browserConfig.contextOptions,
|
||||
} as LaunchBrowserRequest),
|
||||
});
|
||||
const info = await response.json() as BrowserInfo;
|
||||
if (info.error)
|
||||
throw new Error(info.error);
|
||||
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
const dir = await userDataDir(this.browserConfig);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||
if (browserConfig.browserName === 'chromium')
|
||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { devices } from 'playwright';
|
||||
@@ -29,6 +28,7 @@ export type CLIOptions = {
|
||||
blockedOrigins?: string[];
|
||||
blockServiceWorkers?: boolean;
|
||||
browser?: string;
|
||||
browserAgent?: string;
|
||||
caps?: string;
|
||||
cdpEndpoint?: string;
|
||||
config?: string;
|
||||
@@ -96,8 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
||||
// Derive artifact output directory from config.outputDir
|
||||
if (result.saveTrace)
|
||||
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
||||
if (result.browser.browserName === 'chromium')
|
||||
(result.browser.launchOptions as any).cdpPort = await findFreePort();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -171,6 +169,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
|
||||
const result: Config = {
|
||||
browser: {
|
||||
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
||||
browserName,
|
||||
isolated: cliOptions.isolated,
|
||||
userDataDir: cliOptions.userDataDir,
|
||||
@@ -196,17 +195,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
return result;
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
if (!configFile)
|
||||
return {};
|
||||
@@ -232,6 +220,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
|
||||
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||
const browser: FullConfig['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||
launchOptions: {
|
||||
@@ -243,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||
...pickDefined(base.browser?.contextOptions),
|
||||
...pickDefined(overrides.browser?.contextOptions),
|
||||
},
|
||||
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
||||
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
||||
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
||||
};
|
||||
|
||||
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||
|
||||
37
src/fileUtils.ts
Normal file
37
src/fileUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function cacheDir() {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
return path.join(cacheDirectory, 'ms-playwright');
|
||||
}
|
||||
|
||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
}
|
||||
232
src/httpServer.ts
Normal file
232
src/httpServer.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
import mime from 'mime';
|
||||
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
|
||||
|
||||
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||
|
||||
export type Transport = {
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
close?: () => void;
|
||||
onconnect: () => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
onclose: () => void;
|
||||
};
|
||||
|
||||
export class HttpServer {
|
||||
private _server: http.Server;
|
||||
private _urlPrefixPrecise: string = '';
|
||||
private _urlPrefixHumanReadable: string = '';
|
||||
private _port: number = 0;
|
||||
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||
|
||||
constructor() {
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
decorateServer(this._server);
|
||||
}
|
||||
|
||||
server() {
|
||||
return this._server;
|
||||
}
|
||||
|
||||
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ prefix, handler });
|
||||
}
|
||||
|
||||
routePath(path: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ exact: path, handler });
|
||||
}
|
||||
|
||||
port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
private async _tryStart(port: number | undefined, host: string) {
|
||||
const errorPromise = new ManualPromise();
|
||||
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||
this._server.on('error', errorListener);
|
||||
|
||||
try {
|
||||
this._server.listen(port, host);
|
||||
await Promise.race([
|
||||
new Promise(cb => this._server!.once('listening', cb)),
|
||||
errorPromise,
|
||||
]);
|
||||
} finally {
|
||||
this._server.removeListener('error', errorListener);
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||
const host = options.host || 'localhost';
|
||||
if (options.preferredPort) {
|
||||
try {
|
||||
await this._tryStart(options.preferredPort, host);
|
||||
} catch (e: any) {
|
||||
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||
throw e;
|
||||
await this._tryStart(undefined, host);
|
||||
}
|
||||
} else {
|
||||
await this._tryStart(options.port, host);
|
||||
}
|
||||
|
||||
const address = this._server.address();
|
||||
if (typeof address === 'string') {
|
||||
this._urlPrefixPrecise = address;
|
||||
this._urlPrefixHumanReadable = address;
|
||||
} else {
|
||||
this._port = address!.port;
|
||||
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await new Promise(cb => this._server!.close(cb));
|
||||
}
|
||||
|
||||
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||
}
|
||||
|
||||
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||
try {
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
response.setHeader(name, value);
|
||||
if (request.headers.range)
|
||||
this._serveRangeFile(request, response, absoluteFilePath);
|
||||
else
|
||||
this._serveFile(response, absoluteFilePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const content = fs.readFileSync(absoluteFilePath);
|
||||
response.statusCode = 200;
|
||||
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||
response.setHeader('Content-Type', contentType);
|
||||
response.setHeader('Content-Length', content.byteLength);
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const range = request.headers.range;
|
||||
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||
response.statusCode = 400;
|
||||
return response.end('Bad request');
|
||||
}
|
||||
|
||||
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
|
||||
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||
let start: number;
|
||||
let end: number;
|
||||
const size = fs.statSync(absoluteFilePath).size;
|
||||
if (startStr !== '' && endStr === '') {
|
||||
// No end specified: use the whole file
|
||||
start = +startStr;
|
||||
end = size - 1;
|
||||
} else if (startStr === '' && endStr !== '') {
|
||||
// No start specified: calculate start manually
|
||||
start = size - +endStr;
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = +startStr;
|
||||
end = +endStr;
|
||||
}
|
||||
|
||||
// Handle unavailable range request
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${size}`
|
||||
});
|
||||
return response.end();
|
||||
}
|
||||
|
||||
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||
response.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||
});
|
||||
|
||||
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||
readable.pipe(response);
|
||||
}
|
||||
|
||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(200);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
request.on('error', () => response.end());
|
||||
try {
|
||||
if (!request.url) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL('http://localhost' + request.url);
|
||||
for (const route of this._routes) {
|
||||
if (route.exact && url.pathname === route.exact) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
} catch (e) {
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ program
|
||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||
.option('--block-service-workers', 'block service workers')
|
||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||
.option('--config <path>', 'path to the configuration file.')
|
||||
|
||||
Reference in New Issue
Block a user