diff --git a/lib/admin/__tests__/plugin-dev.test.ts b/lib/admin/__tests__/plugin-dev.test.ts index d060b798b..4207af9be 100644 --- a/lib/admin/__tests__/plugin-dev.test.ts +++ b/lib/admin/__tests__/plugin-dev.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -46,6 +47,23 @@ describe('dev plugins', () => { expect(await getEnabledPluginFrameOrigins()).toContain('https://video.example.com'); }); + it('bundles src/ with packages that only declare main and touch Node builtins', async () => { + // Shaped like asn1js (no "exports") and asmcrypto (require('crypto') + // behind a runtime check), which the S/MIME plugin pulls in. + await devPlugin('deps', { ...BASE, id: 'deps' }, { + 'src/index.js': "import { value } from 'legacy-pkg';\nexport default value;", + 'node_modules/legacy-pkg/package.json': JSON.stringify({ name: 'legacy-pkg', main: 'lib/main.js' }), + 'node_modules/legacy-pkg/lib/main.js': + "exports.value = typeof process !== 'undefined' && process.versions?.node ? require('crypto') : 42;", + }); + const { listDevPlugins, readDevBundle } = await import('../plugin-dev'); + + const [entry] = await listDevPlugins(); + + expect(entry?.plugin.id).toBe('deps'); + expect(await readDevBundle(entry)).not.toContain('esbuild failed'); + }); + it('refuses an entrypoint outside the plugin folder', async () => { // The bundle route serves the file without a session. await writeFile(path.join(dir, 'secret.js'), 'TOP SECRET'); diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index a92e4a93a..03441baba 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -2,6 +2,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import path from 'node:path'; +import { builtinModules } from 'node:module'; import { logger } from '@/lib/logger'; import type { ServerPlugin } from './plugin-registry'; import { checkPluginManifest } from '@/lib/extension-package'; @@ -93,6 +94,9 @@ async function bundleEntrypoint(bundlePath: string): Promise { // (`new Function('module', 'exports', 'require', 'React', ...)`). format: 'cjs', platform: 'neutral', + // Neutral leaves mainFields empty, so packages that only declare "main" + // or "module" (asn1js, pkijs, ...) would not resolve. + mainFields: ['browser', 'module', 'main'], write: false, logLevel: 'silent', sourcemap: 'inline', @@ -103,6 +107,10 @@ async function bundleEntrypoint(bundlePath: string): Promise { external: [ 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@plugin-host', + // Browser libraries reach for Node builtins behind runtime checks that + // never pass in the sandbox (asmcrypto's require('crypto')). Leave them + // out instead of failing the whole bundle. + ...builtinModules, ...builtinModules.map((m) => `node:${m}`), ], }); const out = result.outputFiles?.[0]?.text;