Skip to content

Commit a917f32

Browse files
authored
feat(experimental): Load entrypoint options with Vite Runtime API (#648)
1 parent 6ca3767 commit a917f32

18 files changed

+241
-52
lines changed

Diff for: package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"scripts": {
1010
"check": "check && pnpm -r run check",
1111
"test": "vitest",
12-
"test:coverage": "vitest run --coverage.enabled \"--coverage.include=packages/wxt/src/**\" \"--coverage.exclude=packages/wxt/src/core/utils/testing/**\" \"--coverage.exclude=**/*.d.ts\"",
12+
"test:coverage": "vitest run --coverage.enabled \"--coverage.include=packages/wxt/src/**\" \"--coverage.exclude=packages/wxt/src/core/utils/testing/**\" \"--coverage.exclude=**/*.d.ts\" \"--coverage.exclude=**/fixtures/**\"",
1313
"prepare": "simple-git-hooks",
1414
"prepublish": "pnpm -s build",
1515
"docs:gen": "typedoc --options docs/typedoc.json",

Diff for: packages/wxt-demo/wxt.config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ export default defineConfig({
1818
analysis: {
1919
open: true,
2020
},
21+
experimental: {
22+
viteRuntime: true,
23+
},
2124
});

Diff for: packages/wxt/e2e/tests/manifest-content.test.ts

+25-22
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,36 @@
11
import { describe, it, expect } from 'vitest';
22
import { TestProject } from '../utils';
33

4-
describe('Manifest Content', () => {
5-
it.each([
6-
{ browser: undefined, outDir: 'chrome-mv3', expected: undefined },
7-
{ browser: 'chrome', outDir: 'chrome-mv3', expected: undefined },
8-
{ browser: 'firefox', outDir: 'firefox-mv2', expected: true },
9-
{ browser: 'safari', outDir: 'safari-mv2', expected: false },
10-
])(
11-
'should respect the per-browser entrypoint option with %j',
12-
async ({ browser, expected, outDir }) => {
13-
const project = new TestProject();
4+
describe.each([true, false])(
5+
'Manifest Content (Vite runtime? %s)',
6+
(viteRuntime) => {
7+
it.each([
8+
{ browser: undefined, outDir: 'chrome-mv3', expected: undefined },
9+
{ browser: 'chrome', outDir: 'chrome-mv3', expected: undefined },
10+
{ browser: 'firefox', outDir: 'firefox-mv2', expected: true },
11+
{ browser: 'safari', outDir: 'safari-mv2', expected: false },
12+
])(
13+
'should respect the per-browser entrypoint option with %j',
14+
async ({ browser, expected, outDir }) => {
15+
const project = new TestProject();
1416

15-
project.addFile(
16-
'entrypoints/background.ts',
17-
`export default defineBackground({
17+
project.addFile(
18+
'entrypoints/background.ts',
19+
`export default defineBackground({
1820
persistent: {
1921
firefox: true,
2022
safari: false,
2123
},
2224
main: () => {},
2325
})`,
24-
);
25-
await project.build({ browser });
26+
);
27+
await project.build({ browser, experimental: { viteRuntime } });
2628

27-
const safariManifest = await project.getOutputManifest(
28-
`.output/${outDir}/manifest.json`,
29-
);
30-
expect(safariManifest.background.persistent).toBe(expected);
31-
},
32-
);
33-
});
29+
const safariManifest = await project.getOutputManifest(
30+
`.output/${outDir}/manifest.json`,
31+
);
32+
expect(safariManifest.background.persistent).toBe(expected);
33+
},
34+
);
35+
},
36+
);

Diff for: packages/wxt/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
"json5": "^2.2.3",
116116
"jszip": "^3.10.1",
117117
"linkedom": "^0.16.1",
118+
"magicast": "^0.3.4",
118119
"minimatch": "^9.0.3",
119120
"natural-compare": "^1.4.0",
120121
"normalize-path": "^3.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { a } from './test';
2+
3+
function defineSomething<T>(config: T): T {
4+
return config;
5+
}
6+
7+
export default defineSomething({
8+
option: 'some value',
9+
main: () => {
10+
console.log('main', a);
11+
},
12+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
console.log('Side-effect in test.ts');
2+
export const a = 'a';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { createViteBuilder } from '../index';
3+
import { fakeResolvedConfig } from '~/core/utils/testing/fake-objects';
4+
import { createHooks } from 'hookable';
5+
6+
describe('Vite Builder', () => {
7+
describe('importEntrypoint', () => {
8+
it('should import entrypoints, removing runtime values (like the main function)', async () => {
9+
const {
10+
default: { main: _, ...expected },
11+
} = await import('./fixtures/module');
12+
const builder = await createViteBuilder(
13+
fakeResolvedConfig({ root: __dirname }),
14+
createHooks(),
15+
);
16+
const actual = await builder.importEntrypoint<{ default: any }>(
17+
'./fixtures/module.ts',
18+
);
19+
expect(actual).toEqual(expected);
20+
});
21+
});
22+
});

Diff for: packages/wxt/src/core/builders/vite/index.ts

+16
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,22 @@ export async function createViteBuilder(
203203
return {
204204
name: 'Vite',
205205
version: vite.version,
206+
async importEntrypoint(url) {
207+
const baseConfig = await getBaseConfig();
208+
const envConfig: vite.InlineConfig = {
209+
plugins: [
210+
wxtPlugins.webextensionPolyfillMock(wxtConfig),
211+
wxtPlugins.removeEntrypointMainFunction(wxtConfig, url),
212+
],
213+
};
214+
const config = vite.mergeConfig(baseConfig, envConfig);
215+
const server = await vite.createServer(config);
216+
await server.listen();
217+
const runtime = await vite.createViteRuntime(server, { hmr: false });
218+
const module = await runtime.executeUrl(url);
219+
await server.close();
220+
return module.default;
221+
},
206222
async build(group) {
207223
let entryConfig;
208224
if (Array.isArray(group)) entryConfig = getMultiPageConfig(group);

Diff for: packages/wxt/src/core/builders/vite/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './webextensionPolyfillMock';
1313
export * from './excludeBrowserPolyfill';
1414
export * from './entrypointGroupGlobals';
1515
export * from './defineImportMeta';
16+
export * from './removeEntrypointMainFunction';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ResolvedConfig } from '~/types';
2+
import * as vite from 'vite';
3+
import { normalizePath } from '~/core/utils/paths';
4+
import { removeMainFunctionCode } from '~/core/utils/transform';
5+
import { resolve } from 'node:path';
6+
7+
/**
8+
* Transforms entrypoints, removing the main function from the entrypoint if it exists.
9+
*/
10+
export function removeEntrypointMainFunction(
11+
config: ResolvedConfig,
12+
path: string,
13+
): vite.Plugin {
14+
const absPath = normalizePath(resolve(config.root, path));
15+
return {
16+
name: 'wxt:remove-entrypoint-main-function',
17+
transform(code, id) {
18+
if (id === absPath) return removeMainFunctionCode(code);
19+
},
20+
};
21+
}
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { removeMainFunctionCode } from '../transform';
3+
4+
describe('Transform Utils', () => {
5+
describe('removeMainFunctionCode', () => {
6+
it.each(['defineBackground', 'defineUnlistedScript'])(
7+
'should remove the first arrow function argument for %s',
8+
(def) => {
9+
const input = `export default ${def}(() => {
10+
console.log();
11+
})`;
12+
const expected = `export default ${def}(() => {})`;
13+
14+
const actual = removeMainFunctionCode(input).code;
15+
16+
expect(actual).toEqual(expected);
17+
},
18+
);
19+
it.each(['defineBackground', 'defineUnlistedScript'])(
20+
'should remove the first function argument for %s',
21+
(def) => {
22+
const input = `export default ${def}(function () {
23+
console.log();
24+
})`;
25+
const expected = `export default ${def}(function () {})`;
26+
27+
const actual = removeMainFunctionCode(input).code;
28+
29+
expect(actual).toEqual(expected);
30+
},
31+
);
32+
33+
it.each([
34+
'defineBackground',
35+
'defineContentScript',
36+
'defineUnlistedScript',
37+
])('should remove the main field from %s', (def) => {
38+
const input = `export default ${def}({
39+
asdf: "asdf",
40+
main: () => {},
41+
})`;
42+
const expected = `export default ${def}({
43+
asdf: "asdf"
44+
})`;
45+
46+
const actual = removeMainFunctionCode(input).code;
47+
48+
expect(actual).toEqual(expected);
49+
});
50+
});
51+
});

Diff for: packages/wxt/src/core/utils/building/find-entrypoints.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import fs from 'fs-extra';
1919
import { minimatch } from 'minimatch';
2020
import { parseHTML } from 'linkedom';
2121
import JSON5 from 'json5';
22-
import { importEntrypointFile } from '~/core/utils/building';
2322
import glob from 'fast-glob';
2423
import {
2524
getEntrypointName,
@@ -29,11 +28,16 @@ import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
2928
import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths';
3029
import pc from 'picocolors';
3130
import { wxt } from '../../wxt';
31+
import { importEntrypointFile } from './import-entrypoint';
3232

3333
/**
3434
* Return entrypoints and their configuration by looking through the project's files.
3535
*/
3636
export async function findEntrypoints(): Promise<Entrypoint[]> {
37+
// Make sure required TSConfig file exists to load dependencies
38+
await fs.mkdir(wxt.config.wxtDir, { recursive: true });
39+
await fs.writeJson(resolve(wxt.config.wxtDir, 'tsconfig.json'), {});
40+
3741
const relativePaths = await glob(Object.keys(PATH_GLOB_TO_TYPE_MAP), {
3842
cwd: wxt.config.entrypointsDir,
3943
});
@@ -286,7 +290,7 @@ async function getUnlistedScriptEntrypoint({
286290
skipped,
287291
}: EntrypointInfo): Promise<GenericEntrypoint> {
288292
const defaultExport =
289-
await importEntrypointFile<UnlistedScriptDefinition>(inputPath);
293+
await importEntrypoint<UnlistedScriptDefinition>(inputPath);
290294
if (defaultExport == null) {
291295
throw Error(
292296
`${name}: Default export not found, did you forget to call "export default defineUnlistedScript(...)"?`,
@@ -311,7 +315,7 @@ async function getBackgroundEntrypoint({
311315
let options: Omit<BackgroundDefinition, 'main'> = {};
312316
if (inputPath !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
313317
const defaultExport =
314-
await importEntrypointFile<BackgroundDefinition>(inputPath);
318+
await importEntrypoint<BackgroundDefinition>(inputPath);
315319
if (defaultExport == null) {
316320
throw Error(
317321
`${name}: Default export not found, did you forget to call "export default defineBackground(...)"?`,
@@ -341,7 +345,7 @@ async function getContentScriptEntrypoint({
341345
skipped,
342346
}: EntrypointInfo): Promise<ContentScriptEntrypoint> {
343347
const { main: _, ...options } =
344-
await importEntrypointFile<ContentScriptDefinition>(inputPath);
348+
await importEntrypoint<ContentScriptDefinition>(inputPath);
345349
if (options == null) {
346350
throw Error(
347351
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
@@ -484,3 +488,9 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type']> = {
484488
};
485489

486490
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';
491+
492+
function importEntrypoint<T>(path: string) {
493+
return wxt.config.experimental.viteRuntime
494+
? wxt.builder.importEntrypoint<T>(path)
495+
: importEntrypointFile<T>(path);
496+
}

Diff for: packages/wxt/src/core/utils/building/resolve-config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export async function resolveConfig(
152152
alias,
153153
experimental: defu(mergedConfig.experimental, {
154154
includeBrowserPolyfill: true,
155+
viteRuntime: false,
155156
}),
156157
dev: {
157158
server: devServerConfig,

Diff for: packages/wxt/src/core/utils/package.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ export async function getPackageJson(): Promise<
2222
}
2323

2424
export function isModuleInstalled(name: string) {
25-
return import(name).then(() => true).catch(() => false);
25+
return import(/* @vite-ignore */ name).then(() => true).catch(() => false);
2626
}

Diff for: packages/wxt/src/core/utils/testing/fake-objects.ts

+1
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
295295
alias: {},
296296
experimental: {
297297
includeBrowserPolyfill: true,
298+
viteRuntime: false,
298299
},
299300
dev: {
300301
reloadCommand: 'Alt+R',

Diff for: packages/wxt/src/core/utils/transform.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ProxifiedModule, parseModule } from 'magicast';
2+
3+
/**
4+
* Removes any code used at runtime related to an entrypoint's main function.
5+
* - Removes or clears out `main` function from returned object
6+
* - TODO: Removes unused imports after main function has been removed to prevent importing runtime modules
7+
*/
8+
export function removeMainFunctionCode(code: string): {
9+
code: string;
10+
map?: string;
11+
} {
12+
const mod = parseModule(code);
13+
emptyMainFunction(mod);
14+
return mod.generate();
15+
}
16+
17+
function emptyMainFunction(mod: ProxifiedModule) {
18+
if (mod.exports?.default?.$type === 'function-call') {
19+
if (mod.exports.default.$ast?.arguments?.[0]?.body) {
20+
// Remove body from function
21+
// ex: "fn(() => { ... })" to "fn(() => {})"
22+
// ex: "fn(function () { ... })" to "fn(function () {})"
23+
mod.exports.default.$ast.arguments[0].body.body = [];
24+
} else if (mod.exports.default.$ast?.arguments?.[0]?.properties) {
25+
// Remove main field from object
26+
// ex: "fn({ ..., main: () => {} })" to "fn({ ... })"
27+
mod.exports.default.$ast.arguments[0].properties =
28+
mod.exports.default.$ast.arguments[0].properties.filter(
29+
(prop: any) => prop.key.name !== 'main',
30+
);
31+
}
32+
}
33+
}

Diff for: packages/wxt/src/types/index.ts

+14
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,15 @@ export interface InlineConfig {
312312
* })
313313
*/
314314
includeBrowserPolyfill?: boolean;
315+
/**
316+
* When set to `true`, use the Vite Runtime API to load entrypoint options instead of the default, `jiti`.
317+
*
318+
* Lets you use imported variables and leverage your Vite config to add support for non-standard APIs/syntax.
319+
*
320+
* @experimental Early access to try out the feature before it becomes the default.
321+
* @default false
322+
*/
323+
viteRuntime?: boolean;
315324
};
316325
/**
317326
* Config effecting dev mode only.
@@ -929,6 +938,10 @@ export interface WxtBuilder {
929938
* Version of tool used to build. Ex: "5.0.2"
930939
*/
931940
version: string;
941+
/**
942+
* Import the entrypoint file, returning the default export containing the options.
943+
*/
944+
importEntrypoint<T>(path: string): Promise<T>;
932945
/**
933946
* Build a single entrypoint group. This is effectively one of the multiple "steps" during the
934947
* build process.
@@ -1124,6 +1137,7 @@ export interface ResolvedConfig {
11241137
alias: Record<string, string>;
11251138
experimental: {
11261139
includeBrowserPolyfill: boolean;
1140+
viteRuntime: boolean;
11271141
};
11281142
dev: {
11291143
/** Only defined during dev command */

0 commit comments

Comments
 (0)