How the CRXJS Plugin Dynamically Generates and Handles Resource URLs for Chrome Extensions

The CRXJS Vite plugin automatically converts content script dependencies into web-accessible resources and injects them via chrome.runtime.getURL by generating runtime loaders that replace the __SCRIPT__ placeholder with hashed filenames.

The crxjs/chrome-extension-tools repository eliminates manual asset management in Chrome extension development by automating the declaration and resolution of web-accessible resources. When processing Manifest V3 extensions, the plugin intercepts Vite's build output to ensure every script, stylesheet, and static asset is reachable through the extension's runtime URL space, even when filenames contain content hashes.

Registering Content Script Metadata

The plugin begins by tracking every content script entry through a reactive mapping system defined in packages/vite-plugin/src/node/contentScripts.ts. When Vite resolves an entry matching a content-script pattern, the plugin populates an RxMap called contentScripts that stores many-to-one mappings between identifiers.

// packages/vite-plugin/src/node/contentScripts.ts
export const contentScripts = new RxMap<string, ContentScript>()

contentScripts.change$
  .pipe(filter(RxMap.isChangeType.set))
  .subscribe(({ value }) => {
    const keys = ['refId','id','fileName','loaderName','resolvedId','scriptId'] as const
    for (const k of keys) {
      const key = value[k]
      if (typeof key !== 'undefined' && !contentScripts.has(key)) {
        contentScripts.set(key, value)   // many‑to‑one lookup
      }
    }
  })

This registration stage captures the id, fileName, matches patterns, and loader designation for each script. The contentScripts map enables the rest of the pipeline to look up script metadata by any identifier—whether the original source ID, the generated loader name, or the final hashed filename.

Compiling Web-Accessible Resources

During the renderCrxManifest hook in build mode, the plugin scans Vite's output to derive the concrete list of files each content script requires. The core logic resides in packages/vite-plugin/src/node/plugin-webAccessibleResources.ts, which calls compileFileResources() to walk the Rollup chunk graph.

// packages/vite-plugin/src/node/plugin-webAccessibleResources.ts
const { assets, css, imports } = compileFileResources(
  fileName,
  { chunks: bundleChunks, files: viteFiles, config },
)

The compileFileResources() function extracts three critical dependency types:

  • Assets – Image and font files emitted by Vite
  • CSS – Stylesheets that must be injected alongside the script
  • Imports – JavaScript modules imported by the entry point

For loader-type scripts or dynamic content scripts, the plugin automatically adds the entry file itself to the imports set because the loader injects the entry at runtime:

if (type === 'loader' || isDynamicScript) imports.add(fileName)

These gathered paths populate a WebAccessibleResource object that eventually becomes part of the manifest's web_accessible_resources array, ensuring Chrome exposes the files to the extension's content scripts.

Generating Runtime Loaders with chrome.runtime.getURL

To bypass Content Security Policy restrictions and resolve hashed filenames at runtime, CRXJS generates loader files that wrap the actual content scripts. The plugin creates these loaders via factory functions in packages/vite-plugin/src/node/contentScripts.ts, specifically createProLoader() and createDevLoader().

// packages/vite-plugin/src/node/contentScripts.ts
export function createProLoader({ fileName }: { fileName: string }) {
  return contentProLoader.replace(/__SCRIPT__/g, JSON.stringify(fileName))
}

The template string contentProLoader lives in packages/vite-plugin/src/client/iife/content-pro-loader.ts and contains the runtime injection logic:

// packages/vite-plugin/src/client/iife/content-pro-loader.ts
declare const __SCRIPT__: string
const injectTime = performance.now()
;(async () => {
  const { onExecute } = await import(/* @vite-ignore */ chrome.runtime.getURL(__SCRIPT__)) as ContentScriptAPI.ModuleExports
  onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } })
})().catch(console.error)
export {}

During the build, the createProLoader() function replaces the __SCRIPT__ placeholder with the JSON-stringified, hashed filename (e.g., "src/content-abc123.js"). At runtime, the generated loader executes:

import(/* @vite-ignore */ chrome.runtime.getURL('src/content-abc123.js'))

The chrome.runtime.getURL method resolves the URL relative to the extension's origin, guaranteeing that the script loads correctly regardless of the current webpage's domain or the extension's installation path.

