Files
anomalyco__opentui/packages/core/scripts/build.ts
T
Simon Klee 6dec16a77c native: move Zig sources into packages/native (#1391)
Separate the native implementation from the TypeScript bindings. Keep
core on
the public bindings and keep the current native artifact packages and
release
workflows.

packages/native owns the Zig source, tests, benches, and vendor updates.
The
package is private and unpublished for now Core still packages the
@opentui/core-* artifacts. build:native, test:native, and bench:native
still
work from core and delegate to native.

Expose a Zig import root and a no-JS hello example for proof of concept,
not a focus point, just a smoke test.
2026-08-20 11:34:03 +02:00

594 lines
20 KiB
TypeScript

import { spawnSync, type SpawnSyncReturns } from "node:child_process"
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"
import { basename, dirname, isAbsolute, join, relative, resolve } from "path"
import { ModuleKind, ScriptTarget, transpileModule } from "typescript"
import { fileURLToPath } from "url"
import process from "process"
import path from "path"
import { variants, type Variant } from "./variants"
interface PackageJson {
name: string
version: string
license?: string
repository?: any
description?: string
homepage?: string
author?: string
bugs?: any
keywords?: string[]
module?: string
main?: string
types?: string
type?: string
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
interface BunBuildOptions {
chunkNaming?: string
entryPoints: string[]
entryNaming?: string
externalPatterns?: string[]
outputDirectory?: string
outputFile?: string
splitting?: boolean
target: "bun" | "node"
}
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const rootDir = resolve(__dirname, "..")
const nativeRoot = resolve(rootDir, "../native")
const licensePath = path.resolve(__dirname, "../../../LICENSE")
const ghosttyLicensePath = path.resolve(rootDir, "THIRD_PARTY_LICENSES/GHOSTTY")
const packageJson: PackageJson = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"))
const args = process.argv.slice(2)
const buildLib = args.find((arg) => arg === "--lib")
const buildNative = args.find((arg) => arg === "--native")
const isDev = args.includes("--dev")
const buildAll = args.includes("--all") // Build for all platforms
const gpaSafeStats = args.includes("--gpa-safe-stats")
const skipZig = args.includes("--skip-zig")
const getHostVariant = (): Variant => {
const hostVariant = variants.find((variant) => variant.platform === process.platform && variant.arch === process.arch)
if (!hostVariant) {
console.error(`Error: Unsupported host platform for native builds: ${process.platform}-${process.arch}`)
process.exit(1)
}
return hostVariant
}
if (!buildLib && !buildNative) {
console.error("Error: Please specify --lib, --native, or both")
process.exit(1)
}
const getZigTarget = (platform: string, arch: string, abi?: string): string => {
const platformMap: Record<string, string> = { darwin: "macos", win32: "windows", linux: "linux" }
const archMap: Record<string, string> = { x64: "x86_64", arm64: "aarch64" }
const base = `${archMap[arch] ?? arch}-${platformMap[platform] ?? platform}`
return abi ? `${base}-${abi}` : base
}
const replaceLinks = (text: string): string => {
return packageJson.homepage
? text.replace(
/(\[.*?\]\()(\.\/.*?\))/g,
(_, p1: string, p2: string) => `${p1}${packageJson.homepage}/blob/HEAD/${p2.replace("./", "")}`,
)
: text
}
const requiredFields: (keyof PackageJson)[] = ["name", "version", "license", "repository", "description"]
const missingRequired = requiredFields.filter((field) => !packageJson[field])
if (missingRequired.length > 0) {
console.error(`Error: Missing required fields in package.json: ${missingRequired.join(", ")}`)
process.exit(1)
}
const runCommand = (command: string, commandArgs: string[], cwd: string, errorMessage: string): void => {
const result: SpawnSyncReturns<Buffer> = spawnSync(command, commandArgs, {
cwd,
stdio: "inherit",
})
if (result.error) {
console.error(`${errorMessage}: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
console.error(errorMessage)
process.exit(1)
}
}
const runBunBuild = ({
chunkNaming,
entryPoints,
entryNaming,
externalPatterns = [],
outputDirectory = "dist",
outputFile,
splitting = false,
target,
}: BunBuildOptions): void => {
if (outputFile && splitting) {
throw new Error("Bun builds with an output file cannot use splitting")
}
const buildArgs = [
"build",
`--target=${target}`,
outputFile ? `--outfile=${outputFile}` : `--outdir=${outputDirectory}`,
"--sourcemap",
...(entryNaming ? [`--entry-naming=${entryNaming}`] : []),
...(chunkNaming ? [`--chunk-naming=${chunkNaming}`] : []),
...(splitting ? ["--splitting"] : []),
...externalPatterns.flatMap((pattern) => ["--external", pattern]),
...entryPoints,
]
runCommand("bun", buildArgs, rootDir, `Error: Bun ${target} build failed for ${entryPoints.join(", ")}`)
}
const finalizeBunBuildOutput = (temporaryPath: string, outputPath: string): void => {
const temporaryAbsolute = resolve(rootDir, temporaryPath)
const outputAbsolute = resolve(rootDir, outputPath)
const temporaryMap = `${temporaryAbsolute}.map`
const outputMap = `${outputAbsolute}.map`
let source = readFileSync(temporaryAbsolute, "utf8")
source = source.replace(`sourceMappingURL=${basename(temporaryMap)}`, `sourceMappingURL=${basename(outputMap)}`)
writeFileSync(temporaryAbsolute, source)
renameSync(temporaryAbsolute, outputAbsolute)
if (existsSync(temporaryMap)) {
const sourceMap = JSON.parse(readFileSync(temporaryMap, "utf8")) as { sources?: string[] }
if (sourceMap.sources) {
sourceMap.sources = sourceMap.sources.map((sourcePath) => {
if (isAbsolute(sourcePath) || sourcePath.includes(":")) return sourcePath
return relative(dirname(outputMap), resolve(dirname(temporaryMap), sourcePath)).replaceAll("\\", "/")
})
writeFileSync(temporaryMap, JSON.stringify(sourceMap, null, 2) + "\n")
}
renameSync(temporaryMap, outputMap)
}
}
const transpileEntryPoint = (entryPoint: string, outputPath: string): void => {
const sourcePath = join(rootDir, entryPoint)
const sourceText = readFileSync(sourcePath, "utf8")
const result = transpileModule(sourceText, {
compilerOptions: {
module: ModuleKind.ESNext,
sourceMap: true,
target: ScriptTarget.ES2022,
},
fileName: sourcePath,
})
writeFileSync(outputPath, result.outputText)
if (result.sourceMapText) {
writeFileSync(`${outputPath}.map`, result.sourceMapText)
}
}
if (buildNative) {
console.log(`Building native ${isDev ? "dev" : "prod"} binaries${buildAll ? " for all platforms" : ""}...`)
const zigArgs = ["build", `-Doptimize=${isDev ? "Debug" : "ReleaseFast"}`]
if (buildAll) {
zigArgs.push("-Dall")
}
if (gpaSafeStats) {
zigArgs.push("-Dgpa-safe-stats=true")
}
if (!skipZig) runCommand("zig", zigArgs, nativeRoot, "Error: Zig build failed")
const variantsToPackage = buildAll ? variants : [getHostVariant()]
for (const { platform, arch, abi } of variantsToPackage) {
const nativeName = `${packageJson.name}-${platform}-${arch}${abi ? `-${abi}` : ""}`
const nativeDir = join(rootDir, "node_modules", nativeName)
const libDir = join(nativeRoot, "lib", getZigTarget(platform, arch, abi))
rmSync(nativeDir, { recursive: true, force: true })
mkdirSync(nativeDir, { recursive: true })
let copiedFiles = 0
let libraryFileName: string | null = null
for (const name of ["libopentui", "opentui"]) {
for (const ext of [".so", ".dll", ".dylib"]) {
const src = join(libDir, `${name}${ext}`)
if (existsSync(src)) {
const fileName = `${name}${ext}`
copyFileSync(src, join(nativeDir, fileName))
copiedFiles++
if (!libraryFileName) {
libraryFileName = fileName
}
}
}
}
if (copiedFiles === 0) {
// Skip platforms that weren't built
console.log(`Skipping ${platform}-${arch}: no libraries found`)
rmSync(nativeDir, { recursive: true, force: true })
continue
}
const indexJsContent = `import { fileURLToPath } from "node:url"
export default fileURLToPath(new URL("./${libraryFileName}", import.meta.url))
`
writeFileSync(join(nativeDir, "index.js"), indexJsContent)
const indexBunJsContent = `const module = await import("./${libraryFileName}", { with: { type: "file" } })
export default module.default
`
writeFileSync(join(nativeDir, "index.bun.js"), indexBunJsContent)
writeFileSync(join(nativeDir, "index.d.ts"), "declare const path: string\nexport default path\n")
writeFileSync(
join(nativeDir, "package.json"),
JSON.stringify(
{
name: nativeName,
version: packageJson.version,
description: `Prebuilt ${platform}-${arch}${abi ? `-${abi}` : ""} binaries for ${packageJson.name}`,
type: "module",
main: "index.js",
module: "index.js",
types: "index.d.ts",
license: packageJson.license,
author: packageJson.author,
homepage: packageJson.homepage,
repository: packageJson.repository,
bugs: packageJson.bugs,
keywords: [...(packageJson.keywords ?? []), "prebuild", "prebuilt"],
exports: {
".": {
bun: "./index.bun.js",
import: "./index.js",
types: "./index.d.ts",
},
},
os: [platform],
cpu: [arch],
...(abi ? { libc: [abi] } : {}),
},
null,
2,
),
)
writeFileSync(
join(nativeDir, "README.md"),
replaceLinks(
`## ${nativeName}\n\n> Prebuilt ${platform}-${arch}${abi ? `-${abi}` : ""} binaries for \`${packageJson.name}\`.`,
),
)
if (existsSync(licensePath)) copyFileSync(licensePath, join(nativeDir, "LICENSE"))
for (const [source, destination] of [
[ghosttyLicensePath, "LICENSE-GHOSTTY"],
[join(nativeRoot, "src", "vendor", "wuffs", "LICENSE"), "LICENSE-WUFFS"],
[join(nativeRoot, "src", "vendor", "stb", "LICENSE"), "LICENSE-STB"],
[join(nativeRoot, "src", "vendor", "libwebp", "COPYING"), "LICENSE-LIBWEBP"],
[join(nativeRoot, "src", "vendor", "libwebp", "PATENTS"), "PATENTS-LIBWEBP"],
[join(nativeRoot, "src", "vendor", "libwebp", "AUTHORS"), "AUTHORS-LIBWEBP"],
[join(nativeRoot, "src", "vendor", "lcms2", "LICENSE"), "LICENSE-LCMS2"],
] as const) {
if (!existsSync(source)) throw new Error(`Required native license file is missing: ${source}`)
copyFileSync(source, join(nativeDir, destination))
}
console.log("Built:", nativeName)
}
}
if (buildLib) {
console.log("Building library...")
const distDir = join(rootDir, "dist")
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
const externalDeps: string[] = [
...Object.keys(packageJson.optionalDependencies || {}),
...Object.keys(packageJson.peerDependencies || {}),
]
// Build main entry point
if (!packageJson.module) {
console.error("Error: 'module' field not found in package.json")
process.exit(1)
}
const bunOnlyEntryPoints = [
{
entryPoint: "src/runtime-plugin.ts",
outputFile: "runtime-plugin.js",
},
{
entryPoint: "src/runtime-plugin-support-configure.ts",
outputFile: "runtime-plugin-support-configure.js",
},
{
entryPoint: "src/runtime-plugin-support.ts",
outputFile: "runtime-plugin-support.js",
},
]
// Keep runtime assets external so Bun consumers can discover literal file imports
// and Node consumers can resolve package-relative files.
const externalPatterns = [...externalDeps, "@opentui/core/parser.worker", "*.wasm", "*.scm"]
const portableEntryPoints = [packageJson.module, "src/testing.ts", "src/yoga.ts"]
runBunBuild({
chunkNaming: "chunk-node-[hash].[ext]",
entryPoints: portableEntryPoints,
externalPatterns,
splitting: true,
target: "node",
})
finalizeBunBuildOutput("dist/index.js", "dist/index.node.js")
runBunBuild({
chunkNaming: "chunk-bun-[hash].[ext]",
entryNaming: "[name].bun.[ext]",
entryPoints: portableEntryPoints,
externalPatterns,
splitting: true,
target: "bun",
})
runBunBuild({
entryPoints: ["src/node-assets.ts"],
externalPatterns,
target: "node",
})
for (const { entryPoint, outputFile } of bunOnlyEntryPoints) {
transpileEntryPoint(entryPoint, join(distDir, outputFile))
}
// Build updater as a separate entry so generator code stays out of the core runtime bundle.
runCommand(
"bun",
[
"build",
"--target=bun",
"--outdir=dist/lib/tree-sitter",
"--sourcemap",
...externalDeps.flatMap((dep) => ["--external", dep]),
"src/lib/tree-sitter/update-assets.ts",
],
rootDir,
"Error: Bun build failed for src/lib/tree-sitter/update-assets.ts",
)
// Post-process to fix Bun's duplicate export issue
// See: https://github.com/oven-sh/bun/issues/5344
// and: https://github.com/oven-sh/bun/issues/10631
console.log("Post-processing bundled files to fix duplicate exports...")
const bundledFiles = [
"dist/index.node.js",
"dist/node-assets.js",
"dist/testing.js",
"dist/runtime-plugin.js",
"dist/runtime-plugin-support.js",
"dist/runtime-plugin-support-configure.js",
"dist/yoga.js",
"dist/lib/tree-sitter/update-assets.js",
"dist/index.bun.js",
"dist/testing.bun.js",
"dist/yoga.bun.js",
]
for (const filePath of bundledFiles) {
const fullPath = join(rootDir, filePath)
if (existsSync(fullPath)) {
let content = readFileSync(fullPath, "utf8")
const helperExportPattern = /^export\s*\{([^}]*(?:__toESM|__commonJS|__export|__require)[^}]*)\};\s*$/gm
let modified = false
content = content.replace(helperExportPattern, (match, exports) => {
const exportsList = exports.split(",").map((e: string) => e.trim())
const helpers = ["__toESM", "__commonJS", "__export", "__require"]
const nonHelpers = exportsList.filter((e: string) => !helpers.includes(e))
if (nonHelpers.length > 0) {
modified = true
const helperExports = exportsList.filter((e: string) => helpers.includes(e))
return `export { ${helperExports.join(", ")} };`
}
return match
})
if (modified) {
writeFileSync(fullPath, content)
console.log(` Fixed duplicate exports in ${filePath}`)
}
}
}
console.log("Generating TypeScript declarations...")
const tsconfigBuildPath = join(rootDir, "tsconfig.build.json")
runCommand("bunx", ["tsc", "-p", tsconfigBuildPath], rootDir, "Error: TypeScript declaration generation failed")
console.log("TypeScript declarations generated")
// Bun derives dotted outfile paths from the entry name, so build in a temporary directory and move both outputs.
// The tree-sitter WASM remains a separate manifest asset.
runBunBuild({
entryPoints: ["src/lib/tree-sitter/parser.worker.ts"],
externalPatterns: ["*.wasm"],
outputDirectory: "dist/worker",
target: "node",
})
finalizeBunBuildOutput("dist/worker/parser.worker.js", "dist/parser.worker.js")
rmSync(join(distDir, "worker"), { recursive: true, force: true })
if (!existsSync(join(distDir, "parser.worker.js"))) {
throw new Error("Parser worker build did not produce dist/parser.worker.js")
}
const treeSitterSrcDir = join(rootDir, "src", "lib", "tree-sitter")
const copyAssets = (src: string, dest: string) => {
mkdirSync(dest, { recursive: true })
const entries = readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = join(src, entry.name)
const destPath = join(dest, entry.name)
if (entry.isDirectory()) {
copyAssets(srcPath, destPath)
} else if (entry.isFile() && (entry.name.endsWith(".wasm") || entry.name.endsWith(".scm"))) {
copyFileSync(srcPath, destPath)
}
}
}
copyAssets(join(treeSitterSrcDir, "assets"), join(distDir, "assets"))
console.log(" Copied tree-sitter assets (*.wasm, *.scm) to dist/assets/")
const writeBunOnlyStub = (fileName: string, specifier: string, exportNames: string[]): void => {
const errorMessage = `${specifier} is Bun-only and is not available in Node.js. Use Bun to import this entrypoint.`
const namedExports = exportNames
.map((exportName) => `export function ${exportName}() {\n throw new Error(${JSON.stringify(errorMessage)})\n}`)
.join("\n\n")
writeFileSync(
join(distDir, fileName),
`const errorMessage = ${JSON.stringify(errorMessage)}\n\n${namedExports}\n\nthrow new Error(errorMessage)\n`,
)
}
writeBunOnlyStub("runtime-plugin.node.js", `${packageJson.name}/runtime-plugin`, [
"createRuntimePlugin",
"isCoreRuntimeModuleSpecifier",
"runtimeModuleIdForSpecifier",
])
writeBunOnlyStub("runtime-plugin-support.node.js", `${packageJson.name}/runtime-plugin-support`, [
"ensureRuntimePluginSupport",
"createRuntimePlugin",
"runtimeModuleIdForSpecifier",
])
writeBunOnlyStub("runtime-plugin-support-configure.node.js", `${packageJson.name}/runtime-plugin-support/configure`, [
"ensureRuntimePluginSupport",
"createRuntimePlugin",
"runtimeModuleIdForSpecifier",
])
// Configure exports for multiple entry points
const exports = {
".": {
types: "./index.d.ts",
bun: "./index.bun.js",
node: "./index.node.js",
import: "./index.node.js",
},
"./testing": {
bun: "./testing.bun.js",
import: "./testing.js",
types: "./testing.d.ts",
},
"./runtime-plugin": {
types: "./runtime-plugin.d.ts",
bun: "./runtime-plugin.js",
node: "./runtime-plugin.node.js",
default: "./runtime-plugin.node.js",
},
"./runtime-plugin-support": {
types: "./runtime-plugin-support.d.ts",
bun: "./runtime-plugin-support.js",
node: "./runtime-plugin-support.node.js",
default: "./runtime-plugin-support.node.js",
},
"./runtime-plugin-support/configure": {
types: "./runtime-plugin-support-configure.d.ts",
bun: "./runtime-plugin-support-configure.js",
node: "./runtime-plugin-support-configure.node.js",
default: "./runtime-plugin-support-configure.node.js",
},
"./yoga": {
bun: "./yoga.bun.js",
types: "./yoga.d.ts",
import: "./yoga.js",
},
// Conditional exports select the first matching key in declaration order. Bun
// matches `bun` for both import and require, while Node ESM falls through to
// `import`. There is deliberately no `require` or `default`: this module uses
// top-level await, so directing Node CommonJS to it would fail during evaluation.
"./tree-sitter/update-assets": {
types: "./lib/tree-sitter/update-assets.d.ts",
bun: "./lib/tree-sitter/update-assets.js",
import: "./lib/tree-sitter/update-assets.js",
},
"./parser.worker": {
bun: "./parser.worker.js",
node: "./parser.worker.js",
import: "./parser.worker.js",
require: "./parser.worker.js",
types: "./lib/tree-sitter/parser.worker.d.ts",
},
"./node-assets": {
types: "./node-assets.d.ts",
import: "./node-assets.js",
},
}
const optionalDeps: Record<string, string> = Object.fromEntries(
variants.map(({ platform, arch, abi }) => [
`${packageJson.name}-${platform}-${arch}${abi ? `-${abi}` : ""}`,
packageJson.version,
]),
)
writeFileSync(
join(distDir, "package.json"),
JSON.stringify(
{
name: packageJson.name,
module: "index.node.js",
main: "index.node.js",
types: "index.d.ts",
type: packageJson.type,
version: packageJson.version,
description: packageJson.description,
keywords: packageJson.keywords,
license: packageJson.license,
author: packageJson.author,
homepage: packageJson.homepage,
repository: packageJson.repository,
bugs: packageJson.bugs,
exports,
dependencies: packageJson.dependencies,
peerDependencies: packageJson.peerDependencies,
optionalDependencies: {
...packageJson.optionalDependencies,
...optionalDeps,
},
},
null,
2,
),
)
writeFileSync(join(distDir, "README.md"), replaceLinks(readFileSync(join(rootDir, "README.md"), "utf8")))
if (existsSync(licensePath)) copyFileSync(licensePath, join(distDir, "LICENSE"))
if (!existsSync(join(distDir, "parser.worker.js"))) {
throw new Error("Parser worker was removed while assembling the distribution")
}
console.log("Library built at:", distDir)
}