Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions docs/guide/essentials/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,24 @@ Depending on your package manager, the `package.json` in the sources zip will be
WXT uses the command `npm pack <package-name>` to download the package. That means regardless of your package manager, you need to properly setup a `.npmrc` file. NPM and PNPM both respect `.npmrc` files, but Yarn and Bun have their own ways of authorizing private registries, so you'll need to add a `.npmrc` file.
:::

#### Include External Sources (Experimental)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, if this is the right place to add the documentation 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, probably fine.


If your extension is part of a monorepo and imports files from outside the extension directory (like shared libraries), you can enable automatic inclusion of these external files:

```ts [wxt.config.ts]
export default defineConfig({
experimental: {
autoIncludeExternalSources: true, // EXPERIMENTAL
},
});
```

When enabled, WXT will analyze your build output to find all imported files from outside the extension's source directory and automatically include them in the sources zip. This is useful for monorepo setups where extensions import from parent or sibling packages.

:::warning Experimental Feature
The `autoIncludeExternalSources` option is experimental and may change in future versions. Always test your sources zip to ensure it contains all necessary files for rebuilding your extension.
:::

### Safari

> 🚧 Not supported yet
Expand Down
51 changes: 50 additions & 1 deletion packages/wxt/e2e/tests/zip.test.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests don't actually test the new behavior. Please actually include a external file in project, then extract and make sure the are included.

Here's an example of how to unzip and check for file existence: https://github.com/johnnyfekete/wxt/blob/d632e5c3cdd96421662b791c6d6008b32dde666e/packages/wxt/e2e/tests/zip.test.ts#L103-L123

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I changed them. It was a lot more complicated than I thought, so maybe you can think of a nicer way to test files that are outside of the project folder, but I'm using the zip test functionalities that you recommended.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest';
import { TestProject } from '../utils';
import extract from 'extract-zip';
import spawn from 'nano-spawn';
import { readFile, writeFile } from 'fs-extra';
import { readFile, writeFile, ensureDir } from 'fs-extra';
import fs from 'fs-extra';

process.env.WXT_PNPM_IGNORE_WORKSPACE = 'true';

Expand Down Expand Up @@ -307,4 +308,52 @@ describe('Zipping', () => {
await extract(sourcesZip, { dir: unzipDir });
expect(await project.fileExists(unzipDir, 'manifest.json')).toBe(true);
});

describe('autoIncludeExternalSources', () => {
it('should automatically include external source files when autoIncludeExternalSources is enabled', async () => {
const project = new TestProject({
name: 'test-extension',
version: '1.0.0',
});

project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {});',
);

await project.zip({
browser: 'firefox',
experimental: {
autoIncludeExternalSources: true,
},
});

expect(
await project.fileExists('.output/test-extension-1.0.0-sources.zip'),
).toBe(true);
});

it('should not include external source files when autoIncludeExternalSources is disabled', async () => {
const project = new TestProject({
name: 'test-extension',
version: '1.0.0',
});

project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {});',
);

await project.zip({
browser: 'firefox',
experimental: {
autoIncludeExternalSources: false,
},
});

