Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/iife-content-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@crxjs/vite-plugin': minor
---

feat: add IIFE support for main-world content scripts

Add support for IIFE content scripts via the `?iife` import query, enabling
main-world script injection using `chrome.scripting.registerContentScripts` with
`world: 'MAIN'`.

Usage:

```typescript
import mainWorld from './main-world?iife'

chrome.scripting.registerContentScripts([
{
id: 'main-world',
js: [mainWorld],
matches: ['<all_urls>'],
world: 'MAIN',
},
])
```

- IIFE scripts are bundled separately using Rollup
- Dev mode: file changes trigger rebuild + extension reload
- TypeScript support via `client.d.ts` module declaration

Closes #1101
15 changes: 15 additions & 0 deletions packages/vite-plugin/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ declare module '*?script&iife' {
export default fileName
}

declare module '*?iife' {
/**
* Alias for `*?script&iife`.
*
* Script format is IIFE. Use for content scripts with opaque origins.
*
* Exports the file name of the output script file.
*
* If imported inside a content script, RPCE will include the file name in
* `web_accessible_resources`.
*/
const fileName: string
export default fileName
}

declare module '*?script&module' {
/**
* Script format is ESM. No loader and no HMR. Does not support frameworks
Expand Down
145 changes: 144 additions & 1 deletion packages/vite-plugin/src/node/fileWriter-rxjs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as lexer from 'es-module-lexer'
import fsx from 'fs-extra'
import { readFile } from 'fs/promises'
import MagicString from 'magic-string'
import { OutputAsset, OutputChunk } from 'rollup'
import {
BehaviorSubject,
filter,
Expand All @@ -17,13 +19,107 @@ import {
takeUntil,
toArray,
} from 'rxjs'
import { ErrorPayload, ViteDevServer } from 'vite'
import { build as viteBuild, ErrorPayload, ViteDevServer } from 'vite'
import { outputFiles } from './fileWriter-filesMap'
import { getFileName, getOutputPath, getViteUrl } from './fileWriter-utilities'
import { join } from './path'
import { CrxDevAssetId, CrxDevScriptId, CrxPlugin } from './types'
import convertSourceMap from 'convert-source-map'

const { outputFile } = fsx

const getIifeGlobalName = (fileName: string) => {
const base = fileName.split('/').pop() ?? fileName
const sanitized = base.replace(/\W+/g, '_').replace(/^_+/, '')
return `crx_${sanitized || 'content_script'}`
}

const resolveScriptInput = (server: ViteDevServer, id: string) => {
if (id.startsWith('/@fs/')) return id.slice('/@fs/'.length)
if (id.startsWith('/')) return join(server.config.root, id.slice(1))
return id
}

const isOutputChunk = (item: OutputChunk | OutputAsset): item is OutputChunk =>
item.type === 'chunk'

const isOutputAsset = (item: OutputChunk | OutputAsset): item is OutputAsset =>
item.type === 'asset'

async function bundleIife(
server: ViteDevServer,
script: CrxDevScriptId,
fileName: string,
) {
const input = resolveScriptInput(server, script.id)
const sourcemap =
server.config.build.sourcemap === 'inline' ? 'inline' : false

// Use Vite's build API instead of raw Rollup to avoid plugin compatibility issues
// (Vite 6+ plugins are tracked in WeakMaps and can't be reused in separate Rollup builds)
// We use configFile: false to avoid loading user's config which may have incompatible settings
const result = await viteBuild({
root: server.config.root,
mode: server.config.mode,
configFile: false, // Don't load user's config - use minimal IIFE-specific settings
logLevel: 'silent',
resolve: {
// Copy resolve settings from the dev server for consistency
alias: server.config.resolve.alias,
extensions: server.config.resolve.extensions,
conditions: server.config.resolve.conditions,
},
build: {
write: false, // Don't write to disk, we'll handle that
manifest: false, // Don't generate Vite manifest
rollupOptions: {
input,
output: {
format: 'iife',
name: getIifeGlobalName(fileName),
entryFileNames: fileName,
inlineDynamicImports: true, // Required for IIFE format
sourcemap,
},
},
minify: false,
copyPublicDir: false,
},
})

// viteBuild with write: false returns RollupOutput or RollupOutput[]
const outputs = Array.isArray(result) ? result : [result]
const firstOutput = outputs[0]
const output = 'output' in firstOutput ? firstOutput.output : undefined

if (!output) {
throw new Error(`Unable to generate IIFE bundle for "${script.id}"`)
}

const entryChunk = output.find(
(item): item is OutputChunk => isOutputChunk(item) && item.isEntry,
)
if (!entryChunk) {
throw new Error(`Unable to generate IIFE bundle for "${script.id}"`)
}

const assets = output.filter(isOutputAsset).filter(
// Filter out manifest.json to avoid overwriting extension manifest
(asset) =>
asset.fileName !== 'manifest.json' &&
!asset.fileName.startsWith('.vite/'),
)
const extraChunks = output.filter(
(item): item is OutputChunk => isOutputChunk(item) && !item.isEntry,
)

return {
code: entryChunk.code,
assets,
extraChunks,
}
}

/* ----------------- SERVER EVENTS ----------------- */

export interface ServerEventStart {
Expand Down Expand Up @@ -138,6 +234,7 @@ function prepScript(
fileName: string,
script: CrxDevScriptId,
): OperatorFileData {
if (script.type === 'iife') return prepIifeScript(fileName, script)
return ($) =>
$.pipe(
// get script contents from dev server
Expand Down Expand Up @@ -201,6 +298,52 @@ function prepScript(
)
}

function prepIifeScript(
fileName: string,
script: CrxDevScriptId,
): OperatorFileData {
return ($) =>
$.pipe(
mergeMap(async ({ server }) => {
const target = getOutputPath(server, fileName)
const { code, assets, extraChunks } = await bundleIife(
server,
script,
fileName,
)
return { target, source: code, deps: [], server, assets, extraChunks }
}),
mergeMap(
async ({ target, source, deps, server, assets, extraChunks }) => {
const extras = [
...assets.map((asset) => ({
fileName: asset.fileName,
source: asset.source,
})),
...extraChunks.map((chunk) => ({
fileName: chunk.fileName,
source: chunk.code,
})),
].filter((item) => item.fileName !== fileName)

await Promise.all(
extras.map(async (item) => {
const outputPath = getOutputPath(server, item.fileName)
if (typeof item.source === 'undefined' || item.source === null)
return
if (item.source instanceof Uint8Array)
await outputFile(outputPath, item.source)
else
await outputFile(outputPath, item.source, { encoding: 'utf8' })
}),
)

return { target, source, deps }
},
),
)
}

/** Resolves when all existing files in scriptFiles are written. */
export async function allFilesReady(): Promise<void> {
await firstValueFrom(allFilesReady$)
Expand Down
Loading
Loading