diff --git a/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.test.ts b/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.test.ts new file mode 100644 index 00000000..102b7f61 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.test.ts @@ -0,0 +1,250 @@ +import {createHash} from "node:crypto"; +import fs from "node:fs/promises"; +import {SourceMap, type SourceMapPayload} from "node:module"; +import os from "node:os"; +import path from "node:path"; +import {rspack, CssExtractRspackPlugin, type Asset, type Compiler, type Stats} from "@rspack/core"; + +import {CssIdTokenPlugin} from "./index"; + +interface BuildOptions { + context?: string; + scheme?: string; + sourceMap?: boolean; +} + +interface CssBuild { + filename: string; + content: string; +} + +const fixtures = path.resolve(__dirname, "tests/fixtures"); +const projectRoot = path.resolve(__dirname, "../../../../.."); +const compilers = new Set(); +let directory: string; + +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "adnbn-css-extension-id-")); +}); + +afterEach(async () => { + try { + await Promise.all( + Array.from(compilers, compiler => { + return new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); + }) + ); + } finally { + compilers.clear(); + await fs.rm(directory, {recursive: true, force: true}); + } +}); + +const createCompiler = ({context = fixtures, scheme = "chrome-extension", sourceMap = false}: BuildOptions = {}) => { + const compiler = rspack({ + context, + mode: "production", + devtool: sourceMap ? "source-map" : false, + entry: "./entry.js", + output: { + path: path.join(directory, `output-${compilers.size}`), + filename: "bundle.[contenthash].js", + assetModuleFilename: "assets/[name][ext]", + publicPath: "", + hashFunction: "sha256", + hashDigest: "hex", + hashDigestLength: 16, + }, + resolveLoader: {modules: [path.join(projectRoot, "node_modules")]}, + module: { + rules: [ + { + test: /\.css$/, + sideEffects: true, + type: "javascript/auto", + use: [CssExtractRspackPlugin.loader, {loader: "css-loader", options: {modules: false, sourceMap}}], + }, + { + test: /\.(svg|bin)$/, + type: "asset/resource", + oneOf: [ + { + resourceQuery: /browser/, + generator: {publicPath: `${scheme}://__MSG_@@extension_id__/`}, + }, + ], + }, + ], + }, + optimization: {realContentHash: true}, + plugins: [new CssExtractRspackPlugin({filename: "styles.[contenthash].css"}), new CssIdTokenPlugin()], + }); + + compilers.add(compiler); + + return compiler; +}; + +const run = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}) ?? "Missing compilation")); + } else { + resolve(stats); + } + }); + }); +}; + +const cssAsset = (stats: Stats): Asset => { + const asset = stats.compilation.getAssets().find(asset => asset.name.endsWith(".css")); + + if (!asset) { + throw new Error("Expected an emitted CSS asset"); + } + + return asset; +}; + +test.each(["chrome-extension", "moz-extension", "safari-web-extension"])( + "restores every extension ID token in CSS for %s and preserves other assets", + async scheme => { + const stats = await run(createCompiler({scheme})); + const css = cssAsset(stats).source.source().toString(); + + expect(css).toContain(`${scheme}://__MSG_@@extension_id__/assets/icon.svg#first`); + expect(css).toContain(`${scheme}://__MSG_@@extension_id__/assets/icon.svg)`); + expect(css).not.toContain("__MSG_%40@extension_id__"); + expect(css).toContain("__MSG_%40@ui_locale__"); + expect(css).toContain("__MSG_@@extension_id__ __MSG_%40@ui_locale__"); + + const binary = stats.compilation.getAsset("assets/payload.bin")!; + const script = stats.compilation.getAssets().find(asset => asset.name.endsWith(".js"))!; + + expect(binary.source.buffer()).toEqual(await fs.readFile(path.join(fixtures, "payload.bin"))); + expect(script.source.source().toString()).toContain("__MSG_%40@extension_id__"); + } +); + +test("computes the final CSS content hash from the restored bytes", async () => { + const stats = await run(createCompiler()); + const asset = cssAsset(stats); + const hash = createHash("sha256").update(asset.source.buffer()).digest("hex").slice(0, 16); + + expect(asset.name).toBe(`styles.${hash}.css`); +}); + +test("preserves source mappings after multiple token replacements", async () => { + const stats = await run(createCompiler({sourceMap: true})); + const asset = cssAsset(stats); + const css = asset.source.source().toString(); + const map = JSON.parse( + stats.compilation.getAsset(`${asset.name}.map`)!.source.source().toString() + ) as SourceMapPayload; + const original = await fs.readFile(path.join(fixtures, "styles.css"), "utf8"); + const originalLines = original.split(/\r?\n/); + // The CSS minimizer maps selectors. Check the selector after all restored URLs. + const marker = ".unmodified"; + const originalLine = originalLines.findIndex(line => line.includes(marker)); + const markerIndex = css.indexOf(marker); + + expect(map.sourcesContent).toContain(original); + expect(css).not.toContain("__MSG_%40@extension_id__"); + expect(markerIndex).toBeGreaterThanOrEqual(0); + + const beforeMarker = css.slice(0, markerIndex); + + expect( + new SourceMap(map).findEntry( + beforeMarker.split("\n").length - 1, + beforeMarker.length - beforeMarker.lastIndexOf("\n") - 1 + ) + ).toMatchObject({ + originalSource: expect.stringContaining("styles.css"), + originalLine, + originalColumn: originalLines[originalLine].indexOf(marker), + }); +}); + +test("refreshes corrected CSS and hashes when URLs are removed and restored during watch", async () => { + const context = path.join(directory, "watch"); + await fs.cp(fixtures, context, {recursive: true}); + + const compiler = createCompiler({context}); + const builds: CssBuild[] = []; + let failure: Error | undefined; + let notify: (() => void) | undefined; + const watcher = compiler.watch({poll: 50, aggregateTimeout: 20}, (error, stats) => { + if (error || !stats || stats.hasErrors()) { + failure = error ?? new Error(stats?.toString({all: false, errors: true}) ?? "Missing watch compilation"); + } else { + const asset = cssAsset(stats); + builds.push({filename: asset.name, content: asset.source.source().toString()}); + } + + notify?.(); + }); + + const waitForBuild = (selector: string): Promise => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + notify = undefined; + reject(new Error(`Timed out waiting for CSS containing ${selector}`)); + }, 15_000); + + notify = () => { + if (failure) { + clearTimeout(timer); + notify = undefined; + reject(failure); + + return; + } + + const build = builds.find(build => build.content.includes(selector)); + + if (build) { + clearTimeout(timer); + notify = undefined; + resolve(build); + } + }; + + notify(); + }); + }; + + try { + const initial = await waitForBuild(".initialProbe"); + + expect(initial.content).toContain("chrome-extension://__MSG_@@extension_id__/assets/icon.svg#first"); + + await fs.writeFile( + path.join(context, "styles.css"), + await fs.readFile(path.join(fixtures, "styles-clean.css")) + ); + const clean = await waitForBuild(".cleanProbe"); + + expect(clean.content).not.toContain("__MSG_"); + expect(clean.filename).not.toBe(initial.filename); + + await fs.writeFile( + path.join(context, "styles.css"), + await fs.readFile(path.join(fixtures, "styles-changed.css")) + ); + const restored = await waitForBuild(".restoredProbe"); + + expect(restored.content).toContain("chrome-extension://__MSG_@@extension_id__/assets/icon.svg#changed"); + expect(restored.content).not.toContain("__MSG_%40@extension_id__"); + expect(restored.content).not.toContain("#first"); + expect(restored.filename).not.toBe(initial.filename); + expect(restored.filename).not.toBe(clean.filename); + } finally { + await new Promise(resolve => { + watcher.close(resolve); + }); + } +}, 30_000); diff --git a/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.ts b/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.ts new file mode 100644 index 00000000..95961ffd --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/CssIdTokenPlugin.ts @@ -0,0 +1,44 @@ +import {Compilation, type Compiler, sources} from "@rspack/core"; + +import {CssExtensionIdToken} from "./token"; + +const PluginName = "CssIdTokenPlugin"; +const EncodedToken = CssExtensionIdToken.replace("@", "%40"); + +export default class CssIdTokenPlugin { + apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap(PluginName, compilation => { + compilation.hooks.processAssets.tap( + { + name: PluginName, + // Restore the token before extracting source maps and computing final content hashes. + stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, + }, + () => { + for (const asset of compilation.getAssets()) { + if (!asset.name.endsWith(".css")) { + continue; + } + + const content = asset.source.source().toString(); + let index = content.indexOf(EncodedToken); + + if (index === -1) { + continue; + } + + // css-loader's new URL() treats the first @ as userinfo and percent-encodes it. + const source = new sources.ReplaceSource(asset.source); + + while (index !== -1) { + source.replace(index, index + EncodedToken.length - 1, CssExtensionIdToken); + index = content.indexOf(EncodedToken, index + EncodedToken.length); + } + + compilation.updateAsset(asset.name, source); + } + } + ); + }); + } +} diff --git a/src/cli/bundler/plugins/css-id-token/index.ts b/src/cli/bundler/plugins/css-id-token/index.ts new file mode 100644 index 00000000..e2ece8b3 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/index.ts @@ -0,0 +1,2 @@ +export {default as CssIdTokenPlugin} from "./CssIdTokenPlugin"; +export {CssExtensionIdToken} from "./token"; diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/entry.js b/src/cli/bundler/plugins/css-id-token/tests/fixtures/entry.js new file mode 100644 index 00000000..d53d1f12 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/tests/fixtures/entry.js @@ -0,0 +1,4 @@ +import "./styles.css"; +import payload from "./payload.bin"; + +globalThis.resources = {payload, token: "__MSG_%40@extension_id__"}; diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/icon.svg b/src/cli/bundler/plugins/css-id-token/tests/fixtures/icon.svg new file mode 100644 index 00000000..81d01336 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/tests/fixtures/icon.svg @@ -0,0 +1 @@ + diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/payload.bin b/src/cli/bundler/plugins/css-id-token/tests/fixtures/payload.bin new file mode 100644 index 00000000..53799df3 Binary files /dev/null and b/src/cli/bundler/plugins/css-id-token/tests/fixtures/payload.bin differ diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-changed.css b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-changed.css new file mode 100644 index 00000000..31c72706 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-changed.css @@ -0,0 +1,4 @@ +.restoredProbe { + background-image: url("./icon.svg?browser#changed"); + mask-image: url("./icon.svg?browser"); +} diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-clean.css b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-clean.css new file mode 100644 index 00000000..c57f7a70 --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles-clean.css @@ -0,0 +1,3 @@ +.cleanProbe { + color: green; +} diff --git a/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles.css b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles.css new file mode 100644 index 00000000..d00d0b3b --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/tests/fixtures/styles.css @@ -0,0 +1,8 @@ +.initialProbe { + background-image: url("./icon.svg?browser#first"); + mask-image: url("./icon.svg?browser"); +} + +.unmodified { + content: "__MSG_@@extension_id__ __MSG_%40@ui_locale__"; +} diff --git a/src/cli/bundler/plugins/css-id-token/token.ts b/src/cli/bundler/plugins/css-id-token/token.ts new file mode 100644 index 00000000..9ad52c6b --- /dev/null +++ b/src/cli/bundler/plugins/css-id-token/token.ts @@ -0,0 +1 @@ +export const CssExtensionIdToken = "__MSG_@@extension_id__"; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index b1248170..58b0ed31 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -45,7 +45,7 @@ export { type ResourceAccessPluginRequirement, } from "./resource-access"; -export {default as ReplacePlugin} from "./replace"; +export {CssIdTokenPlugin, CssExtensionIdToken} from "./css-id-token"; export {default as WatchPlugin} from "./watch"; diff --git a/src/cli/bundler/plugins/replace/ReplacePlugin.ts b/src/cli/bundler/plugins/replace/ReplacePlugin.ts deleted file mode 100644 index f878f16a..00000000 --- a/src/cli/bundler/plugins/replace/ReplacePlugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {type Compiler} from "@rspack/core"; - -interface ReplaceOptions { - values: Record; -} - -export default class ReplacePlugin { - constructor(private options: ReplaceOptions) {} - - apply(compiler: Compiler): void { - compiler.hooks.emit.tap("ReplacePlugin", compilation => { - for (const filename in compilation.assets) { - let content = compilation.assets[filename].source().toString(); - let hasChanges = false; - - for (const [search, replace] of Object.entries(this.options.values)) { - if (content.includes(search)) { - content = content.replace(new RegExp(search, "g"), replace); - hasChanges = true; - } - } - - if (hasChanges) { - const sourceMap = { - version: 3, - sources: [], - names: [], - mappings: "", - file: filename, - }; - - compilation.assets[filename] = { - source: () => content, - size: () => content.length, - buffer: () => Buffer.from(content), - map: () => sourceMap, - sourceAndMap: () => ({source: content, map: sourceMap}), - updateHash: () => {}, - }; - } - } - }); - } -} diff --git a/src/cli/bundler/plugins/replace/index.ts b/src/cli/bundler/plugins/replace/index.ts deleted file mode 100644 index 1762f930..00000000 --- a/src/cli/bundler/plugins/replace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default} from "./ReplacePlugin"; diff --git a/src/cli/plugins/asset.ts b/src/cli/plugins/asset.ts index 51432d4f..90152557 100644 --- a/src/cli/plugins/asset.ts +++ b/src/cli/plugins/asset.ts @@ -2,7 +2,7 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {appFilenameResolver, ReplacePlugin} from "@cli/bundler"; +import {appFilenameResolver, CssIdTokenPlugin, CssExtensionIdToken} from "@cli/bundler"; import {Browser} from "@typing/browser"; @@ -12,6 +12,14 @@ export default definePlugin(() => { bundler: ({config}) => { const {app, browser, assetsDir, assetsFilename} = config; + let scheme = "chrome-extension"; + + if (browser === Browser.Firefox) { + scheme = "moz-extension"; + } else if (browser === Browser.Safari) { + scheme = "safari-web-extension"; + } + return { output: { assetModuleFilename: appFilenameResolver(app, assetsFilename, assetsDir), @@ -33,7 +41,7 @@ export default definePlugin(() => { // Both queries explicitly opt into an extension URL. Native CSS // localization substitutes the installed extension ID/UUID on load. generator: { - publicPath: `${browser === Browser.Firefox ? "moz" : "chrome"}-extension://__MSG_@@extension_id__/`, + publicPath: `${scheme}://${CssExtensionIdToken}/`, }, }, { @@ -48,14 +56,7 @@ export default definePlugin(() => { }, ], }, - plugins: [ - new ReplacePlugin({ - // Fix for asset/resource publicPath - values: { - "__MSG_%40@extension_id__": "__MSG_@@extension_id__", - }, - }), - ], + plugins: [new CssIdTokenPlugin()], } satisfies RspackConfig; }, }; diff --git a/src/entry/content/README.md b/src/entry/content/README.md index c9606a50..c41e1c99 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -610,9 +610,11 @@ name: the document declaration is visible to the host page and outlives UI unmou } ``` -Both `?browser` and `?chrome` explicitly emit an extension URL for the build target, including -`moz-extension://__MSG_@@extension_id__/` for Firefox and `chrome-extension://__MSG_@@extension_id__/` -for Chromium targets. Without either query, assets retain normal bundler URL handling. +Both `?browser` and `?chrome` explicitly emit an extension URL for the build target: +`moz-extension://__MSG_@@extension_id__/` for Firefox, +`safari-web-extension://__MSG_@@extension_id__/` for Safari, and +`chrome-extension://__MSG_@@extension_id__/` for Chromium targets. +Without either query, assets retain normal bundler URL handling. These queries support fonts, images, media, documents and explicitly imported binary files; `?base64` keeps inline asset delivery. Code, JSON, stylesheets and specialized `?react`/`?raw` imports retain their own loaders. The localization token diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index b97c4039..a82983cf 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -239,7 +239,14 @@ test.each<{ .map(file => stats.compilation.getAsset(file)!.source.source()) .join("\n"); - const scheme = browser === Browser.Firefox ? "moz-extension" : "chrome-extension"; + const scheme = { + [Browser.Chrome]: "chrome-extension", + [Browser.Chromium]: "chrome-extension", + [Browser.Edge]: "chrome-extension", + [Browser.Firefox]: "moz-extension", + [Browser.Opera]: "chrome-extension", + [Browser.Safari]: "safari-web-extension", + }[browser]; for (const name of ["css-font", "image", "movie"]) { const file = map.assets.assets.find(file => file.startsWith(`assets/${name}.`))!; @@ -249,6 +256,12 @@ test.each<{ expect(css).toContain("data:"); + const documentFile = map.assets.assets.find(file => file.startsWith("assets/document."))!; + + expect(execute(map.assets.initial.js).resources.documentFile).toBe( + `${scheme}://__MSG_@@extension_id__/${documentFile}` + ); + for (const entry of [map.content, map.relay]) { expect(entry.initial.js.some(file => file.includes("common"))).toBe(commonChunks); expect(Object.keys(entry.initial).sort()).toEqual(["css", "js"]);