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,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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user