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

> Discover how the CRXJS plugin dynamically generates and handles resource URLs for your Chrome extension. Learn about automatic conversion of dependencies and runtime URL injection.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: internals
- Published: 2026-02-28

---

**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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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.

```ts
// 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-webAccessibleResources.ts), which calls **`compileFileResources()`** to walk the Rollup chunk graph.

```ts
// 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:

```ts
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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/contentScripts.ts), specifically **`createProLoader()`** and **`createDevLoader()`**.

```ts
// 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/client/iife/content-pro-loader.ts) and contains the runtime injection logic:

```ts
// 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:

```js
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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/defineManifest.ts).

```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:

```json
{
  "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:

```ts
// 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:

```ts
// 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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.