diff --git a/Cargo.lock b/Cargo.lock index 481d1cd..b0cc55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,7 +627,9 @@ dependencies = [ "ignite-shared", "serde", "serde_json", + "tempfile", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/README.md b/README.md index cdd6bd7..aeb93cf 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,28 @@ ignite run . --verbose | `ignite init ` | Generate a new service scaffold | | `ignite run ` | Build + execute service in a microVM | | `ignite preflight ` | Run safety validator checks | -| `ignite serve` | Start HTTP REST API server | +| `ignite serve` | Start HTTP REST API server (listens on `9847` by default) | + +## TypeScript SDK + +A Bun-native client for the REST API lives in [`sdk/ts`](sdk/ts) and targets the +daemon's default port, so no configuration is needed on either side: + +```bash +bun install # from the repository root +ignite serve --services ./examples & # listens on 9847 +``` + +```typescript +import { IgniteClient } from '@ignite/sdk'; + +const client = new IgniteClient(); +const result = await client.executeService('hello-bun', { input: { count: 2 } }); +console.log(result.metrics?.stdout); +``` + +See [`sdk/ts/README.md`](sdk/ts/README.md) for timeouts, cancellation, and error +handling. ## Runtime Support diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..4f84770 --- /dev/null +++ b/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ignite-workspace", + }, + "sdk/ts": { + "name": "@ignite/sdk", + "version": "0.9.0", + "devDependencies": { + "@types/bun": "^1.3.0", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@ignite/sdk": ["@ignite/sdk@workspace:sdk/ts"], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/docs/api.md b/docs/api.md index c31b7dd..1b21caf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -64,8 +64,9 @@ ignite serve [options] Options: -- `-p, --port `: API port (default `3000`) -- `-h, --host `: host or IP to bind (default `localhost`) +- `-p, --port `: API port (default `9847`) +- `-H, --host `: host or IP to bind (default `localhost`). Note the capital + `H`: `-h` is reserved for `--help`. - `-s, --services `: path to services root folder (default `./services`) Environment: diff --git a/docs/getting-started.md b/docs/getting-started.md index 28cd61e..4ace05a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -75,13 +75,15 @@ ignite preflight . Start the REST API: ```bash -ignite serve --services ./services --port 3000 +ignite serve --services ./services ``` +This listens on port `9847` by default; pass `--port` to change it. + Execute a service over REST: ```bash -curl -X POST http://localhost:3000/services/hello-world/execute \ +curl -X POST http://localhost:9847/services/hello-world/execute \ -H 'Content-Type: application/json' \ -d '{"input":{"message":"Hello Ignite"}}' ``` diff --git a/docs/man/ignite.1 b/docs/man/ignite.1 index 889cfb9..375b6f2 100644 --- a/docs/man/ignite.1 +++ b/docs/man/ignite.1 @@ -107,7 +107,7 @@ Show details for a specific template. Start the HTTP REST API server. .TP .BR \-p ", " \-\-port " " \fIport\fR -Port to bind API (default: 3000). +Port to bind API (default: 9847). .TP .BR \-H ", " \-\-host " " \fIhost\fR Host to bind API (default: localhost). diff --git a/docs/walkthrough.md b/docs/walkthrough.md index c0e2686..0ac6c9c 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -87,13 +87,13 @@ Run the server pointing to the folder containing the services: ```bash cd .. -ignite serve --services ./services --port 3000 +ignite serve --services ./services ``` -Now, invoke execution from a client using curl: +Now, invoke execution from a client using curl (the daemon listens on `9847`): ```bash -curl -X POST http://localhost:3000/services/data-processor/execute \ +curl -X POST http://localhost:9847/services/data-processor/execute \ -H 'Content-Type: application/json' \ -d '{"input":{"data":[5,10,15],"operation":"max"}}' ``` diff --git a/ignite-cli/src/main.rs b/ignite-cli/src/main.rs index 0dd35e5..161030b 100644 --- a/ignite-cli/src/main.rs +++ b/ignite-cli/src/main.rs @@ -88,11 +88,15 @@ enum Commands { }, /// Start HTTP REST API server Serve { - /// Port to bind API (default 3000) - #[arg(short, long, default_value = "3000")] + /// Port to bind API (default 9847) + #[arg(short, long, default_value = "9847")] port: u16, - /// Host to bind API (default localhost) - #[arg(short, long, default_value = "localhost")] + /// Host to bind API (default localhost). + /// + /// Short flag is `-H`: `-h` is reserved by clap for `--help`, and + /// claiming it here makes clap panic on every `serve` invocation under + /// debug assertions. + #[arg(short = 'H', long, default_value = "localhost")] host: String, /// Path to services folder root #[arg(short, long, default_value = "./services")] @@ -1086,3 +1090,43 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// `serve` previously gave `--host` an auto-derived `-h`, which collides + /// with clap's generated `--help`. Under debug assertions that is a panic + /// on every `ignite serve` invocation, and in release it silently rebinds + /// `-h` to `--host`. `debug_assert` walks the whole command tree, so this + /// also covers every other subcommand. + #[test] + fn cli_definition_has_no_conflicting_flags() { + Cli::command().debug_assert(); + } + + #[test] + fn serve_defaults_to_the_uncommon_port() { + let cli = Cli::parse_from(["ignite", "serve"]); + match cli.command { + Commands::Serve { port, host, .. } => { + assert_eq!(port, 9847, "serve must default to the documented port"); + assert_eq!(host, "localhost"); + } + _ => panic!("expected the serve subcommand"), + } + } + + #[test] + fn serve_accepts_capital_h_for_host() { + let cli = Cli::parse_from(["ignite", "serve", "-H", "0.0.0.0", "-p", "9999"]); + match cli.command { + Commands::Serve { port, host, .. } => { + assert_eq!(host, "0.0.0.0"); + assert_eq!(port, 9999); + } + _ => panic!("expected the serve subcommand"), + } + } +} diff --git a/ignite-http/Cargo.toml b/ignite-http/Cargo.toml index be5ad80..08f15c7 100644 --- a/ignite-http/Cargo.toml +++ b/ignite-http/Cargo.toml @@ -14,4 +14,8 @@ tower-http.workspace = true serde.workspace = true serde_json.workspace = true +[dev-dependencies] +tempfile.workspace = true +tower = { version = "0.5", features = ["util"] } + diff --git a/ignite-http/src/main.rs b/ignite-http/src/main.rs index 73a6cac..18ef404 100644 --- a/ignite-http/src/main.rs +++ b/ignite-http/src/main.rs @@ -19,7 +19,7 @@ async fn main() { let port = std::env::var("PORT") .ok() .and_then(|p| p.parse::().ok()) - .unwrap_or(3000); + .unwrap_or(9847); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); diff --git a/ignite-http/src/server.rs b/ignite-http/src/server.rs index 4f0be16..5b47a4c 100644 --- a/ignite-http/src/server.rs +++ b/ignite-http/src/server.rs @@ -143,20 +143,47 @@ async fn list_services( _auth: RequireAuth, State(state): State>, ) -> impl IntoResponse { + // A misconfigured `--services` path must not look like an empty directory. + // Swallowing the error here returns `{"services": []}` with HTTP 200, which + // is indistinguishable from "no services installed". + let entries = match fs::read_dir(&state.services_path) { + Ok(entries) => entries, + Err(e) => { + tracing::error!( + path = %state.services_path.display(), + error = %e, + "cannot read the configured services directory" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!( + "Cannot read services directory {}: {}", + state.services_path.display(), + e + ) + })), + ); + } + }; + let mut services = Vec::new(); - if let Ok(entries) = fs::read_dir(&state.services_path) { - for entry in entries.flatten() { - let path = entry.path(); - if let Some(name) = path - .file_name() - .and_then(|n| n.to_str()) - .filter(|_| path.is_dir()) - { - services.push(name.to_string()); - } + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path + .file_name() + .and_then(|n| n.to_str()) + .filter(|_| path.is_dir()) + { + services.push(name.to_string()); } } - Json(serde_json::json!({ "services": services })) + // Directory order is filesystem-dependent; sort so the API is deterministic. + services.sort(); + ( + StatusCode::OK, + Json(serde_json::json!({ "services": services })), + ) } #[derive(Deserialize)] @@ -229,7 +256,34 @@ async fn execute_service_handler( ..ExecuteOptions::default() }; - match execute_service(&service_dir, options, None, None) { + // `execute_service` is synchronous and runs for the entire lifetime of the + // microVM. Calling it directly from an async handler parks a Tokio worker + // thread for that whole duration, so enough concurrent executions starve + // the runtime and stall every other route, including `/health`. + let result = + tokio::task::spawn_blocking(move || execute_service(&service_dir, options, None, None)) + .await; + + let result = match result { + Ok(result) => result, + Err(join_err) => { + // The blocking task panicked or was cancelled. Surface it rather + // than reporting success with no metrics. + tracing::error!(error = %join_err, "service execution task failed to complete"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ExecuteResponse { + success: false, + service_name, + metrics: None, + preflight: None, + error: Some(format!("Execution task failed to complete: {join_err}")), + }), + ); + } + }; + + match result { Ok((preflight, metrics)) => ( StatusCode::OK, Json(ExecuteResponse { @@ -344,4 +398,94 @@ mod tests { // Previously hardcoded to 0.1.0 while the crate was 0.9.0. assert_eq!(env!("CARGO_PKG_VERSION"), "0.9.0"); } + + fn test_state(services_path: PathBuf) -> Arc { + Arc::new(ServerState { + services_path, + api_key: None, + rate_limiter: RateLimiter::new(1000, 60), + kernel_path: None, + rootfs_path: None, + runtimes_root: None, + allowed_origins: Vec::new(), + }) + } + + async fn get(state: Arc, uri: &str) -> (StatusCode, serde_json::Value) { + use tower::ServiceExt; + + let response = create_router(state) + .oneshot( + axum::http::Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + #[tokio::test] + async fn list_services_returns_directory_names_sorted() { + let dir = tempfile::tempdir().unwrap(); + for name in ["zebra", "alpha", "middle"] { + fs::create_dir(dir.path().join(name)).unwrap(); + } + // A stray file is not a service and must not be listed. + fs::write(dir.path().join("notes.txt"), b"x").unwrap(); + + let (status, body) = get(test_state(dir.path().to_path_buf()), "/services").await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["services"], + serde_json::json!(["alpha", "middle", "zebra"]) + ); + } + + #[tokio::test] + async fn list_services_reports_an_unreadable_directory_instead_of_an_empty_list() { + // Previously this swallowed the error and returned `{"services": []}` + // with HTTP 200, making a misconfigured path look like an empty one. + let missing = PathBuf::from("/nonexistent/ignite-services-should-not-exist"); + + let (status, body) = get(test_state(missing), "/services").await; + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!( + body["error"].as_str().unwrap_or("").contains("Cannot read"), + "expected a read failure message, got {body}" + ); + assert!( + body.get("services").is_none(), + "a failed listing must not report a services array" + ); + } + + #[tokio::test] + async fn execute_rejects_invalid_service_names_before_touching_the_filesystem() { + use tower::ServiceExt; + + let dir = tempfile::tempdir().unwrap(); + let response = create_router(test_state(dir.path().to_path_buf())) + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/services/..%2f..%2fetc/execute") + .header("content-type", "application/json") + .body(axum::body::Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9a4484 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "ignite-workspace", + "version": "0.9.0", + "private": true, + "description": "Bun workspace root for the Ignite TypeScript packages", + "workspaces": [ + "sdk/*" + ], + "scripts": { + "build": "bun run --filter '*' build", + "test": "bun run --filter '*' test", + "typecheck": "bun run --filter '*' typecheck" + }, + "engines": { + "bun": ">=1.0.0" + }, + "license": "MIT" +} diff --git a/sdk/ts/README.md b/sdk/ts/README.md new file mode 100644 index 0000000..74eca28 --- /dev/null +++ b/sdk/ts/README.md @@ -0,0 +1,151 @@ +# `@ignite/sdk` + +TypeScript SDK for the **Ignite** microVM sandboxing REST API. Built for **Bun**, +and published as standard ESM so Node 18+ and Deno work too. + +## Install + +This package lives in the repo's Bun workspace and is not published to npm yet. +From a clone: + +```bash +bun install # from the repository root +``` + +To consume it from another local project, either add it as a workspace member or +link it: + +```bash +cd sdk/ts && bun link +cd /path/to/your/project && bun link @ignite/sdk +``` + +## Quick Start + +### 1. Start the daemon + +The daemon listens on port **9847** by default — the same port the SDK targets, +so no configuration is needed on either side: + +```bash +ignite serve --services ./examples +``` + +With authentication (recommended for anything not on localhost): + +```bash +IGNITE_API_KEY="my-secret-key" ignite serve --services ./examples +``` + +Use `-p/--port` for a different port and `-H/--host` to change the bind address +(capital `H`; `-h` is `--help`). + +### 2. Call it + +```typescript +import { IgniteClient } from '@ignite/sdk'; + +const client = new IgniteClient({ + // baseUrl defaults to http://localhost:9847 + apiKey: process.env.IGNITE_API_KEY, // optional Bearer token +}); + +const health = await client.health(); +console.log(`ignite v${health.version} is ${health.status}`); + +const services = await client.listServices(); + +const result = await client.executeService('hello-bun', { + input: { name: 'Ignite User', count: 2 }, +}); + +console.log('exit code:', result.metrics?.exitCode); +console.log('stdout:', result.metrics?.stdout); +``` + +## Error handling + +Every failure is a typed error, so a hung daemon and a crashed guest are +distinguishable: + +```typescript +import { IgniteApiError, IgniteTimeoutError, IgniteValidationError } from '@ignite/sdk'; + +try { + await client.executeService('hello-bun', { input: { n: 1 } }); +} catch (err) { + if (err instanceof IgniteValidationError) { + // Bad service name — rejected locally, no request was sent. + } else if (err instanceof IgniteTimeoutError) { + // Our timeout fired. The microVM may still be running. + console.error(`timed out after ${err.timeoutMs}ms`); + } else if (err instanceof IgniteApiError) { + // Daemon responded with a non-2xx, or success:false. + console.error(err.statusCode, err.message, err.responseBody); + } else { + throw err; + } +} +``` + +`IgniteApiError` covers the daemon's two response shapes: the `ExecuteResponse` +body (`{ success, serviceName, error }`) and the plain `{ error }` used for 401 +and 429 rejections. + +## Timeouts and cancellation + +`executeService` boots a microVM, so it gets a 5-minute default budget while +metadata calls get 30 seconds. Both are configurable, and every method accepts +an `AbortSignal`: + +```typescript +const client = new IgniteClient({ timeoutMs: 10_000 }); // metadata calls + +await client.executeService('slow-job', { timeoutMs: 600_000 }); +await client.executeService('slow-job', { timeoutMs: 0 }); // no timeout + +const controller = new AbortController(); +setTimeout(() => controller.abort(), 1_000); +await client.executeService('slow-job', { signal: controller.signal }); +``` + +## API Reference + +### `new IgniteClient(options?: IgniteClientOptions)` + +| Option | Default | Notes | +| ----------- | ---------------------------- | -------------------------------------- | +| `baseUrl` | `http://localhost:9847` | Trailing slashes are stripped | +| `apiKey` | — | Sent as `Authorization: Bearer ` | +| `fetch` | `globalThis.fetch` | Inject a custom implementation | +| `timeoutMs` | `30000` | Default for `health`/`listServices` | + +### Methods + +- `health(options?)` → `HealthResponse` — `{ status, version }`. +- `listServices(options?)` → `string[]` — sorted service directory names. +- `executeService(name, options?)` → `ExecuteServiceResponse` — runs the service + in a microVM. Rejects invalid names locally before any request. + +### Exports + +`isValidServiceName`, `DEFAULT_IGNITE_PORT`, `DEFAULT_BASE_URL`, +`DEFAULT_TIMEOUT_MS`, `DEFAULT_EXECUTE_TIMEOUT_MS`, plus all response types. + +## Development + +```bash +bun test # unit tests, hermetic (mocked fetch) +bun run typecheck # tsc, resolves types only from this repo +bun run build # emit dist/ with .d.ts and sourcemaps +``` + +Integration tests run against a real daemon and are skipped unless you opt in: + +```bash +ignite serve --services ./examples & +IGNITE_TEST_BASE_URL=http://localhost:9847 bun test +``` + +Unit tests assert against hand-written JSON; the integration suite is what +verifies the SDK's types against what the daemon actually emits. diff --git a/sdk/ts/example.ts b/sdk/ts/example.ts new file mode 100644 index 0000000..905481f --- /dev/null +++ b/sdk/ts/example.ts @@ -0,0 +1,69 @@ +/** + * Live example against a running Ignite daemon. + * + * ignite serve --services ./examples # listens on 9847 by default + * bun run sdk/ts/example.ts + * + * Exits non-zero on any failure so CI notices. + */ +import { IgniteApiError, IgniteClient, IgniteTimeoutError } from './src/index.js'; + +const client = new IgniteClient({ + baseUrl: process.env.IGNITE_BASE_URL, + apiKey: process.env.IGNITE_API_KEY, +}); + +async function main(): Promise { + console.log(`Connecting to Ignite daemon at ${client.baseUrl} ...`); + + const health = await client.health(); + console.log(` health: ${health.status} (ignite v${health.version})`); + + const services = await client.listServices(); + console.log(` services: ${services.length > 0 ? services.join(', ') : '(none found)'}`); + + const target = process.env.IGNITE_SERVICE ?? services[0]; + if (!target) { + console.log('No services available to execute. Point --services at a directory.'); + return; + } + + console.log(`\nExecuting "${target}" in a Firecracker microVM ...`); + const result = await client.executeService(target, { + input: { name: 'Ignite User', count: 2 }, + }); + + console.log(` exit code: ${result.metrics?.exitCode ?? 'n/a'}`); + console.log(` duration: ${result.metrics?.executionTimeMs ?? 'n/a'}ms`); + console.log(` cold start: ${result.metrics?.coldStart ?? 'n/a'}`); + if (result.metrics?.stdout) { + console.log(` stdout:\n${result.metrics.stdout}`); + } + if (result.metrics?.stderr) { + console.log(` stderr:\n${result.metrics.stderr}`); + } +} + +main().catch((err: unknown) => { + // Report what actually went wrong. A connection refusal and a service that + // failed inside the VM are very different problems. + if (err instanceof IgniteTimeoutError) { + console.error(`Timed out after ${err.timeoutMs}ms waiting for the daemon.`); + } else if (err instanceof IgniteApiError) { + console.error(`Daemon returned HTTP ${err.statusCode}: ${err.message}`); + if (err.responseBody !== undefined) { + console.error(`Response body: ${JSON.stringify(err.responseBody, null, 2)}`); + } + } else if (err instanceof Error && /fetch failed|ECONNREFUSED|Unable to connect/i.test(err.message)) { + console.error( + `Could not reach the daemon at ${client.baseUrl}. ` + + 'Start it with: ignite serve --services ./examples', + ); + if (err.cause) { + console.error(`Cause: ${String(err.cause)}`); + } + } else { + console.error('Unexpected failure:', err); + } + process.exitCode = 1; +}); diff --git a/sdk/ts/package.json b/sdk/ts/package.json new file mode 100644 index 0000000..aca4975 --- /dev/null +++ b/sdk/ts/package.json @@ -0,0 +1,52 @@ +{ + "name": "@ignite/sdk", + "version": "0.9.0", + "description": "TypeScript SDK for the Ignite microVM sandboxing REST API", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "bun": "./src/index.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test", + "test:integration": "bun test test/integration.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "bun run typecheck && bun run test && bun run build" + }, + "keywords": [ + "ignite", + "microvm", + "sandbox", + "firecracker", + "bun", + "sdk" + ], + "author": "dev-dami", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dev-dami/ignite.git", + "directory": "sdk/ts" + }, + "engines": { + "bun": ">=1.0.0" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "typescript": "^5.6.0" + } +} diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts new file mode 100644 index 0000000..a8a0bd4 --- /dev/null +++ b/sdk/ts/src/client.ts @@ -0,0 +1,271 @@ +import { IgniteApiError, IgniteTimeoutError, IgniteValidationError } from './errors.js'; +import type { + ExecuteOptions, + ExecuteServiceResponse, + HealthResponse, + IgniteClientOptions, + ListServicesResponse, + RequestOptions, +} from './types.js'; + +/** Default uncommon port for the Ignite HTTP daemon. */ +export const DEFAULT_IGNITE_PORT = 9847; +export const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_IGNITE_PORT}`; + +/** Default timeout for cheap metadata calls. */ +export const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Default timeout for `executeService`. Execution boots a microVM and blocks + * for the service's whole run, so it gets a far longer budget than the + * metadata routes. + */ +export const DEFAULT_EXECUTE_TIMEOUT_MS = 300_000; + +/** + * Mirrors `validate_service_name` in `ignite-shared`: 1-63 chars, lowercase + * alphanumeric plus internal hyphens, no traversal sequences. + */ +const SERVICE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/; + +export function isValidServiceName(name: string): boolean { + if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) { + return false; + } + return SERVICE_NAME_RE.test(name); +} + +export class IgniteClient { + readonly baseUrl: string; + readonly timeoutMs: number; + private readonly apiKey?: string; + private readonly customFetch: typeof fetch; + + constructor(options: IgniteClientOptions = {}) { + const rawUrl = options.baseUrl ?? DEFAULT_BASE_URL; + this.baseUrl = rawUrl.replace(/\/+$/, ''); + this.apiKey = options.apiKey; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + const fetchImpl = options.fetch ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw new IgniteValidationError( + 'No fetch implementation available. Pass `fetch` in IgniteClientOptions ' + + 'or run on a runtime with a global fetch (Bun, Node 18+, Deno).', + ); + } + // A custom fetch is used as given; the global one needs `globalThis` as its + // receiver or it throws an illegal-invocation TypeError. + this.customFetch = options.fetch ?? fetchImpl.bind(globalThis); + } + + private buildHeaders(hasBody: boolean): Record { + const headers: Record = {}; + // Only meaningful on requests that actually carry a body. + if (hasBody) { + headers['Content-Type'] = 'application/json'; + } + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + return headers; + } + + /** + * Issue a request under a timeout, chained to any caller-supplied signal. + * Returns the response plus whether our own timeout fired, so the caller can + * tell a timeout apart from a caller-initiated abort. + */ + private async request( + url: string, + init: RequestInit, + timeoutMs: number, + callerSignal: AbortSignal | undefined, + serviceName?: string, + ): Promise { + const controller = new AbortController(); + let timedOut = false; + + const onCallerAbort = () => controller.abort(callerSignal?.reason); + if (callerSignal) { + if (callerSignal.aborted) { + throw callerSignal.reason instanceof Error + ? callerSignal.reason + : new DOMException('The operation was aborted.', 'AbortError'); + } + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + + const timer = + timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs) + : undefined; + + try { + return await this.customFetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if (timedOut) { + throw new IgniteTimeoutError( + `Request to ${url} timed out after ${timeoutMs}ms`, + timeoutMs, + serviceName, + ); + } + throw err; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + callerSignal?.removeEventListener('abort', onCallerAbort); + } + } + + /** Parse a response body as JSON, falling back to raw text. */ + private static async readBody(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return text; + } + } + + /** + * Pull an error message out of whatever the daemon returned. Auth and + * rate-limit rejections use `{"error": "..."}`, which is a different shape + * from the execute response. + */ + private static errorMessage(body: unknown, fallback: string): string { + if (body && typeof body === 'object' && 'error' in body) { + const { error } = body as { error?: unknown }; + if (typeof error === 'string' && error.length > 0) { + return error; + } + } + if (typeof body === 'string' && body.length > 0) { + return body; + } + return fallback; + } + + /** Check health status of the Ignite HTTP API daemon. */ + async health(options: RequestOptions = {}): Promise { + const url = `${this.baseUrl}/health`; + const response = await this.request( + url, + { method: 'GET', headers: this.buildHeaders(false) }, + options.timeoutMs ?? this.timeoutMs, + options.signal, + ); + + const body = await IgniteClient.readBody(response); + if (!response.ok) { + throw new IgniteApiError( + IgniteClient.errorMessage(body, `Health check failed with status ${response.status}`), + response.status, + undefined, + body, + ); + } + return body as HealthResponse; + } + + /** List available sandboxed services under the daemon's services directory. */ + async listServices(options: RequestOptions = {}): Promise { + const url = `${this.baseUrl}/services`; + const response = await this.request( + url, + { method: 'GET', headers: this.buildHeaders(false) }, + options.timeoutMs ?? this.timeoutMs, + options.signal, + ); + + const body = await IgniteClient.readBody(response); + if (!response.ok) { + throw new IgniteApiError( + IgniteClient.errorMessage(body, `Failed to list services (HTTP ${response.status})`), + response.status, + undefined, + body, + ); + } + return (body as ListServicesResponse)?.services ?? []; + } + + /** + * Execute a sandboxed service inside a Firecracker microVM. + * + * @param serviceName Name of the service directory + * @param options Execution payload, preflight overrides, timeout, and signal + */ + async executeService( + serviceName: string, + options: ExecuteOptions = {}, + ): Promise { + // Reject locally rather than spending a round trip on a name the daemon + // will refuse anyway. + if (!isValidServiceName(serviceName)) { + throw new IgniteValidationError( + `Invalid service name ${JSON.stringify(serviceName)}: must be 1-63 characters, ` + + 'lowercase alphanumeric with internal hyphens.', + ); + } + + const url = `${this.baseUrl}/services/${encodeURIComponent(serviceName)}/execute`; + const response = await this.request( + url, + { + method: 'POST', + headers: this.buildHeaders(true), + body: JSON.stringify({ + input: options.input ?? null, + skipPreflight: options.skipPreflight ?? false, + audit: options.audit ?? false, + }), + }, + options.timeoutMs ?? DEFAULT_EXECUTE_TIMEOUT_MS, + options.signal, + serviceName, + ); + + const body = await IgniteClient.readBody(response); + + if (!response.ok) { + throw new IgniteApiError( + IgniteClient.errorMessage( + body, + `Execution of ${serviceName} failed with HTTP status ${response.status}`, + ), + response.status, + serviceName, + body, + ); + } + + if (!body || typeof body !== 'object') { + throw new IgniteApiError( + `Invalid JSON response from server during execution of ${serviceName}`, + response.status, + serviceName, + body, + ); + } + + const data = body as ExecuteServiceResponse; + if (!data.success) { + throw new IgniteApiError( + IgniteClient.errorMessage(body, `Execution of ${serviceName} reported failure`), + response.status, + serviceName, + data, + ); + } + return data; + } +} diff --git a/sdk/ts/src/errors.ts b/sdk/ts/src/errors.ts new file mode 100644 index 0000000..1948b42 --- /dev/null +++ b/sdk/ts/src/errors.ts @@ -0,0 +1,42 @@ +/** Raised for any non-success response from the Ignite daemon. */ +export class IgniteApiError extends Error { + public readonly statusCode: number; + public readonly serviceName?: string; + public readonly responseBody?: unknown; + + constructor(message: string, statusCode: number, serviceName?: string, responseBody?: unknown) { + super(message); + this.name = 'IgniteApiError'; + this.statusCode = statusCode; + this.serviceName = serviceName; + this.responseBody = responseBody; + Object.setPrototypeOf(this, IgniteApiError.prototype); + } +} + +/** + * Raised when a request is aborted by its timeout rather than by the caller's + * own signal. Distinguishing the two matters: a timeout on `executeService` + * does not mean the microVM stopped running. + */ +export class IgniteTimeoutError extends Error { + public readonly timeoutMs: number; + public readonly serviceName?: string; + + constructor(message: string, timeoutMs: number, serviceName?: string) { + super(message); + this.name = 'IgniteTimeoutError'; + this.timeoutMs = timeoutMs; + this.serviceName = serviceName; + Object.setPrototypeOf(this, IgniteTimeoutError.prototype); + } +} + +/** Raised before any network call when arguments cannot be valid. */ +export class IgniteValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'IgniteValidationError'; + Object.setPrototypeOf(this, IgniteValidationError.prototype); + } +} diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts new file mode 100644 index 0000000..804fbc2 --- /dev/null +++ b/sdk/ts/src/index.ts @@ -0,0 +1,26 @@ +export { + IgniteClient, + isValidServiceName, + DEFAULT_IGNITE_PORT, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + DEFAULT_EXECUTE_TIMEOUT_MS, +} from './client.js'; +export { IgniteApiError, IgniteTimeoutError, IgniteValidationError } from './errors.js'; +export type { + ErrorResponse, + ExecuteOptions, + ExecuteServiceResponse, + ExecutionMetrics, + ExecutionReport, + HealthResponse, + IgniteClientOptions, + JsonValue, + ListServicesResponse, + PreflightCheck, + PreflightResult, + PreflightStatus, + RequestOptions, + Warning, + WarningLevel, +} from './types.js'; diff --git a/sdk/ts/src/types.ts b/sdk/ts/src/types.ts new file mode 100644 index 0000000..842fca0 --- /dev/null +++ b/sdk/ts/src/types.ts @@ -0,0 +1,108 @@ +/** A value that survives a JSON round trip. Mirrors `serde_json::Value`. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type PreflightStatus = 'pass' | 'warn' | 'fail'; +export type WarningLevel = 'info' | 'warning' | 'critical'; + +export interface PreflightCheck { + name: string; + status: PreflightStatus; + message: string; + value?: JsonValue; + threshold?: JsonValue; +} + +export interface PreflightResult { + serviceName: string; + timestamp: string; + checks: PreflightCheck[]; + overallStatus: PreflightStatus; +} + +export interface ExecutionMetrics { + executionTimeMs: number; + memoryUsageMb: number; + coldStart: boolean; + coldStartTimeMs?: number; + exitCode: number; + stdout: string; + stderr: string; +} + +export interface Warning { + level: WarningLevel; + message: string; + suggestion?: string; +} + +export interface ExecutionReport { + serviceName: string; + timestamp: string; + preflight: PreflightResult; + execution?: ExecutionMetrics; + warnings: Warning[]; +} + +export interface HealthResponse { + status: string; + version: string; +} + +export interface ListServicesResponse { + services: string[]; +} + +/** Shape the daemon returns for auth, rate-limit, and directory-read failures. */ +export interface ErrorResponse { + error: string; +} + +export interface RequestOptions { + /** + * Abort the request when this signal fires. Combined with `timeoutMs`; + * whichever fires first wins. + */ + signal?: AbortSignal; + /** + * Milliseconds before the request is aborted. Overrides the client default. + * Pass `0` to disable the timeout for this call. + */ + timeoutMs?: number; +} + +export interface ExecuteOptions extends RequestOptions { + /** JSON payload handed to the service. Serialized and passed to the guest. */ + input?: JsonValue; + skipPreflight?: boolean; + audit?: boolean; +} + +export interface ExecuteServiceResponse { + success: boolean; + serviceName: string; + metrics?: ExecutionMetrics; + preflight?: PreflightResult; + error?: string; +} + +export interface IgniteClientOptions { + /** Base URL for the Ignite HTTP REST API (defaults to http://localhost:9847) */ + baseUrl?: string; + /** Optional API Key for Bearer authentication */ + apiKey?: string; + /** Custom fetch implementation (defaults to globalThis.fetch) */ + fetch?: typeof fetch; + /** + * Default request timeout in milliseconds. Applies to every call unless + * overridden per request. Defaults to 30s for `health`/`listServices` and + * {@link DEFAULT_EXECUTE_TIMEOUT_MS} for `executeService`, which boots a + * microVM and legitimately takes longer. Pass `0` to disable. + */ + timeoutMs?: number; +} diff --git a/sdk/ts/test/client.test.ts b/sdk/ts/test/client.test.ts new file mode 100644 index 0000000..721bcb5 --- /dev/null +++ b/sdk/ts/test/client.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'bun:test'; +import { + DEFAULT_BASE_URL, + DEFAULT_EXECUTE_TIMEOUT_MS, + DEFAULT_IGNITE_PORT, + IgniteApiError, + IgniteClient, + IgniteTimeoutError, + IgniteValidationError, + isValidServiceName, +} from '../src/index.js'; + +/** Build a fetch stub returning a fixed JSON body, recording the last call. */ +function jsonFetch(body: unknown, status = 200) { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const impl = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: url.toString(), init }); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + return { impl, calls }; +} + +describe('configuration', () => { + it('defaults to the uncommon port 9847', () => { + expect(DEFAULT_IGNITE_PORT).toBe(9847); + expect(DEFAULT_BASE_URL).toBe('http://localhost:9847'); + expect(new IgniteClient().baseUrl).toBe('http://localhost:9847'); + }); + + it('accepts a custom base URL and strips trailing slashes', () => { + expect(new IgniteClient({ baseUrl: 'http://127.0.0.1:9847///' }).baseUrl).toBe( + 'http://127.0.0.1:9847', + ); + }); + + it('gives execution a longer default timeout than metadata calls', () => { + expect(DEFAULT_EXECUTE_TIMEOUT_MS).toBeGreaterThan(new IgniteClient().timeoutMs); + }); +}); + +describe('headers', () => { + it('sends the bearer token when an apiKey is configured', async () => { + const { impl, calls } = jsonFetch({ status: 'ok', version: '0.9.0' }); + const client = new IgniteClient({ apiKey: 'secret-token-123', fetch: impl }); + + const res = await client.health(); + + expect(res.version).toBe('0.9.0'); + expect((calls[0]!.init!.headers as Record)['Authorization']).toBe( + 'Bearer secret-token-123', + ); + }); + + it('omits Content-Type on GET requests that carry no body', async () => { + const { impl, calls } = jsonFetch({ services: [] }); + await new IgniteClient({ fetch: impl }).listServices(); + + const headers = calls[0]!.init!.headers as Record; + expect(headers['Content-Type']).toBeUndefined(); + }); + + it('sets Content-Type on execute, which does carry a body', async () => { + const { impl, calls } = jsonFetch({ success: true, serviceName: 'hello-bun' }); + await new IgniteClient({ fetch: impl }).executeService('hello-bun'); + + const headers = calls[0]!.init!.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + }); +}); + +describe('listServices', () => { + it('returns the service names', async () => { + const { impl, calls } = jsonFetch({ services: ['hello-bun', 'image-resizer'] }); + const services = await new IgniteClient({ fetch: impl }).listServices(); + + expect(services).toEqual(['hello-bun', 'image-resizer']); + expect(calls[0]!.url).toBe('http://localhost:9847/services'); + }); + + it('surfaces the daemon message when the services directory is unreadable', async () => { + // The daemon returns `{"error": ...}` with a 500 rather than an empty list. + const { impl } = jsonFetch({ error: 'Cannot read services directory /nope: not found' }, 500); + const client = new IgniteClient({ fetch: impl }); + + const err = (await client.listServices().catch((e: unknown) => e)) as IgniteApiError; + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.statusCode).toBe(500); + expect(err.message).toContain('Cannot read services directory'); + }); +}); + +describe('executeService', () => { + it('posts input and preflight options', async () => { + const { impl, calls } = jsonFetch({ + success: true, + serviceName: 'hello-bun', + metrics: { + executionTimeMs: 42, + memoryUsageMb: 24.5, + coldStart: true, + exitCode: 0, + stdout: 'Hello World', + stderr: '', + }, + }); + + const res = await new IgniteClient({ fetch: impl }).executeService('hello-bun', { + input: { count: 3 }, + skipPreflight: true, + }); + + expect(res.success).toBe(true); + expect(res.metrics?.executionTimeMs).toBe(42); + expect(calls[0]!.url).toBe('http://localhost:9847/services/hello-bun/execute'); + + const sent = JSON.parse(calls[0]!.init!.body as string); + expect(sent).toEqual({ input: { count: 3 }, skipPreflight: true, audit: false }); + }); + + it('rejects invalid service names before making a request', async () => { + const { impl, calls } = jsonFetch({ success: true, serviceName: 'x' }); + const client = new IgniteClient({ fetch: impl }); + + for (const bad of ['../etc', 'Bad-Name', 'has/slash', '-leading', 'trailing-', '', 'a..b']) { + await expect(client.executeService(bad)).rejects.toBeInstanceOf(IgniteValidationError); + } + expect(calls.length).toBe(0); + }); + + it('accepts the names the daemon accepts', () => { + expect(isValidServiceName('hello-bun')).toBe(true); + expect(isValidServiceName('a')).toBe(true); + expect(isValidServiceName('svc-1-2')).toBe(true); + expect(isValidServiceName('a'.repeat(63))).toBe(true); + expect(isValidServiceName('a'.repeat(64))).toBe(false); + }); + + it('throws IgniteApiError with the daemon message on 404', async () => { + const { impl } = jsonFetch( + { success: false, serviceName: 'non-existent', error: 'Service directory not found' }, + 404, + ); + + const err = (await new IgniteClient({ fetch: impl }) + .executeService('non-existent') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.statusCode).toBe(404); + expect(err.message).toBe('Service directory not found'); + expect(err.serviceName).toBe('non-existent'); + }); + + it('handles the auth rejection shape, which has no success field', async () => { + // 401 returns `{"error": ...}`, not an ExecuteResponse. + const { impl } = jsonFetch({ error: 'Unauthorized: Invalid or missing API key' }, 401); + + const err = (await new IgniteClient({ fetch: impl }) + .executeService('hello-bun') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.statusCode).toBe(401); + expect(err.message).toBe('Unauthorized: Invalid or missing API key'); + }); + + it('handles the rate-limit rejection shape', async () => { + const { impl } = jsonFetch({ error: 'Rate limit exceeded. Retry later.' }, 429); + + const err = (await new IgniteClient({ fetch: impl }) + .executeService('hello-bun') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err.statusCode).toBe(429); + expect(err.message).toBe('Rate limit exceeded. Retry later.'); + }); + + it('reports non-JSON error bodies instead of a parse crash', async () => { + const impl = (async () => + new Response('502 Bad Gateway', { status: 502 })) as unknown as typeof fetch; + + const err = (await new IgniteClient({ fetch: impl }) + .executeService('hello-bun') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.statusCode).toBe(502); + expect(err.message).toContain('502 Bad Gateway'); + }); + + it('throws when the daemon reports success:false with HTTP 200', async () => { + const { impl } = jsonFetch({ success: false, serviceName: 'x', error: 'guest crashed' }); + + const err = (await new IgniteClient({ fetch: impl }) + .executeService('hello-bun') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.message).toBe('guest crashed'); + }); +}); + +describe('timeouts and cancellation', () => { + /** A fetch that never settles until its signal aborts. */ + const hangingFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }) as unknown as typeof fetch; + + it('times out a hung request instead of hanging forever', async () => { + const client = new IgniteClient({ fetch: hangingFetch, timeoutMs: 50 }); + + const err = (await client.health().catch((e: unknown) => e)) as IgniteTimeoutError; + expect(err).toBeInstanceOf(IgniteTimeoutError); + expect(err.timeoutMs).toBe(50); + }); + + it('applies a per-request timeout override', async () => { + const client = new IgniteClient({ fetch: hangingFetch, timeoutMs: 60_000 }); + + const err = (await client + .executeService('hello-bun', { timeoutMs: 40 }) + .catch((e: unknown) => e)) as IgniteTimeoutError; + + expect(err).toBeInstanceOf(IgniteTimeoutError); + expect(err.timeoutMs).toBe(40); + expect(err.serviceName).toBe('hello-bun'); + }); + + it('honours a caller-supplied AbortSignal and reports it as an abort, not a timeout', async () => { + const controller = new AbortController(); + const client = new IgniteClient({ fetch: hangingFetch, timeoutMs: 60_000 }); + + const pending = client.executeService('hello-bun', { signal: controller.signal }); + controller.abort(); + + const err = (await pending.catch((e: unknown) => e)) as Error; + expect(err).not.toBeInstanceOf(IgniteTimeoutError); + expect(err.name).toBe('AbortError'); + }); + + it('rejects immediately when handed an already-aborted signal', async () => { + const client = new IgniteClient({ fetch: hangingFetch }); + const err = (await client + .health({ signal: AbortSignal.abort() }) + .catch((e: unknown) => e)) as Error; + + expect(err.name).toBe('AbortError'); + }); + + it('does not time out when timeoutMs is 0', async () => { + let settle: ((r: Response) => void) | undefined; + const impl = (async () => + new Promise((resolve) => { + settle = resolve; + })) as unknown as typeof fetch; + + const client = new IgniteClient({ fetch: impl, timeoutMs: 0 }); + const pending = client.health(); + + await Bun.sleep(60); + settle!(new Response(JSON.stringify({ status: 'ok', version: '0.9.0' }))); + + expect((await pending).status).toBe('ok'); + }); +}); diff --git a/sdk/ts/test/integration.test.ts b/sdk/ts/test/integration.test.ts new file mode 100644 index 0000000..037a1f3 --- /dev/null +++ b/sdk/ts/test/integration.test.ts @@ -0,0 +1,68 @@ +/** + * Integration tests against a real Ignite daemon. + * + * Opt in by pointing IGNITE_TEST_BASE_URL at a running server: + * + * ignite serve --services ./examples & + * IGNITE_TEST_BASE_URL=http://localhost:9847 bun test test/integration.test.ts + * + * Skipped by default so `bun test` stays hermetic. These exist because the unit + * tests assert against hand-written JSON: only this file proves the SDK's types + * match what the daemon actually emits. + */ +import { describe, expect, it } from 'bun:test'; +import { IgniteApiError, IgniteClient } from '../src/index.js'; + +const baseUrl = process.env.IGNITE_TEST_BASE_URL; +const liveDescribe = baseUrl ? describe : describe.skip; + +liveDescribe('live daemon', () => { + const client = new IgniteClient({ + baseUrl, + apiKey: process.env.IGNITE_TEST_API_KEY, + timeoutMs: 10_000, + }); + + it('reports health in the documented shape', async () => { + const health = await client.health(); + expect(typeof health.status).toBe('string'); + expect(health.status).toBe('ok'); + // A real semver from the crate, not a placeholder. + expect(health.version).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('lists services as a sorted string array', async () => { + const services = await client.listServices(); + expect(Array.isArray(services)).toBe(true); + for (const name of services) { + expect(typeof name).toBe('string'); + } + expect(services).toEqual([...services].sort()); + }); + + it('returns a 404 in the ExecuteResponse shape for an unknown service', async () => { + // Valid name, no such directory: exercises the real error body rather than + // a mock's approximation of it. + const err = (await client + .executeService('definitely-not-a-real-service') + .catch((e: unknown) => e)) as IgniteApiError; + + expect(err).toBeInstanceOf(IgniteApiError); + expect(err.statusCode).toBe(404); + expect(err.serviceName).toBe('definitely-not-a-real-service'); + + const body = err.responseBody as Record; + expect(body['success']).toBe(false); + expect(typeof body['error']).toBe('string'); + expect(body['serviceName']).toBe('definitely-not-a-real-service'); + }); + + it('keeps serving other routes while an execution is in flight', async () => { + // Regression guard for the handler blocking the Tokio runtime: /health must + // stay responsive during a concurrent (failing) execution. + const execution = client.executeService('definitely-not-a-real-service').catch(() => undefined); + const health = await client.health({ timeoutMs: 5_000 }); + expect(health.status).toBe('ok'); + await execution; + }); +}); diff --git a/sdk/ts/tsconfig.build.json b/sdk/ts/tsconfig.build.json new file mode 100644 index 0000000..b7e6f80 --- /dev/null +++ b/sdk/ts/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["test/**/*", "example.ts", "dist"] +} diff --git a/sdk/ts/tsconfig.json b/sdk/ts/tsconfig.json new file mode 100644 index 0000000..e87cbf9 --- /dev/null +++ b/sdk/ts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun"], + // Pin type resolution to this repo. Without it, tsc walks up past the + // repository root and can silently satisfy `@types/bun` from a global + // node_modules, so `typecheck` passes locally and fails in CI. + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true + }, + "include": ["src/**/*", "test/**/*", "example.ts"] +}