Skip to content

Commit

Permalink
feat!: ESM background support (#398)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The build output has changed slightly. This isn't a huge deal, and no steps are required for users unless you're doing something weird with the output files after a build.

Before:

```
.output/
  <target>/
    chunks/
      popup-<hash>.js
    popup.html
```

After:

```
.output/
  <target>/
    popup.html
    popup.js
```

This applies for all HTML files, not just the popup.
  • Loading branch information
aklinker1 authored Feb 2, 2024
1 parent 41fa320 commit eca3029
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 81 deletions.
64 changes: 34 additions & 30 deletions demo/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
import messages from 'public/_locales/en/messages.json';

export default defineBackground(() => {
console.log(browser.runtime.id);
logId();
console.log({
browser: import.meta.env.BROWSER,
chrome: import.meta.env.CHROME,
firefox: import.meta.env.FIREFOX,
manifestVersion: import.meta.env.MANIFEST_VERSION,
messages,
});
export default defineBackground({
// type: 'module',

// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon/128.png');
browser.runtime.getURL('/example.html#hash');
browser.runtime.getURL('/example.html?query=param');
// @ts-expect-error: should only allow hashes/query params on HTML files
browser.runtime.getURL('/icon-128.png?query=param');
main() {
console.log(browser.runtime.id);
logId();
console.log({
browser: import.meta.env.BROWSER,
chrome: import.meta.env.CHROME,
firefox: import.meta.env.FIREFOX,
manifestVersion: import.meta.env.MANIFEST_VERSION,
messages,
});

// @ts-expect-error: should only accept known message names
browser.i18n.getMessage('test');
browser.i18n.getMessage('prompt_for_name');
browser.i18n.getMessage('hello', 'Aaron');
browser.i18n.getMessage('bye', ['Aaron']);
browser.i18n.getMessage('@@extension_id');
// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon/128.png');
browser.runtime.getURL('/example.html#hash');
browser.runtime.getURL('/example.html?query=param');
// @ts-expect-error: should only allow hashes/query params on HTML files
browser.runtime.getURL('/icon-128.png?query=param');

console.log('WXT MODE:', {
MODE: import.meta.env.MODE,
DEV: import.meta.env.DEV,
PROD: import.meta.env.PROD,
});
// @ts-expect-error: should only accept known message names
browser.i18n.getMessage('test');
browser.i18n.getMessage('prompt_for_name');
browser.i18n.getMessage('hello', 'Aaron');
browser.i18n.getMessage('bye', ['Aaron']);
browser.i18n.getMessage('@@extension_id');

storage.setItem('session:startTime', Date.now());
console.log('WXT MODE:', {
MODE: import.meta.env.MODE,
DEV: import.meta.env.DEV,
PROD: import.meta.env.PROD,
});

storage.setItem('session:startTime', Date.now());
},
});
165 changes: 165 additions & 0 deletions e2e/tests/output-structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,169 @@ describe('Output Directory Structure', () => {
true,
);
});

it('should generate ESM background script when type=module', async () => {
const project = new TestProject();
project.addFile(
'utils/log.ts',
`export function logHello(name: string) {
console.log(\`Hello \${name}!\`);
}`,
);
project.addFile(
'entrypoints/background.ts',
`export default defineBackground({
type: "module",
main() {
logHello("background");
},
})`,
);
project.addFile(
'entrypoints/popup/index.html',
`<html>
<head>
<script type="module" src="./main.ts"></script>
</head>
</html>`,
);
project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);

await project.build({
experimental: {
// Simplify the build output for comparison
includeBrowserPolyfill: false,
},
vite: () => ({
build: {
// Make output for snapshot readible
minify: false,
},
}),
});

expect(await project.serializeFile('.output/chrome-mv3/background.js'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/background.js
----------------------------------------
import { l as logHello } from "./chunks/log-bezs0tt4.js";
function defineBackground(arg) {
if (typeof arg === "function")
return { main: arg };
return arg;
}
const definition = defineBackground({
type: "module",
main() {
logHello("background");
}
});
chrome;
function print(method, ...args) {
return;
}
var logger = {
debug: (...args) => print(console.debug, ...args),
log: (...args) => print(console.log, ...args),
warn: (...args) => print(console.warn, ...args),
error: (...args) => print(console.error, ...args)
};
try {
const res = definition.main();
if (res instanceof Promise) {
console.warn(
"The background's main() function return a promise, but it must be synchonous"
);
}
} catch (err) {
logger.error("The background crashed on startup!");
throw err;
}
"
`);
});

it('should generate IIFE background script when type=undefined', async () => {
const project = new TestProject();
project.addFile(
'utils/log.ts',
`export function logHello(name: string) {
console.log(\`Hello \${name}!\`);
}`,
);
project.addFile(
'entrypoints/background.ts',
`export default defineBackground({
main() {
logHello("background");
},
})`,
);
project.addFile(
'entrypoints/popup/index.html',
`<html>
<head>
<script type="module" src="./main.ts"></script>
</head>
</html>`,
);
project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);

await project.build({
experimental: {
// Simplify the build output for comparison
includeBrowserPolyfill: false,
},
vite: () => ({
build: {
// Make output for snapshot readible
minify: false,
},
}),
});

expect(await project.serializeFile('.output/chrome-mv3/background.js'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/background.js
----------------------------------------
(function() {
"use strict";
function defineBackground(arg) {
if (typeof arg === "function")
return { main: arg };
return arg;
}
function logHello(name) {
console.log(\`Hello \${name}!\`);
}
const definition = defineBackground({
main() {
logHello("background");
}
});
chrome;
function print(method, ...args) {
return;
}
var logger = {
debug: (...args) => print(console.debug, ...args),
log: (...args) => print(console.log, ...args),
warn: (...args) => print(console.warn, ...args),
error: (...args) => print(console.error, ...args)
};
try {
const res = definition.main();
if (res instanceof Promise) {
console.warn(
"The background's main() function return a promise, but it must be synchonous"
);
}
} catch (err) {
logger.error("The background crashed on startup!");
throw err;
}
})();
"
`);
});
});
49 changes: 29 additions & 20 deletions src/core/builders/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,7 @@ export async function createViteBuilder(
* Return the basic config for building an entrypoint in [lib mode](https://vitejs.dev/guide/build.html#library-mode).
*/
const getLibModeConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
let virtualEntrypointType: VirtualEntrypointType | undefined;
switch (entrypoint.type) {
case 'background':
case 'unlisted-script':
virtualEntrypointType = entrypoint.type;
break;
case 'content-script':
virtualEntrypointType =
entrypoint.options.world === 'MAIN'
? 'content-script-main-world'
: 'content-script-isolated-world';
break;
}
const entry = virtualEntrypointType
? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
: entrypoint.inputPath;

const entry = getRollupEntry(entrypoint);
const plugins: NonNullable<vite.UserConfig['plugins']> = [
wxtPlugins.entrypointGroupGlobals(entrypoint),
];
Expand Down Expand Up @@ -157,14 +141,16 @@ export async function createViteBuilder(
build: {
rollupOptions: {
input: entrypoints.reduce<Record<string, string>>((input, entry) => {
input[entry.name] = entry.inputPath;
input[entry.name] = getRollupEntry(entry);
return input;
}, {}),
output: {
// Include a hash to prevent conflicts
chunkFileNames: 'chunks/[name]-[hash].js',
// Include a hash to prevent conflicts
entryFileNames: 'chunks/[name]-[hash].js',
// Place JS entrypoints in main directory without a hash. (popup.html & popup.js are
// next to each other). The unique entrypoint name requirement prevents conflicts with
// scripts of the same name
entryFileNames: '[name].js',
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
Expand Down Expand Up @@ -263,3 +249,26 @@ function getBuildOutputChunks(
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
}

/**
* Returns the input module ID (virtual or real file) for an entrypoint. The returned string should
* be passed as an input to rollup.
*/
function getRollupEntry(entrypoint: Entrypoint): string {
let virtualEntrypointType: VirtualEntrypointType | undefined;
switch (entrypoint.type) {
case 'background':
case 'unlisted-script':
virtualEntrypointType = entrypoint.type;
break;
case 'content-script':
virtualEntrypointType =
entrypoint.options.world === 'MAIN'
? 'content-script-main-world'
: 'content-script-isolated-world';
break;
}
return virtualEntrypointType
? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
: entrypoint.inputPath;
}
44 changes: 30 additions & 14 deletions src/core/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '~/types';
import {
getEntrypointBundlePath,
getEntrypointOutputFile,
resolvePerBrowserOption,
} from '~/core/utils/entrypoints';
import {
Expand Down Expand Up @@ -197,15 +196,6 @@ function createFileReloader(options: {
.join(', ')}`,
);

const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => {
return pc.cyan(
relative(config.outDir, getEntrypointOutputFile(entry, '')),
);
})
.join(pc.dim(', '));

// Rebuild entrypoints on change
const allEntrypoints = await findEntrypoints(config);
const { output: newOutput } = await rebuild(
Expand All @@ -221,15 +211,24 @@ function createFileReloader(options: {
switch (changes.type) {
case 'extension-reload':
server.reloadExtension();
consola.success(`Reloaded extension`);
break;
case 'html-reload':
reloadHtmlPages(changes.rebuildGroups, server, config);
const { reloadedNames } = reloadHtmlPages(
changes.rebuildGroups,
server,
config,
);
consola.success(`Reloaded: ${getFilenameList(reloadedNames)}`);
break;
case 'content-script-reload':
reloadContentScripts(changes.changedSteps, config, server);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => entry.name);
consola.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
break;
}
consola.success(`Reloaded: ${rebuiltNames}`);
});
};
}
Expand Down Expand Up @@ -279,9 +278,26 @@ function reloadHtmlPages(
groups: EntrypointGroup[],
server: WxtDevServer,
config: InternalConfig,
) {
groups.flat().forEach((entry) => {
): { reloadedNames: string[] } {
// groups might contain other files like background/content scripts, and we only care about the HTMl pages
const htmlEntries = groups
.flat()
.filter((entry) => entry.inputPath.endsWith('.html'));

htmlEntries.forEach((entry) => {
const path = getEntrypointBundlePath(entry, config.outDir, '.html');
server.reloadPage(path);
});

return {
reloadedNames: htmlEntries.map((entry) => entry.name),
};
}

function getFilenameList(names: string[]): string {
return names
.map((name) => {
return pc.cyan(name);
})
.join(pc.dim(', '));
}
Loading

0 comments on commit eca3029

Please sign in to comment.