feat!: Use defineUnlistedScript to define unlisted scripts (#167)

BREAKING CHANGE: Unlisted scripts must now `export default defineUnlistedScript(...)`
This commit is contained in:
Aaron
2023-10-14 10:06:52 -05:00
committed by GitHub
parent 8940c41bdb
commit 44464f914f
35 changed files with 332 additions and 46 deletions
+3
View File
@@ -0,0 +1,3 @@
export default defineUnlistedScript(() => {
console.log('injected');
});
+1
View File
@@ -124,6 +124,7 @@ export default defineConfig({
{ text: 'wxt', link: '/api/wxt.md' },
{ text: 'wxt/browser', link: '/api/wxt-browser.md' },
{ text: 'wxt/client', link: '/api/wxt-client.md' },
{ text: 'wxt/sandbox', link: '/api/wxt-sandbox.md' },
],
},
],
+12
View File
@@ -0,0 +1,12 @@
# `wxt/sandbox` Reference
The `wxt/sandbox` module contains exports that do not use the `browser` global (ie: a "sandboxed" environment).
:::warning 🚧 Under construction
This documentation does not exist yet. All APIs are documented with JSDoc, so for now, you can view the documentation in your editor.
```ts
import { defineUnlistedScript } from 'wxt/sandbox';
```
:::
+19 -2
View File
@@ -2,6 +2,8 @@
TypeScript files that are built, but are not included in the manifest.
You are responsible for loading/running these scripts where needed.
## Filenames
<EntrypointPatterns
@@ -13,8 +15,23 @@ TypeScript files that are built, but are not included in the manifest.
## Definition
Unlike the background or content scripts, you can define this script's logic in the top level scope.
```ts
export default defineUnlistedScript(() => {
// Executed when script is loaded
});
```
or
```ts
// Code goes here
export default defineUnlistedScript({
// Set include/exclude if the script should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
// Executed when script is loaded
main() {
// ...
},
});
```
+1
View File
@@ -14,6 +14,7 @@ Some WXT APIs can be used without importing them:
- [`defineContentScript`](/api/wxt-client#defiencontentscript) from `wxt/client`
- [`defineBackground`](/api/wxt-client#definebackground) from `wxt/client`
- [`createContentScriptUi`](/api/wxt-client#createcontentscriptui) from `wxt/client`
- [`defineUnlistedScript`](/api/wxt-sandbox#defineunlistedscript) from `wxt/sandbox`
And more. All [`wxt/client`](/api/wxt-client) APIs can be used without imports.
+1
View File
@@ -22,6 +22,7 @@ describe('Auto Imports', () => {
const defineBackground: typeof import('wxt/client')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/client')['defineContentScript']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
}
"
`);
+4 -1
View File
@@ -172,7 +172,10 @@ describe('Output Directory Structure', () => {
'entrypoints/background.js',
`export default defineBackground(() => {});`,
);
project.addFile('entrypoints/unlisted.js', ``);
project.addFile(
'entrypoints/unlisted.js',
`export default defineUnlistedScript(() => {})`,
);
project.addFile(
'entrypoints/content.js',
`export default defineContentScript({
+5 -1
View File
@@ -5,7 +5,11 @@ describe('Remote Code', () => {
it('should download "url:*" modules and include them in the final bundle', async () => {
const url = 'https://code.jquery.com/jquery-3.7.1.slim.min.js';
const project = new TestProject();
project.addFile('entrypoints/popup.ts', `import "url:${url}"`);
project.addFile(
'entrypoints/popup.ts',
`import "url:${url}"
export default defineUnlistedScript(() => {})`,
);
await project.build();
+4
View File
@@ -44,6 +44,10 @@
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
},
"./sandbox": {
"import": "./dist/sandbox.js",
"types": "./dist/sandbox.d.ts"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
+8 -1
View File
@@ -10,7 +10,7 @@ const spinner = ora('Building WXT').start();
const startTime = Date.now();
const outDir = 'dist';
const virtualEntrypoints = ['background', 'content-script'];
const virtualEntrypoints = ['background', 'content-script', 'unlisted-script'];
await fs.rm(outDir, { recursive: true, force: true });
@@ -48,6 +48,13 @@ await Promise.all([
silent: true,
external: ['vite'],
}),
tsup.build({
entry: { sandbox: 'src/client/sandbox/index.ts' },
format: ['esm'],
sourcemap: 'inline',
dts: true,
silent: true,
}),
...virtualEntrypoints.map((entryName) =>
tsup.build({
entry: {
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { defineBackground } from '../defineBackground';
import { BackgroundDefinition } from '../../core/types';
describe('defineBackground', () => {
it('should return the object definition when given an object', () => {
const definition: BackgroundDefinition = {
include: [''],
persistent: false,
main: vi.fn(),
};
const actual = defineBackground(definition);
expect(actual).toEqual(definition);
});
it('should return the object definition when given a main function', () => {
const main = vi.fn();
const actual = defineBackground(main);
expect(actual).toEqual({ main });
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it, vi } from 'vitest';
import { defineContentScript } from '../defineContentScript';
import { ContentScriptDefinition } from '../../core/types';
describe('defineContentScript', () => {
it('should return the object passed in', () => {
const definition: ContentScriptDefinition = {
matches: [],
include: [''],
main: vi.fn(),
};
const actual = defineContentScript(definition);
expect(actual).toEqual(definition);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest';
import { defineUnlistedScript } from '../defineUnlistedScript';
import { UnlistedScriptDefinition } from '../../../core/types';
describe('defineUnlistedScript', () => {
it('should return the object definition when given an object', () => {
const definition: UnlistedScriptDefinition = {
include: [''],
main: vi.fn(),
};
const actual = defineUnlistedScript(definition);
expect(actual).toEqual(definition);
});
it('should return the object definition when given a main function', () => {
const main = vi.fn();
const actual = defineUnlistedScript(main);
expect(actual).toEqual({ main });
});
});
@@ -0,0 +1,14 @@
import { UnlistedScriptDefinition } from '../../core/types';
export function defineUnlistedScript(
main: () => void,
): UnlistedScriptDefinition;
export function defineUnlistedScript(
definition: UnlistedScriptDefinition,
): UnlistedScriptDefinition;
export function defineUnlistedScript(
arg: (() => void) | UnlistedScriptDefinition,
): UnlistedScriptDefinition {
if (typeof arg === 'function') return { main: arg };
return arg;
}
+1
View File
@@ -0,0 +1 @@
export * from './defineUnlistedScript';
@@ -37,6 +37,6 @@ try {
);
}
} catch (err) {
logger.error('The background script crashed on startup!');
logger.error('The background crashed on startup!');
throw err;
}
@@ -9,6 +9,9 @@ import { ContentScriptContext } from '../utils/ContentScriptContext';
await main(ctx);
} catch (err) {
logger.error('The content script crashed on startup!', err);
logger.error(
`The content script "${__ENTRYPOINT__}" crashed on startup!`,
err,
);
}
})();
@@ -0,0 +1,13 @@
import definition from 'virtual:user-unlisted-script';
import { logger } from '../utils/logger';
(async () => {
try {
await definition.main();
} catch (err) {
logger.error(
`The unlisted script "${__ENTRYPOINT__}" crashed on startup!`,
err,
);
}
})();
@@ -15,6 +15,11 @@ declare module 'virtual:user-content-script' {
export default definition;
}
declare module 'virtual:user-unlisted-script' {
const definition: import('../../').UnlistedScriptDefinition;
export default definition;
}
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
@@ -261,6 +261,43 @@ describe('findEntrypoints', () => {
});
});
it.each<[string, Omit<GenericEntrypoint, 'options'>]>([
[
'injected.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
outputDir: config.outDir,
},
],
[
'injected/index.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
outputDir: config.outDir,
},
],
])(
'should find and load unlisted-script entrypoint config from %s',
async (path, expected) => {
const options: GenericEntrypoint['options'] = {};
globMock.mockResolvedValueOnce([path]);
importEntrypointFileMock.mockResolvedValue(options);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importEntrypointFileMock).toBeCalledWith(
expected.inputPath,
config,
);
},
);
it.each<[string, GenericEntrypoint]>([
// Sandbox
[
@@ -456,28 +493,6 @@ describe('findEntrypoints', () => {
},
],
// unlisted-script
[
'injected.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
outputDir: config.outDir,
options: {},
},
],
[
'injected/index.ts',
{
type: 'unlisted-script',
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
outputDir: config.outDir,
options: {},
},
],
// unlisted-style
[
'iframe.scss',
+5 -1
View File
@@ -53,7 +53,11 @@ async function buildSingleEntrypoint(
config: InternalConfig,
): Promise<BuildStepOutput> {
// Should this entrypoint be wrapped by the vite-plugins/virtualEntrypoint plugin?
const isVirtual = ['background', 'content-script'].includes(entrypoint.type);
const isVirtual = [
'background',
'content-script',
'unlisted-script',
].includes(entrypoint.type);
const entry = isVirtual
? `virtual:wxt-${entrypoint.type}?${entrypoint.inputPath}`
: entrypoint.inputPath;
+44 -10
View File
@@ -10,6 +10,7 @@ import {
InternalConfig,
OptionsEntrypoint,
PopupEntrypoint,
UnlistedScriptDefinition,
} from '../types';
import fs from 'fs-extra';
import { minimatch } from 'minimatch';
@@ -74,15 +75,14 @@ export async function findEntrypoints(
hasBackground = true;
break;
case 'content-script':
entrypoint = await getContentScriptEntrypoint(
config,
getEntrypointName(config.entrypointsDir, path),
path,
);
entrypoint = await getContentScriptEntrypoint(config, path);
break;
case 'unlisted-page':
entrypoint = await getUnlistedPageEntrypoint(config, path);
break;
case 'unlisted-script':
entrypoint = await getUnlistedScriptEntrypoint(config, path);
break;
case 'content-script-style':
entrypoint = {
type,
@@ -288,6 +288,35 @@ async function getUnlistedPageEntrypoint(
};
}
/**
* @param path Absolute path to the script's file.
* @param content String contents of the file at the path.
*/
async function getUnlistedScriptEntrypoint(
config: InternalConfig,
path: string,
): Promise<GenericEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const defaultExport = await importEntrypointFile<UnlistedScriptDefinition>(
path,
config,
);
if (defaultExport == null) {
throw Error(
`${name}: Default export not found, did you forget to call "export default defineUnlistedScript(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
const options: Omit<UnlistedScriptDefinition, 'main'> = moduleOptions;
return {
type: 'unlisted-script',
name,
inputPath: path,
outputDir: config.outDir,
options,
};
}
/**
* @param path Absolute path to the background's TS file.
*/
@@ -295,6 +324,7 @@ async function getBackgroundEntrypoint(
config: InternalConfig,
path: string,
): Promise<BackgroundEntrypoint> {
const name = 'background';
let options: Omit<BackgroundDefinition, 'main'> = {};
if (path !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport = await importEntrypointFile<BackgroundDefinition>(
@@ -302,14 +332,16 @@ async function getBackgroundEntrypoint(
config,
);
if (defaultExport == null) {
throw Error('Background script does not have a default export');
throw Error(
`${name}: Default export not found, did you forget to call "export default defineBackground(...)"?`,
);
}
const { main: _, ...moduleOptions } = defaultExport;
options = moduleOptions;
}
return {
type: 'background',
name: 'background',
name,
inputPath: path,
outputDir: config.outDir,
options: {
@@ -325,17 +357,19 @@ async function getBackgroundEntrypoint(
*/
async function getContentScriptEntrypoint(
config: InternalConfig,
name: string,
path: string,
): Promise<ContentScriptEntrypoint> {
const name = getEntrypointName(config.entrypointsDir, path);
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(path, config);
if (options == null) {
throw Error(`Content script ${name} does not have a default export`);
throw Error(
`${name}: Default export not found, did you forget to call "export default defineContentScript(...)"?`,
);
}
return {
type: 'content-script',
name: getEntrypointName(config.entrypointsDir, path),
name,
inputPath: path,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options,
+7
View File
@@ -435,6 +435,13 @@ export interface BackgroundDefinition extends ExcludableEntrypoint {
main(): void;
}
export interface UnlistedScriptDefinition extends ExcludableEntrypoint {
/**
* Main function executed when the unlisted script is ran.
*/
main(): void | Promise<void>;
}
export type PerBrowserOption<T> = T | { [browser: TargetBrowser]: T };
export interface ExcludableEntrypoint {
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { importEntrypointFile } from '../importEntrypointFile';
import { fakeInternalConfig } from '../../../testing/fake-objects';
import { resolve } from 'node:path';
const entrypointPath = (filename: string) =>
resolve('src/core/utils/__tests__/test-entrypoints', filename);
const config = fakeInternalConfig({
imports: false,
debug: false,
// Run inside the demo folder so that wxt is in the node_modules
// WXT must also be built for these tests to pass
root: 'demo',
});
describe('importEntrypointFile', () => {
it.each([
['background.ts', { main: expect.any(Function) }],
['content.ts', { main: expect.any(Function), matches: ['<all_urls>'] }],
['unlisted.ts', { main: expect.any(Function) }],
['react.tsx', { main: expect.any(Function) }],
['with-named.ts', { main: expect.any(Function) }],
])(
'should return the default export of test-entrypoints/%s',
async (file, expected) => {
const actual = await importEntrypointFile(entrypointPath(file), config);
expect(actual).toEqual(expected);
},
);
it('should return undefined when there is no default export', async () => {
const actual = await importEntrypointFile(
entrypointPath('no-default-export.ts'),
config,
);
expect(actual).toBeUndefined();
});
});
@@ -0,0 +1,5 @@
import { defineBackground } from '../../../../client';
export default defineBackground({
main() {},
});
@@ -0,0 +1,6 @@
import { defineContentScript } from '../../../../client';
export default defineContentScript({
matches: ['<all_urls>'],
main() {},
});
@@ -0,0 +1,3 @@
import { defineUnlistedScript } from '../../../../client/sandbox';
export default defineUnlistedScript(() => {});
@@ -0,0 +1,3 @@
import { defineUnlistedScript } from '../../../../client/sandbox';
export default defineUnlistedScript(() => {});
@@ -0,0 +1,5 @@
import { defineBackground } from '../../../../client';
export const a = {};
export default defineBackground(() => {});
+5 -1
View File
@@ -10,7 +10,11 @@ export function getUnimportOptions(
const defaultOptions: Partial<UnimportOptions> = {
debugLog: config.logger.debug,
imports: [{ name: 'defineConfig', from: 'wxt' }],
presets: [{ package: 'wxt/client' }, { package: 'wxt/browser' }],
presets: [
{ package: 'wxt/client' },
{ package: 'wxt/browser' },
{ package: 'wxt/sandbox' },
],
warn: config.logger.warn,
dirs: ['components', 'composables', 'hooks', 'utils'],
};
+5 -2
View File
@@ -237,10 +237,13 @@ async function resolveInternalViteConfig(
internalVite.plugins.push(plugins.devHtmlPrerender(finalConfig));
internalVite.plugins.push(plugins.unimport(finalConfig));
internalVite.plugins.push(
plugins.virtualEntrypoin('background', finalConfig),
plugins.virtualEntrypoint('background', finalConfig),
);
internalVite.plugins.push(
plugins.virtualEntrypoin('content-script', finalConfig),
plugins.virtualEntrypoint('content-script', finalConfig),
);
internalVite.plugins.push(
plugins.virtualEntrypoint('unlisted-script', finalConfig),
);
internalVite.plugins.push(plugins.devServerGlobals(finalConfig));
internalVite.plugins.push(plugins.tsconfigPaths(finalConfig));
+2 -2
View File
@@ -49,7 +49,6 @@ export async function importEntrypointFile<T>(
cache: false,
debug: config.debug,
esmResolve: true,
interopDefault: true,
alias: {
'webextension-polyfill': resolve(
config.root,
@@ -69,7 +68,8 @@ export async function importEntrypointFile<T>(
});
try {
return await jiti(path);
const res = await jiti(path);
return res.default;
} catch (err) {
config.logger.error(err);
throw err;
+1
View File
@@ -23,6 +23,7 @@ export function removeProjectImportStatements(text: string): string {
const noImports = removeImportStatements(text);
return `import { defineContentScript, defineBackground } from 'wxt/client';
import { defineUnlistedScript } from 'wxt/sandbox';
${noImports}`;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { normalizePath } from '../utils/paths';
/**
* Wraps a user's entrypoint with a vitual version with additional logic.
*/
export function virtualEntrypoin(
export function virtualEntrypoint(
type: Entrypoint['type'],
config: InternalConfig,
): Plugin {