openpencil/packages/op-chrome-extension/scripts/check-sw-imports.mjs
Kayshen-X 64d70d672d feat(extension): add OpenPencil web-capture Chrome extension
MV3 extension that captures the rendered active tab (via the shared
snapshot-extractor contract) and imports it into OpenPencil. Logic lives
in the new op-chrome-extension-core crate (wasm): endpoint rules,
chunked-transfer integrity, /mcp envelope + reply classification,
download-name sanitisation, SSO/account session parsing, and hub
snapshot-inbox delivery. JS is glue only (chrome.* APIs, fetch, popup
DOM, injected page functions).

Capture: full page + element pick. Delivery: local ingress
(POST /api/import/web-snapshot) with /mcp fallback, JSON download, and —
when signed in to OP Hub — the account snapshot inbox. Flat popup UI,
15-locale strings with an in-popup language switcher, store packaging.

Why a crate: keeps the security-sensitive logic in tested Rust rather
than glue JS, and the SW/popup split keeps dynamic import() out of the
service-worker graph (guarded by check-sw-imports).
2026-08-04 21:43:01 +08:00

151 lines
5.4 KiB
JavaScript

/**
* Fail if the MV3 service worker's static import graph contains a dynamic
* `import(` call.
*
* Why this needs a guard rather than a code review: `import()` is disallowed
* on `ServiceWorkerGlobalScope` by the HTML specification
* (https://github.com/w3c/ServiceWorker/issues/1356). Chrome rejects the call
* itself, whenever it happens — deferring it, or making it during the
* worker's initial evaluation, changes nothing. And nothing catches it early:
* the extension loads, the popup works, the manifest validates, and the
* failure only appears when a user triggers the pick flow, as a rejection
* inside the worker. That is a long way from the edit that caused it.
*
* The rule is therefore structural. `background.js` is the worker entry; this
* walks its relative static-import graph and refuses any dynamic `import(` in
* it. The popup keeps its dynamic loader (`wasm-core.js`), which is correct
* for a document — proving the worker never *reaches* that file is this
* guard's real job.
*
* Comments and string literals are stripped before matching, so the many
* prose mentions of `import()` in this codebase's own documentation do not
* trip it. Regular-expression literals are not tracked; nothing here contains
* one, and a false positive from that direction would be a guard that is too
* strict rather than one that lets the bug through.
*/
import { readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const extensionDirectory = resolve(scriptDirectory, '..');
const ENTRY = 'background.js';
/**
* Blank out comments — and, when `keepStrings` is false, string literals too —
* preserving offsets and newlines so reported line numbers stay true.
*
* The dynamic-import scan wants strings gone as well (a quoted `import(` in
* prose is not code). The specifier scan obviously needs them kept, since the
* specifier IS a string; blanking comments alone is enough there, because a
* commented-out import must not pull a file into the graph.
*/
function stripComments(source, keepStrings) {
let out = '';
let index = 0;
const blank = (text) => text.replaceAll(/[^\n]/g, ' ');
while (index < source.length) {
const two = source.slice(index, index + 2);
if (two === '//') {
const end = source.indexOf('\n', index);
const stop = end === -1 ? source.length : end;
out += blank(source.slice(index, stop));
index = stop;
continue;
}
if (two === '/*') {
const end = source.indexOf('*/', index + 2);
const stop = end === -1 ? source.length : end + 2;
out += blank(source.slice(index, stop));
index = stop;
continue;
}
const quote = source[index];
if (quote === '"' || quote === "'" || quote === '`') {
let cursor = index + 1;
while (cursor < source.length) {
if (source[cursor] === '\\') {
cursor += 2;
continue;
}
if (source[cursor] === quote) {
cursor += 1;
break;
}
cursor += 1;
}
const literal = source.slice(index, cursor);
out += keepStrings ? literal : blank(literal);
index = cursor;
continue;
}
out += quote;
index += 1;
}
return out;
}
/** `import` immediately followed by `(`, not preceded by an identifier char or a dot. */
const DYNAMIC_IMPORT = /(^|[^\w$.])import\s*\(/g;
/** Relative specifiers of static imports: `from './x.js'` and `import './x.js'`. */
const STATIC_SPECIFIER = /(?:from|import)\s*['"](\.[^'"]*)['"]/g;
function lineOf(source, offset) {
return source.slice(0, offset).split('\n').length;
}
const queue = [ENTRY];
const visited = [];
const violations = [];
const missing = [];
while (queue.length > 0) {
const current = queue.shift();
if (visited.includes(current)) continue;
visited.push(current);
const absolute = resolve(extensionDirectory, current);
let source;
try {
source = readFileSync(absolute, 'utf8');
} catch {
// `wasm/` is a build product; the guard must run in a checkout that has
// not been built yet, so an unresolvable file is recorded, not fatal.
missing.push(current);
continue;
}
const code = stripComments(source, false);
for (const match of code.matchAll(DYNAMIC_IMPORT)) {
violations.push({ file: current, line: lineOf(code, match.index) });
}
for (const match of stripComments(source, true).matchAll(STATIC_SPECIFIER)) {
const target = resolve(dirname(absolute), match[1]);
if (target.startsWith(extensionDirectory)) {
queue.push(relative(extensionDirectory, target));
}
}
}
if (violations.length > 0) {
for (const { file, line } of violations) {
console.error(`sw-imports: ${file}:${line}: dynamic import() is reachable from ${ENTRY}`);
}
console.error(
'sw-imports: import() is disallowed on ServiceWorkerGlobalScope (w3c/ServiceWorker#1356).',
);
console.error(
`sw-imports: import the module statically in ${ENTRY}, or keep the file that needs`,
);
console.error('sw-imports: a dynamic import out of the worker graph.');
process.exit(1);
}
console.log(`sw-imports: ok (${visited.length} files reachable from ${ENTRY}, no dynamic import)`);
console.log(`sw-imports: graph = ${visited.join(' ')}`);
if (missing.length > 0) {
console.log(`sw-imports: not built yet, skipped = ${missing.join(' ')}`);
}