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