feat(sdk-node): add napi-rs TypeScript binding (@openshell/sdk)

Per RFC 0005, ship openshell-sdk-node as a napi-rs wrapper over the
openshell-sdk crate, exposing the same surface to TypeScript/Node
consumers as @openshell/sdk.

- New crate openshell-sdk-node binds OpenShellClient, OidcRefresher,
  the edge-tunnel dialer, and the sandbox/exec API through napi-rs.
- Generated index.d.ts and index.js are committed; the per-platform
  .node binary is gitignored and rebuilt with `napi build` per host.
- lib.mjs provides a small ESM facade with named exports plus the
  errorCode() helper for typed error discrimination.

Tests: 5-case smoke suite that exercises exports, typed connect errors,
and OidcRefresher single-flight semantics.
This commit is contained in:
Max Dubrinsky
2026-06-25 13:41:49 -04:00
committed by max
parent aa0d3cf9db
commit d7b306349c
11 changed files with 1500 additions and 2 deletions
Generated
+104 -2
View File
@@ -681,7 +681,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
"libloading 0.8.9",
]
[[package]]
@@ -841,6 +841,15 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1045,6 +1054,12 @@ dependencies = [
"rand_core 0.10.0-rc-3",
]
[[package]]
name = "ctor"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5"
[[package]]
name = "ctr"
version = "0.9.2"
@@ -2825,6 +2840,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -3095,6 +3120,65 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "napi"
version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2"
dependencies = [
"anyhow",
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash 2.1.2",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn 2.0.117",
]
[[package]]
name = "napi-sys"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b"
dependencies = [
"libloading 0.9.0",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -3107,6 +3191,12 @@ dependencies = [
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
@@ -3548,7 +3638,7 @@ dependencies = [
"flate2",
"futures",
"libc",
"libloading",
"libloading 0.8.9",
"miette",
"nix",
"oci-client",
@@ -3688,6 +3778,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "openshell-sdk-node"
version = "0.0.0"
dependencies = [
"async-trait",
"napi",
"napi-build",
"napi-derive",
"openshell-sdk",
"tokio",
]
[[package]]
name = "openshell-server"
version = "0.0.0"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
package-lock.json
*.node
+37
View File
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-sdk-node"
description = "napi-rs bindings for the OpenShell Rust SDK; published as @openshell/sdk"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
# Required for napi-rs: produces a dynamic library Node.js loads via N-API.
# `rlib` is kept so the crate can also be exercised by `cargo test --workspace`.
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openshell-sdk = { path = "../openshell-sdk" }
async-trait = "0.1"
napi = { version = "3", default-features = false, features = [
"async",
"napi6",
"tokio_rt",
"error_anyhow",
] }
napi-derive = "3"
tokio = { workspace = true }
[build-dependencies]
napi-build = "2"
# Lints are disabled for this crate because napi-derive expands into patterns
# (raw FFI signatures, Unsafe blocks for N-API, generated TypeScript shim
# functions) that trigger several pedantic lints. The wrapped Rust SDK already
# enforces the workspace lint policy.
[lints]
+6
View File
@@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
fn main() {
napi_build::setup();
}
+138
View File
@@ -0,0 +1,138 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* A live token source backed by a JS callback. Hand off to
* [`OpenShellClient::set_oidc_refresher`] before any RPCs run; the SDK
* proactively refreshes when the token is within 60s of expiry, and
* coalesces concurrent refreshes into a single callback invocation.
*/
export declare class OidcRefresher {
/**
* Create a refresher with an initial token and a JS callback.
*
* The callback must return a Promise resolving to
* `{ accessToken, expiresAt? }`. `expiresAt` is Unix epoch seconds.
*/
constructor(initialToken: string, initialExpiresAt: number | undefined | null, callback: () => Promise<{ accessToken: string; expiresAt?: number }>)
/**
* Snapshot the current token (no refresh check). Mostly useful for
* tests; in steady-state the SDK calls this internally.
*/
currentToken(): string
/**
* Force a refresh now and return the new access token. Concurrent
* callers coalesce.
*/
refresh(): Promise<string>
}
/**
* The JS-facing client. Cheap to share between async tasks; do not call
* `connect` per request.
*/
export declare class OpenShellClient {
/** Open a connection to the gateway described by `options`. */
static connect(options: ConnectOptions): Promise<OpenShellClient>
/** Gateway health snapshot. */
health(): Promise<Health>
/** Create a new sandbox. */
createSandbox(spec: SandboxSpec): Promise<SandboxRef>
/** Fetch a sandbox by name. */
getSandbox(name: string): Promise<SandboxRef>
/** List sandboxes. */
listSandboxes(options?: ListOptions | undefined | null): Promise<Array<SandboxRef>>
/**
* Delete a sandbox by name. Returns `true` when the gateway acknowledged
* the deletion, `false` when it was already absent.
*/
deleteSandbox(name: string): Promise<boolean>
/** Poll until the sandbox reaches `ready` or `timeout_secs` elapses. */
waitReady(name: string, timeoutSecs: number): Promise<SandboxRef>
/** Poll until the sandbox is gone or `timeout_secs` elapses. */
waitDeleted(name: string, timeoutSecs: number): Promise<void>
/** Run a command inside a sandbox; buffers stdout/stderr to the end. */
exec(name: string, command: Array<string>, options?: ExecOptions | undefined | null): Promise<ExecResult>
}
/**
* Connection options. Mirrors [`openshell_sdk::ClientConfig`] with
* JS-friendly field names.
*/
export interface ConnectOptions {
/** Gateway URL (`http://...` or `https://...`). */
gateway: string
/** CA certificate (PEM-encoded). `None` falls back to system roots. */
caCert?: Buffer
/** Bearer token for direct OIDC auth. Mutually exclusive with `edge_token`. */
oidcToken?: string
/** Cloudflare Access bearer token. Routes through a local WebSocket tunnel. */
edgeToken?: string
/** Disable TLS certificate verification (development/debug only). */
insecureSkipVerify?: boolean
}
/** Options for [`OpenShellClient::exec`]. */
export interface ExecOptions {
workdir?: string
environment?: Record<string, string>
/** Timeout in seconds. `None` lets the gateway choose. */
timeoutSecs?: number
/** Optional stdin payload. */
stdin?: Buffer
}
/** Result of a non-streaming exec call. */
export interface ExecResult {
exitCode: number
stdout: Buffer
stderr: Buffer
}
/** Gateway health snapshot. */
export interface Health {
/** Coarse status: `"healthy"`, `"degraded"`, `"unhealthy"`, `"unspecified"`. */
status: string
version: string
}
/** JS-side refresh callback returning a Promise<{ accessToken, expiresAt? }>. */
export interface JsRefreshedToken {
accessToken: string
/**
* Expiry as Unix epoch seconds. Stored as `f64` because JS numbers
* can't hold `u64` exactly past 2^53; values are clamped to that range
* in practice (the year 287396 is fine).
*/
expiresAt?: number
}
/** Options for [`OpenShellClient::list_sandboxes`]. */
export interface ListOptions {
limit?: number
offset?: number
labelSelector?: string
}
/**
* Lifecycle phase: `"unspecified"`, `"provisioning"`, `"ready"`, `"error"`,
* `"deleting"`, `"unknown"`.
*/
export interface SandboxRef {
id: string
name: string
phase: string
labels: Record<string, string>
/** Resource version as a string — JS numbers can't safely hold u64. */
resourceVersion: string
}
/** Caller intent for a new sandbox. */
export interface SandboxSpec {
name?: string
image?: string
labels?: Record<string, string>
environment?: Record<string, string>
providers?: Array<string>
gpu?: boolean
gpuDevice?: string
}
+590
View File
@@ -0,0 +1,590 @@
// prettier-ignore
/* eslint-disable */
// @ts-nocheck
/* auto-generated by NAPI-RS */
const { readFileSync } = require('node:fs')
let nativeBinding = null
const loadErrors = []
const isMusl = () => {
let musl = false
if (process.platform === 'linux') {
musl = isMuslFromFilesystem()
if (musl === null) {
musl = isMuslFromReport()
}
if (musl === null) {
musl = isMuslFromChildProcess()
}
}
return musl
}
const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-')
const isMuslFromFilesystem = () => {
try {
return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')
} catch {
return null
}
}
const isMuslFromReport = () => {
let report = null
if (typeof process.report?.getReport === 'function') {
process.report.excludeNetwork = true
report = process.report.getReport()
}
if (!report) {
return null
}
if (report.header && report.header.glibcVersionRuntime) {
return false
}
if (Array.isArray(report.sharedObjects)) {
if (report.sharedObjects.some(isFileMusl)) {
return true
}
}
return false
}
const isMuslFromChildProcess = () => {
try {
return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
} catch (e) {
// If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false
return false
}
}
function requireNative() {
if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
try {
return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH);
} catch (err) {
loadErrors.push(err)
}
} else if (process.platform === 'android') {
if (process.arch === 'arm64') {
try {
return require('./openshell-sdk.android-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-android-arm64')
const bindingPackageVersion = require('@openshell/sdk-android-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm') {
try {
return require('./openshell-sdk.android-arm-eabi.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-android-arm-eabi')
const bindingPackageVersion = require('@openshell/sdk-android-arm-eabi/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`))
}
} else if (process.platform === 'win32') {
if (process.arch === 'x64') {
if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') {
try {
return require('./openshell-sdk.win32-x64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-win32-x64-gnu')
const bindingPackageVersion = require('@openshell/sdk-win32-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.win32-x64-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-win32-x64-msvc')
const bindingPackageVersion = require('@openshell/sdk-win32-x64-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'ia32') {
try {
return require('./openshell-sdk.win32-ia32-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-win32-ia32-msvc')
const bindingPackageVersion = require('@openshell/sdk-win32-ia32-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./openshell-sdk.win32-arm64-msvc.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-win32-arm64-msvc')
const bindingPackageVersion = require('@openshell/sdk-win32-arm64-msvc/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`))
}
} else if (process.platform === 'darwin') {
try {
return require('./openshell-sdk.darwin-universal.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-darwin-universal')
const bindingPackageVersion = require('@openshell/sdk-darwin-universal/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
if (process.arch === 'x64') {
try {
return require('./openshell-sdk.darwin-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-darwin-x64')
const bindingPackageVersion = require('@openshell/sdk-darwin-x64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./openshell-sdk.darwin-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-darwin-arm64')
const bindingPackageVersion = require('@openshell/sdk-darwin-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`))
}
} else if (process.platform === 'freebsd') {
if (process.arch === 'x64') {
try {
return require('./openshell-sdk.freebsd-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-freebsd-x64')
const bindingPackageVersion = require('@openshell/sdk-freebsd-x64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm64') {
try {
return require('./openshell-sdk.freebsd-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-freebsd-arm64')
const bindingPackageVersion = require('@openshell/sdk-freebsd-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`))
}
} else if (process.platform === 'linux') {
if (process.arch === 'x64') {
if (isMusl()) {
try {
return require('./openshell-sdk.linux-x64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-x64-musl')
const bindingPackageVersion = require('@openshell/sdk-linux-x64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.linux-x64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-x64-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'arm64') {
if (isMusl()) {
try {
return require('./openshell-sdk.linux-arm64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-arm64-musl')
const bindingPackageVersion = require('@openshell/sdk-linux-arm64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.linux-arm64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-arm64-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-arm64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'arm') {
if (isMusl()) {
try {
return require('./openshell-sdk.linux-arm-musleabihf.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-arm-musleabihf')
const bindingPackageVersion = require('@openshell/sdk-linux-arm-musleabihf/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.linux-arm-gnueabihf.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-arm-gnueabihf')
const bindingPackageVersion = require('@openshell/sdk-linux-arm-gnueabihf/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'loong64') {
if (isMusl()) {
try {
return require('./openshell-sdk.linux-loong64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-loong64-musl')
const bindingPackageVersion = require('@openshell/sdk-linux-loong64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.linux-loong64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-loong64-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-loong64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'riscv64') {
if (isMusl()) {
try {
return require('./openshell-sdk.linux-riscv64-musl.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-riscv64-musl')
const bindingPackageVersion = require('@openshell/sdk-linux-riscv64-musl/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
try {
return require('./openshell-sdk.linux-riscv64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-riscv64-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-riscv64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
}
} else if (process.arch === 'ppc64') {
try {
return require('./openshell-sdk.linux-ppc64-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-ppc64-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-ppc64-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 's390x') {
try {
return require('./openshell-sdk.linux-s390x-gnu.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-linux-s390x-gnu')
const bindingPackageVersion = require('@openshell/sdk-linux-s390x-gnu/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`))
}
} else if (process.platform === 'openharmony') {
if (process.arch === 'arm64') {
try {
return require('./openshell-sdk.openharmony-arm64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-openharmony-arm64')
const bindingPackageVersion = require('@openshell/sdk-openharmony-arm64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'x64') {
try {
return require('./openshell-sdk.openharmony-x64.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-openharmony-x64')
const bindingPackageVersion = require('@openshell/sdk-openharmony-x64/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else if (process.arch === 'arm') {
try {
return require('./openshell-sdk.openharmony-arm.node')
} catch (e) {
loadErrors.push(e)
}
try {
const binding = require('@openshell/sdk-openharmony-arm')
const bindingPackageVersion = require('@openshell/sdk-openharmony-arm/package.json').version
if (bindingPackageVersion !== '0.0.0-alpha.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.0.0-alpha.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
loadErrors.push(e)
}
} else {
loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`))
}
} else {
loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`))
}
}
nativeBinding = requireNative()
// NAPI_RS_FORCE_WASI is a tri-state flag:
// unset / any other value → native binding preferred, WASI is only a fallback
// 'true' → force WASI fallback even if native loaded
// 'error' → force WASI and throw if no WASI binding is found
// Treating any non-empty string as truthy (the historical behavior) meant
// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered
// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file.
const forceWasi =
process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error'
if (!nativeBinding || forceWasi) {
let wasiBinding = null
let wasiBindingError = null
try {
wasiBinding = require('./openshell-sdk.wasi.cjs')
nativeBinding = wasiBinding
} catch (err) {
if (forceWasi) {
wasiBindingError = err
}
}
if (!nativeBinding || forceWasi) {
try {
wasiBinding = require('@openshell/sdk-wasm32-wasi')
nativeBinding = wasiBinding
} catch (err) {
if (forceWasi) {
if (!wasiBindingError) {
wasiBindingError = err
} else {
wasiBindingError.cause = err
}
loadErrors.push(err)
}
}
}
if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) {
const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error')
error.cause = wasiBindingError
throw error
}
}
if (!nativeBinding) {
if (loadErrors.length > 0) {
throw new Error(
`Cannot find native binding. ` +
`npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
{
cause: loadErrors.reduce((err, cur) => {
cur.cause = err
return cur
}),
},
)
}
throw new Error(`Failed to load native binding`)
}
module.exports = nativeBinding
module.exports.OidcRefresher = nativeBinding.OidcRefresher
module.exports.OpenShellClient = nativeBinding.OpenShellClient
+24
View File
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
export { OidcRefresher, OpenShellClient } from './index.js'
export type {
ConnectOptions,
ExecOptions,
ExecResult,
Health,
JsRefreshedToken,
ListOptions,
SandboxRef,
SandboxSpec,
} from './index.js'
/**
* Extract the SDK error code from a thrown error.
*
* The native binding prefixes every error message with `[code] ` where
* `code` is one of: `invalid_config`, `tls`, `connect`, `auth`, `io`,
* `not_found`, `already_exists`, `rpc`. Returns `null` when the prefix is
* missing (the error wasn't thrown by this binding).
*/
export declare function errorCode(err: unknown): string | null
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// ESM facade over the auto-generated CommonJS index.
//
// Re-exports the napi-generated classes and adds the `errorCode` helper,
// which parses the `[code] message` prefix the binding uses to surface the
// SDK's discriminable error kind. The auto-generated `index.js`/`index.d.ts`
// pair from napi-rs is left untouched so the build matrix stays cookie-cutter.
import nativeBinding from './index.js'
const { OpenShellClient, OidcRefresher } = nativeBinding
export { OpenShellClient, OidcRefresher }
/**
* Extract the SDK error code from a thrown error.
*
* The native binding prefixes every error message with `[code] ` where
* `code` is one of: `invalid_config`, `tls`, `connect`, `auth`, `io`,
* `not_found`, `already_exists`, `rpc`. Returns `null` when the prefix is
* missing (the error wasn't thrown by this binding).
*
* @param {unknown} err
* @returns {string | null}
*/
export function errorCode(err) {
if (!err || typeof err.message !== 'string') return null
const match = err.message.match(/^\[([^\]]+)\]/)
return match ? match[1] : null
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@openshell/sdk",
"version": "0.0.0-alpha.0",
"description": "TypeScript SDK for OpenShell gateways. Wraps the openshell-sdk Rust core via napi-rs.",
"main": "index.js",
"types": "index.d.ts",
"exports": {
".": {
"import": {
"types": "./lib.d.ts",
"default": "./lib.mjs"
},
"require": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"./package.json": "./package.json"
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/NVIDIA/OpenShell.git",
"directory": "crates/openshell-sdk-node"
},
"engines": {
"node": ">= 18"
},
"files": [
"index.js",
"index.d.ts",
"lib.mjs",
"lib.d.ts"
],
"napi": {
"binaryName": "openshell-sdk",
"targets": [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc"
]
},
"scripts": {
"build": "napi build --platform --release",
"build:debug": "napi build --platform",
"test": "node test/smoke.mjs",
"prepublishOnly": "napi prepublish -t npm"
},
"devDependencies": {
"@napi-rs/cli": "^3"
}
}
+406
View File
@@ -0,0 +1,406 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! napi-rs bindings over [`openshell_sdk`].
//!
//! This crate is a thin adapter — it owns no business logic. It maps the
//! curated SDK surface to JS-shaped types (camelCase keys, string-literal
//! enums, JS `Error` with a discriminable `code` field), and bridges a
//! JS-side OIDC refresh callback to the SDK's [`openshell_sdk::Refresh`]
//! trait.
//!
//! Published as `@openshell/sdk` (alpha; no semver guarantee until 1.0).
//!
//! # Runtime ownership
//!
//! napi-rs v3 provides an ambient tokio runtime that's only available inside
//! `async fn` napi entry points. Every JS-facing function on [`OpenShellClient`]
//! is therefore `async`. Sync FFI entry points cannot call the SDK because
//! tonic requires a reactor; attempting `tokio::spawn` from a sync `#[napi]`
//! function panics with "no reactor running".
#![allow(clippy::needless_pass_by_value, clippy::missing_errors_doc)]
use napi::Status;
use napi::bindgen_prelude::*;
use napi::threadsafe_function::ThreadsafeFunction;
use napi_derive::napi;
use openshell_sdk as sdk;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
// ── Error mapping ─────────────────────────────────────────────────────
fn to_napi_error(error: sdk::SdkError) -> Error {
let code = error.code();
// Embed the SDK code as a `[code] message` prefix. N-API maps the
// status enum to JS `err.code`, which is too coarse for discrimination
// (`GenericFailure` covers most variants), so callers parse the prefix
// or use the `errorCode()` helper exported from the JS shim.
Error::new(Status::GenericFailure, format!("[{code}] {error}"))
}
// ── Public input/output types (JS-shaped) ────────────────────────────
/// Connection options. Mirrors [`openshell_sdk::ClientConfig`] with
/// JS-friendly field names.
#[napi(object)]
#[derive(Default)]
pub struct ConnectOptions {
/// Gateway URL (`http://...` or `https://...`).
pub gateway: String,
/// CA certificate (PEM-encoded). `None` falls back to system roots.
pub ca_cert: Option<Buffer>,
/// Bearer token for direct OIDC auth. Mutually exclusive with `edge_token`.
pub oidc_token: Option<String>,
/// Cloudflare Access bearer token. Routes through a local WebSocket tunnel.
pub edge_token: Option<String>,
/// Disable TLS certificate verification (development/debug only).
pub insecure_skip_verify: Option<bool>,
}
/// Gateway health snapshot.
#[napi(object)]
pub struct Health {
/// Coarse status: `"healthy"`, `"degraded"`, `"unhealthy"`, `"unspecified"`.
pub status: String,
pub version: String,
}
/// Lifecycle phase: `"unspecified"`, `"provisioning"`, `"ready"`, `"error"`,
/// `"deleting"`, `"unknown"`.
#[napi(object)]
pub struct SandboxRef {
pub id: String,
pub name: String,
pub phase: String,
pub labels: HashMap<String, String>,
/// Resource version as a string — JS numbers can't safely hold u64.
pub resource_version: String,
}
/// Caller intent for a new sandbox.
#[napi(object)]
#[derive(Default)]
pub struct SandboxSpec {
pub name: Option<String>,
pub image: Option<String>,
pub labels: Option<HashMap<String, String>>,
pub environment: Option<HashMap<String, String>>,
pub providers: Option<Vec<String>>,
pub gpu: Option<bool>,
pub gpu_device: Option<String>,
}
/// Options for [`OpenShellClient::list_sandboxes`].
#[napi(object)]
#[derive(Default)]
pub struct ListOptions {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub label_selector: Option<String>,
}
/// Options for [`OpenShellClient::exec`].
#[napi(object)]
#[derive(Default)]
pub struct ExecOptions {
pub workdir: Option<String>,
pub environment: Option<HashMap<String, String>>,
/// Timeout in seconds. `None` lets the gateway choose.
pub timeout_secs: Option<u32>,
/// Optional stdin payload.
pub stdin: Option<Buffer>,
}
/// Result of a non-streaming exec call.
#[napi(object)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: Buffer,
pub stderr: Buffer,
}
// ── Type conversions ─────────────────────────────────────────────────
fn phase_to_str(phase: sdk::SandboxPhase) -> &'static str {
match phase {
sdk::SandboxPhase::Provisioning => "provisioning",
sdk::SandboxPhase::Ready => "ready",
sdk::SandboxPhase::Error => "error",
sdk::SandboxPhase::Deleting => "deleting",
sdk::SandboxPhase::Unknown => "unknown",
_ => "unspecified",
}
}
fn status_to_str(status: sdk::ServiceStatus) -> &'static str {
match status {
sdk::ServiceStatus::Healthy => "healthy",
sdk::ServiceStatus::Degraded => "degraded",
sdk::ServiceStatus::Unhealthy => "unhealthy",
_ => "unspecified",
}
}
impl From<sdk::SandboxRef> for SandboxRef {
fn from(r: sdk::SandboxRef) -> Self {
Self {
id: r.id,
name: r.name,
phase: phase_to_str(r.phase).to_string(),
labels: r.labels,
resource_version: r.resource_version.to_string(),
}
}
}
fn sdk_spec_from_js(spec: SandboxSpec) -> sdk::SandboxSpec {
sdk::SandboxSpec {
name: spec.name,
image: spec.image,
labels: spec.labels.unwrap_or_default(),
environment: spec.environment.unwrap_or_default(),
providers: spec.providers.unwrap_or_default(),
gpu: spec.gpu.unwrap_or(false),
gpu_device: spec.gpu_device,
}
}
fn sdk_list_opts_from_js(opts: ListOptions) -> sdk::ListOptions {
sdk::ListOptions {
limit: opts.limit.unwrap_or(0),
offset: opts.offset.unwrap_or(0),
label_selector: opts.label_selector,
}
}
fn sdk_exec_opts_from_js(opts: ExecOptions) -> sdk::ExecOptions {
sdk::ExecOptions {
workdir: opts.workdir,
environment: opts.environment.unwrap_or_default(),
timeout: opts.timeout_secs.map(|s| Duration::from_secs(u64::from(s))),
stdin: opts.stdin.map(|b| b.to_vec()),
}
}
fn build_client_config(opts: ConnectOptions) -> sdk::ClientConfig {
let auth = match (opts.oidc_token, opts.edge_token) {
(Some(token), _) => Some(sdk::AuthConfig::Oidc(token)),
(None, Some(token)) => Some(sdk::AuthConfig::EdgeJwt(token)),
(None, None) => None,
};
let mut cfg = sdk::ClientConfig::new(opts.gateway);
cfg.ca_cert = opts.ca_cert.map(|b| b.to_vec());
cfg.auth = auth;
cfg.insecure_skip_verify = opts.insecure_skip_verify.unwrap_or(false);
cfg
}
// ── OIDC refresh callback bridge ─────────────────────────────────────
/// JS-side refresh callback returning a Promise<{ accessToken, expiresAt? }>.
#[napi(object)]
pub struct JsRefreshedToken {
pub access_token: String,
/// Expiry as Unix epoch seconds. Stored as `f64` because JS numbers
/// can't hold `u64` exactly past 2^53; values are clamped to that range
/// in practice (the year 287396 is fine).
pub expires_at: Option<f64>,
}
/// Bridge between a JS refresh callback and the SDK's [`sdk::Refresh`] trait.
struct JsRefresher {
callback: ThreadsafeFunction<(), Promise<JsRefreshedToken>, (), Status, false>,
}
#[async_trait::async_trait]
impl sdk::Refresh for JsRefresher {
async fn refresh(&self) -> std::result::Result<sdk::RefreshedToken, sdk::RefreshError> {
// Invoke the JS callback; it returns a Promise<JsRefreshedToken>.
let promise =
self.callback.call_async(()).await.map_err(|e| {
sdk::RefreshError::Transient(format!("refresh callback failed: {e}"))
})?;
let result = promise
.await
.map_err(|e| sdk::RefreshError::Transient(format!("refresh promise rejected: {e}")))?;
let token = sdk::RefreshedToken::new(result.access_token);
Ok(match result.expires_at {
Some(expires_at) if expires_at.is_finite() && expires_at > 0.0 => {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
token.with_expires_at(expires_at as u64)
}
}
_ => token,
})
}
}
/// A live token source backed by a JS callback. Hand off to
/// [`OpenShellClient::set_oidc_refresher`] before any RPCs run; the SDK
/// proactively refreshes when the token is within 60s of expiry, and
/// coalesces concurrent refreshes into a single callback invocation.
#[napi]
pub struct OidcRefresher {
inner: Arc<sdk::TokenSource>,
}
#[napi]
impl OidcRefresher {
/// Create a refresher with an initial token and a JS callback.
///
/// The callback must return a Promise resolving to
/// `{ accessToken, expiresAt? }`. `expiresAt` is Unix epoch seconds.
#[napi(constructor)]
pub fn new(
initial_token: String,
initial_expires_at: Option<f64>,
#[napi(ts_arg_type = "() => Promise<{ accessToken: string; expiresAt?: number }>")]
callback: ThreadsafeFunction<(), Promise<JsRefreshedToken>, (), Status, false>,
) -> Self {
let initial = match initial_expires_at {
Some(exp) if exp.is_finite() && exp > 0.0 => {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
sdk::RefreshedToken::new(initial_token).with_expires_at(exp as u64)
}
}
_ => sdk::RefreshedToken::new(initial_token),
};
let refresher = Arc::new(JsRefresher { callback });
Self {
inner: Arc::new(sdk::TokenSource::new(initial, refresher)),
}
}
/// Snapshot the current token (no refresh check). Mostly useful for
/// tests; in steady-state the SDK calls this internally.
#[napi]
pub fn current_token(&self) -> String {
self.inner.snapshot()
}
/// Force a refresh now and return the new access token. Concurrent
/// callers coalesce.
#[napi]
pub async fn refresh(&self) -> Result<String> {
self.inner.refresh_now().await.map_err(to_napi_error)
}
}
// ── Main client class ────────────────────────────────────────────────
/// The JS-facing client. Cheap to share between async tasks; do not call
/// `connect` per request.
#[napi]
pub struct OpenShellClient {
inner: sdk::OpenShellClient,
}
#[napi]
impl OpenShellClient {
/// Open a connection to the gateway described by `options`.
#[napi(factory)]
pub async fn connect(options: ConnectOptions) -> Result<Self> {
let cfg = build_client_config(options);
let inner = sdk::OpenShellClient::connect(cfg)
.await
.map_err(to_napi_error)?;
Ok(Self { inner })
}
/// Gateway health snapshot.
#[napi]
pub async fn health(&self) -> Result<Health> {
let h = self.inner.health().await.map_err(to_napi_error)?;
Ok(Health {
status: status_to_str(h.status).to_string(),
version: h.version,
})
}
/// Create a new sandbox.
#[napi]
pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result<SandboxRef> {
self.inner
.create_sandbox(sdk_spec_from_js(spec))
.await
.map(Into::into)
.map_err(to_napi_error)
}
/// Fetch a sandbox by name.
#[napi]
pub async fn get_sandbox(&self, name: String) -> Result<SandboxRef> {
self.inner
.get_sandbox(&name)
.await
.map(Into::into)
.map_err(to_napi_error)
}
/// List sandboxes.
#[napi]
pub async fn list_sandboxes(&self, options: Option<ListOptions>) -> Result<Vec<SandboxRef>> {
let opts = sdk_list_opts_from_js(options.unwrap_or_default());
let items = self
.inner
.list_sandboxes(opts)
.await
.map_err(to_napi_error)?;
Ok(items.into_iter().map(Into::into).collect())
}
/// Delete a sandbox by name. Returns `true` when the gateway acknowledged
/// the deletion, `false` when it was already absent.
#[napi]
pub async fn delete_sandbox(&self, name: String) -> Result<bool> {
self.inner
.delete_sandbox(&name)
.await
.map_err(to_napi_error)
}
/// Poll until the sandbox reaches `ready` or `timeout_secs` elapses.
#[napi]
pub async fn wait_ready(&self, name: String, timeout_secs: u32) -> Result<SandboxRef> {
self.inner
.wait_ready(&name, Duration::from_secs(u64::from(timeout_secs)))
.await
.map(Into::into)
.map_err(to_napi_error)
}
/// Poll until the sandbox is gone or `timeout_secs` elapses.
#[napi]
pub async fn wait_deleted(&self, name: String, timeout_secs: u32) -> Result<()> {
self.inner
.wait_deleted(&name, Duration::from_secs(u64::from(timeout_secs)))
.await
.map_err(to_napi_error)
}
/// Run a command inside a sandbox; buffers stdout/stderr to the end.
#[napi]
pub async fn exec(
&self,
name: String,
command: Vec<String>,
options: Option<ExecOptions>,
) -> Result<ExecResult> {
let opts = sdk_exec_opts_from_js(options.unwrap_or_default());
let res = self
.inner
.exec(&name, &command, opts)
.await
.map_err(to_napi_error)?;
Ok(ExecResult {
exit_code: res.exit_code,
stdout: res.stdout.into(),
stderr: res.stderr.into(),
})
}
}
+105
View File
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Smoke test for @openshell/sdk.
//
// Verifies the binding's surface: exports, error mapping, and the
// `OidcRefresher` single-flight contract. End-to-end RPC verification
// requires a running mock gateway and lives in the Rust crate's mock tests
// (see `crates/openshell-sdk/tests/client_mock.rs`).
//
// Implemented as a plain async script rather than `node --test` because
// the napi-rs `tokio_rt` feature holds the libuv event loop open and the
// test runner never exits cleanly. We explicitly `process.exit(0)` here.
import { strict as assert } from 'node:assert'
import { errorCode, OidcRefresher, OpenShellClient } from '../lib.mjs'
const cases = []
function test(name, fn) {
cases.push({ name, fn })
}
test('module exports the documented classes', () => {
assert.equal(typeof OpenShellClient, 'function')
assert.equal(typeof OpenShellClient.connect, 'function')
assert.equal(typeof OidcRefresher, 'function')
assert.equal(typeof errorCode, 'function')
})
test('connect rejects against a closed port with a typed error', async () => {
let caught
try {
await OpenShellClient.connect({ gateway: 'http://127.0.0.1:1' })
} catch (err) {
caught = err
}
assert.ok(caught, 'expected connect to fail against 127.0.0.1:1')
assert.equal(errorCode(caught), 'connect', `unexpected error: ${caught?.message}`)
})
test('connect rejects with invalid_config for a malformed gateway URL', async () => {
let caught
try {
await OpenShellClient.connect({ gateway: 'not-a-url' })
} catch (err) {
caught = err
}
assert.ok(caught, 'expected connect to fail on malformed URL')
assert.equal(errorCode(caught), 'invalid_config', `unexpected error: ${caught?.message}`)
})
test('OidcRefresher coalesces concurrent refresh calls', async () => {
let calls = 0
// expiresAt = 1 (Unix epoch 1970) is in the past, so the SDK's reactive
// path treats the token as expired. See refresh.rs `needs_refresh`.
const refresher = new OidcRefresher('initial', 1, async () => {
calls += 1
await new Promise((resolve) => setTimeout(resolve, 25))
return {
accessToken: `token-${calls}`,
expiresAt: Math.floor(Date.now() / 1000) + 3600,
}
})
const results = await Promise.all([
refresher.refresh(),
refresher.refresh(),
refresher.refresh(),
refresher.refresh(),
])
assert.equal(calls, 1, 'callback should have been invoked once for coalesced calls')
assert.equal(new Set(results).size, 1, 'all waiters should observe the same token')
assert.equal(results[0], 'token-1')
assert.equal(refresher.currentToken(), 'token-1')
})
test('OidcRefresher surfaces callback rejections as auth errors', async () => {
const refresher = new OidcRefresher('stale', 1, async () => {
throw new Error('IdP unreachable')
})
let caught
try {
await refresher.refresh()
} catch (err) {
caught = err
}
assert.ok(caught, 'expected refresh to reject when callback throws')
assert.equal(errorCode(caught), 'auth', `unexpected error: ${caught?.message}`)
})
let failed = 0
for (const { name, fn } of cases) {
try {
await fn()
console.log(`ok ${name}`)
} catch (err) {
failed += 1
console.error(`fail ${name}`)
console.error(err)
}
}
console.log(`\n${cases.length - failed}/${cases.length} passed`)
process.exit(failed === 0 ? 0 : 1)