refactor(extension): class-based command registry + modular src layout
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 47s
Testing / test (push) Successful in 52s

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:
2026-06-11 00:33:00 +02:00
parent d2f2a99f3d
commit ba01be1c5d
41 changed files with 2522 additions and 1589 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { CommandArgs, Serializable } from '../types';
import type { JobManager } from './JobManager';
export type CommandRun = (args: CommandArgs) => Promise<Serializable> | Serializable;
export interface CommandSpec { background?: boolean; run: CommandRun; }
export type CommandEntry = CommandRun | CommandSpec;
/** Shared state handed to every command group at construction. */
export interface CommandContext { jobs: JobManager; }
/**
* A command group bundles a set of related subcommands. `commands` is keyed by
* the FULL command id (e.g. "tabs.close") so groups spanning multiple
* namespaces (dom/extract/page, storage/cookies, session/clients) register
* uniformly. `namespace` is documentation/grouping only.
*/
export abstract class CommandGroup {
constructor(protected readonly ctx: CommandContext) {}
abstract readonly namespace: string;
abstract readonly commands: Record<string, CommandEntry>;
}
+94
View File
@@ -0,0 +1,94 @@
import { CommandGroup } from './CommandGroup';
import type { CommandContext, CommandEntry, CommandSpec } from './CommandGroup';
import { NavigationCommands } from '../commands/navigation';
import { TabsMutationCommands } from '../commands/tabs';
import { TabsQueryCommands } from '../commands/tabs-query';
import { GroupsCommands } from '../commands/groups';
import { WindowsCommands } from '../commands/windows';
import { DomCommands } from '../commands/dom';
import { BrowserDataCommands } from '../commands/browser-data';
import { SessionCommands } from '../commands/session';
import { PerfCommands } from '../commands/perf';
import { ExtensionCommands } from '../commands/extension';
import type { CommandArgs, Serializable, DispatchArgs, PageRequest } from '../types';
function isCommandSpec(entry: CommandEntry): entry is CommandSpec {
return typeof entry !== "function";
}
export function makePagedData(items: Serializable[], page: PageRequest) {
const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0);
const requestedLimit = Math.max(1, Number(page.limit) || 100);
const limit = Math.min(requestedLimit, 1000);
const end = Math.min(offset + limit, total);
return {
__browserCliPage: true,
items: items.slice(offset, end),
offset,
limit,
total,
nextOffset: end < total ? end : null,
};
}
export class CommandRegistry {
private readonly entries = new Map<string, CommandEntry>();
constructor(private readonly ctx: CommandContext) {}
/** Flattens a group's full-id-keyed commands into the registry map. */
register(group: CommandGroup): void {
for (const [command, entry] of Object.entries(group.commands)) {
if (this.entries.has(command)) {
throw new Error(`Duplicate command registration: ${command}`);
}
this.entries.set(command, entry);
}
}
private resolve(command: string): CommandEntry {
const entry = this.entries.get(command);
if (!entry) throw new Error(`Unknown command: ${command}`);
return entry;
}
async dispatch(command: string, args: DispatchArgs, opts: { background?: boolean; page?: PageRequest }): Promise<Serializable> {
const entry = this.resolve(command);
if (isCommandSpec(entry) && entry.background && opts.background) {
// Narrow the dynamic IPC dict to the handler's declared arg type at the
// call boundary.
return this.ctx.jobs.start(command, args, jobArgs => Promise.resolve(entry.run(jobArgs as CommandArgs)));
}
const run = isCommandSpec(entry) ? entry.run : entry;
let result = await run(args as CommandArgs);
if (opts.page && Array.isArray(result)) {
result = makePagedData(result, opts.page);
}
return result;
}
}
/**
* Builds the registry and registers every command group. The SessionCommands
* instance is returned alongside because index.ts wires its lifecycle methods
* (chrome.tabs.onActivated → activateLazyTab) and NativeConnection references it
* for the clients.rename_profile reconnect side-effect.
*/
export function assembleRegistry(ctx: CommandContext): { registry: CommandRegistry; session: SessionCommands } {
const registry = new CommandRegistry(ctx);
const session = new SessionCommands(ctx);
registry.register(new NavigationCommands(ctx));
registry.register(new TabsMutationCommands(ctx));
registry.register(new TabsQueryCommands(ctx));
registry.register(new GroupsCommands(ctx));
registry.register(new WindowsCommands(ctx));
registry.register(new DomCommands(ctx));
registry.register(new BrowserDataCommands(ctx));
registry.register(session);
registry.register(new PerfCommands(ctx));
registry.register(new ExtensionCommands(ctx));
return { registry, session };
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Background-job retention helpers + the JobManager that owns the live job map.
*
* `pruneFinishedJobs` / `MAX_FINISHED_JOBS` are kept free of chrome.* /
* service-worker side effects so the retention logic (memory-leak guard) can be
* unit-tested in isolation.
*/
import { getErrorMessage } from '../core';
import type { Job, Serializable, ErrorLike, DispatchArgs } from '../types';
// Cap retained finished jobs so the in-memory Map cannot grow unbounded while
// the service worker is pinned alive by the native port. Kept small: finished
// jobs only need to survive long enough for the CLI to poll their result.
export const MAX_FINISHED_JOBS = 20;
// Watchdog: if a runner never resolves/rejects (e.g. executeScript against a
// dead tab), finalize the job as an error so its persist interval stops instead
// of writing to chrome.storage.local every second forever.
export const JOB_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Evict the oldest *finished* jobs from `jobs` once their count exceeds `max`.
* Running jobs are always kept. Each evicted job's persistence timer (if any)
* is cleared via `clearTimer` so no interval is leaked.
*
* Returns the list of evicted job ids (useful for assertions/logging).
*/
export function pruneFinishedJobs(
jobs: Map<string, Job>,
max = MAX_FINISHED_JOBS,
clearTimer: (handle: NonNullable<Job["__timer"]>) => void = clearInterval,
): string[] {
const finished = [...jobs.values()].filter(job => job.status !== "running");
const excess = finished.length - max;
if (excess <= 0) return [];
const evicted = finished
.sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0))
.slice(0, excess);
for (const job of evicted) {
if (job.__timer) clearTimer(job.__timer);
jobs.delete(job.id);
}
return evicted.map(job => job.id);
}
/** Runs a background command, threading the live `__job` handle for progress. */
export type JobRunner = (args: DispatchArgs) => Promise<Serializable>;
/** Owns the live job map plus its persistence + retention lifecycle. */
export class JobManager {
private readonly jobs = new Map<string, Job>();
/** Snapshot of live jobs (for perf.status etc.). */
list(): Job[] {
return [...this.jobs.values()];
}
private async persistJobs() {
// Persist all running jobs plus the most recent finished ones. Running jobs
// must never be dropped by the cap, otherwise a burst of finished jobs could
// evict a still-active job from storage. Live timer handles are stripped —
// they are not meaningful once stored.
const all = [...this.jobs.values()];
const running = all.filter(job => job.status === "running");
const finished = all.filter(job => job.status !== "running").slice(-MAX_FINISHED_JOBS);
const recentJobs = [...running, ...finished].map(({ __timer, __watchdog, ...rest }) => rest);
await chrome.storage.local.set({ recentJobs });
}
// Evict the oldest finished jobs once their count exceeds the retention cap.
// Recent finished jobs remain queryable via chrome.storage.local (persistJobs)
// even after eviction from the in-memory Map.
private pruneJobs() {
pruneFinishedJobs(this.jobs, MAX_FINISHED_JOBS);
}
async start(command: string, args: DispatchArgs, runner: JobRunner) {
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const job: Job = {
id: jobId,
command,
status: "running",
phase: "queued",
current: 0,
total: null,
percent: 0,
cancelRequested: false,
startedAt: Date.now(),
updatedAt: Date.now(),
finishedAt: null,
result: null,
error: null,
};
this.jobs.set(jobId, job);
job.__timer = setInterval(() => this.persistJobs(), 1000);
job.__watchdog = setTimeout(() => {
job.cancelRequested = true;
void this.finalize(job, "error", { error: `Job timed out after ${JOB_TIMEOUT_MS}ms` });
}, JOB_TIMEOUT_MS);
await this.persistJobs();
runner({ ...args, __job: job })
.then(result => this.finalize(job, "done", { result }))
.catch((error: ErrorLike) =>
this.finalize(job, job.cancelRequested ? "cancelled" : "error", {
error: getErrorMessage(error),
}),
);
return { jobId, command, status: job.status };
}
/**
* Move a running job to a terminal state exactly once. Late settlements (a
* runner that resolves after the watchdog already fired) are ignored so a
* timed-out job cannot be resurrected. Clears both the persist interval and
* the watchdog so no timer outlives the job.
*/
private async finalize(
job: Job,
status: "done" | "error" | "cancelled",
{ result = null, error = null }: { result?: Serializable; error?: string | null },
) {
if (job.status !== "running") return;
job.status = status;
job.phase = status;
if (status === "done") {
job.result = result;
job.current = job.total || job.current;
job.percent = 100;
} else {
job.error = error;
}
job.finishedAt = Date.now();
job.updatedAt = Date.now();
if (job.__timer) clearInterval(job.__timer);
if (job.__watchdog) clearTimeout(job.__watchdog);
job.__timer = null;
job.__watchdog = null;
this.pruneJobs();
await this.persistJobs();
}
async status({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId);
if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get<{ recentJobs?: Job[] }>("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored;
}
async cancel({ jobId }: { jobId?: string }) {
const job = this.jobs.get(jobId);
if (!job) throw new Error(`Job '${jobId}' not running`);
job.cancelRequested = true;
job.updatedAt = Date.now();
await this.persistJobs();
return { jobId, cancelled: true };
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Native-messaging port lifecycle: connect/keepalive/reconnect plus the inbound
* message router that hands commands to the CommandRegistry.
*/
import { getErrorMessage, getProfileAlias } from '../core';
import type { CommandRegistry } from './CommandRegistry';
import type { SessionCommands } from '../commands/session';
import type { ControlMessage, ResponseMessage, IncomingMessage, PageRequest, DispatchArgs, Serializable } from '../types';
const NATIVE_HOST = "com.browsercli.host";
export class NativeConnection {
private port: chrome.runtime.Port | null = null;
private keepaliveEnabled = true;
constructor(
private readonly registry: CommandRegistry,
private readonly session: SessionCommands,
) {}
/** Registers all runtime listeners and opens the initial connection. */
start() {
chrome.runtime.onInstalled.addListener(() => this.connect());
chrome.runtime.onStartup.addListener(() => this.connect());
chrome.runtime.onSuspend.addListener(() => {
this.disconnectPort({ sendBye: true });
});
chrome.windows.onCreated.addListener(() => {
this.keepaliveEnabled = true;
if (!this.port) this.connect();
});
chrome.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({});
if (windows.length > 0) return;
this.keepaliveEnabled = false;
this.disconnectPort({ sendBye: true });
});
// Reconnect poll — wakes the worker to re-establish the native port if it
// dropped. 0.5 min is Chrome's minimum alarm period; lower values (e.g. 0.4)
// are silently clamped and log a warning, so we set it explicitly.
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") {
if (!this.port && this.keepaliveEnabled) this.connect();
}
});
}
private sendControlMessage(targetPort: chrome.runtime.Port | null, message: ControlMessage) {
if (!targetPort) return;
try {
targetPort.postMessage(message);
} catch (e) {
console.warn("[browser-cli] Failed to send control message:", e);
}
}
private sendResponse(targetPort: chrome.runtime.Port | null, message: ResponseMessage) {
if (!targetPort) return;
try {
targetPort.postMessage(message);
} catch (e) {
console.warn("[browser-cli] Failed to send response:", e);
}
}
private disconnectPort({ sendBye = false }: { sendBye?: boolean } = {}) {
const currentPort = this.port;
if (!currentPort) return;
if (sendBye) this.sendControlMessage(currentPort, { type: "bye" });
if (this.port === currentPort) this.port = null;
try {
currentPort.disconnect();
} catch (e) {
console.warn("[browser-cli] Failed to disconnect native port:", e);
}
}
private async connect() {
if (this.port || !this.keepaliveEnabled) return;
try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
this.port = nativePort;
nativePort.onMessage.addListener((msg: IncomingMessage) => this.onMessage(msg));
nativePort.onDisconnect.addListener(() => {
if (this.port === nativePort) this.port = null;
const err = chrome.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
});
// Send hello so native host knows which profile/alias this is
const alias = await getProfileAlias();
nativePort.postMessage({ type: "hello", alias });
console.log("[browser-cli] Connected to native host as profile:", alias);
} catch (e) {
this.port = null;
console.error("[browser-cli] Failed to connect:", e);
}
}
private async onMessage(msg: IncomingMessage) {
const { id, command, args } = msg;
if (!id || !command) return;
// Capture the port that delivered this message. dispatch() is awaited, and
// during that await onDisconnect/rename can null out this.port — so we reply
// on the captured port and bail if it (or this.port) is already gone.
const replyPort = this.port;
console.log("[browser-cli] ←", command, args);
let data: Serializable, error: string | undefined;
try {
const { __page, __background, ...commandArgs } = args || {};
data = await this.registry.dispatch(command, commandArgs as DispatchArgs, {
background: Boolean(__background),
page: __page as PageRequest | undefined,
});
} catch (e) {
error = getErrorMessage(e);
}
if (this.port !== replyPort) {
console.warn("[browser-cli] Port changed before reply, dropping:", command);
return;
}
if (error !== undefined) {
console.log("[browser-cli] → ERROR", command, error);
this.sendResponse(replyPort, { id, success: false, error });
} else {
console.log("[browser-cli] →", command, data);
this.sendResponse(replyPort, { id, success: true, data });
}
if (command === "clients.rename_profile" && error === undefined) {
this.disconnectPort({ sendBye: true });
this.keepaliveEnabled = true;
await this.connect();
}
}
}