feat: per-platform optional dependencies for native binary distribution

Add the esbuild/swc pattern for distributing platform-specific native
binaries via npm optional dependencies. Each supported platform gets its
own @gsd/engine-{platform} package containing just the .node binary.

- 5 platform package stubs (darwin-arm64, darwin-x64, linux-x64-gnu,
  linux-arm64-gnu, win32-x64-msvc) with os/cpu filters
- Rewritten native loader: tries npm package first, then local build
- Version sync script keeps platform packages in lock-step with root
- GitHub Actions workflow for cross-platform build + publish on tag push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-13 14:36:18 -06:00
parent d49af589d0
commit bd8380315c
9 changed files with 342 additions and 16 deletions

134
.github/workflows/build-native.yml vendored Normal file
View file

@ -0,0 +1,134 @@
name: Build Native Binaries
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publish:
description: "Publish platform packages to npm"
required: false
default: "false"
type: choice
options:
- "false"
- "true"
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
target: aarch64-apple-darwin
platform: darwin-arm64
- os: macos-13
target: x86_64-apple-darwin
platform: darwin-x64
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
platform: linux-x64-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
platform: linux-arm64-gnu
cross: true
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: win32-x64-msvc
runs-on: ${{ matrix.os }}
name: Build ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux ARM64)
if: matrix.cross
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Build native addon
working-directory: native/crates/engine
env:
RUSTFLAGS: ""
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.cross && 'aarch64-linux-gnu-gcc' || '' }}
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare artifact (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p artifacts
cp native/target/${{ matrix.target }}/release/libgsd_engine.dylib artifacts/gsd_engine.node 2>/dev/null || \
cp native/target/${{ matrix.target }}/release/libgsd_engine.so artifacts/gsd_engine.node 2>/dev/null || \
echo "No library found"
ls -la artifacts/
- name: Prepare artifact (Windows)
if: runner.os == 'Windows'
run: |
mkdir artifacts
copy native\target\${{ matrix.target }}\release\gsd_engine.dll artifacts\gsd_engine.node
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: native-${{ matrix.platform }}
path: artifacts/gsd_engine.node
if-no-files-found: error
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true'
runs-on: ubuntu-latest
name: Publish platform packages
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Copy binaries to platform packages
run: |
for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do
cp "artifacts/native-${platform}/gsd_engine.node" "native/npm/${platform}/gsd_engine.node"
echo "Copied binary for ${platform}"
ls -la "native/npm/${platform}/"
done
- name: Sync platform package versions
run: node native/scripts/sync-platform-versions.js
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do
echo "Publishing @gsd/engine-${platform}..."
cd "native/npm/${platform}"
npm publish --access public || echo "Failed to publish ${platform} (may already exist)"
cd -
done
- name: Wait for npm registry propagation
run: sleep 30
- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/engine-darwin-arm64",
"version": "2.10.2",
"description": "GSD native engine binary for macOS ARM64",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"main": "gsd_engine.node",
"files": [
"gsd_engine.node"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gsd-build/gsd-2.git"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/engine-darwin-x64",
"version": "2.10.2",
"description": "GSD native engine binary for macOS Intel",
"os": [
"darwin"
],
"cpu": [
"x64"
],
"main": "gsd_engine.node",
"files": [
"gsd_engine.node"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gsd-build/gsd-2.git"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/engine-linux-arm64-gnu",
"version": "2.10.2",
"description": "GSD native engine binary for Linux ARM64 (glibc)",
"os": [
"linux"
],
"cpu": [
"arm64"
],
"main": "gsd_engine.node",
"files": [
"gsd_engine.node"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gsd-build/gsd-2.git"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/engine-linux-x64-gnu",
"version": "2.10.2",
"description": "GSD native engine binary for Linux x64 (glibc)",
"os": [
"linux"
],
"cpu": [
"x64"
],
"main": "gsd_engine.node",
"files": [
"gsd_engine.node"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gsd-build/gsd-2.git"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/engine-win32-x64-msvc",
"version": "2.10.2",
"description": "GSD native engine binary for Windows x64 (MSVC)",
"os": [
"win32"
],
"cpu": [
"x64"
],
"main": "gsd_engine.node",
"files": [
"gsd_engine.node"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gsd-build/gsd-2.git"
}
}

View file

@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Synchronize platform package versions with the root package version.
*
* Reads version from root package.json, writes it to all platform
* package.json files and updates optionalDependencies in root package.json.
*/
const fs = require("fs");
const path = require("path");
const rootDir = path.resolve(__dirname, "..", "..");
const npmDir = path.resolve(__dirname, "..", "npm");
const rootPkgPath = path.join(rootDir, "package.json");
const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
const version = rootPkg.version;
console.log(`[sync-platform-versions] Syncing to version ${version}`);
const platformPackages = [
"darwin-arm64",
"darwin-x64",
"linux-x64-gnu",
"linux-arm64-gnu",
"win32-x64-msvc",
];
// Update each platform package.json
for (const platform of platformPackages) {
const pkgPath = path.join(npmDir, platform, "package.json");
if (!fs.existsSync(pkgPath)) {
console.warn(` Skipping ${platform}: ${pkgPath} not found`);
continue;
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
if (pkg.version !== version) {
console.log(` ${platform}: ${pkg.version} -> ${version}`);
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
} else {
console.log(` ${platform}: already ${version}`);
}
}
// Update optionalDependencies in root package.json
let rootChanged = false;
const optDeps = rootPkg.optionalDependencies || {};
for (const platform of platformPackages) {
const depName = `@gsd/engine-${platform}`;
if (optDeps[depName] && optDeps[depName] !== version) {
console.log(` root optionalDependencies ${depName}: ${optDeps[depName]} -> ${version}`);
optDeps[depName] = version;
rootChanged = true;
}
}
if (rootChanged) {
rootPkg.optionalDependencies = optDeps;
fs.writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, 2) + "\n");
console.log(" Updated root package.json optionalDependencies");
}
console.log("[sync-platform-versions] Done.");

View file

@ -55,7 +55,8 @@
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
"prepublishOnly": "npm run sync-pkg-version && npm run build"
"sync-platform-versions": "node native/scripts/sync-platform-versions.cjs",
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && npm run build"
},
"dependencies": {
"@clack/prompts": "^1.1.0",
@ -82,6 +83,11 @@
"typescript": "^5.4.0"
},
"optionalDependencies": {
"@gsd/engine-darwin-arm64": "2.10.2",
"@gsd/engine-darwin-x64": "2.10.2",
"@gsd/engine-linux-x64-gnu": "2.10.2",
"@gsd/engine-linux-arm64-gnu": "2.10.2",
"@gsd/engine-win32-x64-msvc": "2.10.2",
"fsevents": "~2.3.3"
},
"overrides": {

View file

@ -2,7 +2,10 @@
* Native addon loader.
*
* Locates and loads the compiled Rust N-API addon (`.node` file).
* Tries platform-tagged release builds first, then falls back to dev builds.
* Resolution order:
* 1. @gsd/engine-{platform} npm optional dependency (production install)
* 2. native/addon/gsd_engine.{platform}.node (local release build)
* 3. native/addon/gsd_engine.dev.node (local debug build)
*/
import { createRequire } from "node:module";
@ -15,31 +18,49 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`;
const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"),
];
/** Map Node.js platform/arch to the npm package suffix */
const platformPackageMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64-gnu",
"linux-arm64": "linux-arm64-gnu",
"win32-x64": "win32-x64-msvc",
};
function loadNative(): Record<string, unknown> {
const errors: string[] = [];
for (const candidate of candidates) {
// 1. Try the platform-specific npm optional dependency
const packageSuffix = platformPackageMap[platformTag];
if (packageSuffix) {
try {
return require(candidate) as Record<string, unknown>;
return require(`@gsd/engine-${packageSuffix}`) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${candidate}: ${message}`);
errors.push(`@gsd/engine-${packageSuffix}: ${message}`);
}
}
// 2. Try local release build (native/addon/gsd_engine.{platform}.node)
const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`);
try {
return require(releasePath) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${releasePath}: ${message}`);
}
// 3. Try local dev build (native/addon/gsd_engine.dev.node)
const devPath = path.join(addonDir, "gsd_engine.dev.node");
try {
return require(devPath) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${devPath}: ${message}`);
}
const details = errors.map((e) => ` - ${e}`).join("\n");
const supportedPlatforms = [
"darwin-arm64",
"darwin-x64",
"linux-x64",
"linux-arm64",
"win32-x64",
];
const supportedPlatforms = Object.keys(platformPackageMap);
throw new Error(
`Failed to load gsd_engine native addon for ${platformTag}.\n\n` +
`Tried:\n${details}\n\n` +