Handling Dynamic Resource Placeholders

For scripts requiring fresh URLs on every reload—useful for bypassing caching during development or specific runtime scenarios—the plugin supports a special DYNAMIC_RESOURCE placeholder defined in packages/vite-plugin/src/node/defineManifest.ts.

// packages/vite-plugin/src/node/defineManifest.ts
export const DYNAMIC_RESOURCE = '<dynamic_resource>' as const

When the manifest contains "<dynamic_resource>" in its web_accessible_resources array, the renderCrxManifest hook performs three operations:

  1. Removes the placeholder from static resource entries
  2. Tracks which match patterns belong to dynamic content scripts (dynamicScriptMatches)
  3. Sets use_dynamic_url: true on the generated resource objects

This mechanism allows developers to declare:

{
  "web_accessible_resources": [
    {
      "resources": ["<dynamic_resource>"],
      "matches": ["*://*.example.com/*"]
    }
  ]
}

The plugin replaces the placeholder with actual asset paths at build time while preserving the dynamic URL behavior for Chrome to generate fresh URLs on each extension reload.

Complete Configuration Example

The following Vite configuration demonstrates how the plugin processes a dynamic content script and its assets:

// vite.config.ts
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'

export default defineConfig({
  plugins: [
    crx({
      manifest: {
        name: 'My Extension',
        manifest_version: 3,
        content_scripts: [
          {
            matches: ['*://*.example.com/*'],
            js: ['src/content/main.ts'],
            dynamic: true,
          },
        ],
      },
    }),
  ],
})

Within the content script, static asset references are resolved through the extension runtime:

// src/content/main.ts
const img = new Image()
img.src = chrome.runtime.getURL('assets/logo.png')
document.body.append(img)

The build process automatically adds assets/logo.png to web_accessible_resources and rewrites the filename to include the content hash, while the generated loader ensures the script itself is imported via chrome.runtime.getURL.

Summary

  • The contentScripts RxMap in contentScripts.ts registers metadata for every content script entry, enabling many-to-one identifier lookups.
  • compileFileResources() traverses the Rollup chunk graph to collect assets, CSS, and JS imports for each script.
  • createProLoader() generates IIFE wrappers that replace the __SCRIPT__ token with the hashed filename, injecting the script via chrome.runtime.getURL.
  • The DYNAMIC_RESOURCE placeholder (<dynamic_resource>) enables use_dynamic_url behavior for resources requiring unique URLs on every reload.
  • All resource paths are automatically declared in web_accessible_resources, ensuring Chrome exposes the files to content scripts regardless of filename hashing.

Frequently Asked Questions

How does CRXJS resolve hashed filenames when using chrome.runtime.getURL?

The plugin resolves hashed filenames at build time by replacing the __SCRIPT__ placeholder in loader templates with the actual filename from Vite's manifest. The createProLoader() function performs a string replacement using JSON.stringify(fileName), ensuring the generated loader code contains the literal hashed path (e.g., "src/content-main-a3f7b2.js") when it calls chrome.runtime.getURL.

What is the difference between the production loader and development loader?

The production loader (createProLoader) generates a minimal IIFE that immediately imports the content script via chrome.runtime.getURL and executes its onExecute lifecycle method with performance timings. The development loader (createDevLoader) includes additional HMR (Hot Module Replacement) logic and sourcemap handling to support Vite's dev server, though both ultimately rely on chrome.runtime.getURL to resolve the entry point.

How does the plugin handle CSS files as web-accessible resources?

During the compileFileResources() execution, the plugin identifies CSS files associated with each content script entry and adds them to the css set within the WebAccessibleResource object. These files are explicitly listed in the manifest's web_accessible_resources array, allowing content scripts to inject stylesheets dynamically or reference them through chrome.runtime.getURL when needed for shadow DOM scenarios.

What is the purpose of the <dynamic_resource> placeholder?

The <dynamic_resource> placeholder (exported as DYNAMIC_RESOURCE) signals the plugin to enable Chrome's use_dynamic_url feature for specific match patterns. When declared in the manifest, the plugin strips the placeholder and marks the resulting resources with use_dynamic_url: true, causing Chrome to generate a unique resource URL on every extension reload rather than caching the asset URL indefinitely.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →