refactor(extension): class-based command registry + modular src layout
Restructure the MV3 background worker from a monolithic core.ts/index.ts
into a class-based command architecture. Behavior is identical — the 83
registered commands dispatch byte-for-byte the same as before.
Structure
- One class per command group, each extending CommandGroup and exporting a
`commands` map keyed by the full command id ("tabs.close"). Groups:
Navigation, TabsMutation, TabsQuery, Groups, Windows, Dom (dom/extract/
page), BrowserData (storage/cookies), Session (session/clients + autosave
+ lazy-tab activation), Perf (perf + jobs.status/cancel), Extension.
- CommandRegistry merges the group maps (throws on duplicate ids), routes
background specs to JobManager and paginates array results via
makePagedData. JobManager owns the job map + lifecycle. NativeConnection
owns the native-port lifecycle and the inbound message router.
- index.ts is now thin wiring: JobManager -> ctx -> assembleRegistry ->
onActivated -> NativeConnection.start().
- Infra classes live in classes/ (PascalCase, file = class name); command
groups in commands/; shared helpers split out of core.ts into core/
(errors, throttle, scripting, tab-helpers, group-helpers, storage); all
types moved into types/ (json, jobs, session, tabs, messages,
command-args) behind a barrel.
DRY cleanup
- resolveTabUrl(tabId) and assertScriptableUrl(url, action) collapse the
tab/URL-guard boilerplate duplicated across dom.ts and browser-data.ts.
- processInBatches() centralizes the throttled, cancellable batch loop
shared by tabs.close, group.close and tabs.merge_windows.
- captureCurrentSession() dedups the snapshot-and-signature block shared by
session.save and the autosave path.
- DomArgs type alias replaces 21 inline ContentArgs & { tabId? } copies.
- Drop fetchTabHtml's redundant retry loop (executeScript already retries
transient frame/tab errors), a dead tabInfo import, and two stale
comments referencing a removed asArgs helper.
Type safety & tests
- Full noImplicitAny; no `any`/`unknown` annotations remain in src.
- JS unit-test harness using node --test + node:assert (zero new deps),
bundled via the existing esbuild. Covers JobManager retention/lifecycle
and the autosave listener-wiring/debounce with an in-memory chrome mock.
- The structural pytest checks track the new file homes and the centralized
processInBatches helper.
Verification: npm run check:extension green (tsc + esbuild 84.5kb +
node --check + 18 JS tests); uv run pytest -q -> 409 passed, 105 skipped.
No version bump.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// Shared error helpers for browser-cli extension command handlers.
|
||||
import type { ErrorLike } from '../types';
|
||||
|
||||
/** Extract a human-readable message from a caught/rejected value. */
|
||||
export function getErrorMessage(error: ErrorLike): string {
|
||||
if (typeof error === "string") return error;
|
||||
return (error && error.message) || String(error);
|
||||
}
|
||||
|
||||
export function isBrowserErrorUrl(url: string | null | undefined): boolean {
|
||||
const value = String(url || "").toLowerCase();
|
||||
return value.startsWith("chrome-error://") ||
|
||||
value.startsWith("edge-error://") ||
|
||||
value.startsWith("brave-error://") ||
|
||||
value.startsWith("about:neterror") ||
|
||||
value.startsWith("about:certerror") ||
|
||||
value.startsWith("about:blocked") ||
|
||||
value.startsWith("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isErrorPageScriptError(error: ErrorLike): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return message.includes("error page") ||
|
||||
message.includes("chrome-error://") ||
|
||||
message.includes("edge-error://") ||
|
||||
message.includes("brave-error://") ||
|
||||
message.includes("about:neterror") ||
|
||||
message.includes("about:certerror") ||
|
||||
message.includes("about:tabcrashed");
|
||||
}
|
||||
|
||||
export function isTransientScriptError(error: ErrorLike): boolean {
|
||||
const message = getErrorMessage(error);
|
||||
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Tab-group resolution and normalization helpers.
|
||||
|
||||
export async function resolveGroupId(nameOrId: string | number): Promise<number> {
|
||||
const asInt = parseInt(String(nameOrId));
|
||||
if (!isNaN(asInt)) return asInt;
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
|
||||
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export function normalizeGroupColor(color: string | undefined): chrome.tabGroups.Color {
|
||||
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
|
||||
return (allowed.has(color as string) ? color : "grey") as chrome.tabGroups.Color;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './errors';
|
||||
export * from './throttle';
|
||||
export * from './scripting';
|
||||
export * from './tab-helpers';
|
||||
export * from './group-helpers';
|
||||
export * from './storage';
|
||||
@@ -0,0 +1,23 @@
|
||||
// chrome.scripting.executeScript wrapper with transient-error retry.
|
||||
import { isTransientScriptError } from './errors';
|
||||
import { sleep } from './throttle';
|
||||
import type { Serializable } from '../types';
|
||||
|
||||
export async function executeScript<Args extends Serializable[], Result>(
|
||||
options: chrome.scripting.ScriptInjection<Args, Result>,
|
||||
retries = 3,
|
||||
): Promise<chrome.scripting.InjectionResult<chrome.scripting.Awaited<Result>>[]> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await chrome.scripting.executeScript(options);
|
||||
} catch (e) {
|
||||
if (i < retries - 1 && isTransientScriptError(e)) {
|
||||
await sleep(300);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Unreachable: the loop either returns or throws on the final iteration.
|
||||
throw new Error("executeScript: exhausted retries");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// chrome.storage.local accessors for profile alias, window aliases, and sessions.
|
||||
import type { SessionTab, StoredSession } from '../types';
|
||||
|
||||
export async function getProfileAlias(): Promise<string> {
|
||||
const { profileAlias } = await chrome.storage.local.get<{ profileAlias?: string }>("profileAlias");
|
||||
return profileAlias || "default";
|
||||
}
|
||||
|
||||
export function getSessionTabs(session: StoredSession | undefined | null): SessionTab[] {
|
||||
if (!session) return [];
|
||||
if (Array.isArray(session.tabs)) {
|
||||
return session.tabs
|
||||
.map(entry => typeof entry === "string" ? { url: entry } : entry)
|
||||
.filter(entry => entry?.url);
|
||||
}
|
||||
if (Array.isArray(session.urls)) {
|
||||
return session.urls.filter(Boolean).map(url => ({ url }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getAliases(): Promise<Record<string, string>> {
|
||||
const { windowAliases } = await chrome.storage.local.get<{ windowAliases?: Record<string, string> }>("windowAliases");
|
||||
return windowAliases || {};
|
||||
}
|
||||
|
||||
export async function getSessions(): Promise<Record<string, StoredSession>> {
|
||||
const { sessions } = await chrome.storage.local.get<{ sessions?: Record<string, StoredSession> }>("sessions");
|
||||
return sessions || {};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Tab-related shared helpers: info shaping, scriptable-url checks, active-tab
|
||||
// resolution, and HTML fetching.
|
||||
import { isBrowserErrorUrl, isErrorPageScriptError } from './errors';
|
||||
import { executeScript } from './scripting';
|
||||
import type { TabBlock } from '../types';
|
||||
|
||||
/**
|
||||
* Narrow a plain id array to the non-empty-tuple shape that chrome.tabs.group /
|
||||
* chrome.tabs.ungroup declare. The runtime happily accepts any array (including
|
||||
* a single element); the published @types/chrome just over-constrain the param
|
||||
* to `[number, ...number[]]`. Callers guarantee non-emptiness before calling.
|
||||
*/
|
||||
export function asTabIds(ids: number[]): [number, ...number[]] {
|
||||
return ids as [number, ...number[]];
|
||||
}
|
||||
|
||||
export function tabInfo(t: chrome.tabs.Tab) {
|
||||
return {
|
||||
id: t.id,
|
||||
windowId: t.windowId,
|
||||
active: t.active,
|
||||
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
|
||||
groupId: t.groupId >= 0 ? t.groupId : null,
|
||||
title: t.title,
|
||||
url: t.url || t.pendingUrl || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isScriptableUrl(url: string | undefined | null): boolean {
|
||||
if (!url) return false;
|
||||
return !url.startsWith("chrome://") &&
|
||||
!url.startsWith("brave://") &&
|
||||
!url.startsWith("about:") &&
|
||||
!url.startsWith("edge://") &&
|
||||
!url.startsWith("chrome-extension://");
|
||||
}
|
||||
|
||||
export async function getActiveTab() {
|
||||
const activeTabs = await chrome.tabs.query({ active: true });
|
||||
if (!activeTabs.length) throw new Error("No active tab found");
|
||||
|
||||
const windows = await chrome.windows.getAll({ populate: false });
|
||||
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
|
||||
|
||||
const chooseTab = (predicate: (tab: chrome.tabs.Tab) => boolean) => activeTabs.find(predicate);
|
||||
const byFocusAndScriptable = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byScriptable = (tab: chrome.tabs.Tab) => isScriptableUrl(tab.url || tab.pendingUrl || "");
|
||||
const byFocus = (tab: chrome.tabs.Tab) => focusedWindowIds.has(tab.windowId);
|
||||
|
||||
return chooseTab(byFocusAndScriptable)
|
||||
|| chooseTab(byScriptable)
|
||||
|| chooseTab(byFocus)
|
||||
|| activeTabs[0];
|
||||
}
|
||||
|
||||
/** Resolve the target tab (explicit id or the active tab) and its current URL. */
|
||||
export async function resolveTabUrl(tabId?: number | null): Promise<{ tab: chrome.tabs.Tab; url: string }> {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
return { tab, url: tab.url || tab.pendingUrl || "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw the standard "navigate to a regular web page first" error if `url` is
|
||||
* not scriptable. `action` is the verb phrase incl. preposition, e.g.
|
||||
* "run DOM commands on" or "get HTML of".
|
||||
*/
|
||||
export function assertScriptableUrl(url: string, action: string): void {
|
||||
if (!isScriptableUrl(url)) {
|
||||
throw new Error(`Cannot ${action} ${url} — navigate to a regular web page first`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTabForDirectAction(tabId: number | undefined | null, actionName: string): Promise<chrome.tabs.Tab> {
|
||||
if (tabId != null) {
|
||||
return chrome.tabs.get(tabId);
|
||||
}
|
||||
const allTabs = await chrome.tabs.query({});
|
||||
if (allTabs.length !== 1) {
|
||||
throw new Error(
|
||||
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
|
||||
);
|
||||
}
|
||||
return allTabs[0];
|
||||
}
|
||||
|
||||
export function buildTabBlocks(tabs: chrome.tabs.Tab[]): TabBlock[] {
|
||||
const blocks: TabBlock[] = [];
|
||||
for (const tab of tabs) {
|
||||
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock?.groupId === normalizedGroupId) {
|
||||
lastBlock.tabIds.push(tab.id);
|
||||
lastBlock.endIndex = tab.index;
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
groupId: normalizedGroupId,
|
||||
startIndex: tab.index,
|
||||
endIndex: tab.index,
|
||||
tabIds: [tab.id],
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export async function fetchTabHtml(tabId?: number): Promise<string> {
|
||||
// executeScript already retries transient frame/tab errors internally, so a
|
||||
// single attempt here is enough — we only add error-page handling on top.
|
||||
const { tab, url } = await resolveTabUrl(tabId);
|
||||
if (isBrowserErrorUrl(url)) return "";
|
||||
assertScriptableUrl(url, "get HTML of");
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.documentElement.outerHTML,
|
||||
});
|
||||
return results[0]?.result || "";
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) return "";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Large-operation throttling, performance profile, and job-progress helpers.
|
||||
import type { Job, JobProgressUpdate } from '../types';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const LARGE_OPERATION_BATCH_SIZE = 25;
|
||||
export const LARGE_OPERATION_PAUSE_MS = 25;
|
||||
export const GENTLE_OPERATION_BATCH_SIZE = 8;
|
||||
export const GENTLE_OPERATION_PAUSE_MS = 100;
|
||||
|
||||
export async function hasAudibleTabs() {
|
||||
const audibleTabs = await chrome.tabs.query({ audible: true });
|
||||
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
|
||||
}
|
||||
|
||||
let largeOperationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function runLargeOperation<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
||||
const run = largeOperationQueue.then(async () => {
|
||||
console.log(`[browser-cli] large operation start: ${name}`);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`[browser-cli] large operation done: ${name}`);
|
||||
}
|
||||
});
|
||||
largeOperationQueue = run.then(() => {}, () => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function getPerformanceProfile() {
|
||||
const { performanceProfile } = await chrome.storage.local.get<{ performanceProfile?: string }>("performanceProfile");
|
||||
return performanceProfile || "auto";
|
||||
}
|
||||
|
||||
export async function setPerformanceProfile(profile: string) {
|
||||
const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
|
||||
const performanceProfile = allowed.has(profile) ? profile : "auto";
|
||||
await chrome.storage.local.set({ performanceProfile });
|
||||
return { performanceProfile };
|
||||
}
|
||||
|
||||
export async function getLargeOperationThrottle(itemCount = 0, mode: string = "auto") {
|
||||
const audible = await hasAudibleTabs();
|
||||
const storedProfile = await getPerformanceProfile();
|
||||
const configuredMode = mode && mode !== "auto" ? mode : storedProfile;
|
||||
const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible);
|
||||
let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE;
|
||||
let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS;
|
||||
|
||||
if (configuredMode === "ultra" || itemCount >= 300) {
|
||||
batchSize = Math.max(3, Math.floor(batchSize / 2));
|
||||
pauseMs *= 2;
|
||||
} else if (itemCount >= 100) {
|
||||
batchSize = Math.max(5, Math.floor(batchSize * 0.75));
|
||||
pauseMs = Math.max(pauseMs, 75);
|
||||
}
|
||||
|
||||
return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode };
|
||||
}
|
||||
|
||||
export function updateJobProgress(job: Job | undefined | null, { phase, current, total }: JobProgressUpdate = {}) {
|
||||
if (!job) return;
|
||||
if (phase) job.phase = phase;
|
||||
if (total != null) job.total = total;
|
||||
if (current != null) job.current = current;
|
||||
if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total));
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function throwIfJobCancelled(job: Job | undefined | null) {
|
||||
if (job?.cancelRequested) {
|
||||
throw new Error(`Job '${job.id}' cancelled`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function yieldForLargeOperation(processed: number, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) {
|
||||
if (processed > 0 && processed % batchSize === 0) {
|
||||
await sleep(pauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `handler` over `items` in throttle-sized slices, threading job progress
|
||||
* and cancellation. Each slice is one `handler` call (so chrome batch APIs like
|
||||
* tabs.remove get an array); between slices it reports progress and yields.
|
||||
*
|
||||
* `total`/`baseCurrent` let callers report against a fixed total accumulated
|
||||
* across several calls (e.g. merging tabs from many windows). Returns the
|
||||
* running processed count so the next call can continue from it.
|
||||
*/
|
||||
export async function processInBatches<T>(
|
||||
items: T[],
|
||||
throttle: { batchSize: number; pauseMs: number },
|
||||
handler: (batch: T[]) => Promise<unknown>,
|
||||
progress: { job?: Job | null; phase: string; total?: number; baseCurrent?: number },
|
||||
): Promise<number> {
|
||||
const total = progress.total ?? items.length;
|
||||
let done = progress.baseCurrent ?? 0;
|
||||
updateJobProgress(progress.job, { phase: progress.phase, current: done, total });
|
||||
for (let i = 0; i < items.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(progress.job);
|
||||
const batch = items.slice(i, i + throttle.batchSize);
|
||||
await handler(batch);
|
||||
done += batch.length;
|
||||
updateJobProgress(progress.job, { phase: progress.phase, current: done, total });
|
||||
await yieldForLargeOperation(done, throttle.batchSize, throttle.pauseMs);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
Reference in New Issue
Block a user