core/adapters/browser.adapter.ts
core/adapters/browser.adapter.ts is a file in Bane's Lab Build Scripts. 169 lines of code and 39 definitions.
import { ACTIVE_PORT_FILE, LOCKED_FILE_CODES } from "#configuration/constants/browser.constants";
import type { ActivePort, ConsoleRecord, Session } from "#types/browser.types";
import { EVENT_METHODS, eventRecord } from "#core/converters/browser.converter";
import {
NOT_TEXT_MESSAGE,
SOCKET_FAILED,
consoleEcho,
noReply,
notObjectMessage,
protocolFailed,
socketClosed,
} from "#configuration/strings/browser.strings";
import { existsSync, readFileSync } from "node:fs";
import { isRecord, numberAt, recordAt, textAt } from "#core/selectors/base.selector";
import { join } from "node:path";
import process from "node:process";
import { wait } from "#core/timers/base.timer";
const POLL_INTERVAL_MS = 200;
const SEND_TIMEOUT_MS = 120_000;
const PAGE_TYPE = "page";
const SOCKET_ROOT = "/devtools/";
const PAGE_TARGET = "page/";
const socketUrl = function socketUrl(port: string, target: string): string {
return `ws://127.0.0.1:${port}/devtools/${target}`;
};
const targetOf = function targetOf(path: string): string {
return path.startsWith(SOCKET_ROOT) ? path.slice(SOCKET_ROOT.length) : path;
};
const isLockedFile = function isLockedFile(error: unknown): boolean {
return error instanceof Error && "code" in error && LOCKED_FILE_CODES.has(String(error.code));
};
const readWhenUnlocked = function readWhenUnlocked(file: string): string | null {
try {
return readFileSync(file, "utf8");
} catch (error) {
if (isLockedFile(error)) {
return null;
}
throw error;
}
};
const activePortOf = function activePortOf(profileDir: string): ActivePort | null {
const file = join(profileDir, ACTIVE_PORT_FILE);
const text = existsSync(file) ? readWhenUnlocked(file) : null;
if (text === null) {
return null;
}
const [port = "", path = ""] = text.split("\n").map((line) => line.trim());
return port.length > 0 && path.length > 0 ? { path, port } : null;
};
const pageIdOf = function pageIdOf(targets: unknown): string | null {
const infos = isRecord(targets) ? targets["targetInfos"] : null;
const records = Array.isArray(infos) ? infos.filter(isRecord) : [];
const page = records.find((info) => textAt(info, "type") === PAGE_TYPE);
return page === undefined ? null : textAt(page, "targetId");
};
const pollForPort = async function pollForPort(profileDir: string, deadline: number): Promise<ActivePort | null> {
const active = activePortOf(profileDir);
if (active !== null || Date.now() >= deadline) {
return active;
}
await wait(POLL_INTERVAL_MS);
return pollForPort(profileDir, deadline);
};
const opened = async function opened(socket: WebSocket): Promise<void> {
return new Promise<void>((ready, fail) => {
socket.addEventListener("open", () => {
ready();
});
socket.addEventListener("error", () => {
fail(new Error(SOCKET_FAILED));
});
});
};
interface PendingCall {
readonly method: string;
readonly settle: (value: Record<string, unknown>) => void;
readonly fail: (reason: Error) => void;
readonly timer: ReturnType<typeof setTimeout>;
}
const protocolError = function protocolError(method: string, error: Record<string, unknown>): Error {
return new Error(protocolFailed(method, textAt(error, "message") ?? JSON.stringify(error)));
};
const settleReply = function settleReply(pending: Map<number, PendingCall>, parsed: Record<string, unknown>): void {
const id = numberAt(parsed, "id");
const call = id === null ? undefined : pending.get(id);
if (id === null || call === undefined) {
return;
}
pending.delete(id);
clearTimeout(call.timer);
const error = recordAt(parsed, "error");
if (error === null) {
call.settle(recordAt(parsed, "result") ?? {});
} else {
call.fail(protocolError(call.method, error));
}
};
const failPending = function failPending(pending: Map<number, PendingCall>): void {
for (const call of pending.values()) {
clearTimeout(call.timer);
call.fail(new Error(socketClosed(call.method)));
}
pending.clear();
};
const parsedMessage = function parsedMessage(event: MessageEvent): Record<string, unknown> {
if (typeof event.data !== "string") {
throw new TypeError(NOT_TEXT_MESSAGE);
}
const parsed: unknown = JSON.parse(event.data);
if (!isRecord(parsed)) {
throw new TypeError(notObjectMessage(event.data));
}
return parsed;
};
export const openSession = async function openSession(endpoint: string, echoConsole: boolean): Promise<Session> {
const socket = new WebSocket(endpoint);
const pending = new Map<number, PendingCall>();
const collected: ConsoleRecord[] = [];
let nextId = 0;
await opened(socket);
socket.addEventListener("close", () => {
failPending(pending);
});
socket.addEventListener("message", (event: MessageEvent) => {
const parsed = parsedMessage(event);
const record = EVENT_METHODS.has(textAt(parsed, "method") ?? "") ? eventRecord(parsed) : null;
if (record === null) {
settleReply(pending, parsed);
return;
}
collected.push(record);
if (echoConsole) {
process.stdout.write(consoleEcho(record.text));
}
});
return {
close: (): void => {
socket.close();
},
records: (): readonly ConsoleRecord[] => collected,
send: async (method: string, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> => {
nextId += 1;
const id = nextId;
return new Promise((settle, fail) => {
const timer = setTimeout(() => {
pending.delete(id);
fail(new Error(noReply(method, SEND_TIMEOUT_MS)));
}, SEND_TIMEOUT_MS);
pending.set(id, { fail, method, settle, timer });
socket.send(JSON.stringify({ id, method, params }));
});
},
};
};
export const devtoolsEndpoint = async function devtoolsEndpoint(
profileDir: string,
timeoutMs: number,
): Promise<string | null> {
const active = await pollForPort(profileDir, Date.now() + timeoutMs);
if (active === null) {
return null;
}
const browser = await openSession(socketUrl(active.port, targetOf(active.path)), false);
const targets = await browser.send("Target.getTargets");
browser.close();
const page = pageIdOf(targets);
return page === null ? null : socketUrl(active.port, PAGE_TARGET + page);
};