diff --git a/Cargo.lock b/Cargo.lock index a00195aba..66364b164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/openshell-sdk-node/.gitignore b/crates/openshell-sdk-node/.gitignore new file mode 100644 index 000000000..fb4a29529 --- /dev/null +++ b/crates/openshell-sdk-node/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +*.node diff --git a/crates/openshell-sdk-node/Cargo.toml b/crates/openshell-sdk-node/Cargo.toml new file mode 100644 index 000000000..e798acc21 --- /dev/null +++ b/crates/openshell-sdk-node/Cargo.toml @@ -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] diff --git a/crates/openshell-sdk-node/build.rs b/crates/openshell-sdk-node/build.rs new file mode 100644 index 000000000..010bd1add --- /dev/null +++ b/crates/openshell-sdk-node/build.rs @@ -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(); +} diff --git a/crates/openshell-sdk-node/index.d.ts b/crates/openshell-sdk-node/index.d.ts new file mode 100644 index 000000000..7b1b4453c --- /dev/null +++ b/crates/openshell-sdk-node/index.d.ts @@ -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 +} + +/** + * 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 + /** Gateway health snapshot. */ + health(): Promise + /** Create a new sandbox. */ + createSandbox(spec: SandboxSpec): Promise + /** Fetch a sandbox by name. */ + getSandbox(name: string): Promise + /** List sandboxes. */ + listSandboxes(options?: ListOptions | undefined | null): Promise> + /** + * Delete a sandbox by name. Returns `true` when the gateway acknowledged + * the deletion, `false` when it was already absent. + */ + deleteSandbox(name: string): Promise + /** Poll until the sandbox reaches `ready` or `timeout_secs` elapses. */ + waitReady(name: string, timeoutSecs: number): Promise + /** Poll until the sandbox is gone or `timeout_secs` elapses. */ + waitDeleted(name: string, timeoutSecs: number): Promise + /** Run a command inside a sandbox; buffers stdout/stderr to the end. */ + exec(name: string, command: Array, options?: ExecOptions | undefined | null): Promise +} + +/** + * 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 + /** 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 + /** 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 + environment?: Record + providers?: Array + gpu?: boolean + gpuDevice?: string +} diff --git a/crates/openshell-sdk-node/index.js b/crates/openshell-sdk-node/index.js new file mode 100644 index 000000000..565c20ef5 --- /dev/null +++ b/crates/openshell-sdk-node/index.js @@ -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 diff --git a/crates/openshell-sdk-node/lib.d.ts b/crates/openshell-sdk-node/lib.d.ts new file mode 100644 index 000000000..f6b74039d --- /dev/null +++ b/crates/openshell-sdk-node/lib.d.ts @@ -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 diff --git a/crates/openshell-sdk-node/lib.mjs b/crates/openshell-sdk-node/lib.mjs new file mode 100644 index 000000000..a40d5eff5 --- /dev/null +++ b/crates/openshell-sdk-node/lib.mjs @@ -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 +} diff --git a/crates/openshell-sdk-node/package.json b/crates/openshell-sdk-node/package.json new file mode 100644 index 000000000..c31300b0e --- /dev/null +++ b/crates/openshell-sdk-node/package.json @@ -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" + } +} diff --git a/crates/openshell-sdk-node/src/lib.rs b/crates/openshell-sdk-node/src/lib.rs new file mode 100644 index 000000000..08d2d52d9 --- /dev/null +++ b/crates/openshell-sdk-node/src/lib.rs @@ -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, + /// Bearer token for direct OIDC auth. Mutually exclusive with `edge_token`. + pub oidc_token: Option, + /// Cloudflare Access bearer token. Routes through a local WebSocket tunnel. + pub edge_token: Option, + /// Disable TLS certificate verification (development/debug only). + pub insecure_skip_verify: Option, +} + +/// 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, + /// 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, + pub image: Option, + pub labels: Option>, + pub environment: Option>, + pub providers: Option>, + pub gpu: Option, + pub gpu_device: Option, +} + +/// Options for [`OpenShellClient::list_sandboxes`]. +#[napi(object)] +#[derive(Default)] +pub struct ListOptions { + pub limit: Option, + pub offset: Option, + pub label_selector: Option, +} + +/// Options for [`OpenShellClient::exec`]. +#[napi(object)] +#[derive(Default)] +pub struct ExecOptions { + pub workdir: Option, + pub environment: Option>, + /// Timeout in seconds. `None` lets the gateway choose. + pub timeout_secs: Option, + /// Optional stdin payload. + pub stdin: Option, +} + +/// 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 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, +} + +/// Bridge between a JS refresh callback and the SDK's [`sdk::Refresh`] trait. +struct JsRefresher { + callback: ThreadsafeFunction<(), Promise, (), Status, false>, +} + +#[async_trait::async_trait] +impl sdk::Refresh for JsRefresher { + async fn refresh(&self) -> std::result::Result { + // Invoke the JS callback; it returns a Promise. + 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, +} + +#[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, + #[napi(ts_arg_type = "() => Promise<{ accessToken: string; expiresAt?: number }>")] + callback: ThreadsafeFunction<(), Promise, (), 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> Result> { + 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 { + 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 { + 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, + options: Option, + ) -> Result { + 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(), + }) + } +} diff --git a/crates/openshell-sdk-node/test/smoke.mjs b/crates/openshell-sdk-node/test/smoke.mjs new file mode 100644 index 000000000..cf7dd6f7c --- /dev/null +++ b/crates/openshell-sdk-node/test/smoke.mjs @@ -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)