expect(
await project.fileExists('.output/test-extension-1.0.0-sources.zip'),
).toBe(true);
});
});
});
4 changes: 3 additions & 1 deletion packages/wxt/src/core/resolve-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ export async function resolveConfig(
analysis: resolveAnalysisConfig(root, mergedConfig),
userConfigMetadata: userConfigMetadata ?? {},
alias,
experimental: defu(mergedConfig.experimental, {}),
experimental: defu(mergedConfig.experimental, {
autoIncludeExternalSources: false,
}),
dev: {
server: devServerConfig,
reloadCommand,
Expand Down
262 changes: 262 additions & 0 deletions packages/wxt/src/core/utils/__tests__/external-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gatherExternalFiles } from '../external-files';
import { BuildOutput, OutputChunk } from '../../../types';
import fs from 'fs-extra';
import path from 'node:path';
import { setFakeWxt } from '../testing/fake-objects';

// Mock fs-extra
vi.mock('fs-extra');
const mockFs = vi.mocked(fs);

describe('gatherExternalFiles', () => {
beforeEach(() => {
vi.clearAllMocks();

// Setup fake wxt instance with default config
setFakeWxt({
config: {
zip: {
sourcesRoot: '/project/src',
},
logger: {
info: vi.fn(),
debug: vi.fn(),
},
},
});
});

it('should return empty array when no external files are found', async () => {
const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: [
'/project/src/background.ts',
'/project/src/utils.ts',
],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([]);
});

it('should include external files that exist outside the project directory', async () => {
const externalFile = '/parent/shared/utils.ts';

// Mock fs.access to succeed for external file
mockFs.access.mockImplementation((filePath) => {
if (filePath === externalFile) {
return Promise.resolve();
}
return Promise.reject(new Error('File not found'));
});

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: ['/project/src/background.ts', externalFile],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([externalFile]);
expect(mockFs.access).toHaveBeenCalledWith(externalFile);
});

it('should exclude files in node_modules', async () => {
const nodeModuleFile = '/project/node_modules/some-package/index.js';

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: ['/project/src/background.ts', nodeModuleFile],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([]);
expect(mockFs.access).not.toHaveBeenCalledWith(nodeModuleFile);
});

it('should exclude virtual modules', async () => {
const virtualModule = 'virtual:wxt-background';

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: ['/project/src/background.ts', virtualModule],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([]);
expect(mockFs.access).not.toHaveBeenCalledWith(virtualModule);
});

it('should exclude HTTP URLs', async () => {
const httpUrl = 'http://example.com/script.js';

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: ['/project/src/background.ts', httpUrl],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([]);
expect(mockFs.access).not.toHaveBeenCalledWith(httpUrl);
});

it('should skip non-existent external files', async () => {
const nonExistentFile = '/parent/missing/file.ts';

// Mock fs.access to reject for non-existent file
mockFs.access.mockRejectedValue(new Error('File not found'));

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: ['/project/src/background.ts', nonExistentFile],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([]);
expect(mockFs.access).toHaveBeenCalledWith(nonExistentFile);
});

it('should handle multiple external files and deduplicate them', async () => {
const externalFile1 = '/parent/shared/utils.ts';
const externalFile2 = '/parent/shared/types.ts';

// Mock fs.access to succeed for both external files
mockFs.access.mockImplementation((filePath) => {
if (filePath === externalFile1 || filePath === externalFile2) {
return Promise.resolve();
}
return Promise.reject(new Error('File not found'));
});

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'chunk',
fileName: 'background.js',
moduleIds: [
'/project/src/background.ts',
externalFile1,
externalFile2,
externalFile1, // Duplicate should be ignored
],
} as OutputChunk,
],
entrypoints: [],
},
],
};

const result = await gatherExternalFiles(buildOutput);
expect(result).toHaveLength(2);
expect(result).toContain(externalFile1);
expect(result).toContain(externalFile2);
});

it('should only process chunk-type outputs', async () => {
const externalFile = '/parent/shared/utils.ts';

const buildOutput: BuildOutput = {
manifest: { manifest_version: 3, name: 'test', version: '1.0.0' },
publicAssets: [],
steps: [
{
chunks: [
{
type: 'asset',
fileName: 'icon.png',
},
{
type: 'chunk',
fileName: 'background.js',
moduleIds: [externalFile],
} as OutputChunk,
],
entrypoints: [],
},
],
};

// Mock fs.access to succeed
mockFs.access.mockResolvedValue(undefined);

const result = await gatherExternalFiles(buildOutput);
expect(result).toEqual([externalFile]);
expect(mockFs.access).toHaveBeenCalledOnce();
});
});
Loading