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.
| 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.
| 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.
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.
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).
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'; // named
Type-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().
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.
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.
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
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.
- 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.