feat(native): add prebuilt Node-API flock support

This commit is contained in:
imccyu
2026-09-08 20:49:10 +08:00
parent 7264906f99
commit d927cbff99
93 changed files with 1904 additions and 551 deletions
+82 -76
View File
@@ -1,86 +1,92 @@
/**
* Build every native tool this host can build, into its per-platform
* package.
*
* Targets are derived from the checked-in matrix: each
* `packages/<name>/prebuilds.json` whose `platform` matches this host names
* the binaries to produce; the TOOLS table below maps each `tool` to its C
* source. Builds are NATIVE-ONLY — each Linux architecture compiles its own
* binary with the distro's `musl-gcc` (static musl: runs on glibc and musl
* distros alike, no loader or libc expectations on the consumer host), and
* CI's per-arch runners are the builders of record. No cross toolchain
* exists here on purpose: native runners replace it, and the audit surface
* is the reviewed C source plus the CI job that built the binary.
*
* Binaries land in `packages/<name>/bin/` — git-ignored (root
* `.gitignore`), packed into the platform package's npm tarball behind its
* `prepack` gate (`scripts/verify-launcher-binary.mjs`).
*
* Run: `pnpm run build:native` (Linux with musl-gcc on PATH:
* `apt-get install musl-tools`). Non-Linux hosts fail fast — no platform
* package exists for them to build.
* Build this host's declared system binaries. Landlock is a static musl
* executable; flock uses stable Node-API with separate Linux libc builds.
* Node headers come from the Node installation running this script.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
/** Each native tool's C source, keyed by the `tool` field in prebuilds.json. */
const TOOLS: Record<string, { source: string }> = {
'landlock-run': { source: 'packages/entry/src/main.c' },
const root = resolve(import.meta.dirname, '..')
const { values } = parseArgs({ options: { 'host-addon-only': { type: 'boolean' } }, allowPositionals: false })
const hostAddonOnly = values['host-addon-only'] === true
const sources: Record<string, string> = {
'landlock-run': 'packages/entry/src/main.c',
flock: 'packages/entry/src/flock.c',
}
const repoRoot = resolve(import.meta.dirname, '..')
if (process.platform !== 'linux') {
console.error(`build: native tools are built natively per Linux architecture (no cross toolchain) — nothing to build on ${process.platform}. CI's per-arch runners build and rehearse every platform package.`)
process.exit(1)
}
const hostPlatform = `linux-${process.arch}`
/** This host's platform packages, from the checked-in matrix. */
const targets: { packageDir: string; tool: string; binaryPath: string; kind: string }[] = []
const packagesRoot = join(repoRoot, 'packages')
for (const name of readdirSync(packagesRoot).sort()) {
const prebuildsFile = join(packagesRoot, name, 'prebuilds.json')
if (!existsSync(prebuildsFile)) continue
const prebuilds = JSON.parse(readFileSync(prebuildsFile, 'utf8')) as {
platform: string
binaries: { tool: string; kind: string; path: string }[]
}
if (prebuilds.platform !== hostPlatform) continue
for (const binary of prebuilds.binaries) {
targets.push({ packageDir: join(packagesRoot, name), tool: binary.tool, binaryPath: binary.path, kind: binary.kind })
}
}
if (targets.length === 0) {
console.error(`build: no platform package declares binaries for ${hostPlatform} — supported platforms are the packages/*/prebuilds.json "platform" values.`)
process.exit(1)
interface Binary {
tool: string
kind: string
path: string
napi?: number
libc?: string
}
for (const target of targets) {
const tool = TOOLS[target.tool]
if (tool === undefined) {
console.error(`build: prebuilds.json names unknown tool "${target.tool}" — add it to the TOOLS table in scripts/build.ts.`)
process.exit(1)
}
if (target.kind !== 'static-musl') {
console.error(`build: unknown binary kind "${target.kind}" — the only toolchain here is static musl.`)
process.exit(1)
}
const binary = join(target.packageDir, target.binaryPath)
mkdirSync(dirname(binary), { recursive: true })
// -static against musl: self-contained, no loader/libc expectations on the
// consumer host. -Werror is safe to keep hard: CI pins the builder images,
// and a new warning on a toolchain bump deserves a look, not a pass.
const result = spawnSync('musl-gcc', [
'-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s',
'-o', binary, join(repoRoot, tool.source),
], { stdio: ['ignore', 'inherit', 'inherit'] })
if (result.error !== undefined || result.status !== 0) {
console.error('build: musl-gcc failed' +
(result.error ? ` (${result.error.message} — is musl-tools installed?)` : ''))
process.exit(1)
}
console.log(`build: built ${basename(target.packageDir)}/${target.binaryPath}`)
if (process.platform !== 'linux' && process.platform !== 'darwin') {
if (hostAddonOnly) process.exit(0)
throw new Error('build: system binaries are built on Linux or macOS; no native target for this host')
}
const host = `${process.platform}-${process.arch}`
const libc = process.platform === 'linux'
? ((process.report.getReport() as { header: { glibcVersionRuntime?: string } }).header.glibcVersionRuntime ? 'glibc' : 'musl')
: undefined
const headers = resolve(dirname(process.execPath), '../include/node')
let built = 0
for (const name of readdirSync(join(root, 'packages')).sort()) {
const dir = join(root, 'packages', name)
const metadata = join(dir, 'prebuilds.json')
if (!existsSync(metadata)) continue
const spec = JSON.parse(readFileSync(metadata, 'utf8')) as { platform: string; binaries: Binary[] }
if (spec.platform !== host) continue
for (const binary of spec.binaries) {
if (hostAddonOnly && (binary.kind !== 'node-api' || (binary.libc !== undefined && binary.libc !== libc))) continue
const source = sources[binary.tool]
if (source === undefined) throw new Error(`build: unknown tool ${binary.tool}`)
const output = join(dir, binary.path)
mkdirSync(dirname(output), { recursive: true })
let compiler: string
let flags: string[]
if (binary.kind === 'static-musl' && process.platform === 'linux' && binary.tool === 'landlock-run') {
compiler = 'musl-gcc'
flags = ['-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s']
} else if (binary.kind === 'node-api' && binary.tool === 'flock' && binary.napi === 8) {
if (!existsSync(join(headers, 'node_api.h'))) {
throw new Error(`build: Node-API headers missing at ${headers}; use a Node installation with development headers`)
}
compiler = process.platform === 'linux' && binary.libc === 'musl' ? 'musl-gcc' : 'cc'
flags = ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-fPIC', '-fvisibility=hidden', '-DNAPI_VERSION=8', '-I', headers]
if (process.platform === 'darwin') {
if (binary.libc !== undefined) throw new Error('build: macOS flock does not select a Linux libc')
flags.push('-bundle', '-undefined', 'dynamic_lookup', '-mmacosx-version-min=11.0')
} else {
if (binary.libc !== 'glibc' && binary.libc !== 'musl') {
throw new Error('build: Linux flock must select glibc or musl')
}
flags.push('-shared')
}
} else {
throw new Error(`build: unsupported ${binary.tool}/${binary.kind} target on ${host}`)
}
mkdirSync(join(root, '.release'), { recursive: true })
const temporary = mkdtempSync(join(root, '.release', 'native-build-'))
try {
const pending = join(temporary, basename(output))
const result = spawnSync(compiler, [...flags, '-o', pending, join(root, source)], { stdio: 'inherit' })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`build: ${compiler} failed for ${binary.path}`)
// Readers never see a truncated addon when source checks build concurrently.
renameSync(pending, output)
} finally {
rmSync(temporary, { recursive: true, force: true })
}
console.log(`build: built ${basename(dir)}/${binary.path}`)
built++
}
}
if (built === 0) throw new Error(`build: no declared binaries for ${host}`)