How CRXJS Handles HTML Inline Scripts in Vite for Chrome Extensions

The plugin-htmlInlineScripts module audits Vite plugin output, extracts inline JavaScript, and dynamically loads it through ES modules to satisfy Manifest V3 CSP requirements.

Chrome Extension Tools (CRXJS) bridges the gap between Vite's development features and Chrome's strict Content Security Policy (CSP) for Manifest V3 extensions. The packages/vite-plugin/src/node/plugin-htmlInlineScripts.ts file implements a comprehensive HTML inline script handling system that transforms dangerous inline script tags into compliant dynamically-loaded modules without breaking execution order.

The Manifest V3 Inline Script Problem

Manifest V3 extensions prohibit inline JavaScript execution via Content Security Policy directives. Vite and its ecosystem frequently inject inline scripts during development—for client initialization, hot module replacement, and plugin functionality. Without intervention, these injected scripts cause immediate CSP violations in Chrome extensions.

The CRXJS solution operates as a four-stage pipeline that intercepts, caches, and rewrites script injection patterns before they reach the browser.

Four-Stage Inline Script Transformation Pipeline

Stage 1: Auditing Other Plugins with auditTransformIndexHtml

The plugin begins by wrapping every Vite plugin's transformIndexHtml hook to intercept emitted script tags. The auditTransformIndexHtml function (lines 51‑88) replaces each plugin's original hook with an auditor wrapper that captures all <script> elements.

When plugins emit inline scripts, the auditor immediately strips them from the HTML output and stores them in a temporary cache. External scripts with src attributes pass through but are tracked for dependency ordering.

Stage 2: Per-Page Cache Initialization via prePlugin

Before any HTML processing begins, the prePlugin (lines 90‑109) initializes a dedicated cache entry for each page. The system generates a normalized page identifier using the toKey helper function (lines 25‑28), which converts file paths into consistent cache keys that double as virtual module IDs.

This cache always initializes with Vite's client module (@vite/client) to ensure HMR capabilities survive the transformation process.

Stage 3: Safe Script Extraction with postPlugin

After all other plugins complete their HTML transformations, the postPlugin (lines 112‑141) executes. This hook invokes extractScriptsAndRemove from packages/vite-plugin/src/node/html.ts (lines 10‑17), which parses the final HTML and removes all <script> tags while preserving their metadata.

The plugin distinguishes inline scripts from external sources using the isInlineTag helper (lines 18‑20). It stores both the inline script bodies and external script paths in the page-specific cache, then removes them from the HTML entirely.

Stage 4: Virtual Module Generation and Loading

When the browser requests a script via the special @crx/inline-script virtual URL prefix, the plugin's load hook (lines 173‑197) assembles the final module. This hook concatenates all collected inline script bodies into executable code, builds a JSON array of external module paths, and returns a module that imports the page script loader from client/es/page-inline-script-loader.ts.

The generated module structure follows this pattern:

import loader from 'client/es/page-inline-script-loader.ts'

// Concatenated inline scripts execute first
console.log('inline from react plugin')

// External scripts loaded dynamically
const SCRIPTS = ["./src/main.ts"]
loader(SCRIPTS)

Runtime Loader Mechanics

The client/es/page-inline-script-loader.ts module receives the JSON array of script URLs, resolves them relative to the page directory, and injects each as a separate module-type <script> tag. Because each script loads as an ES module, the browser respects the original execution order while maintaining strict CSP compliance—no inline scripts remain in the final HTML.

Configuration Example

Integrate the inline script handler into your Vite configuration for Chrome extension development:

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

export default defineConfig({
  plugins: [
    crx({ manifest: './manifest.json' }),
    // Activate during development to handle inline scripts
    pluginHtmlInlineScripts(),
  ],
})

During vite serve, the plugin automatically transforms HTML output:

<!-- Original Vite output -->
<script type="module" src="/@vite/client"></script>
<script type="module">console.log('inline script')</script>
<script type="module" src="/src/main.ts"></script>

After processing, the HTML contains only:

<!-- After CRXJS processing -->
<script type="module" src="@crx/inline-script/src/main?t=1677623456789"></script>

Summary

  • The plugin-htmlInlineScripts.ts module intercepts all HTML transformations to audit for inline scripts.
  • It caches scripts per-page using normalized keys generated by toKey, storing data from both inline sources and external modules.
  • The system strips all <script> tags from HTML output using extractScriptsAndRemove and replaces them with a single virtual module loader.
  • Virtual modules served via @crx/inline-script URLs concatenate inline code and dynamically import external scripts through page-inline-script-loader.ts.
  • This architecture guarantees CSP compliance for Manifest V3 while preserving Vite's development features and script execution order.

Frequently Asked Questions

How does the plugin detect inline scripts versus external scripts?

The isInlineTag helper function (lines 18‑20) examines each <script> element for the absence of a src attribute. Tags without src are classified as inline and flagged for extraction and caching, while external scripts are tracked but processed differently during the virtual module generation phase.

Why is the @crx/inline-script virtual module necessary?

Chrome's Manifest V3 CSP prohibits inline JavaScript execution entirely. The virtual module system allows Vite to serve concatenated inline script content as external module files with the @crx/inline-script prefix, effectively converting inline code into importable ES modules that bypass CSP restrictions while maintaining the original execution context.

Does this HTML inline script handling affect production builds?

The plugin-htmlInlineScripts primarily targets development mode where Vite injects client code and HMR scripts inline. Production builds typically emit only external script files, but the plugin ensures that any remaining inline scripts—including those from third-party Vite plugins—are automatically externalized to prevent CSP violations in the packaged extension.

How does the system preserve script execution order?

The plugin maintains execution order by caching scripts in the sequence they appear during the audit phase, then reconstructing that exact order in the virtual module output. The page-inline-script-loader.ts runtime inserts scripts as ES modules in the provided array order, ensuring dependencies execute before dependent code regardless of the asynchronous loading mechanism.

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 →