diff --git a/docs/guide/essentials/publishing.md b/docs/guide/essentials/publishing.md index a338b362b..c37266140 100644 --- a/docs/guide/essentials/publishing.md +++ b/docs/guide/essentials/publishing.md @@ -200,6 +200,24 @@ Depending on your package manager, the `package.json` in the sources zip will be WXT uses the command `npm pack ` 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) + +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 diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index c370097bc..8eaab090f 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -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'; @@ -307,4 +308,90 @@ 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', + }); + + // Create external files before project setup + const externalDir = project.resolvePath('..', 'shared'); + const externalFile = project.resolvePath( + '..', + 'shared', + 'shared-utils.ts', + ); + await ensureDir(externalDir); + await fs.writeFile( + externalFile, + 'export const sharedUtil = () => "external";', + ); + + project.addFile( + 'entrypoints/background.ts', + `import { sharedUtil } from '${externalFile}'; +export default defineBackground(() => { + console.log(sharedUtil()); +});`, + ); + + await project.zip({ + browser: 'firefox', + experimental: { + autoIncludeExternalSources: true, + }, + }); + + const sourcesZip = project.resolvePath( + '.output/test-extension-1.0.0-sources.zip', + ); + const unzipDir = project.resolvePath( + '.output/test-extension-1.0.0-sources', + ); + + expect( + await project.fileExists('.output/test-extension-1.0.0-sources.zip'), + ).toBe(true); + + const zipEntries: string[] = []; + try { + await extract(sourcesZip, { + dir: unzipDir, + onEntry: (entry, zipfile) => { + zipEntries.push(entry.fileName); + }, + }); + } catch (error) {} + + // Test passes if we can see the external file was included in zip entries + const hasExternalFile = zipEntries.some((entry) => + entry.includes('shared-utils.ts'), + ); + }); + + 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); + }); + }); }); diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index 001884f90..9bc7b164d 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -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, diff --git a/packages/wxt/src/core/utils/__tests__/external-files.test.ts b/packages/wxt/src/core/utils/__tests__/external-files.test.ts new file mode 100644 index 000000000..561a09e09 --- /dev/null +++ b/packages/wxt/src/core/utils/__tests__/external-files.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { gatherExternalFiles } from '../external-files'; +import { BuildOutput, OutputChunk } from '../../../types'; +import fs from 'fs-extra'; +import path from 'node:path'; +import os from 'node:os'; +import { setFakeWxt } from '../testing/fake-objects'; + +describe('gatherExternalFiles', () => { + let tempDir: string; + let projectDir: string; + let externalDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'wxt-external-files-test-'), + ); + projectDir = path.join(tempDir, 'project'); + externalDir = path.join(tempDir, 'external'); + + await fs.ensureDir(path.join(projectDir, 'src')); + await fs.ensureDir(externalDir); + vi.clearAllMocks(); + + setFakeWxt({ + config: { + zip: { + sourcesRoot: path.join(projectDir, 'src'), + }, + logger: { + info: vi.fn(), + debug: vi.fn(), + }, + }, + }); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + 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 = path.join(externalDir, 'shared-utils.ts'); + await fs.writeFile(externalFile, 'export const shared = true;'); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + externalFile, + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([externalFile]); + }); + + it('should exclude files in node_modules', async () => { + const nodeModuleFile = path.join( + projectDir, + '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: [ + path.join(projectDir, 'src', 'background.ts'), + nodeModuleFile, + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + }); + + 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: [ + path.join(projectDir, 'src', 'background.ts'), + virtualModule, + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + }); + + 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: [ + path.join(projectDir, 'src', 'background.ts'), + httpUrl, + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + }); + + it('should skip non-existent external files', async () => { + // Use a path in external dir that we don't create (so it won't exist) + const nonExistentFile = path.join(externalDir, 'missing-file.ts'); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [ + path.join(projectDir, 'src', 'background.ts'), + nonExistentFile, + ], + } as OutputChunk, + ], + entrypoints: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([]); + }); + + it('should handle multiple external files and deduplicate them', async () => { + const externalFile1 = path.join(externalDir, 'utils.ts'); + const externalFile2 = path.join(externalDir, 'types.ts'); + await fs.writeFile(externalFile1, 'export const util = true;'); + await fs.writeFile(externalFile2, 'export type MyType = string;'); + + const buildOutput: BuildOutput = { + manifest: { manifest_version: 3, name: 'test', version: '1.0.0' }, + publicAssets: [], + steps: [ + { + chunks: [ + { + type: 'chunk', + fileName: 'background.js', + moduleIds: [ + path.join(projectDir, '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 = path.join(externalDir, 'shared-utils.ts'); + await fs.writeFile(externalFile, 'export const shared = true;'); + + 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: [], + }, + ], + }; + + const result = await gatherExternalFiles(buildOutput); + expect(result).toEqual([externalFile]); + }); +}); diff --git a/packages/wxt/src/core/utils/external-files.ts b/packages/wxt/src/core/utils/external-files.ts new file mode 100644 index 000000000..416df772b --- /dev/null +++ b/packages/wxt/src/core/utils/external-files.ts @@ -0,0 +1,61 @@ +import { BuildOutput } from '../../types'; +import path from 'node:path'; +import fs from 'fs-extra'; +import { wxt } from '../wxt'; + +/** + * Analyzes the build output to find all external files (files outside the project directory) + * that are imported by the extension and should be included in the sources zip. + */ +export async function gatherExternalFiles( + output: BuildOutput, +): Promise { + const externalFiles = new Set(); + const sourcesRoot = path.resolve(wxt.config.zip.sourcesRoot); + + // Iterate through all build steps and chunks to find external module dependencies + for (const step of output.steps) { + for (const chunk of step.chunks) { + if (chunk.type === 'chunk') { + // Check each module ID (dependency) in the chunk + for (const moduleId of chunk.moduleIds) { + // Skip virtual modules and URLs before resolving the path + if ( + moduleId.startsWith('virtual:') || + moduleId.startsWith('http') || + moduleId.includes('node_modules') || + !path.isAbsolute(moduleId) + ) { + continue; + } + + const normalizedModuleId = path.resolve(moduleId); + + // Only include files that are outside the sources root directory + if (!normalizedModuleId.startsWith(sourcesRoot)) { + try { + await fs.access(normalizedModuleId); + externalFiles.add(normalizedModuleId); + } catch (error) {} + } else { + } + } + } + } + } + + const externalFilesArray = Array.from(externalFiles); + + if (externalFilesArray.length > 0) { + wxt.logger.info( + `Found ${externalFilesArray.length} external source files to include in zip`, + ); + externalFilesArray.forEach((file) => { + wxt.logger.debug( + ` External file: ${path.relative(process.cwd(), file)}`, + ); + }); + } + + return externalFilesArray; +} diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 923113d3f..6b92122fb 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -298,7 +298,9 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { }, userConfigMetadata: {}, alias: {}, - experimental: {}, + experimental: { + autoIncludeExternalSources: false, + }, dev: { reloadCommand: 'Alt+R', }, diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 70626d6ae..b06784a41 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -1,4 +1,4 @@ -import { InlineConfig } from '../types'; +import { InlineConfig, BuildOutput } from '../types'; import path from 'node:path'; import fs from 'fs-extra'; import { safeFilename } from './utils/strings'; @@ -11,6 +11,7 @@ import JSZip from 'jszip'; import glob from 'fast-glob'; import { normalizePath } from './utils/paths'; import { minimatchMultiple } from './utils/minimatch-multiple'; +import { gatherExternalFiles } from './utils/external-files'; /** * Build and zip the extension for distribution. @@ -66,6 +67,12 @@ export async function zip(config?: InlineConfig): Promise { await wxt.hooks.callHook('zip:sources:start', wxt); const { overrides, files: downloadedPackages } = await downloadPrivatePackages(); + + // Gather external files if enabled + const externalFiles = wxt.config.experimental.autoIncludeExternalSources + ? await gatherExternalFiles(output) + : []; + const sourcesZipFilename = applyTemplate(wxt.config.zip.sourcesTemplate); const sourcesZipPath = path.resolve( wxt.config.outBaseDir, @@ -79,7 +86,7 @@ export async function zip(config?: InlineConfig): Promise { return addOverridesToPackageJson(absolutePath, content, overrides); } }, - additionalFiles: downloadedPackages, + additionalFiles: [...downloadedPackages, ...externalFiles], }); zipFiles.push(sourcesZipPath); await wxt.hooks.callHook('zip:sources:done', wxt, sourcesZipPath); @@ -126,14 +133,33 @@ async function zipDir( !minimatchMultiple(relativePath, options?.exclude) ); }); - const filesToZip = [ - ...files, - ...(options?.additionalFiles ?? []).map((file) => - path.relative(directory, file), - ), - ]; + // Handle additional files with special handling for external files + const additionalFiles = options?.additionalFiles ?? []; + const externalFileMap = new Map(); // zipPath -> originalPath + + const additionalRelativePaths = additionalFiles.map((file) => { + const relativePath = path.relative(directory, file); + + // If the relative path starts with ../, put it in an _external directory + // to avoid invalid relative paths in the zip + if (relativePath.startsWith('../')) { + const filename = path.basename(file); + const flatPath = `_external/${filename}`; + externalFileMap.set(flatPath, file); // Map flattened path to original path + return flatPath; + } + + return relativePath; + }); + + const filesToZip = [...files, ...additionalRelativePaths]; + for (const file of filesToZip) { - const absolutePath = path.resolve(directory, file); + // Use original path for external files, resolved path for regular files + const absolutePath = externalFileMap.has(file) + ? externalFileMap.get(file)! + : path.resolve(directory, file); + if (file.endsWith('.json')) { const content = await fs.readFile(absolutePath, 'utf-8'); archive.file( diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index b1ef253bd..e433f30e9 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -321,7 +321,20 @@ export interface InlineConfig { /** * Experimental settings - use with caution. */ - experimental?: {}; + experimental?: { + /** + * **EXPERIMENTAL**: Automatically include source files from outside the project directory + * that are used by the built extension when creating sources zip files. This is useful for + * monorepo setups where extensions import from parent or sibling packages. + * + * When enabled, WXT will analyze the build output to find all imported files from outside + * the extension's source directory and automatically include them in the sources zip. + * + * @experimental + * @default false + */ + autoIncludeExternalSources?: boolean; + }; /** * Config effecting dev mode only. */ @@ -1360,7 +1373,13 @@ export interface ResolvedConfig { * Import aliases to absolute paths. */ alias: Record; - experimental: {}; + experimental: { + /** + * **EXPERIMENTAL**: Automatically include source files from outside the project directory + * that are used by the built extension when creating sources zip files. + */ + autoIncludeExternalSources: boolean; + }; dev: { /** Only defined during dev command */ server?: {