11 KiB
Vendor Dependencies & Bundling
Playwright ships a small number of node_modules inlined into a handful of
pre-built "bundle" files under lib/. Everything else is either a source
file compiled per-file, or loaded at runtime from one of the bundles. This
doc covers how the bundling works, how to add or move a vendored package,
and how the dependency checker enforces the contract.
The Bundles
playwright-core
| Output | Entry | Purpose |
|---|---|---|
lib/utilsBundle.js |
src/utilsBundle.ts |
Vendored npm packages (debug, mime, ws, yauzl, yazl, @modelcontextprotocol/sdk, graceful-fs, …). The single home for third-party runtime code in playwright-core. |
lib/coreBundle.js |
src/coreBundle.ts |
Re-exports of playwright-core's own modules (client, iso, utils, cli, server, registry, …) as namespaces. Inlines almost all playwright-core source except utilsBundle. |
lib/server/electron/loader.js |
src/server/electron/loader.ts |
Tiny Electron preload shim. |
The dynamicImportToRequirePlugin in utils/build/build.js rewrites
vendored npm imports at bundle time. For example, a playwright-core
source file containing
import debug from 'debug';
gets rewritten to
const debug = require('./utilsBundle').debug;
before the bundler sees it — so the vendored package never gets inlined
into coreBundle.js. The mapping from npm package name to utilsBundle
export key lives in utils/build/utilsBundleMapping.js.
playwright
| Output | Entry | Purpose |
|---|---|---|
lib/transform/babelBundle.js |
src/transform/babelBundle.ts |
Wraps @babel/core, @babel/traverse, @babel/code-frame, plugins. Shared by every consumer that needs babel. |
lib/transform/esmLoader.js |
src/transform/esmLoader.ts |
Node ESM loader registered via node:module.register(). Output sits next to babelBundle.js so its ./babelBundle sibling require resolves correctly. |
lib/common/index.js |
src/common/index.ts |
Barrel of common/* + transform/* (compilationCache, test, configLoader, fixtures, globals, …). State-holding singletons (currentTestInfo, memoryCache, …) live here. |
lib/runner/index.js |
src/runner/index.ts |
Barrel of runner/* + reporters/* + plugins/*. |
lib/matchers/expect.js |
src/matchers/expect.ts |
Jest-style matchers with expect inlined. |
lib/worker/workerProcessEntry.js |
src/worker/workerProcessEntry.ts |
Entry point spawned per test worker. |
lib/loader/loaderProcessEntry.js |
src/loader/loaderProcessEntry.ts |
Entry point for the test file loader sub-process. |
lib/runner/uiModeReporter.js |
src/runner/uiModeReporter.ts |
Loaded by require.resolve from testServer; passed to child workers as a file path. |
The common and runner bundles externalize ../transform/babelBundle
(among other things) so babel code is not duplicated across them. The
lib/transform/transform.ts module uses libPath('transform', 'babelBundle')
(absolute path via package.ts root) to load the babel bundle at runtime,
so it works regardless of which bundle has inlined it.
Per-file emits (no bundle)
Files outside the bundled entries are compiled 1:1 by esbuild and land
under lib/ mirroring their source layout. The per-file step in
utils/build/build.js lists the specific directories for the
playwright package (cli/, agents/, mcp/, root *.ts, and a few
targeted files like runner/uiModeReporter.ts). Other packages
(playwright-test, html-reporter, trace-viewer, …) are compiled by
the generic per-package loop.
Bundle Sidecars
Every bundled output has two sidecar files next to it:
<bundle>.js.txt— human-readable report listing inlined files (sorted by path, with per-file KB sizes), externals, and total bytes. Written byutils/build/bundle_report.js.<bundle>.js.LICENSE— third-party license texts for every npm package whose source got inlined. Populated fromlicense-checker, memoized once per build invocation. Consumed by the top-levelThirdPartyNotices.txtfiles, which just point readers at the per-bundle sidecars.
Both sidecars are included in the published npm package (controlled by
packages/*/.npmignore).
Adding a Vendored NPM Dependency
Three pieces need to line up when adding a new npm package that you want
inlined into utilsBundle (i.e., loaded through require('./utilsBundle').<key>):
-
Install the package. Add it to the root
package.jsondevDependencies. The monorepo root is where esbuild resolves modules from; the workspace root'snode_modules/<pkg>is what gets inlined intoutilsBundle.js. -
Export it from
src/utilsBundle.ts. Pick one of:import fooLibrary from 'foo'; export const foo = fooLibrary; // default import * as fooLibrary from 'foo'; export const foo = fooLibrary; // namespace export { namedSymbol } from 'foo'; // namedType-only exports (
export type { X } from 'foo') are valid and don't affect runtime. -
Add a mapping entry to
utils/build/utilsBundleMapping.js:'foo': { default: 'foo' }, // or: 'foo': { namespace: 'foo' }, // or: 'foo': { named: { namedSymbol: 'fooNamedSymbol' } },default— matchesimport foo from 'foo'and rewrites torequire('./utilsBundle').foo.namespace— matchesimport * as foo from 'foo'.named— matchesimport { namedSymbol } from 'foo'and rewrites toconst { fooNamedSymbol: namedSymbol } = require('./utilsBundle').- Multiple forms can coexist in one entry (see
yauzl). - The map key is the exact npm specifier as written in source
(including subpaths like
'@babel/core'or'colors/safe').
-
Update DEPS.list. The file or its enclosing folder's
DEPS.listmust authorizenode_modules/<pkg>— otherwisenpm run flint'scheck_depsstep complains about the disallowed external dependency. If the DEPS.list authorizes it, the package.json-dependencies check also gets skipped for that file. -
Run
npm run flint. It runscheck_deps,tsc,eslint, anddocin parallel. A missing mapping typically surfaces asnode_modules/references leaking intocoreBundle.js— the build fails hard viaassertCoreBundleHasNoNodeModules().
In-tree Third-Party Helpers
Some vendored code isn't a published npm package but lives in-tree at
packages/playwright-core/src/server/utils/third_party/ (e.g.
extractZip.ts, lockfile.ts). These are TypeScript files, not
node_modules. They're exposed to callers via two different routes:
- Through
coreBundle.utils. Re-exported fromsrc/server/utils/index.tsviaexport * from './third_party/extractZip'etc. Callers import via the@utils/*path alias:The alias is rewritten at bundle time toimport { extractZip } from '@utils/third_party/extractZip';require('playwright-core/lib/coreBundle').utils.extractZip. - Transitive npm deps via utilsBundle. When a third_party TS file
imports an npm package (e.g.,
lockfile.tsimportsgraceful-fs,retry,signal-exit), those are still rewritten throughutilsBundle— so the mapping inutilsBundleMapping.jsmust list them too.
DEPS.list
Every directory under packages/*/src/ has a DEPS.list constraining
its imports. Three kinds of entries:
| Syntax | Meaning |
|---|---|
./somefile.ts, @isomorphic/** |
Relative or alias source import allowed |
node_modules/<pkg> |
npm package import allowed (exact specifier match) |
"strict" |
No other DEPS inherited; only what's listed is allowed |
Section headers [filename.ts] scope rules to a single file. The
top-level [*] (or no header) applies to everything in the folder plus
subfolders that don't have their own DEPS.list.
A DEPS.list entry of node_modules/<pkg> now shortcuts both layers of
the check: the "disallowed external dependency" error AND the
"dependencies not declared in package.json" report. The per-file
allowlist is the contract — no need to also list the dep in
packages/<pkg>/package.json if only one file uses it and it's
authorized there.
check_deps.js
utils/check_deps.js walks the TypeScript program, visits every
import in src/**, and for each npm specifier:
- Skips if the source file's DEPS.list authorizes
node_modules/<specifier>. - Otherwise records the top-level package name along with the file path that imported it.
- Subtracts
peerDependencies,VENDORED_PACKAGES(fromutilsBundleMapping.js), and any package that resolves withoutnode_modules/(a core module or a local file). - Subtracts packages listed in
packages/<pkg>/package.jsondependencies. - Anything left is reported with the specific file(s) that import it.
The missing-dep error now includes file paths:
Dependencies are not declared in package.json:
expect
src/matchers/expect.ts
@babel/core
src/transform/babelBundle.ts
Bundle-Level Externalization (onResolve plugins)
Two onResolve plugins in utils/build/build.js normalize relative
imports to the sibling bundle at consumer output level:
externalizeUtilsBundlePlugin— matches any relative specifier ending in/utilsBundleor/utilsBundle.js(at any depth:./utilsBundle,../utilsBundle,../../utilsBundle) and marks it external with the single spelling./utilsBundle. This only applies to the coreBundle build because coreBundle inlines source files from all overplaywright-core/src/(different depths) and needs a single consistent external specifier that resolves correctly at runtime fromlib/coreBundle.js.- The babelBundle case is handled differently — instead of a plugin,
consumers' source/output depths are aligned:
esmLoaderbundle output is placed atlib/transform/esmLoader.js(same folder asbabelBundle.js), so./babelBundlefromtransform.tsresolves correctly.commonandrunnerbundles declare'../transform/babelBundle'as a static external; their outputs are atlib/common/index.jsandlib/runner/index.js, both at depth 1, so the source-relative specifier resolves naturally.transform.ts's ownrequire('./babelBundle')was replaced withrequire(libPath('transform', 'babelBundle'))— an absolute path computed at runtime viapackage.ts, which works from any bundle.
Quick Reference
- To add a new vendored npm dep: root
package.json→utilsBundle.tsexport →utilsBundleMapping.jsentry → DEPS.list →npm run flint. - To add a new in-tree third-party helper: drop the
.tsfile underserver/utils/third_party/, re-export fromserver/utils/index.ts, and use@utils/third_party/<name>at call sites. - To add a new bundle entry: add an
EsbuildStepinutils/build/build.js, pick output location so relative externals line up with runtime layout, and list externals for every sibling bundle the entry should not inline. - To expose a bundle file as a package subpath: add it to the
exportsfield inpackages/<pkg>/package.json. - To check what's inside a bundle: read the
.js.txtsidecar next to the output.