diff --git a/.github/workflows/lint-format.yml b/.github/workflows/lint-format.yml index 68d6789..bc093cb 100644 --- a/.github/workflows/lint-format.yml +++ b/.github/workflows/lint-format.yml @@ -25,21 +25,22 @@ jobs: with: node-version: 22 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.24.0 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + - name: Validate prek config - run: npm exec --yes --package @j178/prek -- prek validate-config prek.toml + run: pnpm exec prek validate-config prek.toml - name: Check Zig syntax - run: | - set -euo pipefail - while IFS= read -r -d '' file; do - zig ast-check "$file" - done < <(git ls-files -z '*.zig') - while IFS= read -r -d '' file; do - zig ast-check --zon "$file" - done < <(git ls-files -z '*.zon') + run: pnpm run check:zig - name: Build root package - run: zig build --summary all + run: pnpm run build format: runs-on: ubuntu-latest @@ -59,10 +60,16 @@ jobs: with: node-version: 22 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.24.0 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Format + run: pnpm run format + - name: Check formatting - run: | - set -euo pipefail - git ls-files -z '*.zig' '*.zon' | xargs -0 zig fmt - git ls-files -z '*.js' '*.jsx' '*.ts' '*.tsx' '*.ets' \ - | xargs -0 npm exec --yes --package @ohos-rs/oxk -- oxk format - git diff --exit-code + run: git diff --exit-code diff --git a/.github/workflows/node-addon.yml b/.github/workflows/node-addon.yml index 8505cb0..305d7c0 100644 --- a/.github/workflows/node-addon.yml +++ b/.github/workflows/node-addon.yml @@ -39,26 +39,42 @@ jobs: - name: Verify Zig run: zig version - - name: Setup Node.js + - name: Setup Node.js for install + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.24.0 + + - name: Install node-test dependencies + run: pnpm install --filter zig-napi-node-test --frozen-lockfile + + - name: Setup Node.js for test uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - - name: Build and test Node addon matrix + - name: Build Node addon matrix if: runner.os != 'Windows' - run: | - cd node-test - npm install --no-package-lock - zig build --summary all - find . -maxdepth 2 -name '*.node' -print - npm test + working-directory: node-test + run: npm run build:test + + - name: Test Node addon matrix + if: runner.os != 'Windows' + working-directory: node-test + run: npm run test:run + + - name: Build Windows Node addon matrix + if: runner.os == 'Windows' + shell: pwsh + working-directory: node-test + run: npm run build:test:windows - - name: Build and test Node addon matrix + - name: Test Windows Node addon matrix if: runner.os == 'Windows' shell: pwsh - run: | - cd node-test - npm install --no-package-lock - zig build -Dtarget=x86_64-windows-msvc --summary all - Get-ChildItem -Recurse -Filter *.node | ForEach-Object { $_.FullName } - npm test + working-directory: node-test + run: npm run test:run diff --git a/.gitignore b/.gitignore index ec28151..af74db9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ zig-out node_modules website/.void website/dist +examples/node/*.node +examples/node/npm/*/*.node node-test/*.node +node-test/*.wasm .tmp_arkvm_runner .tmp_arkvm_memory_runner diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..4afce31 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + "node_modules/**", + "website/dist/**", + "website/.void/**", + "**/*.d.ts", + "examples/node/npm/**" + ] +} diff --git a/README.md b/README.md index cdfee6e..9dd9255 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,47 @@ The Node.js example is in `examples/node`: ```bash cd examples/node -zig build -node test.js +pnpm build +pnpm test ``` It installs the addon as `zig-out/node/hello..node`, for example `hello.darwin-arm64.node`, `hello.linux-x64-gnu.node`, or `hello.win32-x64-msvc.node`. +The package also provides a `zig-napi` CLI for Node.js addons. Zig-specific commands such as `new` and `build` are implemented by this project. Packaging commands reuse the community `@napi-rs/cli` API for npm package directory creation, artifact collection, and pre-publish processing. + +The CLI requires Node.js 20.17 or newer. + +Create a new Node addon project: + +```bash +pnpm install +pnpm --filter zig-napi cli new ../../my-addon +cd my-addon +pnpm install +pnpm build +pnpm test +``` + +`zig-napi new` asks for the package name, native addon binary name, and target platforms interactively by default, matching napi-rs' `new` workflow. For scripted usage, pass `--no-interactive` with explicit options: + +```bash +pnpm --filter zig-napi cli new ../../my-addon --no-interactive --name my-addon --addon my_addon --targets x86_64-unknown-linux-gnu +``` + +Pass `--targets ` repeatedly or as a comma-separated list to choose the generated package targets manually, or pass `--enable-all-targets` to enable every napi-rs target known to the CLI. + +Run the bundled Node example: + +```bash +pnpm install +pnpm run node-example:package +pnpm --filter zig-napi-node-example run test +``` + +`zig-napi create-npm-dirs` calls `@napi-rs/cli`'s `createNpmDirs` API and creates `npm/` packages from the `napi` field in `package.json`. `zig-napi artifacts --output-dir zig-out/node` calls the community `artifacts` API and copies Zig's `..node` outputs into those packages and into the root package. `zig-napi pre-publish` calls the community `prePublish` API to update optional dependencies and handle publish preparation. + +Upstream `napi build` and `napi new` are not used directly for Zig addons because they currently expect Cargo projects and napi-rs' Rust templates. + Node.js matrix tests live in `node-test`. It mirrors the NAPI-RS example split with two independent demos: - `node-test/napi-compat-mode` covers compat-mode style APIs and runtime-gated N-API v4/v5/v6/v7/v8 scenarios. @@ -161,9 +196,9 @@ The documentation website lives in `website` and builds as a standalone Vite sit ```bash cd website -npm install -npm run dev -npm run build +pnpm install +pnpm dev +pnpm build ``` ## Credits diff --git a/docs/napi-conversion-refactor-plan.md b/docs/napi-conversion-refactor-plan.md index 2915ac7..b4125da 100644 --- a/docs/napi-conversion-refactor-plan.md +++ b/docs/napi-conversion-refactor-plan.md @@ -54,7 +54,7 @@ Goal: make failed conversion impossible to accidentally use by changing conversi - Run: - `zig build --summary failures` - `zig build --summary failures` in `node-test` - - `just test-node-matrix` + - `pnpm run test:node-matrix` - `git diff --check` ### Estimate diff --git a/examples/node/build.zig b/examples/node/build.zig index c2d3fa3..161de2f 100644 --- a/examples/node/build.zig +++ b/examples/node/build.zig @@ -22,4 +22,15 @@ pub fn build(b: *std.Build) !void { }, }); _ = addon; + + const dts = try napi_build.generateTypeDefinition(b, .{ + .root_source_file = b.path("src/hello.zig"), + .output = b.path("index.d.ts"), + .napi_module = napi, + .node_api = .{ + .version = .v8, + .experimental = false, + }, + }); + b.getInstallStep().dependOn(&dts.step); } diff --git a/examples/node/build.zig.zon b/examples/node/build.zig.zon index fad8760..a2321eb 100644 --- a/examples/node/build.zig.zon +++ b/examples/node/build.zig.zon @@ -4,5 +4,5 @@ .minimum_zig_version = "0.16.0", .fingerprint = 0xc8e4deed2741664f, .dependencies = .{ .@"zig-napi" = .{ .path = "../.." } }, - .paths = .{ "build.zig", "build.zig.zon", "src", "test.js" }, + .paths = .{ "build.zig", "build.zig.zon", "src", "index.js", "test.js", "package.json" }, } diff --git a/examples/node/index.d.ts b/examples/node/index.d.ts new file mode 100644 index 0000000..e8ba35b --- /dev/null +++ b/examples/node/index.d.ts @@ -0,0 +1,5 @@ +/* auto-generated by zig-addon */ +/* eslint-disable */ +export declare function add(left: number, right: number): number; +export declare function hello(): string; +export declare function requestedNapiVersion(): number; diff --git a/examples/node/index.js b/examples/node/index.js new file mode 100644 index 0000000..b1cbd2c --- /dev/null +++ b/examples/node/index.js @@ -0,0 +1,52 @@ +const fs = require("fs"); +const path = require("path"); + +const packageName = "zig-napi-node-example"; +const binaryName = "hello"; + +function loadAddon() { + const platformArchABI = detectPlatformArchABI(); + const optionalPackage = `${packageName}-${platformArchABI}`; + const candidates = [ + () => require(optionalPackage), + () => require(path.join(__dirname, `${binaryName}.${platformArchABI}.node`)), + () => require(path.join(__dirname, "zig-out", "node", `${binaryName}.${platformArchABI}.node`)), + ]; + + const errors = []; + for (const candidate of candidates) { + try { + return candidate(); + } catch (error) { + errors.push(error); + } + } + + throw new Error( + `Unable to load ${binaryName}.${platformArchABI}.node\n` + + errors.map((error) => `- ${error.message}`).join("\n"), + ); +} + +function detectPlatformArchABI() { + if (process.platform === "linux") { + return `${process.platform}-${process.arch}-${isMusl() ? "musl" : "gnu"}`; + } + if (process.platform === "win32") { + return `${process.platform}-${process.arch}-msvc`; + } + return `${process.platform}-${process.arch}`; +} + +function isMusl() { + if (process.report && typeof process.report.getReport === "function") { + return !process.report.getReport().header.glibcVersionRuntime; + } + try { + return fs.readFileSync("/usr/bin/ldd", "utf8").includes("musl"); + } catch { + return false; + } +} + +module.exports = loadAddon(); diff --git a/examples/node/npm/darwin-arm64/README.md b/examples/node/npm/darwin-arm64/README.md new file mode 100644 index 0000000..95d7f5d --- /dev/null +++ b/examples/node/npm/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-darwin-arm64` + +This is the **aarch64-apple-darwin** binary for `zig-napi-node-example` diff --git a/examples/node/npm/darwin-arm64/package.json b/examples/node/npm/darwin-arm64/package.json new file mode 100644 index 0000000..9561f44 --- /dev/null +++ b/examples/node/npm/darwin-arm64/package.json @@ -0,0 +1,16 @@ +{ + "name": "zig-napi-node-example-darwin-arm64", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "main": "hello.darwin-arm64.node", + "files": [ + "hello.darwin-arm64.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "darwin" + ] +} diff --git a/examples/node/npm/darwin-x64/README.md b/examples/node/npm/darwin-x64/README.md new file mode 100644 index 0000000..755e0e8 --- /dev/null +++ b/examples/node/npm/darwin-x64/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-darwin-x64` + +This is the **x86_64-apple-darwin** binary for `zig-napi-node-example` diff --git a/examples/node/npm/darwin-x64/package.json b/examples/node/npm/darwin-x64/package.json new file mode 100644 index 0000000..683d945 --- /dev/null +++ b/examples/node/npm/darwin-x64/package.json @@ -0,0 +1,16 @@ +{ + "name": "zig-napi-node-example-darwin-x64", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "main": "hello.darwin-x64.node", + "files": [ + "hello.darwin-x64.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "darwin" + ] +} diff --git a/examples/node/npm/linux-arm64-gnu/README.md b/examples/node/npm/linux-arm64-gnu/README.md new file mode 100644 index 0000000..88ba6d3 --- /dev/null +++ b/examples/node/npm/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-linux-arm64-gnu` + +This is the **aarch64-unknown-linux-gnu** binary for `zig-napi-node-example` diff --git a/examples/node/npm/linux-arm64-gnu/package.json b/examples/node/npm/linux-arm64-gnu/package.json new file mode 100644 index 0000000..20acceb --- /dev/null +++ b/examples/node/npm/linux-arm64-gnu/package.json @@ -0,0 +1,19 @@ +{ + "name": "zig-napi-node-example-linux-arm64-gnu", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "main": "hello.linux-arm64-gnu.node", + "files": [ + "hello.linux-arm64-gnu.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "linux" + ], + "libc": [ + "glibc" + ] +} diff --git a/examples/node/npm/linux-arm64-musl/README.md b/examples/node/npm/linux-arm64-musl/README.md new file mode 100644 index 0000000..5f4afbf --- /dev/null +++ b/examples/node/npm/linux-arm64-musl/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-linux-arm64-musl` + +This is the **aarch64-unknown-linux-musl** binary for `zig-napi-node-example` diff --git a/examples/node/npm/linux-arm64-musl/package.json b/examples/node/npm/linux-arm64-musl/package.json new file mode 100644 index 0000000..113ba9c --- /dev/null +++ b/examples/node/npm/linux-arm64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "zig-napi-node-example-linux-arm64-musl", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "main": "hello.linux-arm64-musl.node", + "files": [ + "hello.linux-arm64-musl.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "linux" + ], + "libc": [ + "musl" + ] +} diff --git a/examples/node/npm/linux-x64-gnu/README.md b/examples/node/npm/linux-x64-gnu/README.md new file mode 100644 index 0000000..60b0e1c --- /dev/null +++ b/examples/node/npm/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `zig-napi-node-example` diff --git a/examples/node/npm/linux-x64-gnu/package.json b/examples/node/npm/linux-x64-gnu/package.json new file mode 100644 index 0000000..f6b0022 --- /dev/null +++ b/examples/node/npm/linux-x64-gnu/package.json @@ -0,0 +1,19 @@ +{ + "name": "zig-napi-node-example-linux-x64-gnu", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "main": "hello.linux-x64-gnu.node", + "files": [ + "hello.linux-x64-gnu.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "linux" + ], + "libc": [ + "glibc" + ] +} diff --git a/examples/node/npm/linux-x64-musl/README.md b/examples/node/npm/linux-x64-musl/README.md new file mode 100644 index 0000000..0e4d3d0 --- /dev/null +++ b/examples/node/npm/linux-x64-musl/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-linux-x64-musl` + +This is the **x86_64-unknown-linux-musl** binary for `zig-napi-node-example` diff --git a/examples/node/npm/linux-x64-musl/package.json b/examples/node/npm/linux-x64-musl/package.json new file mode 100644 index 0000000..28d38cd --- /dev/null +++ b/examples/node/npm/linux-x64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "zig-napi-node-example-linux-x64-musl", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "main": "hello.linux-x64-musl.node", + "files": [ + "hello.linux-x64-musl.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "linux" + ], + "libc": [ + "musl" + ] +} diff --git a/examples/node/npm/win32-arm64-msvc/README.md b/examples/node/npm/win32-arm64-msvc/README.md new file mode 100644 index 0000000..62c26b2 --- /dev/null +++ b/examples/node/npm/win32-arm64-msvc/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-win32-arm64-msvc` + +This is the **aarch64-pc-windows-msvc** binary for `zig-napi-node-example` diff --git a/examples/node/npm/win32-arm64-msvc/package.json b/examples/node/npm/win32-arm64-msvc/package.json new file mode 100644 index 0000000..062f58e --- /dev/null +++ b/examples/node/npm/win32-arm64-msvc/package.json @@ -0,0 +1,16 @@ +{ + "name": "zig-napi-node-example-win32-arm64-msvc", + "version": "0.1.0", + "cpu": [ + "arm64" + ], + "main": "hello.win32-arm64-msvc.node", + "files": [ + "hello.win32-arm64-msvc.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "win32" + ] +} diff --git a/examples/node/npm/win32-x64-msvc/README.md b/examples/node/npm/win32-x64-msvc/README.md new file mode 100644 index 0000000..847f600 --- /dev/null +++ b/examples/node/npm/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `zig-napi-node-example-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `zig-napi-node-example` diff --git a/examples/node/npm/win32-x64-msvc/package.json b/examples/node/npm/win32-x64-msvc/package.json new file mode 100644 index 0000000..aff185d --- /dev/null +++ b/examples/node/npm/win32-x64-msvc/package.json @@ -0,0 +1,16 @@ +{ + "name": "zig-napi-node-example-win32-x64-msvc", + "version": "0.1.0", + "cpu": [ + "x64" + ], + "main": "hello.win32-x64-msvc.node", + "files": [ + "hello.win32-x64-msvc.node" + ], + "description": "Node.js addon example built with zig-napi.", + "license": "MIT", + "os": [ + "win32" + ] +} diff --git a/examples/node/package.json b/examples/node/package.json new file mode 100644 index 0000000..bca5862 --- /dev/null +++ b/examples/node/package.json @@ -0,0 +1,41 @@ +{ + "name": "zig-napi-node-example", + "version": "0.1.0", + "private": true, + "description": "Node.js addon example built with zig-napi.", + "main": "index.js", + "files": [ + "index.js", + "index.d.ts", + "*.node" + ], + "scripts": { + "build": "zig-napi build -- --summary all", + "create-npm-dirs": "zig-napi create-npm-dirs", + "artifacts": "zig-napi artifacts --output-dir zig-out/node", + "pre-publish": "zig-napi pre-publish --no-gh-release --skip-optional-publish", + "package": "zig-napi package", + "test": "npm run build:test && npm run test:run", + "build:test": "npm run build", + "test:run": "node test.js", + "clean": "rm -rf .zig-cache zig-out *.node npm/*/*.node" + }, + "devDependencies": { + "zig-napi": "workspace:*" + }, + "napi": { + "binaryName": "hello", + "packageName": "zig-napi-node-example", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + }, + "license": "MIT" +} diff --git a/examples/node/test.js b/examples/node/test.js index ac9232c..cfc91c4 100644 --- a/examples/node/test.js +++ b/examples/node/test.js @@ -1,35 +1,5 @@ const assert = require("assert").strict; -const fs = require("fs"); -const path = require("path"); - -function loadAddon() { - return loadAddonVariant("hello"); -} - -function addonCandidates(name) { - const nodeOut = path.join(__dirname, "zig-out", "node"); - return fs - .readdirSync(nodeOut, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(nodeOut, entry.name, `${name}.node`)) - .filter((candidate) => fs.existsSync(candidate)); -} - -function loadAddonVariant(name) { - for (const candidate of addonCandidates(name)) { - try { - return require(candidate); - } catch (error) { - if (error && error.code !== "ERR_DLOPEN_FAILED") { - throw error; - } - } - } - - throw new Error(`Unable to load a host-compatible ${name}.node`); -} - -const addon = loadAddon(); +const addon = require("./index"); assert.equal(addon.add(20, 22), 42); assert.equal(addon.hello(), "hello from node"); diff --git a/justfile b/justfile deleted file mode 100644 index eb34371..0000000 --- a/justfile +++ /dev/null @@ -1,63 +0,0 @@ -set shell := ["bash", "-euo", "pipefail", "-c"] - -default: - @just --list - -init: - npm install --global @ohos-rs/oxk @j178/prek - prek validate-config prek.toml - prek install --config prek.toml --hook-type pre-commit --prepare-hooks --overwrite - -build-example: - #!/usr/bin/env bash - set -euo pipefail - - targets=( - aarch64-linux-ohos - arm-linux-ohoseabi - x86_64-linux-ohos - ) - - for example in examples/*; do - [[ -f "$example/build.zig" ]] || continue - [[ "$(basename "$example")" == "node" ]] && continue - - for target in "${targets[@]}"; do - echo "==> $example ($target)" - (cd "$example" && zig build -Dtarget="$target") - done - done - -build-node-example: - #!/usr/bin/env bash - set -euo pipefail - - cd examples/node - zig build - node test.js - -test-node-matrix: - #!/usr/bin/env bash - set -euo pipefail - - cd node-test - npm install --no-package-lock - target_args="" - case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) target_args="-Dtarget=x86_64-windows-msvc" ;; - esac - zig build $target_args - npm test - -format: - zig fmt $(git ls-files '*.zig' '*.zon') - oxk format $(git ls-files '*.js' '*.jsx' '*.ts' '*.tsx' '*.ets') - -website-dev: - cd website && npm run dev - -website-build: - cd website && npm run build - -website-preview: - cd website && npm run preview diff --git a/memory-testing/assert.ts b/memory-testing/assert.ts index 8173238..c65d979 100644 --- a/memory-testing/assert.ts +++ b/memory-testing/assert.ts @@ -28,7 +28,7 @@ export function assertArrayEqual( export function assertThrows(run: () => void, message: string) { try { run(); - } catch (_) { + } catch { return; } fail(`${message}: expected throw`); diff --git a/memory-testing/native.ts b/memory-testing/native.ts index 10efec6..8be382c 100644 --- a/memory-testing/native.ts +++ b/memory-testing/native.ts @@ -4,7 +4,7 @@ declare function requireNapiPreview(name: string, isApp: boolean): ESObject; declare function print(message: string): void; declare function setInterval(callback: () => void, delay: number): number; declare function clearInterval(id: number): void; -declare const globalThis: ESObject; +const arkGlobal = globalThis as ESObject; type NativeAddon = ESObject; @@ -37,7 +37,7 @@ export function delay(ms: number) { } function forceGc() { - const tools = globalThis.ArkTools; + const tools = arkGlobal.ArkTools; if (!tools) { return; } diff --git a/node-test/package.json b/node-test/package.json index 8148b40..55caf78 100644 --- a/node-test/package.json +++ b/node-test/package.json @@ -4,7 +4,10 @@ "private": true, "scripts": { "build": "zig build --summary all", - "test": "ava --serial", + "test": "npm run build:test && npm run test:run", + "build:test": "npm run build", + "build:test:windows": "zig build -Dtarget=x86_64-windows-msvc --summary all", + "test:run": "ava --serial", "test:compat": "ava --serial \"napi-compat-mode/__tests__/**/*.spec.js\"", "test:napi": "ava --serial \"napi/__tests__/**/*.spec.js\"" }, diff --git a/package.json b/package.json new file mode 100644 index 0000000..7457b47 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "zig-napi-workspace", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.24.0", + "scripts": { + "init": "pnpm run init:check && pnpm run init:hooks", + "init:check": "pnpm exec prek validate-config prek.toml", + "init:hooks": "pnpm exec prek install --config prek.toml --hook-type pre-commit --prepare-hooks --overwrite", + "build": "zig build --summary all", + "check:zig": "pnpm run build", + "build:examples": "pnpm run build:example:allocator-builtin && pnpm run build:example:allocator-custom && pnpm run build:example:basic && pnpm run build:example:benchmark && pnpm run build:example:init && pnpm run build:example:memory", + "build:example:allocator-builtin": "pnpm run build:example:allocator-builtin:ohos-arm64 && pnpm run build:example:allocator-builtin:ohos-arm && pnpm run build:example:allocator-builtin:ohos-x64", + "build:example:allocator-builtin:ohos-arm64": "zig build --build-file examples/allocator-builtin/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:allocator-builtin:ohos-arm": "zig build --build-file examples/allocator-builtin/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:allocator-builtin:ohos-x64": "zig build --build-file examples/allocator-builtin/build.zig -Dtarget=x86_64-linux-ohos", + "build:example:allocator-custom": "pnpm run build:example:allocator-custom:ohos-arm64 && pnpm run build:example:allocator-custom:ohos-arm && pnpm run build:example:allocator-custom:ohos-x64", + "build:example:allocator-custom:ohos-arm64": "zig build --build-file examples/allocator-custom/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:allocator-custom:ohos-arm": "zig build --build-file examples/allocator-custom/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:allocator-custom:ohos-x64": "zig build --build-file examples/allocator-custom/build.zig -Dtarget=x86_64-linux-ohos", + "build:example:basic": "pnpm run build:example:basic:ohos-arm64 && pnpm run build:example:basic:ohos-arm && pnpm run build:example:basic:ohos-x64", + "build:example:basic:ohos-arm64": "zig build --build-file examples/basic/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:basic:ohos-arm": "zig build --build-file examples/basic/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:basic:ohos-x64": "zig build --build-file examples/basic/build.zig -Dtarget=x86_64-linux-ohos", + "build:example:benchmark": "pnpm run build:example:benchmark:ohos-arm64 && pnpm run build:example:benchmark:ohos-arm && pnpm run build:example:benchmark:ohos-x64", + "build:example:benchmark:ohos-arm64": "zig build --build-file examples/benchmark/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:benchmark:ohos-arm": "zig build --build-file examples/benchmark/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:benchmark:ohos-x64": "zig build --build-file examples/benchmark/build.zig -Dtarget=x86_64-linux-ohos", + "build:example:init": "pnpm run build:example:init:ohos-arm64 && pnpm run build:example:init:ohos-arm && pnpm run build:example:init:ohos-x64", + "build:example:init:ohos-arm64": "zig build --build-file examples/init/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:init:ohos-arm": "zig build --build-file examples/init/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:init:ohos-x64": "zig build --build-file examples/init/build.zig -Dtarget=x86_64-linux-ohos", + "build:example:memory": "pnpm run build:example:memory:ohos-arm64 && pnpm run build:example:memory:ohos-arm && pnpm run build:example:memory:ohos-x64", + "build:example:memory:ohos-arm64": "zig build --build-file examples/memory/build.zig -Dtarget=aarch64-linux-ohos", + "build:example:memory:ohos-arm": "zig build --build-file examples/memory/build.zig -Dtarget=arm-linux-ohoseabi", + "build:example:memory:ohos-x64": "zig build --build-file examples/memory/build.zig -Dtarget=x86_64-linux-ohos", + "build:node-example": "pnpm run node-example:build", + "test:node-example": "pnpm run node-example:test", + "node-example:build": "pnpm --filter zig-napi-node-example run build", + "node-example:test": "pnpm --filter zig-napi-node-example run test", + "node-example:create-npm-dirs": "pnpm --filter zig-napi-node-example run create-npm-dirs", + "node-example:artifacts": "pnpm --filter zig-napi-node-example run artifacts", + "node-example:pre-publish": "pnpm --filter zig-napi-node-example run pre-publish", + "node-example:package": "pnpm --filter zig-napi-node-example run package", + "node-example:clean": "pnpm --filter zig-napi-node-example run clean", + "test:node-matrix": "pnpm --filter zig-napi-node-test run test", + "test:node-matrix:build": "pnpm --filter zig-napi-node-test run build:test", + "test:node-matrix:build:windows": "pnpm --filter zig-napi-node-test run build:test:windows", + "test:node-matrix:run": "pnpm --filter zig-napi-node-test run test:run", + "format": "pnpm run format:zig && pnpm run format:js", + "format:zig": "zig fmt build.zig build.zig.zon src benchmark examples node-test test packages/zig-napi", + "format:js": "pnpm exec oxk format benchmark examples memory-testing node-test packages/zig-napi test website/src website/vite.config.ts", + "lint": "pnpm exec oxlint . --deny-warnings", + "lint:fix": "pnpm exec oxlint . --fix", + "precommit": "pnpm run build:examples && pnpm run format && pnpm run lint", + "website:dev": "pnpm --filter zig-napi-website run dev", + "website:build": "pnpm --filter zig-napi-website run build", + "website:preview": "pnpm --filter zig-napi-website run preview" + }, + "devDependencies": { + "@j178/prek": "^0.4.5", + "@ohos-rs/oxk": "^0.6.0", + "oxlint": "^1.71.0" + }, + "license": "MIT" +} diff --git a/packages/zig-napi/bin/zig-napi.js b/packages/zig-napi/bin/zig-napi.js new file mode 100755 index 0000000..d210ac8 --- /dev/null +++ b/packages/zig-napi/bin/zig-napi.js @@ -0,0 +1,502 @@ +#!/usr/bin/env node + +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const { NapiCli } = require("@napi-rs/cli"); +const { Command } = require("commander"); + +const packageDir = path.resolve(__dirname, ".."); +const workspaceRoot = path.resolve(packageDir, "..", ".."); +const templateDir = path.join(packageDir, "templates", "node-addon"); +const napiCli = new NapiCli(); + +const availableTargets = [ + "aarch64-apple-darwin", + "aarch64-linux-android", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-unknown-linux-ohos", + "aarch64-pc-windows-msvc", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-pc-windows-gnu", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-unknown-linux-ohos", + "x86_64-unknown-freebsd", + "i686-pc-windows-msvc", + "armv7-unknown-linux-gnueabihf", + "armv7-unknown-linux-musleabihf", + "armv7-linux-androideabi", + "universal-apple-darwin", + "loongarch64-unknown-linux-gnu", + "riscv64gc-unknown-linux-gnu", + "powerpc64le-unknown-linux-gnu", + "s390x-unknown-linux-gnu", + "wasm32-wasi-preview1-threads", + "wasm32-wasip1-threads", +]; + +const defaultTargets = [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", +]; + +function fail(message) { + console.error(`zig-napi: ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + cwd: options.cwd || process.cwd(), + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error) fail(result.error.message); + if (result.status !== 0) process.exit(result.status || 1); +} + +function normalizePathForZig(value) { + return value.split(path.sep).join("/"); +} + +function sanitizePackageName(input) { + return input + .replace(/[^a-zA-Z0-9@/_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); +} + +function packageLeafName(input) { + return input.startsWith("@") ? input.split("/").pop() : input; +} + +function sanitizeZigName(input) { + const value = packageLeafName(sanitizePackageName(input)).replace(/-/g, "_"); + return /^[a-zA-Z_]/.test(value) ? value : `addon_${value}`; +} + +function collectTargets(value, previous) { + return previous.concat( + value + .split(",") + .map((target) => target.trim()) + .filter(Boolean), + ); +} + +function resolveNewTargets(flags) { + const targets = flags.targets; + + if (!targets.length) { + fail("at least one target must be enabled"); + } + + validateTargets(targets); + + return targets; +} + +function resolveNonInteractiveTargets(flags) { + const targets = flags.enableAllTargets + ? availableTargets + : flags.targets.length + ? flags.targets + : flags.enableDefaultTargets + ? defaultTargets + : []; + + return resolveNewTargets({ ...flags, targets }); +} + +function validateTargets(targets) { + if (!targets.length) { + fail("at least one target must be enabled"); + } + + const seen = new Set(); + for (const target of targets) { + if (!availableTargets.includes(target)) { + fail(`unknown target: ${target}`); + } + if (seen.has(target)) { + fail(`duplicate target: ${target}`); + } + seen.add(target); + } +} + +function formatJsonStringArrayItems(values, indent) { + return values.map((value) => `${indent}${JSON.stringify(value)}`).join(",\n"); +} + +function isInteractive(flags) { + return flags.interactive && process.stdin.isTTY && process.stdout.isTTY; +} + +function validatePackageName(value) { + if (!sanitizePackageName(value)) { + return "Package name must contain at least one valid package name character"; + } + return true; +} + +function validateAddonName(value) { + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) { + return true; + } + return "Addon name must be a valid Zig identifier"; +} + +function normalizePackageNameOrFail(value) { + const packageName = sanitizePackageName(value); + if (!packageName) { + fail("package name must contain at least one valid package name character"); + } + return packageName; +} + +function validateAddonNameOrFail(value) { + const result = validateAddonName(value); + if (result !== true) { + fail(result); + } + return value; +} + +async function promptNewOptions(projectDir, flags) { + if (!isInteractive(flags)) { + if (!projectDir) { + fail("project directory is required; pass or run in an interactive terminal"); + } + const packageName = normalizePackageNameOrFail( + flags.name || sanitizePackageName(path.basename(projectDir)), + ); + return { + projectDir, + packageName, + addonName: validateAddonNameOrFail(flags.addon || sanitizeZigName(packageName)), + targets: resolveNonInteractiveTargets(flags), + }; + } + + const { checkbox, input } = await import("@inquirer/prompts"); + const targetPath = + projectDir || + (await input({ + message: "Target path to create the project, relative to cwd.", + validate: (value) => Boolean(value.trim()) || "Target path is required", + })); + const defaultPackageName = sanitizePackageName(path.basename(targetPath)); + const packageName = + flags.name || + (await input({ + message: "Package name (the name field in your package.json file)", + default: defaultPackageName, + validate: validatePackageName, + })); + const addonName = + flags.addon || + (await input({ + message: "Native addon binary name", + default: sanitizeZigName(packageName), + validate: validateAddonName, + })); + + const targets = flags.enableAllTargets + ? availableTargets + : flags.targets.length + ? flags.targets + : await checkbox({ + loop: false, + message: "Choose target(s) your addon will be compiled to", + choices: availableTargets.map((target) => ({ + name: target, + value: target, + checked: flags.enableDefaultTargets && defaultTargets.includes(target), + })), + }); + + return { + projectDir: targetPath, + packageName: normalizePackageNameOrFail(packageName), + addonName: validateAddonNameOrFail(addonName), + targets: resolveNewTargets({ ...flags, targets }), + }; +} + +function copyTemplate(from, to, replacements) { + const stat = fs.statSync(from); + if (stat.isDirectory()) { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from)) { + copyTemplate(path.join(from, entry), path.join(to, entry), replacements); + } + return; + } + + let outputPath = to; + for (const [key, value] of Object.entries(replacements)) { + outputPath = outputPath.replaceAll(key, value); + } + + let text = fs.readFileSync(from, "utf8"); + for (const [key, value] of Object.entries(replacements)) { + text = text.replaceAll(key, value); + } + fs.writeFileSync(outputPath, text); +} + +function repairZigFingerprint(projectDir) { + const zonPath = path.join(projectDir, "build.zig.zon"); + const result = childProcess.spawnSync("zig", ["build"], { + cwd: projectDir, + encoding: "utf8", + stdio: "pipe", + shell: process.platform === "win32", + }); + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + const match = output.match(/use this value:\s*(0x[0-9a-fA-F]+)/); + if (!match) { + if (result.error) { + console.warn( + `zig-napi: unable to calculate build.zig.zon fingerprint: ${result.error.message}`, + ); + } + return; + } + const zon = fs.readFileSync(zonPath, "utf8"); + fs.writeFileSync( + zonPath, + zon.replace(/\.fingerprint = 0x[0-9a-fA-F]+,/, `.fingerprint = ${match[1]},`), + ); +} + +function napiOptions(flags) { + return { + cwd: path.resolve(process.cwd(), flags.cwd || "."), + configPath: flags.configPath, + packageJsonPath: flags.packageJsonPath, + npmDir: flags.npmDir, + outputDir: flags.outputDir, + buildOutputDir: flags.buildOutputDir, + tagStyle: flags.tagStyle, + ghRelease: flags.ghRelease, + ghReleaseName: flags.ghReleaseName, + ghReleaseId: flags.ghReleaseId, + skipOptionalPublish: flags.skipOptionalPublish, + dryRun: flags.dryRun, + }; +} + +function cleanOptions(options) { + return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)); +} + +async function commandNew(projectDir, flags) { + const options = await promptNewOptions(projectDir, flags); + const targetDir = path.resolve(process.cwd(), options.projectDir); + if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length && !flags.force) { + fail(`${targetDir} is not empty; pass --force to write into it`); + } + + const packageName = options.packageName; + const addonName = options.addonName; + const zigNapiZigPath = flags.zigNapi || path.relative(targetDir, workspaceRoot) || "."; + + fs.mkdirSync(targetDir, { recursive: true }); + copyTemplate(templateDir, targetDir, { + __PACKAGE_NAME__: packageName, + __ADDON_NAME__: addonName, + __ZIG_PACKAGE_NAME__: sanitizeZigName(packageName), + __ZIG_NAPI_ZIG_PATH__: normalizePathForZig(zigNapiZigPath), + ' "__NAPI_TARGETS__"': formatJsonStringArrayItems(options.targets, " "), + __FINGERPRINT__: "0x0", + }); + repairZigFingerprint(targetDir); + + console.log(`Created ${packageName} in ${targetDir}`); +} + +function commandBuild(flags, passthrough = []) { + const cwd = path.resolve(process.cwd(), flags.cwd || "."); + const args = ["build"]; + if (flags.release) args.push("-Doptimize=ReleaseFast"); + if (flags.target) args.push(`-Dtarget=${flags.target}`); + args.push(...passthrough); + run("zig", args, { cwd }); +} + +function commandDts(flags, passthrough = []) { + const cwd = path.resolve(process.cwd(), flags.cwd || "."); + run("zig", ["build", ...passthrough], { cwd }); +} + +async function commandCreateNpmDirs(flags) { + await napiCli.createNpmDirs( + cleanOptions({ + cwd: path.resolve(process.cwd(), flags.cwd || "."), + configPath: flags.configPath, + packageJsonPath: flags.packageJsonPath, + npmDir: flags.npmDir, + dryRun: flags.dryRun, + }), + ); +} + +async function commandArtifacts(flags) { + await napiCli.artifacts(cleanOptions(napiOptions(flags))); +} + +async function commandPrePublish(flags) { + await napiCli.prePublish(cleanOptions(napiOptions(flags))); +} + +async function commandPackage(flags) { + const cwd = path.resolve(process.cwd(), flags.cwd || "."); + await napiCli.createNpmDirs( + cleanOptions({ + cwd, + configPath: flags.configPath, + packageJsonPath: flags.packageJsonPath, + npmDir: flags.npmDir, + dryRun: flags.dryRun, + }), + ); + commandBuild({ cwd, release: flags.release, target: flags.target }); + await napiCli.artifacts( + cleanOptions({ + cwd, + configPath: flags.configPath, + packageJsonPath: flags.packageJsonPath, + outputDir: flags.outputDir || "zig-out/node", + npmDir: flags.npmDir, + buildOutputDir: flags.buildOutputDir, + }), + ); +} + +function addCwdOption(command) { + return command.option("--cwd ", "project directory", "."); +} + +function addBuildOptions(command) { + return addBuildFlags(addCwdOption(command)); +} + +function addBuildFlags(command) { + return command + .option("--release", "build with ReleaseFast optimization") + .option("--target ", "Zig target triple"); +} + +function addNapiPathOptions(command) { + return addCwdOption(command) + .option("--config-path ", "path to napi config") + .option("--package-json-path ", "path to package.json") + .option("--npm-dir ", "npm package directory"); +} + +function addNapiOptions(command) { + return addNapiPathOptions(command) + .option("--output-dir ", "Zig build output directory") + .option("--build-output-dir ", "build output directory") + .option("--tag-style