Skip to main content
Version: Next

Turbopack

taintwire links the chunks of a Turbopack production build (next build --turbopack from Next.js 15.3, the default from 16) through its module registry, the same way it links webpack chunks. A call or value read from one chunk into another gets the CALLS, ARGUMENT_TO, RETURNS_TO and FLOWS_TO edges it would get if both modules were in one file. That covers ES modules, CommonJS modules and import(). Verified on production builds from Next.js 15.3.0, 15.3.9, 15.4.11, 15.5.28, 16.0.11, 16.1.7 and 16.4.2. The helper semantics below come from the Turbopack runtime in the next.js source at each version (turbopack-ecmascript-runtime/js/src/shared/runtime-utils.ts, browser/runtime/base/).

What you get​

Take a Next.js client page that imports getPayload from source.js and lazy-loads renderer.js, which imports it too:

// xss/source.js
export function getPayload() {
return window.name;
}

// xss/renderer.js
import { getPayload } from "./source.js";
export function render() {
document.getElementById("output").innerHTML = getPayload();
}

Turbopack puts source.js in the page's chunk and renderer.js in its own:

// page chunk
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push([document.currentScript,
92640, t => { t.v(e => Promise.all(["static/chunks/1ukv2e6qq3ml9.js"].map(e => t.l(e))).then(() => e(67834))) },
33736, t => { "use strict"; t.s(["f", 0, function () { return window.name }]) }]);

// 1ukv2e6qq3ml9.js
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push([document.currentScript,
67834, t => { "use strict"; var e = t.i(33736); t.s(["render", 0, function () { document.getElementById("output").innerHTML = (0, e.f)() }]) }]);

Load the whole .next/static/chunks/ with addFiles(), and the webpack page's query finds the path from window.name to innerHTML, through (0, e.f)(), which has no callee without the registry links.

Versions​

The runtime's format changed twice before Next.js 16. taintwire recognises each by its shape, so there's nothing to configure:

Next.jsChunk registrationFactoryExports (ctx.s)import()
15.3, 15.4push([script, {id: factory, ...}])ctx => { var {m: t, e: i} = ctx; ... }: one context object, holding module (m) and exports (e)ctx.s({name: getter | [getter, setter]})ctx.r(loader)(ctx.i)
15.5push([script, id, factory, ...])(ctx, module, exports)ctx.s([name, getter, setter?, ...])ctx.A(loader)
16.0+the same, plus groups(ctx, module, exports)ctx.s([name, 0, value | name, getter, setter?, ...])ctx.A(loader)

Before 15.3, Turbopack only ran in the dev server (next dev --turbo), which taintwire doesn't target.

What's recognised​

Every factory gets a context object as its first param, and the module's code goes through it. Below, ctx is that param (t or e once minified).

Modules​

Runtime shapeExample (minified)Becomes
Chunk registration(globalThis.TURBOPACK ||= []).push([script, 33736, t => {...}, ...])Module table: id 33736 → that factory
Object map (15.3, 15.4)push([script, {74323: e => {...}, 38443: function (e) {...}}])The same table
Groupspush([script, [96517, (e, t, n) => {...}, ...], 23911, ...])The inner list is read the same way
Several ids, one factorypush([script, 800, 801, e => {...}])Both ids → that factory
Factory paramse => {...} (ES module), (e, t, n) => {...} (CommonJS)context, module, exports, by position
Context destructuring (15.3, 15.4)function (e) { var {g: n, __dirname: r, m: t, e: i} = e; t.exports = ... }t is the factory's module, i its exports
Ids33736 (production), "[project]/app/x.js [app-client] (ecmascript)" (development)Numeric or string

Exports​

ShapeExampleBecomes
ctx.st.s(["f", 0, value, "g", () => x, v => { x = v }])EXPORTS {name: "f"} to value, {name: "g"} to x; the setter is skipped
ctx.s without value tags (15.5)e.s(["default", () => o, "getPayload", () => t])EXPORTS to each getter's value
ctx.s object (15.3, 15.4)e.s({getPayload: () => a, live: [() => v, x => { v = x }]})The same; for [getter, setter] the getter's value
ctx.s for another modulee.s([...], 3126)The exports of module 3126
ctx.ve.v(value)module.exports = value: the namespace's own value and its default
ctx.nt.n(t.i(36048))This module's namespace is 36048's
CommonJSn.exports = ..., t.named = ..., Object.defineProperty(t, "k", {get})As for webpack

Namespaces and imports​

ShapeExampleResolves to
ctx.i(id)var n = t.i(33736) then (0, n.f)()Module 33736's namespace (IMPORTS {module_id: "33736"})
ctx.r(id)let i = e.r(55944) then i.legacy()55944's module.exports
.default of a CommonJS modulet.i(55944).default.legacy()module.exports, so .legacy is its key
import()t.A(92640).then(m => m.render()), const m = await e.A(76398)ctx.A(id) runs loader module id, e => ....then(() => e(67834)), so m is 67834's namespace
import() (15.3, 15.4)e.r(60718)(e.i).then(m => m.render()), await e.r(77326)(e.i)The same: ctx.r(id) is the loader, called with the import function

How it works​

It's the webpack pass in flattenFiles() with a second runtime dialect: a factory's context param takes the place of webpack's require and exports params. ctx.i/ctx.r are the require, ctx.s is require.d, and ctx.v is module.exports =. ctx.A(id)'s loader takes an import function as its param, and calls of that param are requires. Before 15.5, the bindings destructured from the context as m and e take the place of the module and exports params, and ctx.r(id)(ctx.i) the place of ctx.A(id). Namespaces, re-exports, alias chains and resolution are shared with webpack, so everything under How it works there applies.

Limitations​

Not handledEffectNotes
Dev-server output (next dev --turbo / --turbopack, any version)Not measured. Next.js 13 and 14 dev chunks also use long helper names (__turbopack_import__, ...), which aren't recognisedTurbopack had no production builds before 15.3
ctx.j (dynamic export * of a CommonJS module)Keys known only at runtime aren't exportedNeeds property flow
ctx.S (grouped re-exports)Not linkedTurbopack resolves most re-exports at build time, so imports name the defining module
Framework code between the import and the usenext/dynamic, React.lazy and the interop helpers in Next's own runtime (i._(e.r(id))) aren't modelledAs for webpack
Separate add() callsChunks aren't linkedLoad the whole build with one addFiles()