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,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