feat: 支持 Firefox 浏览器#15
Conversation
- 新增 browser-compat.ts,提供 compatStorage.session 包装层 - 运行时探测 chrome.storage.session 可用性,不可用时降级到 storage.local - .gitignore 添加 dist-firefox/ 和 *.xpi
- tab-store.ts 4 处 chrome.storage.session 调用替换为 compatStorage.session - popup-cache.ts 1 处 chrome.storage.session.set 替换为 compatStorage.session.set - index.ts onStartup 中添加 clearLegacySessionKeys 清理降级遗留数据
- 新增 build-scripts/package-firefox.mjs - 复制 dist/ 并转换 manifest.json(service_worker → background.scripts,添加 browser_specific_settings.gecko) - 解决 CRXJS ES module 代码分割问题:shared chunks 包裹 IIFE 隔离作用域 - package.json 添加 build:firefox 脚本
- meta 步骤输出 xpi_name - 打包 zip 后新增 Firefox .xpi 打包步骤 - 上传产物包含 .xpi 和 .sha256
Firefox 的 chrome.scripting.executeScript 可能返回原始 Promise 而非自动 await 后的 resolved 值,导致页面检测结果为空。
Reviewer's Guide添加 Firefox 浏览器支持:围绕 Firefox 构建与打包流水线流程图flowchart TD
A[pnpm build] --> B[dist/]
B --> C[Run package-firefox.mjs]
C --> D[Copy dist/ to dist-firefox/]
D --> E[Inline ES module chunks into background.js]
E --> F[Transform manifest.json<br>service_worker -> background.scripts<br>add browser_specific_settings.gecko]
F --> G[Create stackprism-vX.Y.Z.xpi in release/]
subgraph GitHubActions_release_workflow
H[Compute meta<br>zip_name, crx_name, xpi_name]
H --> I[打包 zip]
H --> J[打包 Firefox .xpi<br>node build-scripts/package-firefox.mjs]
J --> K[Generate xpi SHA256]
I --> L[Upload assets to GitHub Release<br>zip, zip.sha256, xpi, xpi.sha256]
K --> L
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your Experience打开你的 dashboard 以:
Getting HelpOriginal review guide in EnglishReviewer's GuideAdds Firefox browser support with a minimal compatibility layer around chrome.storage.session, a Firefox-specific packaging script/flow, and small behavior fixes for background scripts and CI release automation. Flow diagram for Firefox build and packaging pipelineflowchart TD
A[pnpm build] --> B[dist/]
B --> C[Run package-firefox.mjs]
C --> D[Copy dist/ to dist-firefox/]
D --> E[Inline ES module chunks into background.js]
E --> F[Transform manifest.json<br>service_worker -> background.scripts<br>add browser_specific_settings.gecko]
F --> G[Create stackprism-vX.Y.Z.xpi in release/]
subgraph GitHubActions_release_workflow
H[Compute meta<br>zip_name, crx_name, xpi_name]
H --> I[打包 zip]
H --> J[打包 Firefox .xpi<br>node build-scripts/package-firefox.mjs]
J --> K[Generate xpi SHA256]
I --> L[Upload assets to GitHub Release<br>zip, zip.sha256, xpi, xpi.sha256]
K --> L
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - 我发现了 3 个问题,并留下了一些整体反馈:
build-scripts/package-firefox.mjs中的 ES 模块内联逻辑依赖于对import{...}from"..."和export{...}这类模式非常严格的正则匹配,如果 CRXJS 的输出格式稍有变化就很容易失效;建议要么使用一个轻量的 JS 解析器(例如 acorn),要么放宽 / 统一这些正则,使其能容忍空白字符和不同语法形式。compatStorage.session.remove辅助方法目前只接受string[],但底层的 Chrome API 接受string | string[];将签名扩展为同时支持这两种形式,可以降低后续调用点误用的风险,也能更贴近原生 API。
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The ES module inlining logic in `build-scripts/package-firefox.mjs` relies on very specific regexes for `import{...}from"..."` and `export{...}` patterns, which could easily break if CRXJS output formatting changes; consider either using a small JS parser (e.g. acorn) or loosening/centralizing these regexes to tolerate whitespace and different syntaxes.
- The `compatStorage.session.remove` helper only accepts `string[]`, whereas the underlying Chrome API accepts `string | string[]`; aligning the signature to support both would make it harder to misuse in future call sites and keep it closer to the native API.
## Individual Comments
### Comment 1
<location path="src/utils/browser-compat.ts" line_range="18-23" />
<code_context>
+
+export const compatStorage = {
+ session: {
+ get: async (key: string): Promise<Record<string, unknown>> => {
+ if (await checkSessionSupport()) {
+ return chrome.storage.session.get(key)
+ }
+ const result = await chrome.storage.local.get(SESSION_PREFIX + key)
+ const raw = result[SESSION_PREFIX + key]
+ return raw ? { [key]: raw } : {}
+ },
</code_context>
<issue_to_address>
**issue (bug_risk):** 回退到 local storage 的 session get 逻辑会丢失存储在本地存储中的合法假值。
在使用 local storage 的分支中,`raw ? { [key]: raw } : {}` 会把合法的假值(`0`、`false`、`''`)当作缺失值处理,因此永远无法返回这些值。应当检查键是否存在,而不是检查其真值性,例如:
```ts
const storageKey = SESSION_PREFIX + key
const result = await chrome.storage.local.get(storageKey)
if (Object.prototype.hasOwnProperty.call(result, storageKey)) {
return { [key]: result[storageKey] }
}
return {}
```
</issue_to_address>
### Comment 2
<location path="build-scripts/package-firefox.mjs" line_range="5" />
<code_context>
+import { resolve, dirname, basename } from 'node:path'
+import { execFileSync } from 'node:child_process'
+
+const root = resolve(import.meta.dirname, '..')
+const distDir = resolve(root, 'dist')
+const firefoxDir = resolve(root, 'dist-firefox')
</code_context>
<issue_to_address>
**issue (bug_risk):** `import.meta.dirname` 是非标准特性,在当前的 Node 版本中会失败。
请改用 Node 标准的 `import.meta.url` 方案:
```js
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const root = resolve(__dirname, '..')
```
这样可以在各受支持的 Node 版本上可靠工作。
</issue_to_address>
### Comment 3
<location path="build-scripts/package-firefox.mjs" line_range="24-36" />
<code_context>
+// 2. Store each chunk's exports in a per-chunk namespace
+// 3. Replace the entry chunk's imports with variable declarations from those namespaces
+
+const parseAllImportBindings = (code) => {
+ const re = /import\{([^}]*)\}from"([^"]*)"/g
+ const imports = []
+ let match
+ while ((match = re.exec(code)) !== null) {
+ const bindings = match[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/)
+ return { exported: parts[0].trim(), local: (parts[1] || parts[0]).trim() }
+ })
+ imports.push({ path: match[2], bindings })
+ }
+ return imports
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** import/export 解析与一种非常特定的压缩后代码形态强耦合,在打包器稍有调整时就可能出错。
这些正则假设了一种非常狭窄的格式(无空格、双引号、无换行、只支持具名 import/export)。只要 CRXJS/打包器输出有变化(例如空格、引号形式不同,或出现默认/命名空间导入),就可能在不报错的情况下破坏生成的 background bundle。
为了提高稳健性,可以:
- 先进行格式归一化 / 放宽格式要求(例如允许任意空白),或者
- 明确只支持当前模式,对不支持的形态抛出清晰的错误。
第二种方式更安全:与其生成无效的 `background.js`,不如尽早以可读错误失败。
```suggestion
const parseAllImportBindings = (code) => {
// Supports minified and non-minified named imports:
// import { foo, bar as baz } from "chunk.js"
// Allows arbitrary whitespace and both quote styles.
const namedImportRe = /import\s*\{\s*([^}]*)\}\s*from\s*(['"])(.*?)\2/g
const imports = []
let match
while ((match = namedImportRe.exec(code)) !== null) {
const [, rawBindings, , importPath] = match
const bindings = rawBindings
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
const parts = s.split(/\s+as\s+/)
const exported = parts[0]?.trim()
const local = (parts[1] || parts[0] || '').trim()
if (!exported || !local) {
throw new Error(
`[package-firefox] Unsupported import binding "${s}" in "${importPath}". ` +
`Only named imports of the form 'import { foo, bar as baz } from "./chunk.js"' are supported.`,
)
}
return { exported, local }
})
imports.push({ path: importPath, bindings })
}
// Detect other import forms (default, namespace, or side-effect imports) and fail fast.
// This intentionally does *not* match the named import shape above.
const unsupportedImportRe = /import\s+(?!\{)[^'";]+(?:['"][^'"]*['"])?/g
if (unsupportedImportRe.test(code)) {
throw new Error(
'[package-firefox] Unsupported import statement detected in background chunks. ' +
'This script only supports named imports of the form ' +
'"import { foo, bar as baz } from \\"./chunk.js\\"". ' +
'Please adjust the bundler output or extend package-firefox.mjs to handle this shape.',
)
}
// If there are "import {" sequences that we failed to parse, also fail fast.
if (imports.length === 0 && /import\s*\{/.test(code)) {
throw new Error(
'[package-firefox] Failed to parse named imports in background chunks. ' +
'Expected imports of the form "import { foo, bar as baz } from \\"./chunk.js\\"".',
)
}
return imports
}
```
</issue_to_address>帮我变得更有用!请对每条评论点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English
Hey - I've found 3 issues, and left some high level feedback:
- The ES module inlining logic in
build-scripts/package-firefox.mjsrelies on very specific regexes forimport{...}from"..."andexport{...}patterns, which could easily break if CRXJS output formatting changes; consider either using a small JS parser (e.g. acorn) or loosening/centralizing these regexes to tolerate whitespace and different syntaxes. - The
compatStorage.session.removehelper only acceptsstring[], whereas the underlying Chrome API acceptsstring | string[]; aligning the signature to support both would make it harder to misuse in future call sites and keep it closer to the native API.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The ES module inlining logic in `build-scripts/package-firefox.mjs` relies on very specific regexes for `import{...}from"..."` and `export{...}` patterns, which could easily break if CRXJS output formatting changes; consider either using a small JS parser (e.g. acorn) or loosening/centralizing these regexes to tolerate whitespace and different syntaxes.
- The `compatStorage.session.remove` helper only accepts `string[]`, whereas the underlying Chrome API accepts `string | string[]`; aligning the signature to support both would make it harder to misuse in future call sites and keep it closer to the native API.
## Individual Comments
### Comment 1
<location path="src/utils/browser-compat.ts" line_range="18-23" />
<code_context>
+
+export const compatStorage = {
+ session: {
+ get: async (key: string): Promise<Record<string, unknown>> => {
+ if (await checkSessionSupport()) {
+ return chrome.storage.session.get(key)
+ }
+ const result = await chrome.storage.local.get(SESSION_PREFIX + key)
+ const raw = result[SESSION_PREFIX + key]
+ return raw ? { [key]: raw } : {}
+ },
</code_context>
<issue_to_address>
**issue (bug_risk):** Fallback session get loses valid falsy values stored in local storage.
In the local-storage path, `raw ? { [key]: raw } : {}` treats valid falsy values (`0`, `false`, `''`) as missing, so they can never be returned. Instead, check for key presence, not truthiness, e.g.:
```ts
const storageKey = SESSION_PREFIX + key
const result = await chrome.storage.local.get(storageKey)
if (Object.prototype.hasOwnProperty.call(result, storageKey)) {
return { [key]: result[storageKey] }
}
return {}
```
</issue_to_address>
### Comment 2
<location path="build-scripts/package-firefox.mjs" line_range="5" />
<code_context>
+import { resolve, dirname, basename } from 'node:path'
+import { execFileSync } from 'node:child_process'
+
+const root = resolve(import.meta.dirname, '..')
+const distDir = resolve(root, 'dist')
+const firefoxDir = resolve(root, 'dist-firefox')
</code_context>
<issue_to_address>
**issue (bug_risk):** `import.meta.dirname` is non-standard and will fail on current Node versions.
Use Node’s standard `import.meta.url` pattern instead:
```js
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const root = resolve(__dirname, '..')
```
This will work reliably across supported Node versions.
</issue_to_address>
### Comment 3
<location path="build-scripts/package-firefox.mjs" line_range="24-36" />
<code_context>
+// 2. Store each chunk's exports in a per-chunk namespace
+// 3. Replace the entry chunk's imports with variable declarations from those namespaces
+
+const parseAllImportBindings = (code) => {
+ const re = /import\{([^}]*)\}from"([^"]*)"/g
+ const imports = []
+ let match
+ while ((match = re.exec(code)) !== null) {
+ const bindings = match[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/)
+ return { exported: parts[0].trim(), local: (parts[1] || parts[0]).trim() }
+ })
+ imports.push({ path: match[2], bindings })
+ }
+ return imports
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Import/export parsing is tightly coupled to a very specific minified shape and may break with small bundler changes.
These regexes assume a very narrow format (no spaces, double quotes, no line breaks, named-only imports/exports). Any change in CRXJS/bundler output (e.g., different spacing, quotes, or default/namespace imports) could silently break the background bundle.
To improve robustness, either:
- Normalize/relax formatting (e.g., tolerate arbitrary whitespace), or
- Explicitly support only the current patterns and throw a clear error on unsupported shapes.
The second option is safer: fail fast with a descriptive error instead of emitting an invalid `background.js`.
```suggestion
const parseAllImportBindings = (code) => {
// Supports minified and non-minified named imports:
// import { foo, bar as baz } from "chunk.js"
// Allows arbitrary whitespace and both quote styles.
const namedImportRe = /import\s*\{\s*([^}]*)\}\s*from\s*(['"])(.*?)\2/g
const imports = []
let match
while ((match = namedImportRe.exec(code)) !== null) {
const [, rawBindings, , importPath] = match
const bindings = rawBindings
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
const parts = s.split(/\s+as\s+/)
const exported = parts[0]?.trim()
const local = (parts[1] || parts[0] || '').trim()
if (!exported || !local) {
throw new Error(
`[package-firefox] Unsupported import binding "${s}" in "${importPath}". ` +
`Only named imports of the form 'import { foo, bar as baz } from "./chunk.js"' are supported.`,
)
}
return { exported, local }
})
imports.push({ path: importPath, bindings })
}
// Detect other import forms (default, namespace, or side-effect imports) and fail fast.
// This intentionally does *not* match the named import shape above.
const unsupportedImportRe = /import\s+(?!\{)[^'";]+(?:['"][^'"]*['"])?/g
if (unsupportedImportRe.test(code)) {
throw new Error(
'[package-firefox] Unsupported import statement detected in background chunks. ' +
'This script only supports named imports of the form ' +
'"import { foo, bar as baz } from \\"./chunk.js\\"". ' +
'Please adjust the bundler output or extend package-firefox.mjs to handle this shape.',
)
}
// If there are "import {" sequences that we failed to parse, also fail fast.
if (imports.length === 0 && /import\s*\{/.test(code)) {
throw new Error(
'[package-firefox] Failed to parse named imports in background chunks. ' +
'Expected imports of the form "import { foo, bar as baz } from \\"./chunk.js\\"".',
)
}
return imports
}
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
- browser-compat: storage.local 降级 get 改用 hasOwnProperty 检查,避免 0/false/'' 等 falsy 值被丢弃 - package-firefox: import.meta.dirname 替换为 fileURLToPath,兼容 Node 20 CI 环境 - package-firefox: import/export 解析增加守卫,遇到不支持的模块语法时抛出明确错误
There was a problem hiding this comment.
看了一下,整体思路对,但有几处没法直接合,挑大的说。
package-firefox.mjs 那个手写的 ESM 解析器实在太脆。靠 /import\{([^}]*)\}from"([^"]*)"/g 锁死 Vite 压缩后的输出格式 —— 哪天 minifier 多吐一个空格、注释,或者出现 default / namespace import,脚本就静默生成一份坏的 background.js,里面那个 throw 也只在 strip 完还残留 import\s 才触发,够不上兜底。换成 esbuild 给 background 二次 bundle 成 IIFE(format: 'iife'),或者让 Vite 单独给 background 跑一次 rollupOptions.output.format='iife',都能彻底解决。esbuild 通过 Vite 已经在依赖里了,不用新装。
另外两个也算 blocker:
execFileSync('zip', ...)系统 zip 在 Linux 上没问题,Windows 本地跑pnpm build:firefox会直接挂。换archiver之类 Node 端的就行。gecko.id写的是stackprism@stackprism.dev,这个域不归项目持有,AMO 签名之后 ID 永久绑死,改不了。建议换成stackprism@setube.github.io。
零碎几点:
detection.ts里typeof rawResult.then === 'function' ? await rawResult : rawResult直接await rawResult就够,await对非 Promise 是 no-op。clearLegacySessionKeys只挂在onStartup,从老版本(无 session 支持)升上来的浏览器,旧__sp_session__:前缀 key 会留在 local 里。size 级问题,不影响正确性,下个版本再处理也行。- sourcery 那两条(falsy /
import.meta.dirname)从 diff 看已经改过了,是过时评论。
改下 ESM 解析 / zip / id 这三处就能走流程。
- 用 esbuild bundle + format:'iife' 替代手写 ESM 解析器,不再依赖 minifier 输出格式 - 用 archiver 替代系统 zip 命令,兼容 Windows 构建环境 - gecko.id 修正为 stackprism@setube.github.io
await 对非 Promise 值是 no-op,无需额外的 typeof then 守卫
- onInstalled 回调中也调用 clearLegacySessionKeys,覆盖扩展更新场景 - package-firefox.mjs: 修复 resolve 变量遮蔽 path.resolve - package-firefox.mjs: archive.glob 排除 .DS_Store 等隐藏文件
概述
添加 Firefox 浏览器支持,扩展 StackPrism 的平台覆盖范围。
改动说明
兼容层(最小侵入)
src/utils/browser-compat.ts,提供compatStorage.session包装层chrome.storage.session可用性,Firefox 128+ 直接使用原生 API,旧版降级到storage.localtab-store.ts和popup-cache.ts中 5 处chrome.storage.session调用Firefox 打包
build-scripts/package-firefox.mjsservice_worker→background.scriptsbrowser_specific_settings.geckopnpm build:firefox即可生成.xpi文件Firefox 兼容修复
detection.ts:executeScriptMAIN world 结果兼容 Firefox 的 Promise 返回行为CI
.xpi并上传到 GitHub Release技术细节
world: 'MAIN'和完整storage.session)chrome.*API 中仅storage.session需要兼容包装,其余在 Firefox MV3 下完全兼容webextension-polyfill未引入 — Firefox MV3 原生支持chrome.*回调式 API测试
pnpm build)pnpm build:firefox)pnpm test:unit)pnpm lint)background.scripts+browser_specific_settings.gecko)Summary by Sourcery
为扩展添加 Firefox 打包和运行时兼容性,以支持在 Firefox 和 Chrome 上同时运行该扩展。
New Features:
.xpi安装包。chrome.storage.session的浏览器上也能正常工作。Bug Fixes:
executeScript执行结果,以符合 Firefox 后台脚本的行为。Enhancements:
Build:
build:firefoxnpm 脚本以及用于 Firefox 打包的脚本,以重写后台 chunk 和 manifest,从而实现 Firefox 兼容。CI:
.xpi构件。Original summary in English
Summary by Sourcery
Add Firefox packaging and runtime compatibility to support running the extension on Firefox alongside Chrome.
New Features:
Bug Fixes:
Enhancements:
Build:
CI: