Skip to content

Commit 13bf3d9

Browse files
authored
[turbopack] Make it possible to synchronously access native bindings (#85787)
## Enable synchronous access to native bindings ## What? This PR refactors the SWC bindings loading mechanism to support synchronous access to bindings after they've been loaded. It introduces new functions `installBindings()` and `getBindingsSync()` to properly manage the bindings lifecycle. `installBindings` is called early by tools that rely on them. There were of course a few complexities: * webpack loaders need to defensively install bindings to support being run by webpack * the hacky `loadNativeSync` method used by `transformSync` remains required due to the jest-transformer API (i didn't see a clear initialization location). * the `experimental.useWasmBinary` configuration is broken by design * if the config is `next.config.ts` when we load SWC to transpile it, but that requires the native bindings, so we might load native bindings even when you ask for wasm. I added a warning message for this case and the workaround is to use the node native transpilation support. * this should probably be a command line flag if it is important * we need to lazily load the `swc/index.ts` module since it transitively loads `react` which reads `NODE_ENV` and various next entrypoints might modify that. ## Why? This makes it clearer that we are correctly applying the `useWasmBinary` option and can simplify some code paths that now don't need to be `async`. This will be important in a future PR where i move some parts of issue formatting into rust/wasm. Having those APIs be async will decrease their usability.
1 parent 24cfba6 commit 13bf3d9

File tree

25 files changed

+185
-111
lines changed

25 files changed

+185
-111
lines changed

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,5 +903,7 @@
903903
"902": "Invalid handler fields configured for \"cacheHandlers\":\\n%s",
904904
"903": "The file \"%s\" must export a function, either as a default export or as a named \"%s\" export.\\nThis function is what Next.js runs for every request handled by this %s.\\n\\nWhy this happens:\\n%s- The file exists but doesn't export a function.\\n- The export is not a function (e.g., an object or constant).\\n- There's a syntax error preventing the export from being recognized.\\n\\nTo fix it:\\n- Ensure this file has either a default or \"%s\" function export.\\n\\nLearn more: https://nextjs.org/docs/messages/middleware-to-proxy",
905905
"904": "The file \"%s\" must export a function, either as a default export or as a named \"%s\" export.",
906-
"905": "Page \"%s\" cannot use \\`export const unstable_prefetch = ...\\` without enabling \\`cacheComponents\\`."
906+
"905": "Page \"%s\" cannot use \\`export const unstable_prefetch = ...\\` without enabling \\`cacheComponents\\`.",
907+
"906": "Bindings not loaded yet, but they are being loaded, did you forget to await?",
908+
"907": "bindings not loaded yet. Either call `loadBindings` to wait for them to be available or ensure that `installBindings` has already been called."
907909
}

packages/next/src/build/babel/loader/get-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from './util'
1818
import * as Log from '../../output/log'
1919
import { isReactCompilerRequired } from '../../swc'
20+
import { installBindings } from '../../swc/install-bindings'
2021

