// Babel plugin: turn `require('….node')` / `import … from '….node'` into // `_requireNodeLibrary(key, path)` for the GPUI desktop runtime. // // On a phone an app may only load code that was embedded and signed at build // time, so addons are discovered ahead of time and loaded through a shim. A // desktop app has none of those restrictions: it can `dlopen` any file, so the // stock `.node` binary a package publishes to npm loads unchanged. All this // plugin does is tell the runtime which file to open and give it a stable name // for that file. // // It is a no-op unless Babel's caller reports `platform === 'desktop'`, so one // `babel.config.js` works for iOS, Android and desktop. 'use strict'; const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); // ELF magic and e_machine values, enough to tell a Linux addon for the host // architecture from one built for another platform or CPU. const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); // Mach-O magics as the byte order they appear in the file: // CF FA ED FE / CE FA ED FE 64- and 32-bit thin, little-endian fields // FE ED FA CF / FE ED FA CE the same, big-endian fields // CA FE BA BE universal, big-endian table // BE BA FE CA universal, little-endian table const MACHO_THIN_LE = Buffer.from([0xcf, 0xfa, 0xed, 0xfe]); const MACHO_THIN_LE_32 = Buffer.from([0xce, 0xfa, 0xed, 0xfe]); const MACHO_THIN_BE = Buffer.from([0xfe, 0xed, 0xfa, 0xcf]); const MACHO_THIN_BE_32 = Buffer.from([0xfe, 0xed, 0xfa, 0xce]); const MACHO_FAT_BE = Buffer.from([0xca, 0xfe, 0xba, 0xbe]); const MACHO_FAT_LE = Buffer.from([0xbe, 0xba, 0xfe, 0xca]); // cputype values that mean "the host CPU" for each arch name. const CPU_TYPES = { arm64: 0x0100000c, x64: 0x01000007, ia32: 0x00000007, }; const ELF_MACHINE = { arm64: 0xb7, x64: 0x3e, ia32: 0x03, }; /** * Platform markers that appear in a prebuild directory or file name, and the * host platform each one belongs to. A segment naming a *different* platform * is foreign; one naming the host's is not. */ const PLATFORM_MARKERS = { darwin: 'darwin', macos: 'darwin', osx: 'darwin', win32: 'win32', windows: 'win32', linux: 'linux', linuxmusl: 'linux', android: 'android', freebsd: 'freebsd', openharmony: 'openharmony', }; /** * Whether a segment names a libc other than the host's. `musl` on a glibc host * cannot load (and the reverse), and neither can an `eabi`/`gnueabihf` ARM * variant on a soft-float host. */ function namesForeignLibc(segment) { return /\bmusl\b|musleabihf|musl-/u.test(segment); } /** * Whether a segment names a platform or CPU other than the host's. Architecture * tokens are compared after normalizing the common spellings, so `x86_64` and * `amd64` both become `x64` before the `x86` token could match and a plain * `linux-x64` segment is not mistaken for 32-bit. */ function namesForeignTarget(segment, hostPlatform, hostArch) { const lower = segment.toLowerCase(); // A platform marker is only foreign when it is not the host's. `darwin` and // `linux` both appear in the paths the plugin is *supposed* to accept on // their own platform. for (const [marker, platform] of Object.entries(PLATFORM_MARKERS)) { if (!lower.includes(marker)) continue; if (platform !== hostPlatform) return true; // The host platform matched; a foreign libc in the same segment still // disqualifies it. if (namesForeignLibc(lower)) return true; } const tokens = lower .replace(/x86[_-]?64|amd64/gu, 'x64') .replace(/aarch64/gu, 'arm64') .split(/[^a-z0-9]+/u) .filter(Boolean); // `ios` as a whole token: it would otherwise match unrelated names such as // `studios`. if (tokens.includes('ios') && hostPlatform !== 'ios') return true; const hostToken = hostArch === 'arm64' ? 'arm64' : 'x64'; const archTokens = tokens.filter(token => ['x64', 'arm64', 'arm', 'ia32', 'x86'].includes(token), ); // A segment naming several architectures is a multi-arch artifact (a // `darwin-x64+arm64` prebuild directory, or one universal Mach-O file), so it // is foreign only when *none* of the architectures it names is the host's. // A segment naming exactly one is foreign when that one is not the host's. // The byte-level header check still has the final say for a thin binary. return archTokens.length > 0 && !archTokens.includes(hostToken); } function isElfForArch(header, arch) { if (!header.subarray(0, 4).equals(ELF_MAGIC)) return null; const machine = ELF_MACHINE[arch]; if (machine === undefined) return false; return header.readUInt16LE(18) === machine; } // PE/COFF. A Windows binary starts with `MZ`; the 4-byte little-endian value at // offset 0x3C is the file offset of the `PE\0\0` signature, and the 2-byte // machine field follows it. Only the first 64 bytes of the file are read, so // the signature is checked to be within that window before reading the machine. const DOS_MAGIC = Buffer.from([0x4d, 0x5a]); // "MZ" const PE_SIGNATURE = Buffer.from([0x50, 0x45, 0x00, 0x00]); // "PE\0\0" const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64, ia32: 0x014c, }; /** * Whether a PE file is built for `arch`. Returns null when the bytes are not a * PE file at all, and true for an unrecognized machine value (an unusual-but * valid binary is never rejected, matching the Mach-O helper). * * The `PE\0\0` signature lives at the offset named by the 4-byte field at 0x3C, * which a normal linker places right after the 64-byte DOS header but is not * required to; `header` therefore only has to cover the offset named, and a * caller reading a short prefix reports "not a PE" rather than "wrong arch". */ function isPeForArch(header, arch) { if (header.length < 2 || !header.subarray(0, 2).equals(DOS_MAGIC)) { return null; } if (header.length < 0x40) return false; const peOffset = header.readUInt32LE(0x3c); if (peOffset + 6 > header.length) return false; if (!header.subarray(peOffset, peOffset + 4).equals(PE_SIGNATURE)) { return false; } const machine = header.readUInt16LE(peOffset + 4); const expected = PE_MACHINE[arch]; if (expected === undefined) return true; if (machine === expected) return true; // 0x0 is IMAGE_FILE_MACHINE_UNKNOWN, which some linkers write; treat it as // acceptable rather than rejecting a working binary. return machine === 0x0000; } /** * Whether a Mach-O file contains a slice for `arch`. A thin file has one * cputype at offset 4; a universal file has a table of them after the 8-byte * header, each `fat_arch` starting with its own cputype. Anything we cannot * parse is accepted, so an unusual-but-valid binary is never rejected. */ function machoHasArch(header, arch) { const expected = CPU_TYPES[arch]; if (expected === undefined) return false; const magic = header.subarray(0, 4); const isFat = magic.equals(MACHO_FAT_BE) || magic.equals(MACHO_FAT_LE); const read = magic.equals(MACHO_FAT_LE) || magic.equals(MACHO_THIN_LE) || magic.equals(MACHO_THIN_LE_32) ? offset => header.readUInt32LE(offset) : offset => header.readUInt32BE(offset); if (isFat) { if (header.length < 8) return true; const count = read(4); if (count === 0 || count > 64) return true; const fatEntrySize = 20; if (header.length < 8 + count * fatEntrySize) return true; for (let index = 0; index < count; index += 1) { if (read(8 + index * fatEntrySize) === expected) return true; } return false; } if (header.length < 8) return true; return read(4) === expected; } /** * Whether `filePath` is a shared library for the host. A file built for another * platform or CPU is treated as missing, so a package's per-platform binary * list does not make the bundle try to load one that cannot work. */ function isHostPrebuiltNodeAddon(filePath, hostPlatform, hostArch) { const segments = filePath.split(path.sep); if ( segments.some(segment => namesForeignTarget(segment, hostPlatform, hostArch), ) ) { return false; } let handle; try { handle = fs.openSync(filePath, 'r'); // One window covers every header this function reads: ELF's and Mach-O's // first bytes, a universal Mach-O table, and a PE image whose `PE\0\0` // signature sits past the 64-byte DOS header plus a stub (MSVC places it // around 0xE8). Reading past the end of a short file is not an error. const header = Buffer.alloc(1024); const read = fs.readSync(handle, header, 0, header.length, 0); if (read < 20) return false; const headerBytes = header.subarray(0, read); if (hostPlatform === 'linux') { return isElfForArch(headerBytes, hostArch) === true; } if (hostPlatform === 'darwin') { const magic = headerBytes.subarray(0, 4); const known = magic.equals(MACHO_THIN_LE) || magic.equals(MACHO_THIN_LE_32) || magic.equals(MACHO_THIN_BE) || magic.equals(MACHO_THIN_BE_32) || magic.equals(MACHO_FAT_BE) || magic.equals(MACHO_FAT_LE); if (!known) return false; return machoHasArch(headerBytes, hostArch); } if (hostPlatform === 'win32') { // `true` only, like the two branches above: `isPeForArch` returns null for // "not a PE file at all", and `null !== false` is true, so accepting // anything but an explicit rejection let a Linux `.node` or a macOS // Mach-O through on a Windows host. return isPeForArch(headerBytes, hostArch) === true; } // Platforms with no header check accept every file, which is the same as no // guard. return true; } catch { return false; } finally { if (handle !== undefined) fs.closeSync(handle); } } /** * The stable, relative key for an addon (section 3.3 of the plan): * * - if the real path has a `node_modules` segment, everything from the first * `node_modules/` onward, with `/` separators; * - otherwise `local//`. * * The key is how a packaged app finds the addon after `node_modules` is gone, * so it must be portable: always `/`, never `..`, never a leading slash. */ function addonKey(absolutePath) { const normalized = absolutePath.split(path.sep).join('/'); const marker = '/node_modules/'; const index = normalized.indexOf(marker); const key = index === -1 ? `local/${crypto .createHash('sha1') .update(normalized) .digest('hex') .slice(0, 8)}/${path.posix.basename(normalized)}` : normalized.slice(index + 1); if ( key.startsWith('/') || key.includes('\\') || key.split('/').includes('..') ) { throw new Error(`invalid node addon key '${key}' for ${absolutePath}`); } return key; } /** Files Metro would resolve before an extensionless `.node` file. */ const RESOLVABLE_EXTENSIONS = ['.js', '.cjs', '.mjs', '.json', '.ts', '.tsx']; /** * Resolves a `require(`${...}.node`)` whose interpolated * segment is a platform/arch tag (R7). Packages such as `farmhash` build the * file name from `process.platform` and `process.arch` in a template literal, * which no run-time platform data can turn into a path here. * * The static prefix locates the directory; every `.node` file matching the * static suffix is a candidate, and the one usable on the host wins. That is * safe precisely because the tag is the only thing that varies: the candidate * set is exactly the set of platform builds the package ships. */ function resolveInterpolatedAddon(prefix, suffix, options) { const { hostPlatform, hostArch } = options; const directory = path.dirname(prefix); const basePrefix = path.basename(prefix); let entries; try { entries = fs.readdirSync(directory); } catch { return null; } const candidates = entries .filter( entry => entry.startsWith(basePrefix) && entry.endsWith(suffix) && entry.length > basePrefix.length + suffix.length, ) .sort(); for (const entry of candidates) { // The path segment named by the interpolated part must not name a foreign // target, and the bytes must be the host's. const middle = entry.slice(basePrefix.length, entry.length - suffix.length); if (namesForeignTarget(middle, hostPlatform, hostArch)) continue; const candidate = path.join(directory, entry); if (isHostPrebuiltNodeAddon(candidate, hostPlatform, hostArch)) { return candidate; } } return null; } /** * Classifies a specifier written in `fromDirectory`: * * { kind: 'addon', path } a host `.node` file to load; * { kind: 'missing' } an explicit `.node` specifier with no usable file * (`missingModuleExpression` must stand in for it); * null not an addon reference at all, so the require or * import is left exactly as written. * * The `missing` case exists because packages list a binary per platform and * wrap each in `try`/`catch`: the ones that are not installed must fail at run * time, not at bundle time. */ function resolveAddon(specifier, fromDirectory, options) { const { hostPlatform, hostArch } = options; const usable = filePath => isHostPrebuiltNodeAddon(filePath, hostPlatform, hostArch) ? filePath : null; const tryFile = candidate => { if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { const resolved = usable(candidate); return resolved === null ? { kind: 'missing' } : { kind: 'addon', path: resolved }; } return { kind: 'missing' }; }; const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); const isAbsolute = path.isAbsolute(specifier); // R1: an explicit `.node` specifier. if (specifier.endsWith('.node')) { if (isRelative || isAbsolute) { return tryFile( isAbsolute ? specifier : path.resolve(fromDirectory, specifier), ); } // A bare specifier, either `pkg/x.node` or an addon a package lists in its // own manifest. `require.resolve` follows the package's own entry points. try { const resolved = require.resolve(specifier, { paths: [fromDirectory] }); if (fs.statSync(resolved).isFile()) { const addonPath = usable(resolved); return addonPath === null ? { kind: 'missing' } : { kind: 'addon', path: addonPath }; } } catch {} return { kind: 'missing' }; } // R2: a relative specifier with no extension, where `.node` exists and // nothing Metro would resolve first does. if (isRelative || isAbsolute) { const candidate = path.resolve(fromDirectory, specifier); if ( RESOLVABLE_EXTENSIONS.some(extension => fs.existsSync(candidate + extension), ) ) { return null; } // A package.json or index file is a real module too. if (fs.existsSync(path.join(candidate, 'package.json'))) return null; for (const index of ['index.js', 'index.ts', 'index.tsx', 'index.json']) { if (fs.existsSync(path.join(candidate, index))) return null; } if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { return null; } if (!fs.existsSync(`${candidate}.node`)) return null; return tryFile(`${candidate}.node`); } // R3: a bare package whose installed `main` (or `exports` entry) is an addon. try { const entry = require.resolve(specifier, { paths: [fromDirectory] }); if (!entry.endsWith('.node')) return null; const addonPath = usable(entry); return addonPath === null ? { kind: 'missing' } : { kind: 'addon', path: addonPath }; } catch { // Not installed here. A package a platform-specific branch would have // loaded is not our business; leave the require to Metro, which reports it // the same way it would for any missing module. return null; } } // --- R4–R6: packages that pick their binary at run time --------------------- // // Most packages do not write `require('./x.node')` themselves. They use a small // loader that chooses the binary with `fs`, `path` and `process.platform` — none // of which exist in Hermes. On desktop the build machine *is* the target, so the // plugin can run that selection at build time instead, using the package's own // data. /** `node-gyp-build`'s own prebuild layout, plus the directories it probes. */ const NODE_GYP_BUILD_DIRECTORIES = [ '.', 'build', 'build/Debug', 'build/Release', 'build/RelWithDebInfo', 'build/MinSizeRel', 'out', ]; /** * `prebuilds/-+` directory tags, as `node-gyp-build` * parses them. The arch list may be a `+`-joined union, which is how a package * ships one universal binary for two architectures. */ function parsePrebuildTuple(name) { const parts = name.split('-'); if (parts.length !== 2) return null; const architectures = parts[1].split('+'); if ( !parts[0] || architectures.length === 0 || !architectures.every(Boolean) ) { return null; } return { name, platform: parts[0], architectures }; } /** * The tags `node-gyp-build` reads out of a file name (`bcrypt.glibc.node`, * `node.napi.node`, `node.abi115.node`, `node.napi.armv8.node`). */ function parsePrebuildTags(file) { const parts = file.split('.'); const extension = parts.pop(); if (extension !== 'node') return null; const tags = { file, specificity: 0 }; for (const tag of parts) { if (tag === 'node' || tag === 'electron' || tag === 'node-webkit') { tags.runtime = tag; } else if (tag === 'napi') { tags.napi = true; } else if (tag.startsWith('abi')) { tags.abi = tag.slice(3); } else if (tag.startsWith('uv')) { tags.uv = tag.slice(2); } else if (tag.startsWith('armv')) { tags.armv = tag.slice(4); } else if (tag === 'glibc' || tag === 'musl') { tags.libc = tag; } else { // An unknown tag carries no meaning to node-gyp-build. continue; } tags.specificity += 1; } return tags; } /** * Whether a parsed file matches this host. `node` + `napi` is * runtime-agnostic, so a `node.napi.node` loads in any N-API host; a plain * `abi` tag is tied to the Node ABI and does not match us. */ function prebuildTagsMatchHost(tags, hostPlatform, hostArch) { if (tags === null) return false; // We are an N-API host with no Node ABI, so only `napi` (or no runtime/ABI // tag at all) qualifies. A `uv` tag means the addon needs a libuv // version, which this runtime does not provide. if (tags.uv) return false; if (tags.abi && !tags.napi) return false; if (tags.armv) { const armv = hostArch === 'arm64' ? '8' : null; if (armv === null || tags.armv !== armv) return false; } if (tags.libc) { const hostLibc = hostPlatform === 'linux' ? linuxLibc() : null; if (hostLibc === null || tags.libc !== hostLibc) return false; } if (tags.runtime && tags.runtime !== 'node') return false; return true; } /** * Which libc this *build machine* uses, for the `glibc` / `musl` file tags. * Matches `node-gyp-build`'s own check (it looks for Alpine): the bundler runs * on the machine the app is built for, which is the whole premise of resolving * this at build time. */ function linuxLibc() { return fs.existsSync('/etc/alpine-release') ? 'musl' : 'glibc'; } /** * Resolves `require('node-gyp-build')()` at build time (R4). * * The plan asks for `node-gyp-build`'s own resolver rather than a copy of it, * and that is what runs first: its `path()` export is called with * `process.platform` / `process.arch` temporarily set to the host the bundle is * being built for. Both properties are configurable, so this always restores * them. * * It is only called when the requested host is the build host: `path()` also * reads the ABI and libuv versions of the *running* Node, which have no meaning * for a foreign target. For a cross-build, or when the package is not * installed, the resolver below reproduces the same algorithm against the * requested host. It is also the fallback when `path()` throws. */ function resolveNodeGypBuild(directory, options) { const { hostPlatform, hostArch } = options; if (hostPlatform === process.platform && hostArch === process.arch) { const viaNodeGypBuild = resolveWithNodeGypBuild(directory); if (viaNodeGypBuild !== null) return viaNodeGypBuild; } return resolvePrebuilds(directory, options); } /** * Runs the installed `node-gyp-build`'s `path()` for `directory`, with the * process's platform and architecture forced to the host. Returns null when the * package is not installed, when its resolver throws (its error message is the * only report it gives), or when it finds nothing usable. */ function resolveWithNodeGypBuild(directory) { const platformDescriptor = Object.getOwnPropertyDescriptor( process, 'platform', ); const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch'); let resolved = null; try { Object.defineProperty(process, 'platform', { value: process.platform, configurable: true, }); Object.defineProperty(process, 'arch', { value: process.arch, configurable: true, }); const nodeGypBuild = require( require.resolve('node-gyp-build', { paths: [directory, __dirname] }), ); const candidate = nodeGypBuild.path(directory); if ( typeof candidate === 'string' && fs.existsSync(candidate) && isHostPrebuiltNodeAddon(candidate, process.platform, process.arch) && // `node-gyp-build`'s own resolver consults the *running* Node's // `process.versions`, and under recent Node versions it can settle on an // `electron.napi.node` build (it treats the absent // `process.versions.electron` as a match). An Electron ABI is not this // runtime's, so a file tagged with a runtime other than `node` is // rejected here and the ported resolver below picks the right one. !prefersForeignRuntime(candidate) ) { resolved = candidate; } } catch { resolved = null; } finally { // Restore whatever the properties were, descriptor and all. if (platformDescriptor) { Object.defineProperty(process, 'platform', platformDescriptor); } else { delete process.platform; } if (archDescriptor) { Object.defineProperty(process, 'arch', archDescriptor); } else { delete process.arch; } } return resolved; } /** * Whether a file name is tagged for a runtime this platform is not. Our runtime * is Hermes with Node-API; an Electron or NW.js build links a different ABI. */ function prefersForeignRuntime(file) { const tags = parsePrebuildTags(path.basename(file)); return tags !== null && tags.runtime !== undefined && tags.runtime !== 'node'; } /** * A faithful re-implementation of `node-gyp-build`'s selection, for a host that * is not the running process (or when its resolver cannot be reached). * * The order is its own: an in-tree `build/Release` or `build/Debug` binary * first, then a `prebuilds/` directory whose `-` tuple * matches the host (single-arch preferred over a `+`-joined union), then the * most specific matching file tag. */ function resolvePrebuilds(directory, options) { const { hostPlatform, hostArch } = options; // node-gyp-build checks these before the prebuilds directory. for (const subdirectory of ['build/Release', 'build/Debug']) { const base = path.join(directory, subdirectory); let entries; try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.node')) continue; const filePath = path.join(base, entry.name); if (isHostPrebuiltNodeAddon(filePath, hostPlatform, hostArch)) { return filePath; } } } const prebuildsRoot = path.join(directory, 'prebuilds'); let tuples = []; try { tuples = fs .readdirSync(prebuildsRoot) .map(parsePrebuildTuple) .filter(Boolean) .filter( tuple => tuple.platform === hostPlatform && tuple.architectures.includes(hostArch), ) // Fewer architectures means more specific; a `darwin-arm64` directory // beats a `darwin-x64+arm64` one. .sort((a, b) => a.architectures.length - b.architectures.length); } catch { tuples = []; } for (const tuple of tuples) { const prebuilds = path.join(prebuildsRoot, tuple.name); let parsed = []; try { parsed = fs.readdirSync(prebuilds).map(parsePrebuildTags); } catch { continue; } const winner = parsed .filter(tags => prebuildTagsMatchHost(tags, hostPlatform, hostArch)) .sort((a, b) => { // An ABI-specific build is preferred over a plain napi one; with no // ABI of our own, that only ever picks `node.napi` last. if (Boolean(a.abi) !== Boolean(b.abi)) return a.abi ? -1 : 1; return b.specificity - a.specificity; })[0]; if (!winner) continue; const candidate = path.join(prebuilds, winner.file); if (isHostPrebuiltNodeAddon(candidate, hostPlatform, hostArch)) { return candidate; } } // The in-tree builds and the plain `build/Release` layout. for (const subdirectory of NODE_GYP_BUILD_DIRECTORIES) { const base = path.join(directory, subdirectory); let entries; try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.node')) continue; const filePath = path.join(base, entry.name); if (isHostPrebuiltNodeAddon(filePath, hostPlatform, hostArch)) { return filePath; } } } return null; } /** * The directories `bindings` searches, in its own order (R5). Read from its * source: it walks up from the calling module's directory looking for a * `package.json`, then tries these relative to each ancestor. */ const BINDINGS_DIRECTORIES = [ 'build/Release', 'build/Debug', 'build/RelWithDebInfo', 'build/MinSizeRel', 'out/Release', 'out/Debug', 'Release', 'Debug', 'Release/node-v', 'Debug/node-v', 'build', 'out', 'compiled', 'addon-build/release/install-root', 'addon-build/debug/install-root', 'addon-build/default/install-root', ]; /** * Resolves `require('bindings')('')` at build time (R5). `bindings` * searches upward from the package root for `package.json` and then tries each * candidate directory; this walks the same ancestors of `fromDirectory`. */ function resolveBindings(name, fromDirectory, options) { const { hostPlatform, hostArch } = options; let directory = fromDirectory; for (;;) { const candidates = [directory]; const parent = path.dirname(directory); if (parent !== directory) candidates.push(parent); for (const base of candidates) { for (const subdirectory of BINDINGS_DIRECTORIES) { const target = path.join(base, subdirectory, name); for (const candidate of [target, `${target}.node`]) { try { if (!fs.statSync(candidate).isFile()) continue; } catch { continue; } if (isHostPrebuiltNodeAddon(candidate, hostPlatform, hostArch)) { return candidate; } } } } // `bindings` stops at the directory holding `package.json`, so a nested // call inside a package resolves against that package only. if (fs.existsSync(path.join(directory, 'package.json'))) break; if (parent === directory) break; directory = parent; } return null; } /** * `process.platform` / `process.arch` as a napi-rs loader spells them in the * file name (`linux-x64-gnu`, `win32-arm64-msvc`). The host's own triple, from * most to least preferred: glibc beats musl on Linux, msvc beats gnu on * Windows. */ function napiRsTriples(hostPlatform, hostArch) { const platform = hostPlatform; if (platform === 'linux') { return [`linux-${hostArch}-gnu`, `linux-${hostArch}-musl`]; } if (platform === 'win32') { return [`win32-${hostArch}-msvc`, `win32-${hostArch}-gnu`]; } if (platform === 'darwin') { return [`darwin-${hostArch}`, 'darwin-universal']; } return [`${platform}-${hostArch}`]; } /** * Detects a napi-rs generated loader and returns the binary it would pick (R6). * * The generated file is one big `if (process.platform === …)` chain whose * branches `require` either a local `..node` or a per-platform * package `-`. Detection is a heuristic, per the plan: a module * with several `.node` requires for *different* platforms is one of these. When * a file matches but no host binary exists it is left untouched. */ function resolveNapiRsLoader(source, filename, options) { const { hostPlatform, hostArch } = options; const triples = napiRsTriples(hostPlatform, hostArch); // Every triple named in the file, so a non-loader that happens to require a // couple of binaries is not misread. const mentioned = new Set(); const triplePattern = /\b(?:darwin|linux|win32|android|freebsd|openharmony)-(?:x64|arm64|arm|ia32|loong64|riscv64|ppc64|s390x)(?:-(?:gnu|musl|msvc|eabi|gnueabihf|musleabihf))?\b/gu; for (const match of source.matchAll(triplePattern)) { mentioned.add(match[0]); } // A napi-rs loader names at least three platforms; a hand-written file that // happens to name two is left alone. const platforms = new Set([...mentioned].map(triple => triple.split('-')[0])); if (platforms.size < 3) return null; const directory = path.dirname(filename); // The local copy first, exactly as the loader tries it. for (const triple of triples) { const local = path.join(directory, `${triple}.node`); if (fs.existsSync(local)) { if (isHostPrebuiltNodeAddon(local, hostPlatform, hostArch)) return local; } // `..node` for any prefix the file uses. let entries = []; try { entries = fs.readdirSync(directory); } catch { entries = []; } for (const entry of entries) { if (!entry.endsWith(`.${triple}.node`)) continue; const filePath = path.join(directory, entry); if (isHostPrebuiltNodeAddon(filePath, hostPlatform, hostArch)) { return filePath; } } } // Then the per-platform packages, whose own entry is the `.node` file itself. const scoped = /require\((['"])(@[^'"]+|[^'"./][^'"]*)\1\)/gu; const packageNames = new Set(); for (const match of source.matchAll(scoped)) { packageNames.add(match[2]); } for (const triple of triples) { for (const packageName of packageNames) { if (!packageName.endsWith(triple)) continue; try { const entry = require.resolve(packageName, { paths: [directory] }); if ( entry.endsWith('.node') && isHostPrebuiltNodeAddon(entry, hostPlatform, hostArch) ) { return entry; } } catch {} } } return null; } module.exports = function babelPluginNodeAddons(api, options = {}) { const t = api.types; const hostPlatform = options.hostPlatform ?? process.platform; const hostArch = options.hostArch ?? process.arch; const embedPaths = options.embedPaths !== false; const pluginOptions = { hostPlatform, hostArch }; // Resolving a bare specifier costs a `require.resolve`, and the visitor sees // every literal `require` in the bundle, so the answer is memoised per file // directory and specifier. const resolutionCache = new Map(); // One warning per (file, reason), so a loader in a widely-imported module // does not fill the bundler's output. const warned = new Set(); const warnOnce = (state, message) => { const key = `${state.file.opts.filename}\u0000${message}`; if (warned.has(key)) return; warned.add(key); state.file.metadata.rnGpuiNodeAddonWarnings ??= []; state.file.metadata.rnGpuiNodeAddonWarnings.push(message); // `console` is all a Babel plugin has; Metro shows it with the file name. console.warn(`[react-native-gpui/babel-plugin-node-addons] ${message}`); }; // Read once, at config time: `api.caller` caches the answer and refuses a // callback once traversal has started. const isGpuiPlatform = api.caller(caller => caller?.platform) === 'desktop'; const missingModuleExpression = specifier => { const error = t.callExpression(t.identifier('Error'), [ t.stringLiteral(`Cannot find module '${specifier}'`), ]); // (() => { const error = new Error(...); error.code = 'MODULE_NOT_FOUND'; // throw error; })() return t.callExpression( t.arrowFunctionExpression( [], t.blockStatement([ t.variableDeclaration('const', [ t.variableDeclarator(t.identifier('error'), error), ]), t.expressionStatement( t.assignmentExpression( '=', t.memberExpression(t.identifier('error'), t.identifier('code')), t.stringLiteral('MODULE_NOT_FOUND'), ), ), t.throwStatement(t.identifier('error')), ]), ), [], ); }; const requireNodeLibraryCall = resolvedPath => { const key = addonKey(fs.realpathSync(resolvedPath)); const args = [t.stringLiteral(key)]; if (embedPaths) args.push(t.stringLiteral(resolvedPath)); return t.callExpression(t.identifier('_requireNodeLibrary'), args); }; /** * The expression an addon reference becomes. `null` means the specifier is * not an addon reference after all and must be left alone. */ const addonExpression = (specifier, fromDirectory) => { const cacheKey = `${fromDirectory}\u0000${specifier}`; let resolution = resolutionCache.get(cacheKey); if (resolution === undefined) { resolution = resolveAddon(specifier, fromDirectory, pluginOptions); resolutionCache.set(cacheKey, resolution); } if (resolution === null) return null; if (resolution.kind === 'missing') return missingModuleExpression(specifier); return requireNodeLibraryCall(resolution.path); }; /** The directory argument of a `node-gyp-build`-style call, if static. */ const staticDirectoryArgument = (argument, fromDirectory) => { if (argument === undefined) return null; // `__dirname` if (argument.type === 'Identifier' && argument.name === '__dirname') { return fromDirectory; } // `path.join(__dirname, 'literal', ...)` or `path.resolve(...)`, either // reached bare or through the module: `require('path').join(...)`. if (argument.type === 'CallExpression') { const callee = argument.callee; if ( callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier' || (callee.property.name !== 'join' && callee.property.name !== 'resolve') ) { return null; } const isPathModule = object => (object.type === 'Identifier' && object.name === 'path') || (object.type === 'CallExpression' && object.callee.type === 'Identifier' && object.callee.name === 'require' && object.arguments.length === 1 && object.arguments[0].type === 'StringLiteral' && object.arguments[0].value === 'path'); if (!isPathModule(callee.object)) return null; const parts = []; let base = null; for (const part of argument.arguments) { if (part.type === 'StringLiteral') { parts.push(part.value); } else if (part.type === 'Identifier' && part.name === '__dirname') { base = fromDirectory; } else { return null; } } if (base === null) return null; return path.resolve(base, ...parts); } return null; }; /** The string argument of a `bindings('')` call, if static. */ const staticBindingsName = argument => { if (argument === undefined) return null; if (argument.type === 'StringLiteral') return argument.value; // `{ bindings: '' }` if (argument.type === 'ObjectExpression') { for (const property of argument.properties) { if ( property.type === 'ObjectProperty' && !property.computed && property.key.type === 'Identifier' && property.key.name === 'bindings' && property.value.type === 'StringLiteral' ) { return property.value.value; } } } return null; }; /** * R4/R5: recognises `require('node-gyp-build')()` and * `require('bindings')()` and returns the expression that replaces the * whole call, or `null` when resolution failed (a throwing expression is * emitted and one warning printed), or `undefined` when the node is not one * of these calls at all. */ const loaderCallExpression = (node, fromDirectory, state) => { // The outer call is `require('x')(...)`: its callee is the inner `require`. if (node.callee.type !== 'CallExpression') return undefined; const inner = node.callee; if ( inner.callee.type !== 'Identifier' || inner.callee.name !== 'require' || inner.arguments.length !== 1 || inner.arguments[0].type !== 'StringLiteral' ) { return undefined; } const loaderName = inner.arguments[0].value; if (loaderName === 'node-gyp-build') { const directory = staticDirectoryArgument( node.arguments[0], fromDirectory, ); if (directory === null) return undefined; const resolved = resolveNodeGypBuild(directory, pluginOptions); if (resolved !== null) return requireNodeLibraryCall(resolved); warnOnce( state, `node-gyp-build: no ${pluginOptions.hostPlatform}-${pluginOptions.hostArch} binary under ${directory}`, ); return missingModuleExpression('node-gyp-build'); } if (loaderName === 'bindings') { const name = staticBindingsName(node.arguments[0]); if (name === null) return undefined; const resolved = resolveBindings(name, fromDirectory, pluginOptions); if (resolved !== null) return requireNodeLibraryCall(resolved); warnOnce( state, `bindings: no ${pluginOptions.hostPlatform}-${pluginOptions.hostArch} binary named '${name}' from ${fromDirectory}`, ); return missingModuleExpression('bindings'); } return undefined; }; return { name: 'react-native-gpui-node-addons', visitor: { CallExpression(nodePath, state) { if (!isGpuiPlatform) return; const { node } = nodePath; const filename = state.file.opts.filename; if (typeof filename !== 'string' || filename === 'unknown') return; const fromDirectory = path.dirname(filename); // R4/R5: the whole loader call is replaced, so it is matched before the // `require` inside it — `require('node-gyp-build')(__dirname)` would // otherwise be visited on the way down and left as it is anyway, since // neither specifier ends in `.node`. const loader = loaderCallExpression(node, fromDirectory, state); if (loader !== undefined) { if (loader === null) return; nodePath.replaceWith(loader); return; } if ( node.callee.type !== 'Identifier' || node.callee.name !== 'require' || node.arguments.length !== 1 ) { return; } // R7: `require(`./build/thing-${platform}.node`)`, where the // interpolated segments are platform/arch tags. The static prefix and // suffix locate the candidates; the tags themselves cannot be // evaluated. Any number of interpolations is allowed as long as the // suffix still ends in `.node` — the glob between prefix and suffix // absorbs them all. if (node.arguments[0].type === 'TemplateLiteral') { if (nodePath.parentPath.isCallExpression({ callee: node })) return; const { quasis } = node.arguments[0]; if (node.arguments[0].expressions.length === 0) return; const prefix = quasis[0].value.cooked ?? quasis[0].value.raw; const suffix = quasis[quasis.length - 1].value.cooked ?? quasis[quasis.length - 1].value.raw; if (!suffix.endsWith('.node') || !prefix.startsWith('.')) return; const resolved = resolveInterpolatedAddon( path.resolve(fromDirectory, prefix), suffix, pluginOptions, ); if (resolved === null) { warnOnce( state, `no ${pluginOptions.hostPlatform}-${pluginOptions.hostArch} file matches ${prefix}…${suffix}`, ); nodePath.replaceWith( missingModuleExpression(`${prefix}…${suffix}`), ); return; } nodePath.replaceWith(requireNodeLibraryCall(resolved)); return; } if (node.arguments[0].type !== 'StringLiteral') return; const specifier = node.arguments[0].value; // A dynamic `import()` is a different node type entirely, and the // `require` a bundler synthesises from it is not what this rewrites. if (nodePath.parentPath.isCallExpression({ callee: node })) return; const expression = addonExpression(specifier, path.dirname(filename)); // `addonExpression` returns null when the specifier turned out not to // name an addon, so every other require is left for Metro. if (expression === null) return; nodePath.replaceWith(expression); }, // R6: a napi-rs generated loader is replaced as a whole module, since // every branch of its `process.platform` chain is dead here. Program(nodePath, state) { if (!isGpuiPlatform) return; const filename = state.file.opts.filename; if (typeof filename !== 'string' || filename === 'unknown') return; // Babel hands the original source here even when the transform was // given a string rather than a file, which is how the unit tests drive // it; the on-disk file name is still what relative requires resolve // against. const source = state.file.code; if (typeof source !== 'string') return; // Cheap gate before the full check: a napi-rs loader always branches // on the platform. if (!/(?:process\.platform|process\.config)/u.test(source)) return; const resolved = resolveNapiRsLoader(source, filename, pluginOptions); if (resolved === null) return; for (const statement of nodePath.get('body')) { statement.remove(); } nodePath.pushContainer( 'body', t.expressionStatement( t.assignmentExpression( '=', t.memberExpression( t.identifier('module'), t.identifier('exports'), ), requireNodeLibraryCall(resolved), ), ), ); }, ImportDeclaration(nodePath, state) { if (!isGpuiPlatform) return; const { node } = nodePath; if ( typeof node.source.value !== 'string' || !/\.node$/u.test(node.source.value) ) { return; } const filename = state.file.opts.filename; if (typeof filename !== 'string' || filename === 'unknown') return; const specifier = node.source.value; const expression = addonExpression(specifier, path.dirname(filename)); if (expression === null) return; // Every form binds the same exports object: a default import is the // object itself, a namespace import is the object itself, and named // imports are destructured from it. const statements = []; const shared = t.identifier('_nodeAddonExports'); const named = node.specifiers.filter( item => item.type === 'ImportSpecifier' && item.imported, ); const bound = node.specifiers.filter( item => item.type === 'ImportDefaultSpecifier' || item.type === 'ImportNamespaceSpecifier', ); const needsShared = bound.length > 0 || named.length > 0; if (needsShared) { statements.push( t.variableDeclaration('const', [ t.variableDeclarator(shared, expression), ]), ); } for (const item of bound) { statements.push( t.variableDeclaration('const', [ t.variableDeclarator(item.local, shared), ]), ); } if (named.length > 0) { statements.push( t.variableDeclaration('const', [ t.variableDeclarator( t.objectPattern( named.map(item => t.objectProperty(item.imported, item.local), ), ), shared, ), ]), ); } if (statements.length === 0) { // A bare `import 'x.node'` still has to load the addon. statements.push(t.expressionStatement(expression)); } nodePath.replaceWithMultiple(statements); }, }, }; }; module.exports.addonKey = addonKey; module.exports.isHostPrebuiltNodeAddon = isHostPrebuiltNodeAddon; module.exports.resolveAddon = resolveAddon;