2122
/**
2223
* An internal (non-exported) type used by babel.
@@ -570,6 +571,10 @@ export default async function getConfig(
570571
inputSourceMap?: SourceMap | undefined
571572
}
572573
): Promise<ResolvedBabelConfig | null> {
574+
// Install bindings early so they are definitely available to the loader.
575+
// When run by webpack in next this is already done with correct configuration so this is a no-op.
576+
// In turbopack loaders are run in a subprocess so it may or may not be done.
577+
await installBindings()
573578
const cacheCharacteristics = await getCacheCharacteristics(
574579
loaderOptions,
575580
source,

packages/next/src/build/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import type { NextError } from '../lib/is-error'
151151
import { isEdgeRuntime } from '../lib/is-edge-runtime'
152152
import { recursiveCopy } from '../lib/recursive-copy'
153153
import { lockfilePatchPromise, teardownTraceSubscriber } from './swc'
154+
import { installBindings } from './swc/install-bindings'
154155
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
155156
import { getFilesInDir } from '../lib/get-files-in-dir'
156157
import { eventSwcPlugins } from '../telemetry/events/swc-plugins'
@@ -964,6 +965,8 @@ export default async function build(
964965
// Reading the config can modify environment variables that influence the bundler selection.
965966
bundler = finalizeBundlerFromConfig(bundler)
966967
nextBuildSpan.setAttribute('bundler', getBundlerForTelemetry(bundler))
968+
// Install the native bindings early so we can have synchronous access later.
969+
await installBindings(config.experimental?.useWasmBinary)
967970

968971
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
969972
NextBuildContext.config = config

packages/next/src/build/jest/jest.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { PHASE_TEST } from '../../shared/lib/constants'
55
import loadJsConfig from '../load-jsconfig'
66
import * as Log from '../output/log'
77
import { findPagesDir } from '../../lib/find-pages-dir'
8-
import { loadBindings, lockfilePatchPromise } from '../swc'
8+
import { lockfilePatchPromise } from '../swc'
9+
import { installBindings } from '../swc/install-bindings'
910
import type { JestTransformerConfig } from '../swc/jest-transformer'
1011
import type { Config } from '@jest/types'
1112

@@ -96,7 +97,7 @@ export default function nextJest(options: { dir?: string } = {}) {
9697
: customJestConfig) ?? {}
9798

9899
// eagerly load swc bindings instead of waiting for transform calls
99-
await loadBindings(nextConfig?.experimental?.useWasmBinary)
100+
await installBindings(nextConfig?.experimental?.useWasmBinary)
100101

101102
if (lockfilePatchPromise.cur) {
102103
await lockfilePatchPromise.cur

packages/next/src/build/load-entrypoint.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs/promises'
22
import path from 'path'
3-
import { loadBindings } from './swc'
3+
import { getBindingsSync } from './swc'
44

55
// NOTE: this should be updated if this loader file is moved.
66
const PACKAGE_ROOT = path.normalize(path.join(__dirname, '../..'))
@@ -39,14 +39,12 @@ export async function loadEntrypoint(
3939
injections?: Record<string, string>,
4040
imports?: Record<string, string | null>
4141
): Promise<string> {
42-
let bindings = await loadBindings()
43-
4442
const templatePath = path.resolve(
4543
path.join(TEMPLATES_ESM_FOLDER, `${entrypoint}.js`)
4644
)
4745
let content = await fs.readFile(templatePath)
4846

49-
return bindings.expandNextJsTemplate(
47+
return getBindingsSync().expandNextJsTemplate(
5048
content,
5149
// Ensure that we use unix-style path separators for the import paths
5250
path.join(TEMPLATE_SRC_FOLDER, `${entrypoint}.js`).replace(/\\/g, '/'),

packages/next/src/build/lockfile.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { bold, cyan } from '../lib/picocolors'
22
import * as Log from './output/log'
3+
import { getBindingsSync } from './swc'
34

45
import type { Binding, Lockfile as NativeLockfile } from './swc/types'
56

@@ -56,16 +57,11 @@ export class Lockfile {
5657
* - If we fail to acquire the lock, we return `undefined`.
5758
* - If we're on wasm, this always returns a dummy `Lockfile` object.
5859
*/
59-
static async tryAcquire(
60+
static tryAcquire(
6061
path: string,
6162
unlockOnExit: boolean = true
62-
): Promise<Lockfile | undefined> {
63-
const { loadBindings } = require('./swc') as typeof import('./swc')
64-
// Ideally we could provide a sync version of `tryAcquire`, but
65-
// `loadBindings` is async. We're okay with skipping async-loaded wasm
66-
// bindings and the internal `loadNative` function is synchronous, but it
67-
// lacks some checks that `loadBindings` has.
68-
const bindings = await loadBindings()
63+
): Lockfile | undefined {
64+
const bindings = getBindingsSync()
6965
if (bindings.isWasm) {
7066
Log.info(
7167
`Skipping creating a lockfile at ${cyan(path)} because we're using WASM bindings`
@@ -74,7 +70,7 @@ export class Lockfile {
7470
} else {
7571
let nativeLockfile
7672
try {
77-
nativeLockfile = await bindings.lockfileTryAcquire(path)
73+
nativeLockfile = bindings.lockfileTryAcquireSync(path)
7874
} catch (e) {
7975
// this happens if there's an IO error (e.g. `ENOENT`), which is
8076
// different than if we just didn't acquire the lock
@@ -123,7 +119,7 @@ export class Lockfile {
123119
const startMs = Date.now()
124120
let lockfile
125121
while (Date.now() - startMs < MAX_RETRY_MS) {
126-
lockfile = await Lockfile.tryAcquire(path, unlockOnExit)
122+
lockfile = Lockfile.tryAcquire(path, unlockOnExit)
127123
if (lockfile !== undefined) break
128124
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
129125
}

packages/next/src/build/next-config-ts/transpile-config.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,9 @@ async function handleCJS({
177177
const nextConfigString = await readFile(nextConfigPath, 'utf8')
178178
// lazy require swc since it loads React before even setting NODE_ENV
179179
// resulting loading Development React on Production
180-
const { transform } = require('../swc') as typeof import('../swc')
181-
const { code } = await transform(nextConfigString, swcOptions)
180+
const { loadBindings } = require('../swc') as typeof import('../swc')
181+
const bindings = await loadBindings()
182+
const { code } = await bindings.transform(nextConfigString, swcOptions)
182183

183184
// register require hook only if require exists
184185
if (code.includes('require(')) {
@@ -187,7 +188,21 @@ async function handleCJS({
187188
}
188189

189190
// filename & extension don't matter here
190-
return requireFromString(code, resolve(cwd, 'next.config.compiled.js'))
191+
const config = requireFromString(
192+
code,
193+
resolve(cwd, 'next.config.compiled.js')
194+
)
195+
// At this point we have already loaded the bindings without this configuration setting due to the `transform` call above.
196+
// Possibly we fell back to wasm in which case, it all works out but if not we need to warn
197+
// that the configuration was ignored.
198+
if (config?.experimental?.useWasmBinary && !bindings.isWasm) {
199+
warn(
200+
'Using a next.config.ts file is incompatible with `experimental.useWasmBinary` unless ' +
201+
'`--experimental-next-config-strip-types` is also passed.\nSetting `useWasmBinary` to `false'
202+
)
203+
config.experimental.useWasmBinary = false
204+
}
205+
return config
191206
} catch (error) {
192207
throw error
193208
} finally {

packages/next/src/build/swc/index.ts

Lines changed: 48 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -160,21 +160,33 @@ let lastNativeBindingsLoadErrorCode:
160160
| 'unsupported_target'
161161
| string
162162
| undefined = undefined
163-
// Used to cache calls to `loadBindings`
164-
let pendingBindings: Promise<Binding>
165-
// some things call `loadNative` directly instead of `loadBindings`... Cache calls to that
166-
// separately.
167-
let nativeBindings: Binding
168-
// can allow hacky sync access to bindings for loadBindingsSync
169-
let wasmBindings: Binding
163+
// Used to cache racing calls to `loadBindings`
164+
let pendingBindings: Promise<Binding> | undefined
165+
// The cached loaded bindings
166+
let loadedBindings: Binding | undefined = undefined
170167
let downloadWasmPromise: any
171168
let swcTraceFlushGuard: any
172169
let downloadNativeBindingsPromise: Promise<void> | undefined = undefined
173170

174171
export const lockfilePatchPromise: { cur?: Promise<void> } = {}
175172

173+
/** Access the native bindings which should already have been loaded via `installBindings. Throws if they are not available. */
174+
export function getBindingsSync(): Binding {
175+
if (!loadedBindings) {
176+
if (pendingBindings) {
177+
throw new Error(
178+
'Bindings not loaded yet, but they are being loaded, did you forget to await?'
179+
)
180+
}
181+
throw new Error(
182+
'bindings not loaded yet. Either call `loadBindings` to wait for them to be available or ensure that `installBindings` has already been called.'
183+
)
184+
}
185+
return loadedBindings
186+
}
187+
176188
/**
177-
* Attempts to load a native or wasm binding.
189+
* Loads the native or wasm binding.
178190
*
179191
* By default, this first tries to use a native binding, falling back to a wasm binding if that
180192
* fails.
@@ -184,6 +196,9 @@ export const lockfilePatchPromise: { cur?: Promise<void> } = {}
184196
export async function loadBindings(
185197
useWasmBinary: boolean = false
186198
): Promise<Binding> {
199+
if (loadedBindings) {
200+
return loadedBindings
201+
}
187202
if (pendingBindings) {
188203
return pendingBindings
189204
}
@@ -281,7 +296,9 @@ export async function loadBindings(
281296

282297
logLoadFailure(attempts, true)
283298
})
284-
return pendingBindings
299+
loadedBindings = await pendingBindings
300+
pendingBindings = undefined
301+
return loadedBindings
285302
}
286303

287304
async function tryLoadNativeWithFallback(attempts: Array<string>) {
@@ -363,12 +380,6 @@ function loadBindingsSync() {
363380
attempts = attempts.concat(a)
364381
}
365382

366-
// HACK: we can leverage the wasm bindings if they are already loaded
367-
// this may introduce race conditions
368-
if (wasmBindings) {
369-
return wasmBindings
370-
}
371-
372383
logLoadFailure(attempts)
373384
throw new Error('Failed to load bindings', { cause: attempts })
374385
}
@@ -1182,7 +1193,7 @@ async function loadWasm(importPath = '') {
11821193

11831194
// Note wasm binary does not support async intefaces yet, all async
11841195
// interface coereces to sync interfaces.
1185-
wasmBindings = {
1196+
let wasmBindings = {
11861197
css: {
11871198
lightning: {
11881199
transform: function (_options: any) {
@@ -1312,8 +1323,8 @@ async function loadWasm(importPath = '') {
13121323
* wasm fallback.
13131324
*/
13141325
function loadNative(importPath?: string) {
1315-
if (nativeBindings) {
1316-
return nativeBindings
1326+
if (loadedBindings) {
1327+
return loadedBindings
13171328
}
13181329

13191330
if (process.env.NEXT_TEST_WASM) {
@@ -1379,7 +1390,7 @@ function loadNative(importPath?: string) {
13791390
}
13801391

13811392
if (bindings) {
1382-
nativeBindings = {
1393+
loadedBindings = {
13831394
isWasm: false,
13841395
transform(src: string, options: any) {
13851396
const isModule =
@@ -1518,7 +1529,7 @@ function loadNative(importPath?: string) {
15181529
return bindings.lockfileUnlockSync(lockfile)
15191530
},
15201531
}
1521-
return nativeBindings
1532+
return loadedBindings
15221533
}
15231534

15241535
throw attempts
@@ -1539,54 +1550,40 @@ function toBuffer(t: any) {
15391550
return Buffer.from(JSON.stringify(t))
15401551
}
15411552

1542-
export async function isWasm(): Promise<boolean> {
1543-
let bindings = await loadBindings()
1544-
return bindings.isWasm
1545-
}
1546-
15471553
export async function transform(src: string, options?: any): Promise<any> {
1548-
let bindings = await loadBindings()
1554+
let bindings = getBindingsSync()
15491555
return bindings.transform(src, options)
15501556
}
15511557

1558+
/** Synchronously transforms the source and loads the native bindings. */
15521559
export function transformSync(src: string, options?: any): any {
1553-
let bindings = loadBindingsSync()
1560+
const bindings = loadBindingsSync()
15541561
return bindings.transformSync(src, options)
15551562
}
15561563

1557-
export async function minify(
1564+
export function minify(
15581565
src: string,
15591566
options: any
15601567
): Promise<{ code: string; map: any }> {
1561-
let bindings = await loadBindings()
1568+
const bindings = getBindingsSync()
15621569
return bindings.minify(src, options)
15631570
}
15641571

1565-
export async function isReactCompilerRequired(
1566-
filename: string
1567-
): Promise<boolean> {
1568-
let bindings = await loadBindings()
1572+
export function isReactCompilerRequired(filename: string): Promise<boolean> {
1573+
const bindings = getBindingsSync()
15691574
return bindings.reactCompiler.isReactCompilerRequired(filename)
15701575
}
15711576

15721577
export async function parse(src: string, options: any): Promise<any> {
1573-
let bindings = await loadBindings()
1574-
let parserOptions = getParserOptions(options)
1575-
return bindings
1576-
.parse(src, parserOptions)
1577-
.then((astStr: any) => JSON.parse(astStr))
1578+
const bindings = getBindingsSync()
1579+
const parserOptions = getParserOptions(options)
1580+
const parsed = await bindings.parse(src, parserOptions)
1581+
return JSON.parse(parsed)
15781582
}
15791583

15801584
export function getBinaryMetadata() {
1581-
let bindings
1582-
try {
1583-
bindings = loadNative()
1584-
} catch (e) {
1585-
// Suppress exceptions, this fn allows to fail to load native bindings
1586-
}
1587-
15881585
return {
1589-
target: bindings?.getTargetTriple?.(),
1586+
target: loadedBindings?.getTargetTriple?.(),
15901587
}
15911588
}
15921589

@@ -1597,8 +1594,8 @@ export function getBinaryMetadata() {
15971594
export function initCustomTraceSubscriber(traceFileName?: string) {
15981595
if (!swcTraceFlushGuard) {
15991596
// Wasm binary doesn't support trace emission
1600-
let bindings = loadNative()
1601-
swcTraceFlushGuard = bindings.initCustomTraceSubscriber?.(traceFileName)
1597+
swcTraceFlushGuard =
1598+
getBindingsSync().initCustomTraceSubscriber?.(traceFileName)
16021599
}
16031600
}
16041601

@@ -1625,9 +1622,8 @@ function once(fn: () => void): () => void {
16251622
*/
16261623
export const teardownTraceSubscriber = once(() => {
16271624
try {
1628-
let bindings = loadNative()
16291625
if (swcTraceFlushGuard) {
1630-
bindings.teardownTraceSubscriber?.(swcTraceFlushGuard)
1626+
getBindingsSync().teardownTraceSubscriber?.(swcTraceFlushGuard)
16311627
}
16321628
} catch (e) {
16331629
// Suppress exceptions, this fn allows to fail to load native bindings
@@ -1637,14 +1633,12 @@ export const teardownTraceSubscriber = once(() => {
16371633
export async function getModuleNamedExports(
16381634
resourcePath: string
16391635
): Promise<string[]> {
1640-
const bindings = await loadBindings()
1641-
return bindings.rspack.getModuleNamedExports(resourcePath)
1636+
return getBindingsSync().rspack.getModuleNamedExports(resourcePath)
16421637
}
16431638

16441639
export async function warnForEdgeRuntime(
16451640
source: string,
16461641
isProduction: boolean
16471642
): Promise<NapiSourceDiagnostic[]> {
1648-
const bindings = await loadBindings()
1649-
return bindings.rspack.warnForEdgeRuntime(source, isProduction)
1643+
return getBindingsSync().rspack.warnForEdgeRuntime(source, isProduction)
16501644
}

0 commit comments

Comments
 (0)