From bc816aa40d967eb36308fbc969a681c035964326 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 11:52:01 +0200 Subject: [PATCH 01/25] feat(ts-sdk): add typed runtime config package --- .gitignore | 2 + sdks/typescript/sof-sdk/README.md | 29 +++++ sdks/typescript/sof-sdk/package-lock.json | 47 ++++++++ sdks/typescript/sof-sdk/package.json | 27 +++++ sdks/typescript/sof-sdk/src/errors.ts | 11 ++ sdks/typescript/sof-sdk/src/index.ts | 3 + sdks/typescript/sof-sdk/src/result.ts | 32 ++++++ sdks/typescript/sof-sdk/src/runtime/index.ts | 2 + .../src/runtime/runtime-config.test.ts | 107 ++++++++++++++++++ .../sof-sdk/src/runtime/runtime-config.ts | 68 +++++++++++ .../src/runtime/runtime-delivery-profile.ts | 51 +++++++++ sdks/typescript/sof-sdk/tsconfig.json | 21 ++++ 12 files changed, 400 insertions(+) create mode 100644 sdks/typescript/sof-sdk/README.md create mode 100644 sdks/typescript/sof-sdk/package-lock.json create mode 100644 sdks/typescript/sof-sdk/package.json create mode 100644 sdks/typescript/sof-sdk/src/errors.ts create mode 100644 sdks/typescript/sof-sdk/src/index.ts create mode 100644 sdks/typescript/sof-sdk/src/result.ts create mode 100644 sdks/typescript/sof-sdk/src/runtime/index.ts create mode 100644 sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts create mode 100644 sdks/typescript/sof-sdk/src/runtime/runtime-config.ts create mode 100644 sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts create mode 100644 sdks/typescript/sof-sdk/tsconfig.json diff --git a/.gitignore b/.gitignore index 8d0d085f..0fc1846a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target/ +**/node_modules/ +**/dist/ # Editor/OS noise .DS_Store diff --git a/sdks/typescript/sof-sdk/README.md b/sdks/typescript/sof-sdk/README.md new file mode 100644 index 00000000..343b6156 --- /dev/null +++ b/sdks/typescript/sof-sdk/README.md @@ -0,0 +1,29 @@ +# `@sof/sdk` + +Unified TypeScript SDK surface for SOF. + +This initial package slice provides: + +- checked `Result` primitives +- enum-backed runtime delivery profile types +- typed SOF runtime config serialization for `SOF_RUNTIME_DELIVERY_PROFILE` + +## Example + +```ts +import { + ObserverRuntimeConfig, + RuntimeDeliveryProfile, + runtimeDeliveryProfileToEnvValue, +} from "@sof/sdk"; + +const config = new ObserverRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, +}); + +const env = config.toEnvironment(); +// { SOF_RUNTIME_DELIVERY_PROFILE: "balanced" } + +runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.DeliveryDisciplined); +// "delivery_disciplined" +``` diff --git a/sdks/typescript/sof-sdk/package-lock.json b/sdks/typescript/sof-sdk/package-lock.json new file mode 100644 index 00000000..a88db4a3 --- /dev/null +++ b/sdks/typescript/sof-sdk/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@sof/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sof/sdk", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdks/typescript/sof-sdk/package.json b/sdks/typescript/sof-sdk/package.json new file mode 100644 index 00000000..fc408db3 --- /dev/null +++ b/sdks/typescript/sof-sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@sof/sdk", + "version": "0.1.0", + "private": true, + "description": "Unified SOF TypeScript SDK", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test dist/**/*.test.js" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.9.3" + } +} diff --git a/sdks/typescript/sof-sdk/src/errors.ts b/sdks/typescript/sof-sdk/src/errors.ts new file mode 100644 index 00000000..9fb1db0d --- /dev/null +++ b/sdks/typescript/sof-sdk/src/errors.ts @@ -0,0 +1,11 @@ +export enum ValidationErrorKind { + InvalidRuntimeDeliveryProfile = 1, +} + +export interface ValidationError { + readonly kind: ValidationErrorKind; + readonly field: string; + readonly received: string; + readonly message: string; + readonly allowedValues?: readonly string[]; +} diff --git a/sdks/typescript/sof-sdk/src/index.ts b/sdks/typescript/sof-sdk/src/index.ts new file mode 100644 index 00000000..843f8952 --- /dev/null +++ b/sdks/typescript/sof-sdk/src/index.ts @@ -0,0 +1,3 @@ +export * from "./errors.js"; +export * from "./result.js"; +export * from "./runtime/index.js"; diff --git a/sdks/typescript/sof-sdk/src/result.ts b/sdks/typescript/sof-sdk/src/result.ts new file mode 100644 index 00000000..b95510df --- /dev/null +++ b/sdks/typescript/sof-sdk/src/result.ts @@ -0,0 +1,32 @@ +export enum ResultTag { + Ok = 1, + Err = 2, +} + +export type Ok = { + readonly tag: ResultTag.Ok; + readonly value: T; +}; + +export type Err = { + readonly tag: ResultTag.Err; + readonly error: E; +}; + +export type Result = Ok | Err; + +export function ok(value: T): Ok { + return { tag: ResultTag.Ok, value }; +} + +export function err(error: E): Err { + return { tag: ResultTag.Err, error }; +} + +export function isOk(result: Result): result is Ok { + return result.tag === ResultTag.Ok; +} + +export function isErr(result: Result): result is Err { + return result.tag === ResultTag.Err; +} diff --git a/sdks/typescript/sof-sdk/src/runtime/index.ts b/sdks/typescript/sof-sdk/src/runtime/index.ts new file mode 100644 index 00000000..69b4c414 --- /dev/null +++ b/sdks/typescript/sof-sdk/src/runtime/index.ts @@ -0,0 +1,2 @@ +export * from "./runtime-config.js"; +export * from "./runtime-delivery-profile.js"; diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts b/sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts new file mode 100644 index 00000000..639e8db7 --- /dev/null +++ b/sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ValidationErrorKind } from "../errors.js"; +import { isErr, isOk, ResultTag } from "../result.js"; +import { + ObserverRuntimeConfig, + RuntimeDeliveryProfile, + parseRuntimeDeliveryProfile, + runtimeDeliveryProfileEnvKey, + runtimeDeliveryProfileToEnvValue, +} from "./index.js"; + +test("result and runtime delivery profile discriminants stay stable", () => { + assert.equal(ResultTag.Ok, 1); + assert.equal(ResultTag.Err, 2); + assert.equal(RuntimeDeliveryProfile.LatencyOptimized, 1); + assert.equal(RuntimeDeliveryProfile.Balanced, 2); + assert.equal(RuntimeDeliveryProfile.DeliveryDisciplined, 3); +}); + +test("runtime delivery profile maps to the documented env values", () => { + assert.equal( + runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.LatencyOptimized), + "latency_optimized", + ); + assert.equal( + runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.Balanced), + "balanced", + ); + assert.equal( + runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.DeliveryDisciplined), + "delivery_disciplined", + ); +}); + +test("runtime delivery profile parser accepts documented aliases", () => { + const balanced = parseRuntimeDeliveryProfile(" balanced "); + const disciplined = parseRuntimeDeliveryProfile("delivery-disciplined"); + + assert.equal(isOk(balanced), true); + assert.equal(isOk(disciplined), true); + + if (isOk(balanced)) { + assert.equal(balanced.value, RuntimeDeliveryProfile.Balanced); + } + if (isOk(disciplined)) { + assert.equal( + disciplined.value, + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + } +}); + +test("runtime config omits the default profile unless requested", () => { + const config = new ObserverRuntimeConfig(); + + assert.deepEqual(config.toEnvironment(), {}); + assert.deepEqual(config.toEnvironment({ includeDefaults: true }), { + [runtimeDeliveryProfileEnvKey]: "latency_optimized", + }); +}); + +test("runtime config serializes explicit profile selection", () => { + const config = new ObserverRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + }); + + assert.deepEqual(config.toEnvironment(), { + [runtimeDeliveryProfileEnvKey]: "balanced", + }); +}); + +test("runtime config parses environment values into typed config", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [runtimeDeliveryProfileEnvKey]: "delivery_disciplined", + }); + + assert.equal(isOk(config), true); + if (isOk(config)) { + assert.equal( + config.value.runtimeDeliveryProfile, + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + } +}); + +test("runtime config rejects invalid delivery profile values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [runtimeDeliveryProfileEnvKey]: "fastest", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal( + config.error.kind, + ValidationErrorKind.InvalidRuntimeDeliveryProfile, + ); + assert.equal(config.error.field, runtimeDeliveryProfileEnvKey); + assert.equal(config.error.received, "fastest"); + assert.deepEqual(config.error.allowedValues, [ + "latency_optimized", + "balanced", + "delivery_disciplined", + ]); + } +}); diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-config.ts b/sdks/typescript/sof-sdk/src/runtime/runtime-config.ts new file mode 100644 index 00000000..abf958b2 --- /dev/null +++ b/sdks/typescript/sof-sdk/src/runtime/runtime-config.ts @@ -0,0 +1,68 @@ +import type { ValidationError } from "../errors.js"; +import { isErr, ok, type Result } from "../result.js"; +import { + parseRuntimeDeliveryProfile, + RuntimeDeliveryProfile, + runtimeDeliveryProfileEnvKey, + runtimeDeliveryProfileToEnvValue, +} from "./runtime-delivery-profile.js"; + +export interface ObserverRuntimeConfigInit { + readonly runtimeDeliveryProfile?: RuntimeDeliveryProfile; +} + +export interface ObserverRuntimeEnvironmentOptions { + readonly includeDefaults?: boolean; +} + +export class ObserverRuntimeConfig { + readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; + + constructor(init: ObserverRuntimeConfigInit = {}) { + this.runtimeDeliveryProfile = + init.runtimeDeliveryProfile ?? RuntimeDeliveryProfile.LatencyOptimized; + } + + toEnvironment( + options: ObserverRuntimeEnvironmentOptions = {}, + ): Readonly> { + if ( + options.includeDefaults !== true && + this.runtimeDeliveryProfile === RuntimeDeliveryProfile.LatencyOptimized + ) { + return {}; + } + + return { + [runtimeDeliveryProfileEnvKey]: runtimeDeliveryProfileToEnvValue( + this.runtimeDeliveryProfile, + ), + }; + } + + static fromEnvironment( + env: Readonly>, + ): Result { + const runtimeDeliveryProfile = env[runtimeDeliveryProfileEnvKey]; + + if ( + runtimeDeliveryProfile === undefined || + runtimeDeliveryProfile.trim() === "" + ) { + return ok(new ObserverRuntimeConfig()); + } + + const parsedRuntimeDeliveryProfile = parseRuntimeDeliveryProfile( + runtimeDeliveryProfile, + ); + if (isErr(parsedRuntimeDeliveryProfile)) { + return parsedRuntimeDeliveryProfile; + } + + return ok( + new ObserverRuntimeConfig({ + runtimeDeliveryProfile: parsedRuntimeDeliveryProfile.value, + }), + ); + } +} diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts new file mode 100644 index 00000000..eddc1709 --- /dev/null +++ b/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts @@ -0,0 +1,51 @@ +import { ValidationErrorKind, type ValidationError } from "../errors.js"; +import { err, ok, type Result } from "../result.js"; + +export enum RuntimeDeliveryProfile { + LatencyOptimized = 1, + Balanced = 2, + DeliveryDisciplined = 3, +} + +export const runtimeDeliveryProfileEnvKey = "SOF_RUNTIME_DELIVERY_PROFILE"; + +export function runtimeDeliveryProfileToEnvValue( + profile: RuntimeDeliveryProfile, +): string { + switch (profile) { + case RuntimeDeliveryProfile.LatencyOptimized: + return "latency_optimized"; + case RuntimeDeliveryProfile.Balanced: + return "balanced"; + case RuntimeDeliveryProfile.DeliveryDisciplined: + return "delivery_disciplined"; + } +} + +export function parseRuntimeDeliveryProfile( + input: string, +): Result { + const normalized = input.trim().toLowerCase().replaceAll("-", "_"); + + switch (normalized) { + case "latency_optimized": + return ok(RuntimeDeliveryProfile.LatencyOptimized); + case "balanced": + return ok(RuntimeDeliveryProfile.Balanced); + case "delivery_disciplined": + return ok(RuntimeDeliveryProfile.DeliveryDisciplined); + default: + return err({ + kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, + field: runtimeDeliveryProfileEnvKey, + received: input, + message: + "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", + allowedValues: [ + "latency_optimized", + "balanced", + "delivery_disciplined", + ], + }); + } +} diff --git a/sdks/typescript/sof-sdk/tsconfig.json b/sdks/typescript/sof-sdk/tsconfig.json new file mode 100644 index 00000000..93b36ef3 --- /dev/null +++ b/sdks/typescript/sof-sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "sourceMap": true, + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} From 182aa23b9344be5be441d9273f0d1cebc1e11b4e Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:00:01 +0200 Subject: [PATCH 02/25] refactor(ts-sdk): flatten package and brand runtime types --- sdks/typescript/{sof-sdk => }/README.md | 12 ++- .../{sof-sdk => }/package-lock.json | 0 sdks/typescript/{sof-sdk => }/package.json | 4 + sdks/typescript/sof-sdk/src/errors.ts | 11 --- sdks/typescript/sof-sdk/src/index.ts | 3 - sdks/typescript/sof-sdk/src/runtime/index.ts | 2 - .../src/runtime/runtime-delivery-profile.ts | 51 ------------ sdks/typescript/src/brand.ts | 9 +++ sdks/typescript/src/environment.ts | 52 ++++++++++++ sdks/typescript/src/errors.ts | 13 +++ sdks/typescript/src/index.ts | 5 ++ sdks/typescript/{sof-sdk => }/src/result.ts | 0 sdks/typescript/src/runtime.ts | 2 + .../src/runtime/runtime-config.test.ts | 62 +++++++++----- .../src/runtime/runtime-config.ts | 57 ++++++++++--- .../src/runtime/runtime-delivery-profile.ts | 80 +++++++++++++++++++ sdks/typescript/{sof-sdk => }/tsconfig.json | 0 17 files changed, 264 insertions(+), 99 deletions(-) rename sdks/typescript/{sof-sdk => }/README.md (58%) rename sdks/typescript/{sof-sdk => }/package-lock.json (100%) rename sdks/typescript/{sof-sdk => }/package.json (85%) delete mode 100644 sdks/typescript/sof-sdk/src/errors.ts delete mode 100644 sdks/typescript/sof-sdk/src/index.ts delete mode 100644 sdks/typescript/sof-sdk/src/runtime/index.ts delete mode 100644 sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts create mode 100644 sdks/typescript/src/brand.ts create mode 100644 sdks/typescript/src/environment.ts create mode 100644 sdks/typescript/src/errors.ts create mode 100644 sdks/typescript/src/index.ts rename sdks/typescript/{sof-sdk => }/src/result.ts (100%) create mode 100644 sdks/typescript/src/runtime.ts rename sdks/typescript/{sof-sdk => }/src/runtime/runtime-config.test.ts (62%) rename sdks/typescript/{sof-sdk => }/src/runtime/runtime-config.ts (52%) create mode 100644 sdks/typescript/src/runtime/runtime-delivery-profile.ts rename sdks/typescript/{sof-sdk => }/tsconfig.json (100%) diff --git a/sdks/typescript/sof-sdk/README.md b/sdks/typescript/README.md similarity index 58% rename from sdks/typescript/sof-sdk/README.md rename to sdks/typescript/README.md index 343b6156..2f15f847 100644 --- a/sdks/typescript/sof-sdk/README.md +++ b/sdks/typescript/README.md @@ -5,8 +5,10 @@ Unified TypeScript SDK surface for SOF. This initial package slice provides: - checked `Result` primitives +- branded/value-object types for domain strings - enum-backed runtime delivery profile types - typed SOF runtime config serialization for `SOF_RUNTIME_DELIVERY_PROFILE` +- typed environment entry helpers instead of only raw string maps ## Example @@ -14,7 +16,8 @@ This initial package slice provides: import { ObserverRuntimeConfig, RuntimeDeliveryProfile, - runtimeDeliveryProfileToEnvValue, + runtimeDeliveryProfileEnvValues, + runtimeDeliveryProfileEnvVarName, } from "@sof/sdk"; const config = new ObserverRuntimeConfig({ @@ -22,8 +25,11 @@ const config = new ObserverRuntimeConfig({ }); const env = config.toEnvironment(); +// [{ name: "SOF_RUNTIME_DELIVERY_PROFILE", value: "balanced" }] + +const envRecord = config.toEnvironmentRecord(); // { SOF_RUNTIME_DELIVERY_PROFILE: "balanced" } -runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.DeliveryDisciplined); -// "delivery_disciplined" +runtimeDeliveryProfileEnvVarName; +runtimeDeliveryProfileEnvValues.deliveryDisciplined; ``` diff --git a/sdks/typescript/sof-sdk/package-lock.json b/sdks/typescript/package-lock.json similarity index 100% rename from sdks/typescript/sof-sdk/package-lock.json rename to sdks/typescript/package-lock.json diff --git a/sdks/typescript/sof-sdk/package.json b/sdks/typescript/package.json similarity index 85% rename from sdks/typescript/sof-sdk/package.json rename to sdks/typescript/package.json index fc408db3..3fd3001a 100644 --- a/sdks/typescript/sof-sdk/package.json +++ b/sdks/typescript/package.json @@ -10,6 +10,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "import": "./dist/runtime.js" } }, "files": [ diff --git a/sdks/typescript/sof-sdk/src/errors.ts b/sdks/typescript/sof-sdk/src/errors.ts deleted file mode 100644 index 9fb1db0d..00000000 --- a/sdks/typescript/sof-sdk/src/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum ValidationErrorKind { - InvalidRuntimeDeliveryProfile = 1, -} - -export interface ValidationError { - readonly kind: ValidationErrorKind; - readonly field: string; - readonly received: string; - readonly message: string; - readonly allowedValues?: readonly string[]; -} diff --git a/sdks/typescript/sof-sdk/src/index.ts b/sdks/typescript/sof-sdk/src/index.ts deleted file mode 100644 index 843f8952..00000000 --- a/sdks/typescript/sof-sdk/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./errors.js"; -export * from "./result.js"; -export * from "./runtime/index.js"; diff --git a/sdks/typescript/sof-sdk/src/runtime/index.ts b/sdks/typescript/sof-sdk/src/runtime/index.ts deleted file mode 100644 index 69b4c414..00000000 --- a/sdks/typescript/sof-sdk/src/runtime/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./runtime-config.js"; -export * from "./runtime-delivery-profile.js"; diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts deleted file mode 100644 index eddc1709..00000000 --- a/sdks/typescript/sof-sdk/src/runtime/runtime-delivery-profile.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ValidationErrorKind, type ValidationError } from "../errors.js"; -import { err, ok, type Result } from "../result.js"; - -export enum RuntimeDeliveryProfile { - LatencyOptimized = 1, - Balanced = 2, - DeliveryDisciplined = 3, -} - -export const runtimeDeliveryProfileEnvKey = "SOF_RUNTIME_DELIVERY_PROFILE"; - -export function runtimeDeliveryProfileToEnvValue( - profile: RuntimeDeliveryProfile, -): string { - switch (profile) { - case RuntimeDeliveryProfile.LatencyOptimized: - return "latency_optimized"; - case RuntimeDeliveryProfile.Balanced: - return "balanced"; - case RuntimeDeliveryProfile.DeliveryDisciplined: - return "delivery_disciplined"; - } -} - -export function parseRuntimeDeliveryProfile( - input: string, -): Result { - const normalized = input.trim().toLowerCase().replaceAll("-", "_"); - - switch (normalized) { - case "latency_optimized": - return ok(RuntimeDeliveryProfile.LatencyOptimized); - case "balanced": - return ok(RuntimeDeliveryProfile.Balanced); - case "delivery_disciplined": - return ok(RuntimeDeliveryProfile.DeliveryDisciplined); - default: - return err({ - kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, - field: runtimeDeliveryProfileEnvKey, - received: input, - message: - "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", - allowedValues: [ - "latency_optimized", - "balanced", - "delivery_disciplined", - ], - }); - } -} diff --git a/sdks/typescript/src/brand.ts b/sdks/typescript/src/brand.ts new file mode 100644 index 00000000..d287ee82 --- /dev/null +++ b/sdks/typescript/src/brand.ts @@ -0,0 +1,9 @@ +declare const brandSymbol: unique symbol; + +export type Brand = T & { + readonly [brandSymbol]: Name; +}; + +export function brand(value: T): Brand { + return value as Brand; +} diff --git a/sdks/typescript/src/environment.ts b/sdks/typescript/src/environment.ts new file mode 100644 index 00000000..f7097488 --- /dev/null +++ b/sdks/typescript/src/environment.ts @@ -0,0 +1,52 @@ +import { brand, type Brand } from "./brand.js"; + +export type EnvVarName = Brand; + +export interface EnvironmentVariable< + Name extends EnvVarName = EnvVarName, + Value extends string = string, +> { + readonly name: Name; + readonly value: Value; +} + +export type EnvironmentInput = + | Readonly> + | readonly EnvironmentVariable[]; + +export function envVarName(value: Name): EnvVarName { + return brand(value); +} + +export function environmentVariable< + Name extends EnvVarName, + Value extends string, +>(name: Name, value: Value): EnvironmentVariable { + return { name, value }; +} + +export function environmentVariablesToRecord( + variables: readonly EnvironmentVariable[], +): Readonly> { + return Object.fromEntries( + variables.map((variable) => [variable.name, variable.value]), + ); +} + +export function readEnvironmentVariable( + input: EnvironmentInput, + name: EnvVarName, +): string | undefined { + if (Array.isArray(input)) { + for (let index = input.length - 1; index >= 0; index -= 1) { + const variable = input[index]; + if (variable?.name === name) { + return variable.value; + } + } + return undefined; + } + + const record = input as Readonly>; + return record[name]; +} diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts new file mode 100644 index 00000000..b2392620 --- /dev/null +++ b/sdks/typescript/src/errors.ts @@ -0,0 +1,13 @@ +import type { EnvVarName } from "./environment.js"; + +export enum ValidationErrorKind { + InvalidRuntimeDeliveryProfile = 1, +} + +export interface ValidationError { + readonly kind: ValidationErrorKind; + readonly field: EnvVarName; + readonly received: string; + readonly message: string; + readonly allowedValues?: readonly AllowedValue[]; +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts new file mode 100644 index 00000000..9302d627 --- /dev/null +++ b/sdks/typescript/src/index.ts @@ -0,0 +1,5 @@ +export * from "./brand.js"; +export * from "./environment.js"; +export * from "./errors.js"; +export * from "./result.js"; +export * from "./runtime.js"; diff --git a/sdks/typescript/sof-sdk/src/result.ts b/sdks/typescript/src/result.ts similarity index 100% rename from sdks/typescript/sof-sdk/src/result.ts rename to sdks/typescript/src/result.ts diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts new file mode 100644 index 00000000..8b820816 --- /dev/null +++ b/sdks/typescript/src/runtime.ts @@ -0,0 +1,2 @@ +export * from "./runtime/runtime-config.js"; +export * from "./runtime/runtime-delivery-profile.js"; diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts similarity index 62% rename from sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts rename to sdks/typescript/src/runtime/runtime-config.test.ts index 639e8db7..57a021bd 100644 --- a/sdks/typescript/sof-sdk/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -1,15 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { environmentVariable } from "../environment.js"; import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { ObserverRuntimeConfig, RuntimeDeliveryProfile, parseRuntimeDeliveryProfile, - runtimeDeliveryProfileEnvKey, + runtimeDeliveryProfileAllowedValues, + runtimeDeliveryProfileEnvValues, + runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, -} from "./index.js"; +} from "../runtime.js"; test("result and runtime delivery profile discriminants stay stable", () => { assert.equal(ResultTag.Ok, 1); @@ -22,15 +25,15 @@ test("result and runtime delivery profile discriminants stay stable", () => { test("runtime delivery profile maps to the documented env values", () => { assert.equal( runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.LatencyOptimized), - "latency_optimized", + runtimeDeliveryProfileEnvValues.latencyOptimized, ); assert.equal( runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.Balanced), - "balanced", + runtimeDeliveryProfileEnvValues.balanced, ); assert.equal( runtimeDeliveryProfileToEnvValue(RuntimeDeliveryProfile.DeliveryDisciplined), - "delivery_disciplined", + runtimeDeliveryProfileEnvValues.deliveryDisciplined, ); }); @@ -55,9 +58,10 @@ test("runtime delivery profile parser accepts documented aliases", () => { test("runtime config omits the default profile unless requested", () => { const config = new ObserverRuntimeConfig(); - assert.deepEqual(config.toEnvironment(), {}); - assert.deepEqual(config.toEnvironment({ includeDefaults: true }), { - [runtimeDeliveryProfileEnvKey]: "latency_optimized", + assert.deepEqual(config.toEnvironment(), []); + assert.deepEqual(config.toEnvironmentRecord({ includeDefaults: true }), { + [runtimeDeliveryProfileEnvVarName]: + runtimeDeliveryProfileEnvValues.latencyOptimized, }); }); @@ -66,14 +70,18 @@ test("runtime config serializes explicit profile selection", () => { runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, }); - assert.deepEqual(config.toEnvironment(), { - [runtimeDeliveryProfileEnvKey]: "balanced", - }); + assert.deepEqual(config.toEnvironment(), [ + environmentVariable( + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileEnvValues.balanced, + ), + ]); }); test("runtime config parses environment values into typed config", () => { const config = ObserverRuntimeConfig.fromEnvironment({ - [runtimeDeliveryProfileEnvKey]: "delivery_disciplined", + [runtimeDeliveryProfileEnvVarName]: + runtimeDeliveryProfileEnvValues.deliveryDisciplined, }); assert.equal(isOk(config), true); @@ -85,9 +93,26 @@ test("runtime config parses environment values into typed config", () => { } }); +test("runtime config parses typed environment variables into typed config", () => { + const config = ObserverRuntimeConfig.fromEnvironmentVariables([ + environmentVariable( + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileEnvValues.balanced, + ), + ]); + + assert.equal(isOk(config), true); + if (isOk(config)) { + assert.equal( + config.value.runtimeDeliveryProfile, + RuntimeDeliveryProfile.Balanced, + ); + } +}); + test("runtime config rejects invalid delivery profile values", () => { const config = ObserverRuntimeConfig.fromEnvironment({ - [runtimeDeliveryProfileEnvKey]: "fastest", + [runtimeDeliveryProfileEnvVarName]: "fastest", }); assert.equal(isErr(config), true); @@ -96,12 +121,11 @@ test("runtime config rejects invalid delivery profile values", () => { config.error.kind, ValidationErrorKind.InvalidRuntimeDeliveryProfile, ); - assert.equal(config.error.field, runtimeDeliveryProfileEnvKey); + assert.equal(config.error.field, runtimeDeliveryProfileEnvVarName); assert.equal(config.error.received, "fastest"); - assert.deepEqual(config.error.allowedValues, [ - "latency_optimized", - "balanced", - "delivery_disciplined", - ]); + assert.deepEqual( + config.error.allowedValues, + runtimeDeliveryProfileAllowedValues, + ); } }); diff --git a/sdks/typescript/sof-sdk/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts similarity index 52% rename from sdks/typescript/sof-sdk/src/runtime/runtime-config.ts rename to sdks/typescript/src/runtime/runtime-config.ts index abf958b2..ae7c3838 100644 --- a/sdks/typescript/sof-sdk/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -1,9 +1,17 @@ +import { + environmentVariable, + environmentVariablesToRecord, + readEnvironmentVariable, + type EnvironmentInput, + type EnvironmentVariable, +} from "../environment.js"; import type { ValidationError } from "../errors.js"; import { isErr, ok, type Result } from "../result.js"; import { parseRuntimeDeliveryProfile, RuntimeDeliveryProfile, - runtimeDeliveryProfileEnvKey, + type RuntimeDeliveryProfileEnvValue, + runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, } from "./runtime-delivery-profile.js"; @@ -15,6 +23,11 @@ export interface ObserverRuntimeEnvironmentOptions { readonly includeDefaults?: boolean; } +export type ObserverRuntimeEnvironmentVariable = EnvironmentVariable< + typeof runtimeDeliveryProfileEnvVarName, + RuntimeDeliveryProfileEnvValue +>; + export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; @@ -25,25 +38,37 @@ export class ObserverRuntimeConfig { toEnvironment( options: ObserverRuntimeEnvironmentOptions = {}, - ): Readonly> { + ): readonly ObserverRuntimeEnvironmentVariable[] { if ( options.includeDefaults !== true && this.runtimeDeliveryProfile === RuntimeDeliveryProfile.LatencyOptimized ) { - return {}; + return []; } - return { - [runtimeDeliveryProfileEnvKey]: runtimeDeliveryProfileToEnvValue( - this.runtimeDeliveryProfile, + return [ + environmentVariable( + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileToEnvValue( + this.runtimeDeliveryProfile, + ), ), - }; + ]; + } + + toEnvironmentRecord( + options: ObserverRuntimeEnvironmentOptions = {}, + ): Readonly> { + return environmentVariablesToRecord(this.toEnvironment(options)); } static fromEnvironment( - env: Readonly>, - ): Result { - const runtimeDeliveryProfile = env[runtimeDeliveryProfileEnvKey]; + env: EnvironmentInput, + ): Result> { + const runtimeDeliveryProfile = readEnvironmentVariable( + env, + runtimeDeliveryProfileEnvVarName, + ); if ( runtimeDeliveryProfile === undefined || @@ -65,4 +90,16 @@ export class ObserverRuntimeConfig { }), ); } + + static fromEnvironmentRecord( + env: Readonly>, + ): Result> { + return ObserverRuntimeConfig.fromEnvironment(env); + } + + static fromEnvironmentVariables( + env: readonly ObserverRuntimeEnvironmentVariable[], + ): Result> { + return ObserverRuntimeConfig.fromEnvironment(env); + } } diff --git a/sdks/typescript/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/src/runtime/runtime-delivery-profile.ts new file mode 100644 index 00000000..b3072a4f --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-delivery-profile.ts @@ -0,0 +1,80 @@ +import { brand, type Brand } from "../brand.js"; +import { envVarName } from "../environment.js"; +import { ValidationErrorKind, type ValidationError } from "../errors.js"; +import { err, ok, type Result } from "../result.js"; + +export enum RuntimeDeliveryProfile { + LatencyOptimized = 1, + Balanced = 2, + DeliveryDisciplined = 3, +} + +export type RuntimeDeliveryProfileEnvValue = Brand< + string, + "RuntimeDeliveryProfileEnvValue" +>; + +function asRuntimeDeliveryProfileEnvValue( + value: Value, +): RuntimeDeliveryProfileEnvValue { + return brand(value); +} + +export const runtimeDeliveryProfileEnvVarName = envVarName( + "SOF_RUNTIME_DELIVERY_PROFILE", +); + +export const runtimeDeliveryProfileEnvValues = { + latencyOptimized: asRuntimeDeliveryProfileEnvValue("latency_optimized"), + balanced: asRuntimeDeliveryProfileEnvValue("balanced"), + deliveryDisciplined: asRuntimeDeliveryProfileEnvValue( + "delivery_disciplined", + ), +} as const; + +export const runtimeDeliveryProfileAllowedValues: readonly RuntimeDeliveryProfileEnvValue[] = + [ + runtimeDeliveryProfileEnvValues.latencyOptimized, + runtimeDeliveryProfileEnvValues.balanced, + runtimeDeliveryProfileEnvValues.deliveryDisciplined, + ]; + +export function runtimeDeliveryProfileToEnvValue( + profile: RuntimeDeliveryProfile, +): RuntimeDeliveryProfileEnvValue { + switch (profile) { + case RuntimeDeliveryProfile.LatencyOptimized: + return runtimeDeliveryProfileEnvValues.latencyOptimized; + case RuntimeDeliveryProfile.Balanced: + return runtimeDeliveryProfileEnvValues.balanced; + case RuntimeDeliveryProfile.DeliveryDisciplined: + return runtimeDeliveryProfileEnvValues.deliveryDisciplined; + } +} + +export function parseRuntimeDeliveryProfile( + input: string, +): Result< + RuntimeDeliveryProfile, + ValidationError +> { + const normalized = input.trim().toLowerCase().replaceAll("-", "_"); + + switch (normalized) { + case "latency_optimized": + return ok(RuntimeDeliveryProfile.LatencyOptimized); + case "balanced": + return ok(RuntimeDeliveryProfile.Balanced); + case "delivery_disciplined": + return ok(RuntimeDeliveryProfile.DeliveryDisciplined); + default: + return err({ + kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, + field: runtimeDeliveryProfileEnvVarName, + received: input, + message: + "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", + allowedValues: runtimeDeliveryProfileAllowedValues, + }); + } +} diff --git a/sdks/typescript/sof-sdk/tsconfig.json b/sdks/typescript/tsconfig.json similarity index 100% rename from sdks/typescript/sof-sdk/tsconfig.json rename to sdks/typescript/tsconfig.json From d8c5baedc09c915f7745404a1187d03087c4c580 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:08:33 +0200 Subject: [PATCH 03/25] feat(ts-sdk): add typed runtime policy config --- sdks/typescript/README.md | 33 +++- sdks/typescript/src/errors.ts | 3 + sdks/typescript/src/runtime.ts | 1 + .../src/runtime/runtime-config.test.ts | 169 +++++++++++++++- sdks/typescript/src/runtime/runtime-config.ts | 176 ++++++++++++++--- sdks/typescript/src/runtime/runtime-policy.ts | 180 ++++++++++++++++++ 6 files changed, 529 insertions(+), 33 deletions(-) create mode 100644 sdks/typescript/src/runtime/runtime-policy.ts diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 2f15f847..a8278dd7 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -6,8 +6,12 @@ This initial package slice provides: - checked `Result` primitives - branded/value-object types for domain strings -- enum-backed runtime delivery profile types -- typed SOF runtime config serialization for `SOF_RUNTIME_DELIVERY_PROFILE` +- enum-backed runtime policy types +- typed SOF runtime config serialization for: + - `SOF_RUNTIME_DELIVERY_PROFILE` + - `SOF_SHRED_TRUST_MODE` + - `SOF_PROVIDER_STREAM_CAPABILITY_POLICY` + - `SOF_PROVIDER_STREAM_ALLOW_EOF` - typed environment entry helpers instead of only raw string maps ## Example @@ -15,21 +19,42 @@ This initial package slice provides: ```ts import { ObserverRuntimeConfig, + ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, + ShredTrustMode, + providerStreamAllowEofEnvVarName, + providerStreamCapabilityPolicyEnvVarName, runtimeDeliveryProfileEnvValues, runtimeDeliveryProfileEnvVarName, + shredTrustModeEnvVarName, } from "@sof/sdk"; const config = new ObserverRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + providerStreamAllowEof: true, }); const env = config.toEnvironment(); -// [{ name: "SOF_RUNTIME_DELIVERY_PROFILE", value: "balanced" }] +// [ +// { name: "SOF_RUNTIME_DELIVERY_PROFILE", value: "balanced" }, +// { name: "SOF_SHRED_TRUST_MODE", value: "trusted_raw_shred_provider" }, +// { name: "SOF_PROVIDER_STREAM_CAPABILITY_POLICY", value: "strict" }, +// { name: "SOF_PROVIDER_STREAM_ALLOW_EOF", value: "true" }, +// ] const envRecord = config.toEnvironmentRecord(); -// { SOF_RUNTIME_DELIVERY_PROFILE: "balanced" } +// { +// SOF_RUNTIME_DELIVERY_PROFILE: "balanced", +// SOF_SHRED_TRUST_MODE: "trusted_raw_shred_provider", +// SOF_PROVIDER_STREAM_CAPABILITY_POLICY: "strict", +// SOF_PROVIDER_STREAM_ALLOW_EOF: "true", +// } runtimeDeliveryProfileEnvVarName; runtimeDeliveryProfileEnvValues.deliveryDisciplined; +shredTrustModeEnvVarName; +providerStreamCapabilityPolicyEnvVarName; +providerStreamAllowEofEnvVarName; ``` diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index b2392620..23aa4e47 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -2,6 +2,9 @@ import type { EnvVarName } from "./environment.js"; export enum ValidationErrorKind { InvalidRuntimeDeliveryProfile = 1, + InvalidShredTrustMode = 2, + InvalidProviderStreamCapabilityPolicy = 3, + InvalidProviderStreamAllowEof = 4, } export interface ValidationError { diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index 8b820816..4f5ff71f 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -1,2 +1,3 @@ export * from "./runtime/runtime-config.js"; export * from "./runtime/runtime-delivery-profile.js"; +export * from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 57a021bd..e8842613 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -6,20 +6,40 @@ import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { ObserverRuntimeConfig, + ProviderStreamCapabilityPolicy, + providerStreamAllowEofEnvVarName, + providerStreamCapabilityPolicyAllowedValues, + providerStreamCapabilityPolicyEnvValues, + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyToEnvValue, + parseProviderStreamCapabilityPolicy, + parseRuntimeBoolean, + parseShredTrustMode, RuntimeDeliveryProfile, + runtimeBooleanAllowedValues, + runtimeBooleanEnvValues, parseRuntimeDeliveryProfile, + shredTrustModeAllowedValues, + shredTrustModeEnvValues, + shredTrustModeEnvVarName, + shredTrustModeToEnvValue, + ShredTrustMode, runtimeDeliveryProfileAllowedValues, runtimeDeliveryProfileEnvValues, runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, } from "../runtime.js"; -test("result and runtime delivery profile discriminants stay stable", () => { +test("result and runtime policy discriminants stay stable", () => { assert.equal(ResultTag.Ok, 1); assert.equal(ResultTag.Err, 2); assert.equal(RuntimeDeliveryProfile.LatencyOptimized, 1); assert.equal(RuntimeDeliveryProfile.Balanced, 2); assert.equal(RuntimeDeliveryProfile.DeliveryDisciplined, 3); + assert.equal(ShredTrustMode.PublicUntrusted, 1); + assert.equal(ShredTrustMode.TrustedRawShredProvider, 2); + assert.equal(ProviderStreamCapabilityPolicy.Warn, 1); + assert.equal(ProviderStreamCapabilityPolicy.Strict, 2); }); test("runtime delivery profile maps to the documented env values", () => { @@ -37,6 +57,29 @@ test("runtime delivery profile maps to the documented env values", () => { ); }); +test("runtime policy enums map to the documented env values", () => { + assert.equal( + shredTrustModeToEnvValue(ShredTrustMode.PublicUntrusted), + shredTrustModeEnvValues.publicUntrusted, + ); + assert.equal( + shredTrustModeToEnvValue(ShredTrustMode.TrustedRawShredProvider), + shredTrustModeEnvValues.trustedRawShredProvider, + ); + assert.equal( + providerStreamCapabilityPolicyToEnvValue( + ProviderStreamCapabilityPolicy.Warn, + ), + providerStreamCapabilityPolicyEnvValues.warn, + ); + assert.equal( + providerStreamCapabilityPolicyToEnvValue( + ProviderStreamCapabilityPolicy.Strict, + ), + providerStreamCapabilityPolicyEnvValues.strict, + ); +}); + test("runtime delivery profile parser accepts documented aliases", () => { const balanced = parseRuntimeDeliveryProfile(" balanced "); const disciplined = parseRuntimeDeliveryProfile("delivery-disciplined"); @@ -55,19 +98,46 @@ test("runtime delivery profile parser accepts documented aliases", () => { } }); -test("runtime config omits the default profile unless requested", () => { +test("runtime policy parsers accept documented aliases", () => { + const trusted = parseShredTrustMode("trusted-raw-shred-provider"); + const strict = parseProviderStreamCapabilityPolicy(" STRICT "); + const allowEof = parseRuntimeBoolean("YES", providerStreamAllowEofEnvVarName); + + assert.equal(isOk(trusted), true); + assert.equal(isOk(strict), true); + assert.equal(isOk(allowEof), true); + + if (isOk(trusted)) { + assert.equal(trusted.value, ShredTrustMode.TrustedRawShredProvider); + } + if (isOk(strict)) { + assert.equal(strict.value, ProviderStreamCapabilityPolicy.Strict); + } + if (isOk(allowEof)) { + assert.equal(allowEof.value, true); + } +}); + +test("runtime config omits default policy values unless requested", () => { const config = new ObserverRuntimeConfig(); assert.deepEqual(config.toEnvironment(), []); assert.deepEqual(config.toEnvironmentRecord({ includeDefaults: true }), { [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.latencyOptimized, + [shredTrustModeEnvVarName]: shredTrustModeEnvValues.publicUntrusted, + [providerStreamCapabilityPolicyEnvVarName]: + providerStreamCapabilityPolicyEnvValues.warn, + [providerStreamAllowEofEnvVarName]: runtimeBooleanEnvValues.false, }); }); -test("runtime config serializes explicit profile selection", () => { +test("runtime config serializes explicit runtime policy selection", () => { const config = new ObserverRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + providerStreamAllowEof: true, }); assert.deepEqual(config.toEnvironment(), [ @@ -75,13 +145,29 @@ test("runtime config serializes explicit profile selection", () => { runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues.balanced, ), + environmentVariable( + shredTrustModeEnvVarName, + shredTrustModeEnvValues.trustedRawShredProvider, + ), + environmentVariable( + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyEnvValues.strict, + ), + environmentVariable( + providerStreamAllowEofEnvVarName, + runtimeBooleanEnvValues.true, + ), ]); }); -test("runtime config parses environment values into typed config", () => { +test("runtime config parses environment values into typed runtime policy config", () => { const config = ObserverRuntimeConfig.fromEnvironment({ [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.deliveryDisciplined, + [shredTrustModeEnvVarName]: shredTrustModeEnvValues.trustedRawShredProvider, + [providerStreamCapabilityPolicyEnvVarName]: + providerStreamCapabilityPolicyEnvValues.strict, + [providerStreamAllowEofEnvVarName]: "on", }); assert.equal(isOk(config), true); @@ -90,6 +176,15 @@ test("runtime config parses environment values into typed config", () => { config.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined, ); + assert.equal( + config.value.shredTrustMode, + ShredTrustMode.TrustedRawShredProvider, + ); + assert.equal( + config.value.providerStreamCapabilityPolicy, + ProviderStreamCapabilityPolicy.Strict, + ); + assert.equal(config.value.providerStreamAllowEof, true); } }); @@ -99,6 +194,18 @@ test("runtime config parses typed environment variables into typed config", () = runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues.balanced, ), + environmentVariable( + shredTrustModeEnvVarName, + shredTrustModeEnvValues.publicUntrusted, + ), + environmentVariable( + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyEnvValues.warn, + ), + environmentVariable( + providerStreamAllowEofEnvVarName, + runtimeBooleanEnvValues.false, + ), ]); assert.equal(isOk(config), true); @@ -107,6 +214,12 @@ test("runtime config parses typed environment variables into typed config", () = config.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced, ); + assert.equal(config.value.shredTrustMode, ShredTrustMode.PublicUntrusted); + assert.equal( + config.value.providerStreamCapabilityPolicy, + ProviderStreamCapabilityPolicy.Warn, + ); + assert.equal(config.value.providerStreamAllowEof, false); } }); @@ -129,3 +242,51 @@ test("runtime config rejects invalid delivery profile values", () => { ); } }); + +test("runtime config rejects invalid shred trust mode values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [shredTrustModeEnvVarName]: "trusted", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal(config.error.kind, ValidationErrorKind.InvalidShredTrustMode); + assert.equal(config.error.field, shredTrustModeEnvVarName); + assert.deepEqual(config.error.allowedValues, shredTrustModeAllowedValues); + } +}); + +test("runtime config rejects invalid provider stream capability policy values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [providerStreamCapabilityPolicyEnvVarName]: "fail-fast", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal( + config.error.kind, + ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, + ); + assert.equal(config.error.field, providerStreamCapabilityPolicyEnvVarName); + assert.deepEqual( + config.error.allowedValues, + providerStreamCapabilityPolicyAllowedValues, + ); + } +}); + +test("runtime config rejects invalid provider stream eof values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [providerStreamAllowEofEnvVarName]: "sometimes", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal( + config.error.kind, + ValidationErrorKind.InvalidProviderStreamAllowEof, + ); + assert.equal(config.error.field, providerStreamAllowEofEnvVarName); + assert.deepEqual(config.error.allowedValues, runtimeBooleanAllowedValues); + } +}); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index ae7c3838..a23f3c1b 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -14,46 +14,123 @@ import { runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, } from "./runtime-delivery-profile.js"; +import { + parseProviderStreamCapabilityPolicy, + parseRuntimeBoolean, + parseShredTrustMode, + ProviderStreamCapabilityPolicy, + providerStreamAllowEofEnvVarName, + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyToEnvValue, + type ProviderStreamCapabilityPolicyEnvValue, + runtimeBooleanToEnvValue, + type RuntimeBooleanEnvValue, + ShredTrustMode, + shredTrustModeEnvVarName, + shredTrustModeToEnvValue, + type ShredTrustModeEnvValue, +} from "./runtime-policy.js"; export interface ObserverRuntimeConfigInit { readonly runtimeDeliveryProfile?: RuntimeDeliveryProfile; + readonly shredTrustMode?: ShredTrustMode; + readonly providerStreamCapabilityPolicy?: ProviderStreamCapabilityPolicy; + readonly providerStreamAllowEof?: boolean; } export interface ObserverRuntimeEnvironmentOptions { readonly includeDefaults?: boolean; } -export type ObserverRuntimeEnvironmentVariable = EnvironmentVariable< - typeof runtimeDeliveryProfileEnvVarName, - RuntimeDeliveryProfileEnvValue ->; +export type ObserverRuntimeEnvironmentVariable = + | EnvironmentVariable< + typeof runtimeDeliveryProfileEnvVarName, + RuntimeDeliveryProfileEnvValue + > + | EnvironmentVariable + | EnvironmentVariable< + typeof providerStreamCapabilityPolicyEnvVarName, + ProviderStreamCapabilityPolicyEnvValue + > + | EnvironmentVariable< + typeof providerStreamAllowEofEnvVarName, + RuntimeBooleanEnvValue + >; + +export type ObserverRuntimeValidationError = + | ValidationError + | ValidationError + | ValidationError + | ValidationError; export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; + readonly shredTrustMode: ShredTrustMode; + readonly providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy; + readonly providerStreamAllowEof: boolean; constructor(init: ObserverRuntimeConfigInit = {}) { this.runtimeDeliveryProfile = init.runtimeDeliveryProfile ?? RuntimeDeliveryProfile.LatencyOptimized; + this.shredTrustMode = init.shredTrustMode ?? ShredTrustMode.PublicUntrusted; + this.providerStreamCapabilityPolicy = + init.providerStreamCapabilityPolicy ?? ProviderStreamCapabilityPolicy.Warn; + this.providerStreamAllowEof = init.providerStreamAllowEof ?? false; } toEnvironment( options: ObserverRuntimeEnvironmentOptions = {}, ): readonly ObserverRuntimeEnvironmentVariable[] { + const environment: ObserverRuntimeEnvironmentVariable[] = []; + if ( options.includeDefaults !== true && this.runtimeDeliveryProfile === RuntimeDeliveryProfile.LatencyOptimized - ) { - return []; + ) {} else { + environment.push( + environmentVariable( + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileToEnvValue(this.runtimeDeliveryProfile), + ), + ); + } + + if ( + options.includeDefaults !== true && + this.shredTrustMode === ShredTrustMode.PublicUntrusted + ) {} else { + environment.push( + environmentVariable( + shredTrustModeEnvVarName, + shredTrustModeToEnvValue(this.shredTrustMode), + ), + ); + } + + if ( + options.includeDefaults !== true && + this.providerStreamCapabilityPolicy === ProviderStreamCapabilityPolicy.Warn + ) {} else { + environment.push( + environmentVariable( + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyToEnvValue( + this.providerStreamCapabilityPolicy, + ), + ), + ); } - return [ - environmentVariable( - runtimeDeliveryProfileEnvVarName, - runtimeDeliveryProfileToEnvValue( - this.runtimeDeliveryProfile, + if (options.includeDefaults === true || this.providerStreamAllowEof) { + environment.push( + environmentVariable( + providerStreamAllowEofEnvVarName, + runtimeBooleanToEnvValue(this.providerStreamAllowEof), ), - ), - ]; + ); + } + + return environment; } toEnvironmentRecord( @@ -64,42 +141,91 @@ export class ObserverRuntimeConfig { static fromEnvironment( env: EnvironmentInput, - ): Result> { + ): Result { const runtimeDeliveryProfile = readEnvironmentVariable( env, runtimeDeliveryProfileEnvVarName, ); + const shredTrustMode = readEnvironmentVariable(env, shredTrustModeEnvVarName); + const providerStreamCapabilityPolicy = readEnvironmentVariable( + env, + providerStreamCapabilityPolicyEnvVarName, + ); + const providerStreamAllowEof = readEnvironmentVariable( + env, + providerStreamAllowEofEnvVarName, + ); + let parsedRuntimeDeliveryProfile = RuntimeDeliveryProfile.LatencyOptimized; if ( - runtimeDeliveryProfile === undefined || - runtimeDeliveryProfile.trim() === "" + runtimeDeliveryProfile !== undefined && + runtimeDeliveryProfile.trim() !== "" ) { - return ok(new ObserverRuntimeConfig()); + const parsed = parseRuntimeDeliveryProfile(runtimeDeliveryProfile); + if (isErr(parsed)) { + return parsed; + } + parsedRuntimeDeliveryProfile = parsed.value; } - const parsedRuntimeDeliveryProfile = parseRuntimeDeliveryProfile( - runtimeDeliveryProfile, - ); - if (isErr(parsedRuntimeDeliveryProfile)) { - return parsedRuntimeDeliveryProfile; + let parsedShredTrustMode = ShredTrustMode.PublicUntrusted; + if (shredTrustMode !== undefined && shredTrustMode.trim() !== "") { + const parsed = parseShredTrustMode(shredTrustMode); + if (isErr(parsed)) { + return parsed; + } + parsedShredTrustMode = parsed.value; + } + + let parsedProviderStreamCapabilityPolicy = + ProviderStreamCapabilityPolicy.Warn; + if ( + providerStreamCapabilityPolicy !== undefined && + providerStreamCapabilityPolicy.trim() !== "" + ) { + const parsed = parseProviderStreamCapabilityPolicy( + providerStreamCapabilityPolicy, + ); + if (isErr(parsed)) { + return parsed; + } + parsedProviderStreamCapabilityPolicy = parsed.value; + } + + let parsedProviderStreamAllowEof = false; + if ( + providerStreamAllowEof !== undefined && + providerStreamAllowEof.trim() !== "" + ) { + const parsed = parseRuntimeBoolean( + providerStreamAllowEof, + providerStreamAllowEofEnvVarName, + ); + if (isErr(parsed)) { + return parsed; + } + parsedProviderStreamAllowEof = parsed.value; } return ok( new ObserverRuntimeConfig({ - runtimeDeliveryProfile: parsedRuntimeDeliveryProfile.value, + runtimeDeliveryProfile: parsedRuntimeDeliveryProfile, + shredTrustMode: parsedShredTrustMode, + providerStreamCapabilityPolicy: parsedProviderStreamCapabilityPolicy, + providerStreamAllowEof: parsedProviderStreamAllowEof, }), ); } static fromEnvironmentRecord( env: Readonly>, - ): Result> { + ): Result { return ObserverRuntimeConfig.fromEnvironment(env); } static fromEnvironmentVariables( env: readonly ObserverRuntimeEnvironmentVariable[], - ): Result> { + ): Result { return ObserverRuntimeConfig.fromEnvironment(env); } } diff --git a/sdks/typescript/src/runtime/runtime-policy.ts b/sdks/typescript/src/runtime/runtime-policy.ts new file mode 100644 index 00000000..a629f6cb --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-policy.ts @@ -0,0 +1,180 @@ +import { brand, type Brand } from "../brand.js"; +import { envVarName, type EnvVarName } from "../environment.js"; +import { ValidationErrorKind, type ValidationError } from "../errors.js"; +import { err, ok, type Result } from "../result.js"; + +export enum ShredTrustMode { + PublicUntrusted = 1, + TrustedRawShredProvider = 2, +} + +export enum ProviderStreamCapabilityPolicy { + Warn = 1, + Strict = 2, +} + +export type ShredTrustModeEnvValue = Brand; +export type ProviderStreamCapabilityPolicyEnvValue = Brand< + string, + "ProviderStreamCapabilityPolicyEnvValue" +>; +export type RuntimeBooleanEnvValue = Brand; + +function asShredTrustModeEnvValue( + value: Value, +): ShredTrustModeEnvValue { + return brand(value); +} + +function asProviderStreamCapabilityPolicyEnvValue( + value: Value, +): ProviderStreamCapabilityPolicyEnvValue { + return brand(value); +} + +function asRuntimeBooleanEnvValue( + value: Value, +): RuntimeBooleanEnvValue { + return brand(value); +} + +export const shredTrustModeEnvVarName = envVarName("SOF_SHRED_TRUST_MODE"); +export const providerStreamCapabilityPolicyEnvVarName = envVarName( + "SOF_PROVIDER_STREAM_CAPABILITY_POLICY", +); +export const providerStreamAllowEofEnvVarName = envVarName( + "SOF_PROVIDER_STREAM_ALLOW_EOF", +); + +export const shredTrustModeEnvValues = { + publicUntrusted: asShredTrustModeEnvValue("public_untrusted"), + trustedRawShredProvider: asShredTrustModeEnvValue( + "trusted_raw_shred_provider", + ), +} as const; + +export const providerStreamCapabilityPolicyEnvValues = { + warn: asProviderStreamCapabilityPolicyEnvValue("warn"), + strict: asProviderStreamCapabilityPolicyEnvValue("strict"), +} as const; + +export const runtimeBooleanEnvValues = { + true: asRuntimeBooleanEnvValue("true"), + false: asRuntimeBooleanEnvValue("false"), +} as const; + +export const shredTrustModeAllowedValues: readonly ShredTrustModeEnvValue[] = [ + shredTrustModeEnvValues.publicUntrusted, + shredTrustModeEnvValues.trustedRawShredProvider, +]; + +export const providerStreamCapabilityPolicyAllowedValues: readonly ProviderStreamCapabilityPolicyEnvValue[] = + [ + providerStreamCapabilityPolicyEnvValues.warn, + providerStreamCapabilityPolicyEnvValues.strict, + ]; + +export const runtimeBooleanAllowedValues: readonly RuntimeBooleanEnvValue[] = [ + runtimeBooleanEnvValues.true, + runtimeBooleanEnvValues.false, +]; + +export function shredTrustModeToEnvValue( + mode: ShredTrustMode, +): ShredTrustModeEnvValue { + switch (mode) { + case ShredTrustMode.PublicUntrusted: + return shredTrustModeEnvValues.publicUntrusted; + case ShredTrustMode.TrustedRawShredProvider: + return shredTrustModeEnvValues.trustedRawShredProvider; + } +} + +export function providerStreamCapabilityPolicyToEnvValue( + policy: ProviderStreamCapabilityPolicy, +): ProviderStreamCapabilityPolicyEnvValue { + switch (policy) { + case ProviderStreamCapabilityPolicy.Warn: + return providerStreamCapabilityPolicyEnvValues.warn; + case ProviderStreamCapabilityPolicy.Strict: + return providerStreamCapabilityPolicyEnvValues.strict; + } +} + +export function runtimeBooleanToEnvValue(value: boolean): RuntimeBooleanEnvValue { + return value ? runtimeBooleanEnvValues.true : runtimeBooleanEnvValues.false; +} + +export function parseShredTrustMode( + input: string, +): Result> { + const normalized = input.trim().toLowerCase().replaceAll("-", "_"); + + switch (normalized) { + case "public_untrusted": + return ok(ShredTrustMode.PublicUntrusted); + case "trusted_raw_shred_provider": + return ok(ShredTrustMode.TrustedRawShredProvider); + default: + return err({ + kind: ValidationErrorKind.InvalidShredTrustMode, + field: shredTrustModeEnvVarName, + received: input, + message: + "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + allowedValues: shredTrustModeAllowedValues, + }); + } +} + +export function parseProviderStreamCapabilityPolicy( + input: string, +): Result< + ProviderStreamCapabilityPolicy, + ValidationError +> { + const normalized = input.trim().toLowerCase(); + + switch (normalized) { + case "warn": + return ok(ProviderStreamCapabilityPolicy.Warn); + case "strict": + return ok(ProviderStreamCapabilityPolicy.Strict); + default: + return err({ + kind: ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, + field: providerStreamCapabilityPolicyEnvVarName, + received: input, + message: "provider stream capability policy must be warn or strict", + allowedValues: providerStreamCapabilityPolicyAllowedValues, + }); + } +} + +export function parseRuntimeBoolean( + input: string, + field: EnvVarName, +): Result> { + const normalized = input.trim().toLowerCase(); + + switch (normalized) { + case "1": + case "true": + case "yes": + case "on": + return ok(true); + case "0": + case "false": + case "no": + case "off": + return ok(false); + default: + return err({ + kind: ValidationErrorKind.InvalidProviderStreamAllowEof, + field, + received: input, + message: "boolean env value must be true or false", + allowedValues: runtimeBooleanAllowedValues, + }); + } +} From 140a66c93b58d971a518dba87d79010c2f379f5a Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:18:47 +0200 Subject: [PATCH 04/25] feat(ts-sdk): add typed derived-state runtime config --- sdks/typescript/README.md | 45 ++- sdks/typescript/src/errors.ts | 3 + sdks/typescript/src/runtime.ts | 1 + sdks/typescript/src/runtime/derived-state.ts | 285 ++++++++++++++++++ .../src/runtime/runtime-config.test.ts | 232 ++++++++++++++ sdks/typescript/src/runtime/runtime-config.ts | 277 ++++++++++++++++- 6 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 sdks/typescript/src/runtime/derived-state.ts diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index a8278dd7..160def3d 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -7,21 +7,34 @@ This initial package slice provides: - checked `Result` primitives - branded/value-object types for domain strings - enum-backed runtime policy types -- typed SOF runtime config serialization for: +- typed SOF runtime config serialization and parsing for: - `SOF_RUNTIME_DELIVERY_PROFILE` - `SOF_SHRED_TRUST_MODE` - `SOF_PROVIDER_STREAM_CAPABILITY_POLICY` - `SOF_PROVIDER_STREAM_ALLOW_EOF` + - `SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS` + - `SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS` + - `SOF_DERIVED_STATE_REPLAY_BACKEND` + - `SOF_DERIVED_STATE_REPLAY_DIR` + - `SOF_DERIVED_STATE_REPLAY_DURABILITY` + - `SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES` + - `SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS` +- nested derived-state runtime config with safe defaults and checkpoint-only replay helper - typed environment entry helpers instead of only raw string maps ## Example ```ts import { + DerivedStateReplayBackend, + DerivedStateReplayConfig, + DerivedStateReplayDurability, + DerivedStateRuntimeConfig, ObserverRuntimeConfig, ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, ShredTrustMode, + derivedStateReplayDirectory, providerStreamAllowEofEnvVarName, providerStreamCapabilityPolicyEnvVarName, runtimeDeliveryProfileEnvValues, @@ -34,6 +47,17 @@ const config = new ObserverRuntimeConfig({ shredTrustMode: ShredTrustMode.TrustedRawShredProvider, providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, providerStreamAllowEof: true, + derivedState: new DerivedStateRuntimeConfig({ + checkpointIntervalMs: 60_000, + recoveryIntervalMs: 10_000, + replay: new DerivedStateReplayConfig({ + backend: DerivedStateReplayBackend.Disk, + replayDirectory: derivedStateReplayDirectory(".sof-replay"), + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, + }), + }), }); const env = config.toEnvironment(); @@ -42,6 +66,13 @@ const env = config.toEnvironment(); // { name: "SOF_SHRED_TRUST_MODE", value: "trusted_raw_shred_provider" }, // { name: "SOF_PROVIDER_STREAM_CAPABILITY_POLICY", value: "strict" }, // { name: "SOF_PROVIDER_STREAM_ALLOW_EOF", value: "true" }, +// { name: "SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS", value: "60000" }, +// { name: "SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS", value: "10000" }, +// { name: "SOF_DERIVED_STATE_REPLAY_BACKEND", value: "disk" }, +// { name: "SOF_DERIVED_STATE_REPLAY_DIR", value: ".sof-replay" }, +// { name: "SOF_DERIVED_STATE_REPLAY_DURABILITY", value: "fsync" }, +// { name: "SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES", value: "1024" }, +// { name: "SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS", value: "2" }, // ] const envRecord = config.toEnvironmentRecord(); @@ -50,11 +81,23 @@ const envRecord = config.toEnvironmentRecord(); // SOF_SHRED_TRUST_MODE: "trusted_raw_shred_provider", // SOF_PROVIDER_STREAM_CAPABILITY_POLICY: "strict", // SOF_PROVIDER_STREAM_ALLOW_EOF: "true", +// SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS: "60000", +// SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS: "10000", +// SOF_DERIVED_STATE_REPLAY_BACKEND: "disk", +// SOF_DERIVED_STATE_REPLAY_DIR: ".sof-replay", +// SOF_DERIVED_STATE_REPLAY_DURABILITY: "fsync", +// SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "1024", +// SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS: "2", // } +const parsed = ObserverRuntimeConfig.fromEnvironmentRecord(envRecord); +const checkpointOnly = DerivedStateReplayConfig.checkpointOnly(); + runtimeDeliveryProfileEnvVarName; runtimeDeliveryProfileEnvValues.deliveryDisciplined; shredTrustModeEnvVarName; providerStreamCapabilityPolicyEnvVarName; providerStreamAllowEofEnvVarName; +parsed; +checkpointOnly; ``` diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 23aa4e47..4c71469a 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -5,6 +5,9 @@ export enum ValidationErrorKind { InvalidShredTrustMode = 2, InvalidProviderStreamCapabilityPolicy = 3, InvalidProviderStreamAllowEof = 4, + InvalidDerivedStateReplayBackend = 5, + InvalidDerivedStateReplayDurability = 6, + InvalidNonNegativeInteger = 7, } export interface ValidationError { diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index 4f5ff71f..da4414da 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -1,3 +1,4 @@ +export * from "./runtime/derived-state.js"; export * from "./runtime/runtime-config.js"; export * from "./runtime/runtime-delivery-profile.js"; export * from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/derived-state.ts b/sdks/typescript/src/runtime/derived-state.ts new file mode 100644 index 00000000..f79b188a --- /dev/null +++ b/sdks/typescript/src/runtime/derived-state.ts @@ -0,0 +1,285 @@ +import { brand, type Brand } from "../brand.js"; +import { envVarName } from "../environment.js"; +import { ValidationErrorKind, type ValidationError } from "../errors.js"; +import { err, ok, type Result } from "../result.js"; + +export enum DerivedStateReplayBackend { + Memory = 1, + Disk = 2, +} + +export enum DerivedStateReplayDurability { + Flush = 1, + Fsync = 2, +} + +export type DerivedStateReplayBackendEnvValue = Brand< + string, + "DerivedStateReplayBackendEnvValue" +>; +export type DerivedStateReplayDurabilityEnvValue = Brand< + string, + "DerivedStateReplayDurabilityEnvValue" +>; +export type NonNegativeIntegerEnvValue = Brand< + string, + "NonNegativeIntegerEnvValue" +>; +export type DerivedStateReplayDirectory = Brand< + string, + "DerivedStateReplayDirectory" +>; + +function asDerivedStateReplayBackendEnvValue( + value: Value, +): DerivedStateReplayBackendEnvValue { + return brand(value); +} + +function asDerivedStateReplayDurabilityEnvValue( + value: Value, +): DerivedStateReplayDurabilityEnvValue { + return brand(value); +} + +function asNonNegativeIntegerEnvValue( + value: Value, +): NonNegativeIntegerEnvValue { + return brand(value); +} + +function asDerivedStateReplayDirectory( + value: Value, +): DerivedStateReplayDirectory { + return brand(value); +} + +export const derivedStateCheckpointIntervalEnvVarName = envVarName( + "SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS", +); +export const derivedStateRecoveryIntervalEnvVarName = envVarName( + "SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS", +); +export const derivedStateReplayBackendEnvVarName = envVarName( + "SOF_DERIVED_STATE_REPLAY_BACKEND", +); +export const derivedStateReplayDirEnvVarName = envVarName( + "SOF_DERIVED_STATE_REPLAY_DIR", +); +export const derivedStateReplayDurabilityEnvVarName = envVarName( + "SOF_DERIVED_STATE_REPLAY_DURABILITY", +); +export const derivedStateReplayMaxEnvelopesEnvVarName = envVarName( + "SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES", +); +export const derivedStateReplayMaxSessionsEnvVarName = envVarName( + "SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS", +); + +export const derivedStateReplayBackendEnvValues = { + memory: asDerivedStateReplayBackendEnvValue("memory"), + disk: asDerivedStateReplayBackendEnvValue("disk"), +} as const; + +export const derivedStateReplayDurabilityEnvValues = { + flush: asDerivedStateReplayDurabilityEnvValue("flush"), + fsync: asDerivedStateReplayDurabilityEnvValue("fsync"), +} as const; + +export const derivedStateReplayBackendAllowedValues: readonly DerivedStateReplayBackendEnvValue[] = + [ + derivedStateReplayBackendEnvValues.memory, + derivedStateReplayBackendEnvValues.disk, + ]; + +export const derivedStateReplayDurabilityAllowedValues: readonly DerivedStateReplayDurabilityEnvValue[] = + [ + derivedStateReplayDurabilityEnvValues.flush, + derivedStateReplayDurabilityEnvValues.fsync, + ]; + +export const defaultDerivedStateReplayDirectory = asDerivedStateReplayDirectory( + ".sof-derived-state-replay", +); + +export function derivedStateReplayDirectory( + value: string, +): DerivedStateReplayDirectory { + return asDerivedStateReplayDirectory(value); +} + +export function derivedStateReplayBackendToEnvValue( + backend: DerivedStateReplayBackend, +): DerivedStateReplayBackendEnvValue { + switch (backend) { + case DerivedStateReplayBackend.Memory: + return derivedStateReplayBackendEnvValues.memory; + case DerivedStateReplayBackend.Disk: + return derivedStateReplayBackendEnvValues.disk; + } +} + +export function derivedStateReplayDurabilityToEnvValue( + durability: DerivedStateReplayDurability, +): DerivedStateReplayDurabilityEnvValue { + switch (durability) { + case DerivedStateReplayDurability.Flush: + return derivedStateReplayDurabilityEnvValues.flush; + case DerivedStateReplayDurability.Fsync: + return derivedStateReplayDurabilityEnvValues.fsync; + } +} + +export function parseDerivedStateReplayBackend( + input: string, +): Result< + DerivedStateReplayBackend, + ValidationError +> { + const normalized = input.trim().toLowerCase(); + + switch (normalized) { + case "memory": + return ok(DerivedStateReplayBackend.Memory); + case "disk": + return ok(DerivedStateReplayBackend.Disk); + default: + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayBackend, + field: derivedStateReplayBackendEnvVarName, + received: input, + message: "derived-state replay backend must be memory or disk", + allowedValues: derivedStateReplayBackendAllowedValues, + }); + } +} + +export function parseDerivedStateReplayDurability( + input: string, +): Result< + DerivedStateReplayDurability, + ValidationError +> { + const normalized = input.trim().toLowerCase(); + + switch (normalized) { + case "flush": + return ok(DerivedStateReplayDurability.Flush); + case "fsync": + return ok(DerivedStateReplayDurability.Fsync); + default: + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayDurability, + field: derivedStateReplayDurabilityEnvVarName, + received: input, + message: "derived-state replay durability must be flush or fsync", + allowedValues: derivedStateReplayDurabilityAllowedValues, + }); + } +} + +export function parseNonNegativeInteger( + input: string, + field: ReturnType, +): Result { + if (input.trim() === "") { + return err({ + kind: ValidationErrorKind.InvalidNonNegativeInteger, + field, + received: input, + message: "numeric env value must be a non-negative integer", + }); + } + + const parsed = Number(input); + if (!Number.isInteger(parsed) || parsed < 0) { + return err({ + kind: ValidationErrorKind.InvalidNonNegativeInteger, + field, + received: input, + message: "numeric env value must be a non-negative integer", + }); + } + + return ok(parsed); +} + +function requireNonNegativeInteger(field: string, value: number): number { + if (!Number.isInteger(value) || value < 0) { + throw new RangeError(`${field} must be a non-negative integer`); + } + + return value; +} + +export function nonNegativeIntegerToEnvValue( + value: number, +): NonNegativeIntegerEnvValue { + return asNonNegativeIntegerEnvValue(`${requireNonNegativeInteger("value", value)}`); +} + +export interface DerivedStateReplayConfigInit { + readonly backend?: DerivedStateReplayBackend; + readonly replayDirectory?: DerivedStateReplayDirectory; + readonly durability?: DerivedStateReplayDurability; + readonly maxEnvelopes?: number; + readonly maxSessions?: number; +} + +export class DerivedStateReplayConfig { + readonly backend: DerivedStateReplayBackend; + readonly replayDirectory: DerivedStateReplayDirectory; + readonly durability: DerivedStateReplayDurability; + readonly maxEnvelopes: number; + readonly maxSessions: number; + + constructor(init: DerivedStateReplayConfigInit = {}) { + this.backend = init.backend ?? DerivedStateReplayBackend.Memory; + this.replayDirectory = + init.replayDirectory ?? defaultDerivedStateReplayDirectory; + this.durability = init.durability ?? DerivedStateReplayDurability.Flush; + this.maxEnvelopes = requireNonNegativeInteger( + "maxEnvelopes", + init.maxEnvelopes ?? 8_192, + ); + this.maxSessions = requireNonNegativeInteger( + "maxSessions", + init.maxSessions ?? 4, + ); + } + + static checkpointOnly(): DerivedStateReplayConfig { + return new DerivedStateReplayConfig({ + maxEnvelopes: 0, + maxSessions: 0, + }); + } + + isEnabled(): boolean { + return this.maxEnvelopes > 0; + } +} + +export interface DerivedStateRuntimeConfigInit { + readonly checkpointIntervalMs?: number; + readonly recoveryIntervalMs?: number; + readonly replay?: DerivedStateReplayConfig; +} + +export class DerivedStateRuntimeConfig { + readonly checkpointIntervalMs: number; + readonly recoveryIntervalMs: number; + readonly replay: DerivedStateReplayConfig; + + constructor(init: DerivedStateRuntimeConfigInit = {}) { + this.checkpointIntervalMs = requireNonNegativeInteger( + "checkpointIntervalMs", + init.checkpointIntervalMs ?? 30_000, + ); + this.recoveryIntervalMs = requireNonNegativeInteger( + "recoveryIntervalMs", + init.recoveryIntervalMs ?? 5_000, + ); + this.replay = init.replay ?? new DerivedStateReplayConfig(); + } +} diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index e8842613..d6d0b04d 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -5,6 +5,29 @@ import { environmentVariable } from "../environment.js"; import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { + defaultDerivedStateReplayDirectory, + derivedStateCheckpointIntervalEnvVarName, + DerivedStateReplayBackend, + derivedStateReplayBackendAllowedValues, + derivedStateReplayBackendEnvValues, + derivedStateReplayBackendEnvVarName, + derivedStateReplayBackendToEnvValue, + derivedStateReplayDirectory, + derivedStateReplayDirEnvVarName, + derivedStateRecoveryIntervalEnvVarName, + DerivedStateReplayConfig, + DerivedStateReplayDurability, + derivedStateReplayDurabilityAllowedValues, + derivedStateReplayDurabilityEnvValues, + derivedStateReplayDurabilityEnvVarName, + derivedStateReplayDurabilityToEnvValue, + derivedStateReplayMaxEnvelopesEnvVarName, + derivedStateReplayMaxSessionsEnvVarName, + DerivedStateRuntimeConfig, + nonNegativeIntegerToEnvValue, + parseDerivedStateReplayBackend, + parseDerivedStateReplayDurability, + parseNonNegativeInteger, ObserverRuntimeConfig, ProviderStreamCapabilityPolicy, providerStreamAllowEofEnvVarName, @@ -40,6 +63,10 @@ test("result and runtime policy discriminants stay stable", () => { assert.equal(ShredTrustMode.TrustedRawShredProvider, 2); assert.equal(ProviderStreamCapabilityPolicy.Warn, 1); assert.equal(ProviderStreamCapabilityPolicy.Strict, 2); + assert.equal(DerivedStateReplayBackend.Memory, 1); + assert.equal(DerivedStateReplayBackend.Disk, 2); + assert.equal(DerivedStateReplayDurability.Flush, 1); + assert.equal(DerivedStateReplayDurability.Fsync, 2); }); test("runtime delivery profile maps to the documented env values", () => { @@ -78,6 +105,23 @@ test("runtime policy enums map to the documented env values", () => { ), providerStreamCapabilityPolicyEnvValues.strict, ); + assert.equal( + derivedStateReplayBackendToEnvValue(DerivedStateReplayBackend.Memory), + derivedStateReplayBackendEnvValues.memory, + ); + assert.equal( + derivedStateReplayBackendToEnvValue(DerivedStateReplayBackend.Disk), + derivedStateReplayBackendEnvValues.disk, + ); + assert.equal( + derivedStateReplayDurabilityToEnvValue(DerivedStateReplayDurability.Flush), + derivedStateReplayDurabilityEnvValues.flush, + ); + assert.equal( + derivedStateReplayDurabilityToEnvValue(DerivedStateReplayDurability.Fsync), + derivedStateReplayDurabilityEnvValues.fsync, + ); + assert.equal(nonNegativeIntegerToEnvValue(8192), "8192"); }); test("runtime delivery profile parser accepts documented aliases", () => { @@ -102,10 +146,19 @@ test("runtime policy parsers accept documented aliases", () => { const trusted = parseShredTrustMode("trusted-raw-shred-provider"); const strict = parseProviderStreamCapabilityPolicy(" STRICT "); const allowEof = parseRuntimeBoolean("YES", providerStreamAllowEofEnvVarName); + const replayBackend = parseDerivedStateReplayBackend("DISK"); + const replayDurability = parseDerivedStateReplayDurability(" flush "); + const nonNegativeInteger = parseNonNegativeInteger( + "8192", + derivedStateReplayMaxEnvelopesEnvVarName, + ); assert.equal(isOk(trusted), true); assert.equal(isOk(strict), true); assert.equal(isOk(allowEof), true); + assert.equal(isOk(replayBackend), true); + assert.equal(isOk(replayDurability), true); + assert.equal(isOk(nonNegativeInteger), true); if (isOk(trusted)) { assert.equal(trusted.value, ShredTrustMode.TrustedRawShredProvider); @@ -116,6 +169,15 @@ test("runtime policy parsers accept documented aliases", () => { if (isOk(allowEof)) { assert.equal(allowEof.value, true); } + if (isOk(replayBackend)) { + assert.equal(replayBackend.value, DerivedStateReplayBackend.Disk); + } + if (isOk(replayDurability)) { + assert.equal(replayDurability.value, DerivedStateReplayDurability.Flush); + } + if (isOk(nonNegativeInteger)) { + assert.equal(nonNegativeInteger.value, 8192); + } }); test("runtime config omits default policy values unless requested", () => { @@ -129,6 +191,15 @@ test("runtime config omits default policy values unless requested", () => { [providerStreamCapabilityPolicyEnvVarName]: providerStreamCapabilityPolicyEnvValues.warn, [providerStreamAllowEofEnvVarName]: runtimeBooleanEnvValues.false, + [derivedStateCheckpointIntervalEnvVarName]: "30000", + [derivedStateRecoveryIntervalEnvVarName]: "5000", + [derivedStateReplayBackendEnvVarName]: + derivedStateReplayBackendEnvValues.memory, + [derivedStateReplayDirEnvVarName]: defaultDerivedStateReplayDirectory, + [derivedStateReplayDurabilityEnvVarName]: + derivedStateReplayDurabilityEnvValues.flush, + [derivedStateReplayMaxEnvelopesEnvVarName]: "8192", + [derivedStateReplayMaxSessionsEnvVarName]: "4", }); }); @@ -138,6 +209,17 @@ test("runtime config serializes explicit runtime policy selection", () => { shredTrustMode: ShredTrustMode.TrustedRawShredProvider, providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, providerStreamAllowEof: true, + derivedState: new DerivedStateRuntimeConfig({ + checkpointIntervalMs: 60_000, + recoveryIntervalMs: 10_000, + replay: new DerivedStateReplayConfig({ + backend: DerivedStateReplayBackend.Disk, + replayDirectory: derivedStateReplayDirectory(".sof-replay"), + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, + }), + }), }); assert.deepEqual(config.toEnvironment(), [ @@ -157,6 +239,22 @@ test("runtime config serializes explicit runtime policy selection", () => { providerStreamAllowEofEnvVarName, runtimeBooleanEnvValues.true, ), + environmentVariable(derivedStateCheckpointIntervalEnvVarName, "60000"), + environmentVariable(derivedStateRecoveryIntervalEnvVarName, "10000"), + environmentVariable( + derivedStateReplayBackendEnvVarName, + derivedStateReplayBackendEnvValues.disk, + ), + environmentVariable( + derivedStateReplayDirEnvVarName, + derivedStateReplayDirectory(".sof-replay"), + ), + environmentVariable( + derivedStateReplayDurabilityEnvVarName, + derivedStateReplayDurabilityEnvValues.fsync, + ), + environmentVariable(derivedStateReplayMaxEnvelopesEnvVarName, "1024"), + environmentVariable(derivedStateReplayMaxSessionsEnvVarName, "2"), ]); }); @@ -168,6 +266,13 @@ test("runtime config parses environment values into typed runtime policy config" [providerStreamCapabilityPolicyEnvVarName]: providerStreamCapabilityPolicyEnvValues.strict, [providerStreamAllowEofEnvVarName]: "on", + [derivedStateCheckpointIntervalEnvVarName]: "45000", + [derivedStateRecoveryIntervalEnvVarName]: "7000", + [derivedStateReplayBackendEnvVarName]: "disk", + [derivedStateReplayDirEnvVarName]: ".sof-disk-tail", + [derivedStateReplayDurabilityEnvVarName]: "fsync", + [derivedStateReplayMaxEnvelopesEnvVarName]: "2048", + [derivedStateReplayMaxSessionsEnvVarName]: "6", }); assert.equal(isOk(config), true); @@ -185,6 +290,22 @@ test("runtime config parses environment values into typed runtime policy config" ProviderStreamCapabilityPolicy.Strict, ); assert.equal(config.value.providerStreamAllowEof, true); + assert.equal(config.value.derivedState.checkpointIntervalMs, 45_000); + assert.equal(config.value.derivedState.recoveryIntervalMs, 7_000); + assert.equal( + config.value.derivedState.replay.backend, + DerivedStateReplayBackend.Disk, + ); + assert.equal( + config.value.derivedState.replay.replayDirectory, + derivedStateReplayDirectory(".sof-disk-tail"), + ); + assert.equal( + config.value.derivedState.replay.durability, + DerivedStateReplayDurability.Fsync, + ); + assert.equal(config.value.derivedState.replay.maxEnvelopes, 2048); + assert.equal(config.value.derivedState.replay.maxSessions, 6); } }); @@ -206,6 +327,34 @@ test("runtime config parses typed environment variables into typed config", () = providerStreamAllowEofEnvVarName, runtimeBooleanEnvValues.false, ), + environmentVariable( + derivedStateCheckpointIntervalEnvVarName, + nonNegativeIntegerToEnvValue(30_000), + ), + environmentVariable( + derivedStateRecoveryIntervalEnvVarName, + nonNegativeIntegerToEnvValue(5_000), + ), + environmentVariable( + derivedStateReplayBackendEnvVarName, + derivedStateReplayBackendEnvValues.memory, + ), + environmentVariable( + derivedStateReplayDirEnvVarName, + defaultDerivedStateReplayDirectory, + ), + environmentVariable( + derivedStateReplayDurabilityEnvVarName, + derivedStateReplayDurabilityEnvValues.flush, + ), + environmentVariable( + derivedStateReplayMaxEnvelopesEnvVarName, + nonNegativeIntegerToEnvValue(8_192), + ), + environmentVariable( + derivedStateReplayMaxSessionsEnvVarName, + nonNegativeIntegerToEnvValue(4), + ), ]); assert.equal(isOk(config), true); @@ -220,6 +369,10 @@ test("runtime config parses typed environment variables into typed config", () = ProviderStreamCapabilityPolicy.Warn, ); assert.equal(config.value.providerStreamAllowEof, false); + assert.equal( + config.value.derivedState.replay.backend, + DerivedStateReplayBackend.Memory, + ); } }); @@ -290,3 +443,82 @@ test("runtime config rejects invalid provider stream eof values", () => { assert.deepEqual(config.error.allowedValues, runtimeBooleanAllowedValues); } }); + +test("runtime config rejects invalid derived-state replay backend values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [derivedStateReplayBackendEnvVarName]: "remote", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal( + config.error.kind, + ValidationErrorKind.InvalidDerivedStateReplayBackend, + ); + assert.equal(config.error.field, derivedStateReplayBackendEnvVarName); + assert.deepEqual( + config.error.allowedValues, + derivedStateReplayBackendAllowedValues, + ); + } +}); + +test("runtime config rejects invalid derived-state replay durability values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [derivedStateReplayDurabilityEnvVarName]: "sync", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal( + config.error.kind, + ValidationErrorKind.InvalidDerivedStateReplayDurability, + ); + assert.equal(config.error.field, derivedStateReplayDurabilityEnvVarName); + assert.deepEqual( + config.error.allowedValues, + derivedStateReplayDurabilityAllowedValues, + ); + } +}); + +test("runtime config rejects invalid derived-state numeric values", () => { + const config = ObserverRuntimeConfig.fromEnvironment({ + [derivedStateReplayMaxEnvelopesEnvVarName]: "-1", + }); + + assert.equal(isErr(config), true); + if (isErr(config)) { + assert.equal(config.error.kind, ValidationErrorKind.InvalidNonNegativeInteger); + assert.equal(config.error.field, derivedStateReplayMaxEnvelopesEnvVarName); + } +}); + +test("derived-state replay config exposes checkpoint-only helper", () => { + const replay = DerivedStateReplayConfig.checkpointOnly(); + + assert.equal(replay.maxEnvelopes, 0); + assert.equal(replay.maxSessions, 0); + assert.equal(replay.isEnabled(), false); +}); + +test("derived-state configs reject invalid programmatic numeric values", () => { + assert.throws( + () => + new DerivedStateRuntimeConfig({ + checkpointIntervalMs: -1, + }), + /checkpointIntervalMs must be a non-negative integer/, + ); + assert.throws( + () => + new DerivedStateReplayConfig({ + maxSessions: -1, + }), + /maxSessions must be a non-negative integer/, + ); + assert.throws( + () => nonNegativeIntegerToEnvValue(-1), + /value must be a non-negative integer/, + ); +}); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index a23f3c1b..403a81a3 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -14,6 +14,31 @@ import { runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, } from "./runtime-delivery-profile.js"; +import { + defaultDerivedStateReplayDirectory, + derivedStateCheckpointIntervalEnvVarName, + derivedStateRecoveryIntervalEnvVarName, + DerivedStateReplayBackend, + derivedStateReplayBackendEnvVarName, + derivedStateReplayBackendToEnvValue, + derivedStateReplayDirEnvVarName, + DerivedStateReplayDurability, + derivedStateReplayDurabilityEnvVarName, + derivedStateReplayDurabilityToEnvValue, + derivedStateReplayMaxEnvelopesEnvVarName, + derivedStateReplayMaxSessionsEnvVarName, + DerivedStateReplayConfig, + type DerivedStateReplayBackendEnvValue, + type DerivedStateReplayDirectory, + type DerivedStateReplayDurabilityEnvValue, + DerivedStateRuntimeConfig, + type NonNegativeIntegerEnvValue, + nonNegativeIntegerToEnvValue, + parseDerivedStateReplayBackend, + parseDerivedStateReplayDurability, + parseNonNegativeInteger, + derivedStateReplayDirectory, +} from "./derived-state.js"; import { parseProviderStreamCapabilityPolicy, parseRuntimeBoolean, @@ -36,6 +61,7 @@ export interface ObserverRuntimeConfigInit { readonly shredTrustMode?: ShredTrustMode; readonly providerStreamCapabilityPolicy?: ProviderStreamCapabilityPolicy; readonly providerStreamAllowEof?: boolean; + readonly derivedState?: DerivedStateRuntimeConfig; } export interface ObserverRuntimeEnvironmentOptions { @@ -55,19 +81,48 @@ export type ObserverRuntimeEnvironmentVariable = | EnvironmentVariable< typeof providerStreamAllowEofEnvVarName, RuntimeBooleanEnvValue + > + | EnvironmentVariable< + typeof derivedStateCheckpointIntervalEnvVarName, + NonNegativeIntegerEnvValue + > + | EnvironmentVariable< + typeof derivedStateRecoveryIntervalEnvVarName, + NonNegativeIntegerEnvValue + > + | EnvironmentVariable< + typeof derivedStateReplayBackendEnvVarName, + DerivedStateReplayBackendEnvValue + > + | EnvironmentVariable + | EnvironmentVariable< + typeof derivedStateReplayDurabilityEnvVarName, + DerivedStateReplayDurabilityEnvValue + > + | EnvironmentVariable< + typeof derivedStateReplayMaxEnvelopesEnvVarName, + NonNegativeIntegerEnvValue + > + | EnvironmentVariable< + typeof derivedStateReplayMaxSessionsEnvVarName, + NonNegativeIntegerEnvValue >; export type ObserverRuntimeValidationError = | ValidationError | ValidationError | ValidationError - | ValidationError; + | ValidationError + | ValidationError + | ValidationError + | ValidationError; export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; readonly shredTrustMode: ShredTrustMode; readonly providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy; readonly providerStreamAllowEof: boolean; + readonly derivedState: DerivedStateRuntimeConfig; constructor(init: ObserverRuntimeConfigInit = {}) { this.runtimeDeliveryProfile = @@ -76,6 +131,7 @@ export class ObserverRuntimeConfig { this.providerStreamCapabilityPolicy = init.providerStreamCapabilityPolicy ?? ProviderStreamCapabilityPolicy.Warn; this.providerStreamAllowEof = init.providerStreamAllowEof ?? false; + this.derivedState = init.derivedState ?? new DerivedStateRuntimeConfig(); } toEnvironment( @@ -130,6 +186,93 @@ export class ObserverRuntimeConfig { ); } + if ( + options.includeDefaults === true || + this.derivedState.checkpointIntervalMs !== 30_000 + ) { + environment.push( + environmentVariable( + derivedStateCheckpointIntervalEnvVarName, + nonNegativeIntegerToEnvValue(this.derivedState.checkpointIntervalMs), + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.recoveryIntervalMs !== 5_000 + ) { + environment.push( + environmentVariable( + derivedStateRecoveryIntervalEnvVarName, + nonNegativeIntegerToEnvValue(this.derivedState.recoveryIntervalMs), + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.replay.backend !== DerivedStateReplayBackend.Memory + ) { + environment.push( + environmentVariable( + derivedStateReplayBackendEnvVarName, + derivedStateReplayBackendToEnvValue(this.derivedState.replay.backend), + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.replay.replayDirectory !== + defaultDerivedStateReplayDirectory + ) { + environment.push( + environmentVariable( + derivedStateReplayDirEnvVarName, + this.derivedState.replay.replayDirectory, + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.replay.durability !== DerivedStateReplayDurability.Flush + ) { + environment.push( + environmentVariable( + derivedStateReplayDurabilityEnvVarName, + derivedStateReplayDurabilityToEnvValue( + this.derivedState.replay.durability, + ), + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.replay.maxEnvelopes !== 8_192 + ) { + environment.push( + environmentVariable( + derivedStateReplayMaxEnvelopesEnvVarName, + nonNegativeIntegerToEnvValue(this.derivedState.replay.maxEnvelopes), + ), + ); + } + + if ( + options.includeDefaults === true || + this.derivedState.replay.maxSessions !== 4 + ) { + environment.push( + environmentVariable( + derivedStateReplayMaxSessionsEnvVarName, + nonNegativeIntegerToEnvValue(this.derivedState.replay.maxSessions), + ), + ); + } + return environment; } @@ -155,6 +298,34 @@ export class ObserverRuntimeConfig { env, providerStreamAllowEofEnvVarName, ); + const derivedStateCheckpointInterval = readEnvironmentVariable( + env, + derivedStateCheckpointIntervalEnvVarName, + ); + const derivedStateRecoveryInterval = readEnvironmentVariable( + env, + derivedStateRecoveryIntervalEnvVarName, + ); + const derivedStateReplayBackend = readEnvironmentVariable( + env, + derivedStateReplayBackendEnvVarName, + ); + const derivedStateReplayDir = readEnvironmentVariable( + env, + derivedStateReplayDirEnvVarName, + ); + const derivedStateReplayDurability = readEnvironmentVariable( + env, + derivedStateReplayDurabilityEnvVarName, + ); + const derivedStateReplayMaxEnvelopes = readEnvironmentVariable( + env, + derivedStateReplayMaxEnvelopesEnvVarName, + ); + const derivedStateReplayMaxSessions = readEnvironmentVariable( + env, + derivedStateReplayMaxSessionsEnvVarName, + ); let parsedRuntimeDeliveryProfile = RuntimeDeliveryProfile.LatencyOptimized; if ( @@ -207,12 +378,116 @@ export class ObserverRuntimeConfig { parsedProviderStreamAllowEof = parsed.value; } + let parsedCheckpointIntervalMs = 30_000; + if ( + derivedStateCheckpointInterval !== undefined && + derivedStateCheckpointInterval.trim() !== "" + ) { + const parsed = parseNonNegativeInteger( + derivedStateCheckpointInterval, + derivedStateCheckpointIntervalEnvVarName, + ); + if (isErr(parsed)) { + return parsed; + } + parsedCheckpointIntervalMs = parsed.value; + } + + let parsedRecoveryIntervalMs = 5_000; + if ( + derivedStateRecoveryInterval !== undefined && + derivedStateRecoveryInterval.trim() !== "" + ) { + const parsed = parseNonNegativeInteger( + derivedStateRecoveryInterval, + derivedStateRecoveryIntervalEnvVarName, + ); + if (isErr(parsed)) { + return parsed; + } + parsedRecoveryIntervalMs = parsed.value; + } + + let parsedDerivedStateReplayBackend = DerivedStateReplayBackend.Memory; + if ( + derivedStateReplayBackend !== undefined && + derivedStateReplayBackend.trim() !== "" + ) { + const parsed = parseDerivedStateReplayBackend(derivedStateReplayBackend); + if (isErr(parsed)) { + return parsed; + } + parsedDerivedStateReplayBackend = parsed.value; + } + + let parsedDerivedStateReplayDirectory = defaultDerivedStateReplayDirectory; + if (derivedStateReplayDir !== undefined && derivedStateReplayDir.trim() !== "") { + parsedDerivedStateReplayDirectory = derivedStateReplayDirectory( + derivedStateReplayDir, + ); + } + + let parsedDerivedStateReplayDurability = DerivedStateReplayDurability.Flush; + if ( + derivedStateReplayDurability !== undefined && + derivedStateReplayDurability.trim() !== "" + ) { + const parsed = parseDerivedStateReplayDurability( + derivedStateReplayDurability, + ); + if (isErr(parsed)) { + return parsed; + } + parsedDerivedStateReplayDurability = parsed.value; + } + + let parsedDerivedStateReplayMaxEnvelopes = 8_192; + if ( + derivedStateReplayMaxEnvelopes !== undefined && + derivedStateReplayMaxEnvelopes.trim() !== "" + ) { + const parsed = parseNonNegativeInteger( + derivedStateReplayMaxEnvelopes, + derivedStateReplayMaxEnvelopesEnvVarName, + ); + if (isErr(parsed)) { + return parsed; + } + parsedDerivedStateReplayMaxEnvelopes = parsed.value; + } + + let parsedDerivedStateReplayMaxSessions = 4; + if ( + derivedStateReplayMaxSessions !== undefined && + derivedStateReplayMaxSessions.trim() !== "" + ) { + const parsed = parseNonNegativeInteger( + derivedStateReplayMaxSessions, + derivedStateReplayMaxSessionsEnvVarName, + ); + if (isErr(parsed)) { + return parsed; + } + parsedDerivedStateReplayMaxSessions = parsed.value; + } + return ok( new ObserverRuntimeConfig({ runtimeDeliveryProfile: parsedRuntimeDeliveryProfile, shredTrustMode: parsedShredTrustMode, providerStreamCapabilityPolicy: parsedProviderStreamCapabilityPolicy, providerStreamAllowEof: parsedProviderStreamAllowEof, + derivedState: new DerivedStateRuntimeConfig({ + checkpointIntervalMs: parsedCheckpointIntervalMs, + recoveryIntervalMs: parsedRecoveryIntervalMs, + replay: new DerivedStateReplayConfig({ + backend: parsedDerivedStateReplayBackend, + replayDirectory: parsedDerivedStateReplayDirectory, + durability: parsedDerivedStateReplayDurability, + maxEnvelopes: parsedDerivedStateReplayMaxEnvelopes, + maxSessions: parsedDerivedStateReplayMaxSessions, + }), + }), }), ); } From 8243b4a7cdd91332283d8ca7bf5080077859514e Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:25:40 +0200 Subject: [PATCH 05/25] refactor(ts-sdk): harden runtime config ergonomics --- sdks/typescript/README.md | 13 +- sdks/typescript/src/environment.ts | 17 +- sdks/typescript/src/runtime/derived-state.ts | 103 ++++++++-- .../src/runtime/runtime-config.test.ts | 111 +++++++++- sdks/typescript/src/runtime/runtime-config.ts | 191 ++++++++++++++---- .../src/runtime/runtime-delivery-profile.ts | 28 ++- sdks/typescript/src/runtime/runtime-policy.ts | 51 ++++- 7 files changed, 437 insertions(+), 77 deletions(-) diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 160def3d..92be0a84 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -21,6 +21,7 @@ This initial package slice provides: - `SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS` - nested derived-state runtime config with safe defaults and checkpoint-only replay helper - typed environment entry helpers instead of only raw string maps +- plain-object nested config construction, so common cases do not require chained `new` calls ## Example @@ -29,12 +30,10 @@ import { DerivedStateReplayBackend, DerivedStateReplayConfig, DerivedStateReplayDurability, - DerivedStateRuntimeConfig, ObserverRuntimeConfig, ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, ShredTrustMode, - derivedStateReplayDirectory, providerStreamAllowEofEnvVarName, providerStreamCapabilityPolicyEnvVarName, runtimeDeliveryProfileEnvValues, @@ -47,17 +46,17 @@ const config = new ObserverRuntimeConfig({ shredTrustMode: ShredTrustMode.TrustedRawShredProvider, providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, providerStreamAllowEof: true, - derivedState: new DerivedStateRuntimeConfig({ + derivedState: { checkpointIntervalMs: 60_000, recoveryIntervalMs: 10_000, - replay: new DerivedStateReplayConfig({ + replay: { backend: DerivedStateReplayBackend.Disk, - replayDirectory: derivedStateReplayDirectory(".sof-replay"), + replayDirectory: ".sof-replay", durability: DerivedStateReplayDurability.Fsync, maxEnvelopes: 1024, maxSessions: 2, - }), - }), + }, + }, }); const env = config.toEnvironment(); diff --git a/sdks/typescript/src/environment.ts b/sdks/typescript/src/environment.ts index f7097488..5546f0d1 100644 --- a/sdks/typescript/src/environment.ts +++ b/sdks/typescript/src/environment.ts @@ -28,9 +28,16 @@ export function environmentVariable< export function environmentVariablesToRecord( variables: readonly EnvironmentVariable[], ): Readonly> { - return Object.fromEntries( - variables.map((variable) => [variable.name, variable.value]), - ); + const record: Record = Object.create(null) as Record< + string, + string + >; + + for (const variable of variables) { + record[variable.name] = variable.value; + } + + return record; } export function readEnvironmentVariable( @@ -48,5 +55,9 @@ export function readEnvironmentVariable( } const record = input as Readonly>; + if (!Object.prototype.hasOwnProperty.call(record, name)) { + return undefined; + } + return record[name]; } diff --git a/sdks/typescript/src/runtime/derived-state.ts b/sdks/typescript/src/runtime/derived-state.ts index f79b188a..9134411b 100644 --- a/sdks/typescript/src/runtime/derived-state.ts +++ b/sdks/typescript/src/runtime/derived-state.ts @@ -13,6 +13,14 @@ export enum DerivedStateReplayDurability { Fsync = 2, } +export const defaultDerivedStateCheckpointIntervalMs = 30_000; +export const defaultDerivedStateRecoveryIntervalMs = 5_000; +export const defaultDerivedStateReplayBackend = DerivedStateReplayBackend.Memory; +export const defaultDerivedStateReplayDurability = + DerivedStateReplayDurability.Flush; +export const defaultDerivedStateReplayMaxEnvelopes = 8_192; +export const defaultDerivedStateReplayMaxSessions = 4; + export type DerivedStateReplayBackendEnvValue = Brand< string, "DerivedStateReplayBackendEnvValue" @@ -105,13 +113,66 @@ export const defaultDerivedStateReplayDirectory = asDerivedStateReplayDirectory( export function derivedStateReplayDirectory( value: string, ): DerivedStateReplayDirectory { + if (value.trim() === "") { + throw new RangeError("replayDirectory must not be empty"); + } + if (value.includes("\u0000")) { + throw new RangeError("replayDirectory must not contain NUL bytes"); + } + return asDerivedStateReplayDirectory(value); } +export function isDerivedStateReplayBackend( + value: DerivedStateReplayBackend, +): value is DerivedStateReplayBackend { + switch (value) { + case DerivedStateReplayBackend.Memory: + case DerivedStateReplayBackend.Disk: + return true; + default: + return false; + } +} + +export function isDerivedStateReplayDurability( + value: DerivedStateReplayDurability, +): value is DerivedStateReplayDurability { + switch (value) { + case DerivedStateReplayDurability.Flush: + case DerivedStateReplayDurability.Fsync: + return true; + default: + return false; + } +} + +function requireDerivedStateReplayBackend( + value: DerivedStateReplayBackend, +): DerivedStateReplayBackend { + if (!isDerivedStateReplayBackend(value)) { + throw new RangeError(`unknown derived-state replay backend: ${String(value)}`); + } + + return value; +} + +function requireDerivedStateReplayDurability( + value: DerivedStateReplayDurability, +): DerivedStateReplayDurability { + if (!isDerivedStateReplayDurability(value)) { + throw new RangeError( + `unknown derived-state replay durability: ${String(value)}`, + ); + } + + return value; +} + export function derivedStateReplayBackendToEnvValue( backend: DerivedStateReplayBackend, ): DerivedStateReplayBackendEnvValue { - switch (backend) { + switch (requireDerivedStateReplayBackend(backend)) { case DerivedStateReplayBackend.Memory: return derivedStateReplayBackendEnvValues.memory; case DerivedStateReplayBackend.Disk: @@ -122,7 +183,7 @@ export function derivedStateReplayBackendToEnvValue( export function derivedStateReplayDurabilityToEnvValue( durability: DerivedStateReplayDurability, ): DerivedStateReplayDurabilityEnvValue { - switch (durability) { + switch (requireDerivedStateReplayDurability(durability)) { case DerivedStateReplayDurability.Flush: return derivedStateReplayDurabilityEnvValues.flush; case DerivedStateReplayDurability.Fsync: @@ -212,6 +273,16 @@ function requireNonNegativeInteger(field: string, value: number): number { return value; } +function normalizeReplayDirectory( + value: DerivedStateReplayDirectory | string | undefined, +): DerivedStateReplayDirectory { + if (value === undefined) { + return defaultDerivedStateReplayDirectory; + } + + return derivedStateReplayDirectory(value); +} + export function nonNegativeIntegerToEnvValue( value: number, ): NonNegativeIntegerEnvValue { @@ -220,7 +291,7 @@ export function nonNegativeIntegerToEnvValue( export interface DerivedStateReplayConfigInit { readonly backend?: DerivedStateReplayBackend; - readonly replayDirectory?: DerivedStateReplayDirectory; + readonly replayDirectory?: DerivedStateReplayDirectory | string; readonly durability?: DerivedStateReplayDurability; readonly maxEnvelopes?: number; readonly maxSessions?: number; @@ -234,17 +305,20 @@ export class DerivedStateReplayConfig { readonly maxSessions: number; constructor(init: DerivedStateReplayConfigInit = {}) { - this.backend = init.backend ?? DerivedStateReplayBackend.Memory; - this.replayDirectory = - init.replayDirectory ?? defaultDerivedStateReplayDirectory; - this.durability = init.durability ?? DerivedStateReplayDurability.Flush; + this.backend = requireDerivedStateReplayBackend( + init.backend ?? defaultDerivedStateReplayBackend, + ); + this.replayDirectory = normalizeReplayDirectory(init.replayDirectory); + this.durability = requireDerivedStateReplayDurability( + init.durability ?? defaultDerivedStateReplayDurability, + ); this.maxEnvelopes = requireNonNegativeInteger( "maxEnvelopes", - init.maxEnvelopes ?? 8_192, + init.maxEnvelopes ?? defaultDerivedStateReplayMaxEnvelopes, ); this.maxSessions = requireNonNegativeInteger( "maxSessions", - init.maxSessions ?? 4, + init.maxSessions ?? defaultDerivedStateReplayMaxSessions, ); } @@ -263,7 +337,7 @@ export class DerivedStateReplayConfig { export interface DerivedStateRuntimeConfigInit { readonly checkpointIntervalMs?: number; readonly recoveryIntervalMs?: number; - readonly replay?: DerivedStateReplayConfig; + readonly replay?: DerivedStateReplayConfig | DerivedStateReplayConfigInit; } export class DerivedStateRuntimeConfig { @@ -274,12 +348,15 @@ export class DerivedStateRuntimeConfig { constructor(init: DerivedStateRuntimeConfigInit = {}) { this.checkpointIntervalMs = requireNonNegativeInteger( "checkpointIntervalMs", - init.checkpointIntervalMs ?? 30_000, + init.checkpointIntervalMs ?? defaultDerivedStateCheckpointIntervalMs, ); this.recoveryIntervalMs = requireNonNegativeInteger( "recoveryIntervalMs", - init.recoveryIntervalMs ?? 5_000, + init.recoveryIntervalMs ?? defaultDerivedStateRecoveryIntervalMs, ); - this.replay = init.replay ?? new DerivedStateReplayConfig(); + this.replay = + init.replay instanceof DerivedStateReplayConfig + ? init.replay + : new DerivedStateReplayConfig(init.replay); } } diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index d6d0b04d..47f83a51 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { environmentVariable } from "../environment.js"; +import { + environmentVariable, + environmentVariablesToRecord, +} from "../environment.js"; import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { @@ -182,9 +185,11 @@ test("runtime policy parsers accept documented aliases", () => { test("runtime config omits default policy values unless requested", () => { const config = new ObserverRuntimeConfig(); + const envRecord = config.toEnvironmentRecord({ includeDefaults: true }); assert.deepEqual(config.toEnvironment(), []); - assert.deepEqual(config.toEnvironmentRecord({ includeDefaults: true }), { + assert.equal(Object.getPrototypeOf(envRecord), null); + assert.deepEqual({ ...envRecord }, { [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.latencyOptimized, [shredTrustModeEnvVarName]: shredTrustModeEnvValues.publicUntrusted, @@ -209,17 +214,17 @@ test("runtime config serializes explicit runtime policy selection", () => { shredTrustMode: ShredTrustMode.TrustedRawShredProvider, providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, providerStreamAllowEof: true, - derivedState: new DerivedStateRuntimeConfig({ + derivedState: { checkpointIntervalMs: 60_000, recoveryIntervalMs: 10_000, - replay: new DerivedStateReplayConfig({ + replay: { backend: DerivedStateReplayBackend.Disk, - replayDirectory: derivedStateReplayDirectory(".sof-replay"), + replayDirectory: ".sof-replay", durability: DerivedStateReplayDurability.Fsync, maxEnvelopes: 1024, maxSessions: 2, - }), - }), + }, + }, }); assert.deepEqual(config.toEnvironment(), [ @@ -376,6 +381,57 @@ test("runtime config parses typed environment variables into typed config", () = } }); +test("runtime config supports nested plain-object construction", () => { + const config = new ObserverRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + derivedState: { + checkpointIntervalMs: 15_000, + replay: { + backend: DerivedStateReplayBackend.Disk, + replayDirectory: ".sof-plain-object", + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 256, + maxSessions: 3, + }, + }, + }); + + assert.equal(config.derivedState.checkpointIntervalMs, 15_000); + assert.equal(config.derivedState.replay.backend, DerivedStateReplayBackend.Disk); + assert.equal( + config.derivedState.replay.replayDirectory, + derivedStateReplayDirectory(".sof-plain-object"), + ); + assert.equal(config.derivedState.replay.maxEnvelopes, 256); + assert.equal(config.derivedState.replay.maxSessions, 3); +}); + +test("environment helpers ignore inherited env values and use a null-prototype record", () => { + const inherited = Object.create({ + [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.balanced, + }) as Record; + + const parsed = ObserverRuntimeConfig.fromEnvironmentRecord(inherited); + + assert.equal(isOk(parsed), true); + if (isOk(parsed)) { + assert.equal( + parsed.value.runtimeDeliveryProfile, + RuntimeDeliveryProfile.LatencyOptimized, + ); + } + + const record = environmentVariablesToRecord([ + environmentVariable( + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileEnvValues.balanced, + ), + ]); + + assert.equal(Object.getPrototypeOf(record), null); + assert.equal(record[runtimeDeliveryProfileEnvVarName], "balanced"); +}); + test("runtime config rejects invalid delivery profile values", () => { const config = ObserverRuntimeConfig.fromEnvironment({ [runtimeDeliveryProfileEnvVarName]: "fastest", @@ -522,3 +578,44 @@ test("derived-state configs reject invalid programmatic numeric values", () => { /value must be a non-negative integer/, ); }); + +test("runtime config helpers reject invalid programmatic enum and path values", () => { + assert.throws( + () => runtimeDeliveryProfileToEnvValue(99 as RuntimeDeliveryProfile), + /unknown runtime delivery profile/, + ); + assert.throws( + () => shredTrustModeToEnvValue(99 as ShredTrustMode), + /unknown shred trust mode/, + ); + assert.throws( + () => + providerStreamCapabilityPolicyToEnvValue( + 99 as ProviderStreamCapabilityPolicy, + ), + /unknown provider stream capability policy/, + ); + assert.throws( + () => + derivedStateReplayBackendToEnvValue(99 as DerivedStateReplayBackend), + /unknown derived-state replay backend/, + ); + assert.throws( + () => + derivedStateReplayDurabilityToEnvValue( + 99 as DerivedStateReplayDurability, + ), + /unknown derived-state replay durability/, + ); + assert.throws( + () => derivedStateReplayDirectory(" "), + /replayDirectory must not be empty/, + ); + assert.throws( + () => + new ObserverRuntimeConfig({ + providerStreamAllowEof: "true" as unknown as boolean, + }), + /providerStreamAllowEof must be a boolean/, + ); +}); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index 403a81a3..433810d3 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -8,6 +8,8 @@ import { import type { ValidationError } from "../errors.js"; import { isErr, ok, type Result } from "../result.js"; import { + defaultRuntimeDeliveryProfile, + isRuntimeDeliveryProfile, parseRuntimeDeliveryProfile, RuntimeDeliveryProfile, type RuntimeDeliveryProfileEnvValue, @@ -15,14 +17,18 @@ import { runtimeDeliveryProfileToEnvValue, } from "./runtime-delivery-profile.js"; import { + defaultDerivedStateCheckpointIntervalMs, + defaultDerivedStateRecoveryIntervalMs, + defaultDerivedStateReplayBackend, defaultDerivedStateReplayDirectory, + defaultDerivedStateReplayDurability, + defaultDerivedStateReplayMaxEnvelopes, + defaultDerivedStateReplayMaxSessions, derivedStateCheckpointIntervalEnvVarName, derivedStateRecoveryIntervalEnvVarName, - DerivedStateReplayBackend, derivedStateReplayBackendEnvVarName, derivedStateReplayBackendToEnvValue, derivedStateReplayDirEnvVarName, - DerivedStateReplayDurability, derivedStateReplayDurabilityEnvVarName, derivedStateReplayDurabilityToEnvValue, derivedStateReplayMaxEnvelopesEnvVarName, @@ -32,6 +38,7 @@ import { type DerivedStateReplayDirectory, type DerivedStateReplayDurabilityEnvValue, DerivedStateRuntimeConfig, + type DerivedStateRuntimeConfigInit, type NonNegativeIntegerEnvValue, nonNegativeIntegerToEnvValue, parseDerivedStateReplayBackend, @@ -40,6 +47,11 @@ import { derivedStateReplayDirectory, } from "./derived-state.js"; import { + defaultProviderStreamAllowEof, + defaultProviderStreamCapabilityPolicy, + defaultShredTrustMode, + isProviderStreamCapabilityPolicy, + isShredTrustMode, parseProviderStreamCapabilityPolicy, parseRuntimeBoolean, parseShredTrustMode, @@ -61,7 +73,7 @@ export interface ObserverRuntimeConfigInit { readonly shredTrustMode?: ShredTrustMode; readonly providerStreamCapabilityPolicy?: ProviderStreamCapabilityPolicy; readonly providerStreamAllowEof?: boolean; - readonly derivedState?: DerivedStateRuntimeConfig; + readonly derivedState?: DerivedStateRuntimeConfig | DerivedStateRuntimeConfigInit; } export interface ObserverRuntimeEnvironmentOptions { @@ -117,6 +129,52 @@ export type ObserverRuntimeValidationError = | ValidationError | ValidationError; +function requireBoolean(field: string, value: boolean): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`${field} must be a boolean`); + } + + return value; +} + +function requireObserverRuntimeDeliveryProfile( + value: RuntimeDeliveryProfile, +): RuntimeDeliveryProfile { + if (!isRuntimeDeliveryProfile(value)) { + throw new RangeError(`unknown runtime delivery profile: ${String(value)}`); + } + + return value; +} + +function requireObserverShredTrustMode(value: ShredTrustMode): ShredTrustMode { + if (!isShredTrustMode(value)) { + throw new RangeError(`unknown shred trust mode: ${String(value)}`); + } + + return value; +} + +function requireObserverProviderStreamCapabilityPolicy( + value: ProviderStreamCapabilityPolicy, +): ProviderStreamCapabilityPolicy { + if (!isProviderStreamCapabilityPolicy(value)) { + throw new RangeError( + `unknown provider stream capability policy: ${String(value)}`, + ); + } + + return value; +} + +function shouldIncludeValue( + currentValue: T, + defaultValue: T, + options: ObserverRuntimeEnvironmentOptions, +): boolean { + return options.includeDefaults === true || currentValue !== defaultValue; +} + export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; readonly shredTrustMode: ShredTrustMode; @@ -125,13 +183,25 @@ export class ObserverRuntimeConfig { readonly derivedState: DerivedStateRuntimeConfig; constructor(init: ObserverRuntimeConfigInit = {}) { - this.runtimeDeliveryProfile = - init.runtimeDeliveryProfile ?? RuntimeDeliveryProfile.LatencyOptimized; - this.shredTrustMode = init.shredTrustMode ?? ShredTrustMode.PublicUntrusted; + this.runtimeDeliveryProfile = requireObserverRuntimeDeliveryProfile( + init.runtimeDeliveryProfile ?? defaultRuntimeDeliveryProfile, + ); + this.shredTrustMode = requireObserverShredTrustMode( + init.shredTrustMode ?? defaultShredTrustMode, + ); this.providerStreamCapabilityPolicy = - init.providerStreamCapabilityPolicy ?? ProviderStreamCapabilityPolicy.Warn; - this.providerStreamAllowEof = init.providerStreamAllowEof ?? false; - this.derivedState = init.derivedState ?? new DerivedStateRuntimeConfig(); + requireObserverProviderStreamCapabilityPolicy( + init.providerStreamCapabilityPolicy ?? + defaultProviderStreamCapabilityPolicy, + ); + this.providerStreamAllowEof = requireBoolean( + "providerStreamAllowEof", + init.providerStreamAllowEof ?? defaultProviderStreamAllowEof, + ); + this.derivedState = + init.derivedState instanceof DerivedStateRuntimeConfig + ? init.derivedState + : new DerivedStateRuntimeConfig(init.derivedState); } toEnvironment( @@ -140,9 +210,12 @@ export class ObserverRuntimeConfig { const environment: ObserverRuntimeEnvironmentVariable[] = []; if ( - options.includeDefaults !== true && - this.runtimeDeliveryProfile === RuntimeDeliveryProfile.LatencyOptimized - ) {} else { + shouldIncludeValue( + this.runtimeDeliveryProfile, + defaultRuntimeDeliveryProfile, + options, + ) + ) { environment.push( environmentVariable( runtimeDeliveryProfileEnvVarName, @@ -152,9 +225,8 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults !== true && - this.shredTrustMode === ShredTrustMode.PublicUntrusted - ) {} else { + shouldIncludeValue(this.shredTrustMode, defaultShredTrustMode, options) + ) { environment.push( environmentVariable( shredTrustModeEnvVarName, @@ -164,9 +236,12 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults !== true && - this.providerStreamCapabilityPolicy === ProviderStreamCapabilityPolicy.Warn - ) {} else { + shouldIncludeValue( + this.providerStreamCapabilityPolicy, + defaultProviderStreamCapabilityPolicy, + options, + ) + ) { environment.push( environmentVariable( providerStreamCapabilityPolicyEnvVarName, @@ -177,7 +252,13 @@ export class ObserverRuntimeConfig { ); } - if (options.includeDefaults === true || this.providerStreamAllowEof) { + if ( + shouldIncludeValue( + this.providerStreamAllowEof, + defaultProviderStreamAllowEof, + options, + ) + ) { environment.push( environmentVariable( providerStreamAllowEofEnvVarName, @@ -187,8 +268,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.checkpointIntervalMs !== 30_000 + shouldIncludeValue( + this.derivedState.checkpointIntervalMs, + defaultDerivedStateCheckpointIntervalMs, + options, + ) ) { environment.push( environmentVariable( @@ -199,8 +283,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.recoveryIntervalMs !== 5_000 + shouldIncludeValue( + this.derivedState.recoveryIntervalMs, + defaultDerivedStateRecoveryIntervalMs, + options, + ) ) { environment.push( environmentVariable( @@ -211,8 +298,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.replay.backend !== DerivedStateReplayBackend.Memory + shouldIncludeValue( + this.derivedState.replay.backend, + defaultDerivedStateReplayBackend, + options, + ) ) { environment.push( environmentVariable( @@ -223,9 +313,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.replay.replayDirectory !== - defaultDerivedStateReplayDirectory + shouldIncludeValue( + this.derivedState.replay.replayDirectory, + defaultDerivedStateReplayDirectory, + options, + ) ) { environment.push( environmentVariable( @@ -236,8 +328,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.replay.durability !== DerivedStateReplayDurability.Flush + shouldIncludeValue( + this.derivedState.replay.durability, + defaultDerivedStateReplayDurability, + options, + ) ) { environment.push( environmentVariable( @@ -250,8 +345,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.replay.maxEnvelopes !== 8_192 + shouldIncludeValue( + this.derivedState.replay.maxEnvelopes, + defaultDerivedStateReplayMaxEnvelopes, + options, + ) ) { environment.push( environmentVariable( @@ -262,8 +360,11 @@ export class ObserverRuntimeConfig { } if ( - options.includeDefaults === true || - this.derivedState.replay.maxSessions !== 4 + shouldIncludeValue( + this.derivedState.replay.maxSessions, + defaultDerivedStateReplayMaxSessions, + options, + ) ) { environment.push( environmentVariable( @@ -327,7 +428,7 @@ export class ObserverRuntimeConfig { derivedStateReplayMaxSessionsEnvVarName, ); - let parsedRuntimeDeliveryProfile = RuntimeDeliveryProfile.LatencyOptimized; + let parsedRuntimeDeliveryProfile = defaultRuntimeDeliveryProfile; if ( runtimeDeliveryProfile !== undefined && runtimeDeliveryProfile.trim() !== "" @@ -339,7 +440,7 @@ export class ObserverRuntimeConfig { parsedRuntimeDeliveryProfile = parsed.value; } - let parsedShredTrustMode = ShredTrustMode.PublicUntrusted; + let parsedShredTrustMode = defaultShredTrustMode; if (shredTrustMode !== undefined && shredTrustMode.trim() !== "") { const parsed = parseShredTrustMode(shredTrustMode); if (isErr(parsed)) { @@ -349,7 +450,7 @@ export class ObserverRuntimeConfig { } let parsedProviderStreamCapabilityPolicy = - ProviderStreamCapabilityPolicy.Warn; + defaultProviderStreamCapabilityPolicy; if ( providerStreamCapabilityPolicy !== undefined && providerStreamCapabilityPolicy.trim() !== "" @@ -363,7 +464,7 @@ export class ObserverRuntimeConfig { parsedProviderStreamCapabilityPolicy = parsed.value; } - let parsedProviderStreamAllowEof = false; + let parsedProviderStreamAllowEof = defaultProviderStreamAllowEof; if ( providerStreamAllowEof !== undefined && providerStreamAllowEof.trim() !== "" @@ -378,7 +479,7 @@ export class ObserverRuntimeConfig { parsedProviderStreamAllowEof = parsed.value; } - let parsedCheckpointIntervalMs = 30_000; + let parsedCheckpointIntervalMs = defaultDerivedStateCheckpointIntervalMs; if ( derivedStateCheckpointInterval !== undefined && derivedStateCheckpointInterval.trim() !== "" @@ -393,7 +494,7 @@ export class ObserverRuntimeConfig { parsedCheckpointIntervalMs = parsed.value; } - let parsedRecoveryIntervalMs = 5_000; + let parsedRecoveryIntervalMs = defaultDerivedStateRecoveryIntervalMs; if ( derivedStateRecoveryInterval !== undefined && derivedStateRecoveryInterval.trim() !== "" @@ -408,7 +509,7 @@ export class ObserverRuntimeConfig { parsedRecoveryIntervalMs = parsed.value; } - let parsedDerivedStateReplayBackend = DerivedStateReplayBackend.Memory; + let parsedDerivedStateReplayBackend = defaultDerivedStateReplayBackend; if ( derivedStateReplayBackend !== undefined && derivedStateReplayBackend.trim() !== "" @@ -427,7 +528,7 @@ export class ObserverRuntimeConfig { ); } - let parsedDerivedStateReplayDurability = DerivedStateReplayDurability.Flush; + let parsedDerivedStateReplayDurability = defaultDerivedStateReplayDurability; if ( derivedStateReplayDurability !== undefined && derivedStateReplayDurability.trim() !== "" @@ -441,7 +542,8 @@ export class ObserverRuntimeConfig { parsedDerivedStateReplayDurability = parsed.value; } - let parsedDerivedStateReplayMaxEnvelopes = 8_192; + let parsedDerivedStateReplayMaxEnvelopes = + defaultDerivedStateReplayMaxEnvelopes; if ( derivedStateReplayMaxEnvelopes !== undefined && derivedStateReplayMaxEnvelopes.trim() !== "" @@ -456,7 +558,8 @@ export class ObserverRuntimeConfig { parsedDerivedStateReplayMaxEnvelopes = parsed.value; } - let parsedDerivedStateReplayMaxSessions = 4; + let parsedDerivedStateReplayMaxSessions = + defaultDerivedStateReplayMaxSessions; if ( derivedStateReplayMaxSessions !== undefined && derivedStateReplayMaxSessions.trim() !== "" diff --git a/sdks/typescript/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/src/runtime/runtime-delivery-profile.ts index b3072a4f..a889dad6 100644 --- a/sdks/typescript/src/runtime/runtime-delivery-profile.ts +++ b/sdks/typescript/src/runtime/runtime-delivery-profile.ts @@ -9,6 +9,9 @@ export enum RuntimeDeliveryProfile { DeliveryDisciplined = 3, } +export const defaultRuntimeDeliveryProfile = + RuntimeDeliveryProfile.LatencyOptimized; + export type RuntimeDeliveryProfileEnvValue = Brand< string, "RuntimeDeliveryProfileEnvValue" @@ -39,10 +42,33 @@ export const runtimeDeliveryProfileAllowedValues: readonly RuntimeDeliveryProfil runtimeDeliveryProfileEnvValues.deliveryDisciplined, ]; +export function isRuntimeDeliveryProfile( + value: RuntimeDeliveryProfile, +): value is RuntimeDeliveryProfile { + switch (value) { + case RuntimeDeliveryProfile.LatencyOptimized: + case RuntimeDeliveryProfile.Balanced: + case RuntimeDeliveryProfile.DeliveryDisciplined: + return true; + default: + return false; + } +} + +function requireRuntimeDeliveryProfile( + value: RuntimeDeliveryProfile, +): RuntimeDeliveryProfile { + if (!isRuntimeDeliveryProfile(value)) { + throw new RangeError(`unknown runtime delivery profile: ${String(value)}`); + } + + return value; +} + export function runtimeDeliveryProfileToEnvValue( profile: RuntimeDeliveryProfile, ): RuntimeDeliveryProfileEnvValue { - switch (profile) { + switch (requireRuntimeDeliveryProfile(profile)) { case RuntimeDeliveryProfile.LatencyOptimized: return runtimeDeliveryProfileEnvValues.latencyOptimized; case RuntimeDeliveryProfile.Balanced: diff --git a/sdks/typescript/src/runtime/runtime-policy.ts b/sdks/typescript/src/runtime/runtime-policy.ts index a629f6cb..48f5ad32 100644 --- a/sdks/typescript/src/runtime/runtime-policy.ts +++ b/sdks/typescript/src/runtime/runtime-policy.ts @@ -13,6 +13,11 @@ export enum ProviderStreamCapabilityPolicy { Strict = 2, } +export const defaultShredTrustMode = ShredTrustMode.PublicUntrusted; +export const defaultProviderStreamCapabilityPolicy = + ProviderStreamCapabilityPolicy.Warn; +export const defaultProviderStreamAllowEof = false; + export type ShredTrustModeEnvValue = Brand; export type ProviderStreamCapabilityPolicyEnvValue = Brand< string, @@ -79,10 +84,52 @@ export const runtimeBooleanAllowedValues: readonly RuntimeBooleanEnvValue[] = [ runtimeBooleanEnvValues.false, ]; +export function isShredTrustMode(value: ShredTrustMode): value is ShredTrustMode { + switch (value) { + case ShredTrustMode.PublicUntrusted: + case ShredTrustMode.TrustedRawShredProvider: + return true; + default: + return false; + } +} + +export function isProviderStreamCapabilityPolicy( + value: ProviderStreamCapabilityPolicy, +): value is ProviderStreamCapabilityPolicy { + switch (value) { + case ProviderStreamCapabilityPolicy.Warn: + case ProviderStreamCapabilityPolicy.Strict: + return true; + default: + return false; + } +} + +function requireShredTrustMode(value: ShredTrustMode): ShredTrustMode { + if (!isShredTrustMode(value)) { + throw new RangeError(`unknown shred trust mode: ${String(value)}`); + } + + return value; +} + +function requireProviderStreamCapabilityPolicy( + value: ProviderStreamCapabilityPolicy, +): ProviderStreamCapabilityPolicy { + if (!isProviderStreamCapabilityPolicy(value)) { + throw new RangeError( + `unknown provider stream capability policy: ${String(value)}`, + ); + } + + return value; +} + export function shredTrustModeToEnvValue( mode: ShredTrustMode, ): ShredTrustModeEnvValue { - switch (mode) { + switch (requireShredTrustMode(mode)) { case ShredTrustMode.PublicUntrusted: return shredTrustModeEnvValues.publicUntrusted; case ShredTrustMode.TrustedRawShredProvider: @@ -93,7 +140,7 @@ export function shredTrustModeToEnvValue( export function providerStreamCapabilityPolicyToEnvValue( policy: ProviderStreamCapabilityPolicy, ): ProviderStreamCapabilityPolicyEnvValue { - switch (policy) { + switch (requireProviderStreamCapabilityPolicy(policy)) { case ProviderStreamCapabilityPolicy.Warn: return providerStreamCapabilityPolicyEnvValues.warn; case ProviderStreamCapabilityPolicy.Strict: From 74d37f7c60a8105a9ec1db2aea84e38d29135e39 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:34:07 +0200 Subject: [PATCH 06/25] feat(ts-sdk): add presets and focused subpath exports --- sdks/typescript/README.md | 40 ++++++++- sdks/typescript/package.json | 18 +++- sdks/typescript/src/package-exports.test.ts | 24 ++++++ sdks/typescript/src/runtime/derived-state.ts | 71 +++++++++++++++ .../src/runtime/runtime-config.test.ts | 86 +++++++++++++++++++ sdks/typescript/src/runtime/runtime-config.ts | 64 +++++++++++++- 6 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 sdks/typescript/src/package-exports.test.ts diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 92be0a84..88bb9719 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -22,6 +22,8 @@ This initial package slice provides: - nested derived-state runtime config with safe defaults and checkpoint-only replay helper - typed environment entry helpers instead of only raw string maps - plain-object nested config construction, so common cases do not require chained `new` calls +- one-line runtime profile presets such as `ObserverRuntimeConfig.balanced()` +- focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` ## Example @@ -41,8 +43,7 @@ import { shredTrustModeEnvVarName, } from "@sof/sdk"; -const config = new ObserverRuntimeConfig({ - runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, +const config = ObserverRuntimeConfig.balanced({ shredTrustMode: ShredTrustMode.TrustedRawShredProvider, providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, providerStreamAllowEof: true, @@ -100,3 +101,38 @@ providerStreamAllowEofEnvVarName; parsed; checkpointOnly; ``` + +## Focused Imports + +```ts +import { + ObserverRuntimeConfig, + observerRuntimeConfigForProfile, +} from "@sof/sdk/runtime/config"; +import { + ProviderStreamCapabilityPolicy, + ShredTrustMode, +} from "@sof/sdk/runtime/policy"; +import { + DerivedStateReplayBackend, + DerivedStateReplayDurability, +} from "@sof/sdk/runtime/derived-state"; +import { RuntimeDeliveryProfile } from "@sof/sdk/runtime/delivery-profile"; + +const config = observerRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, + { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + }, + }, + }, +); + +ObserverRuntimeConfig.latencyOptimized(); +config; +``` diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 3fd3001a..6e388162 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -14,6 +14,22 @@ "./runtime": { "types": "./dist/runtime.d.ts", "import": "./dist/runtime.js" + }, + "./runtime/config": { + "types": "./dist/runtime/runtime-config.d.ts", + "import": "./dist/runtime/runtime-config.js" + }, + "./runtime/policy": { + "types": "./dist/runtime/runtime-policy.d.ts", + "import": "./dist/runtime/runtime-policy.js" + }, + "./runtime/derived-state": { + "types": "./dist/runtime/derived-state.d.ts", + "import": "./dist/runtime/derived-state.js" + }, + "./runtime/delivery-profile": { + "types": "./dist/runtime/runtime-delivery-profile.d.ts", + "import": "./dist/runtime/runtime-delivery-profile.js" } }, "files": [ @@ -22,7 +38,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && node --test dist/**/*.test.js" + "test": "npm run build && node --test dist/*.test.js dist/**/*.test.js" }, "devDependencies": { "@types/node": "^24.6.0", diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts new file mode 100644 index 00000000..ab0511dc --- /dev/null +++ b/sdks/typescript/src/package-exports.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +test("package exports resolve the documented public entry points", async () => { + const root = await import("@sof/sdk"); + const runtime = await import("@sof/sdk/runtime"); + const config = await import("@sof/sdk/runtime/config"); + const policy = await import("@sof/sdk/runtime/policy"); + const derivedState = await import("@sof/sdk/runtime/derived-state"); + const deliveryProfile = await import("@sof/sdk/runtime/delivery-profile"); + + assert.equal(root.ObserverRuntimeConfig, config.ObserverRuntimeConfig); + assert.equal(root.observerRuntimeConfig, config.observerRuntimeConfig); + assert.equal(runtime.ObserverRuntimeConfig, config.ObserverRuntimeConfig); + assert.equal(runtime.ShredTrustMode, policy.ShredTrustMode); + assert.equal( + runtime.DerivedStateReplayConfig, + derivedState.DerivedStateReplayConfig, + ); + assert.equal( + runtime.RuntimeDeliveryProfile, + deliveryProfile.RuntimeDeliveryProfile, + ); +}); diff --git a/sdks/typescript/src/runtime/derived-state.ts b/sdks/typescript/src/runtime/derived-state.ts index 9134411b..e4c7cad9 100644 --- a/sdks/typescript/src/runtime/derived-state.ts +++ b/sdks/typescript/src/runtime/derived-state.ts @@ -297,6 +297,10 @@ export interface DerivedStateReplayConfigInit { readonly maxSessions?: number; } +export type DerivedStateReplayConfigInput = + | DerivedStateReplayConfig + | DerivedStateReplayConfigInit; + export class DerivedStateReplayConfig { readonly backend: DerivedStateReplayBackend; readonly replayDirectory: DerivedStateReplayDirectory; @@ -329,6 +333,30 @@ export class DerivedStateReplayConfig { }); } + static create( + init: DerivedStateReplayConfigInput = {}, + ): DerivedStateReplayConfig { + return derivedStateReplayConfig(init); + } + + static memory( + init: Omit = {}, + ): DerivedStateReplayConfig { + return new DerivedStateReplayConfig({ + ...init, + backend: DerivedStateReplayBackend.Memory, + }); + } + + static disk( + init: Omit = {}, + ): DerivedStateReplayConfig { + return new DerivedStateReplayConfig({ + ...init, + backend: DerivedStateReplayBackend.Disk, + }); + } + isEnabled(): boolean { return this.maxEnvelopes > 0; } @@ -340,6 +368,10 @@ export interface DerivedStateRuntimeConfigInit { readonly replay?: DerivedStateReplayConfig | DerivedStateReplayConfigInit; } +export type DerivedStateRuntimeConfigInput = + | DerivedStateRuntimeConfig + | DerivedStateRuntimeConfigInit; + export class DerivedStateRuntimeConfig { readonly checkpointIntervalMs: number; readonly recoveryIntervalMs: number; @@ -359,4 +391,43 @@ export class DerivedStateRuntimeConfig { ? init.replay : new DerivedStateReplayConfig(init.replay); } + + static create( + init: DerivedStateRuntimeConfigInput = {}, + ): DerivedStateRuntimeConfig { + return derivedStateRuntimeConfig(init); + } + + static checkpointOnly( + init: Omit = {}, + ): DerivedStateRuntimeConfig { + return new DerivedStateRuntimeConfig({ + ...init, + replay: DerivedStateReplayConfig.checkpointOnly(), + }); + } +} + +export function derivedStateReplayConfig( + init?: DerivedStateReplayConfigInput, +): DerivedStateReplayConfig { + if (init === undefined) { + return new DerivedStateReplayConfig(); + } + + return init instanceof DerivedStateReplayConfig + ? init + : new DerivedStateReplayConfig(init); +} + +export function derivedStateRuntimeConfig( + init?: DerivedStateRuntimeConfigInput, +): DerivedStateRuntimeConfig { + if (init === undefined) { + return new DerivedStateRuntimeConfig(); + } + + return init instanceof DerivedStateRuntimeConfig + ? init + : new DerivedStateRuntimeConfig(init); } diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 47f83a51..084bd579 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -27,7 +27,11 @@ import { derivedStateReplayMaxEnvelopesEnvVarName, derivedStateReplayMaxSessionsEnvVarName, DerivedStateRuntimeConfig, + derivedStateReplayConfig, + derivedStateRuntimeConfig, nonNegativeIntegerToEnvValue, + observerRuntimeConfig, + observerRuntimeConfigForProfile, parseDerivedStateReplayBackend, parseDerivedStateReplayDurability, parseNonNegativeInteger, @@ -381,6 +385,49 @@ test("runtime config parses typed environment variables into typed config", () = } }); +test("runtime config preset helpers create one-line common profiles", () => { + const latency = ObserverRuntimeConfig.latencyOptimized(); + const balanced = ObserverRuntimeConfig.balanced({ + providerStreamAllowEof: true, + }); + const disciplined = ObserverRuntimeConfig.deliveryDisciplined({ + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + }); + const explicit = ObserverRuntimeConfig.forProfile( + RuntimeDeliveryProfile.Balanced, + { + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + }, + ); + const functionPreset = observerRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + + assert.equal( + latency.runtimeDeliveryProfile, + RuntimeDeliveryProfile.LatencyOptimized, + ); + assert.equal(balanced.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); + assert.equal(balanced.providerStreamAllowEof, true); + assert.equal( + disciplined.runtimeDeliveryProfile, + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + assert.equal( + disciplined.shredTrustMode, + ShredTrustMode.TrustedRawShredProvider, + ); + assert.equal(explicit.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); + assert.equal( + explicit.providerStreamCapabilityPolicy, + ProviderStreamCapabilityPolicy.Strict, + ); + assert.equal( + functionPreset.runtimeDeliveryProfile, + RuntimeDeliveryProfile.DeliveryDisciplined, + ); +}); + test("runtime config supports nested plain-object construction", () => { const config = new ObserverRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, @@ -406,6 +453,45 @@ test("runtime config supports nested plain-object construction", () => { assert.equal(config.derivedState.replay.maxSessions, 3); }); +test("derived-state factory helpers reduce nested constructor ceremony", () => { + const replay = DerivedStateReplayConfig.disk({ + replayDirectory: ".sof-disk", + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 512, + }); + const runtime = DerivedStateRuntimeConfig.checkpointOnly({ + checkpointIntervalMs: 20_000, + }); + const replayFromFunction = derivedStateReplayConfig({ + backend: DerivedStateReplayBackend.Memory, + maxEnvelopes: 128, + }); + const runtimeFromFunction = derivedStateRuntimeConfig({ + replay: { + backend: DerivedStateReplayBackend.Disk, + replayDirectory: ".sof-function", + }, + }); + const observer = observerRuntimeConfig({ + derivedState: runtimeFromFunction, + }); + + assert.equal(replay.backend, DerivedStateReplayBackend.Disk); + assert.equal(replay.replayDirectory, derivedStateReplayDirectory(".sof-disk")); + assert.equal(replay.maxEnvelopes, 512); + assert.equal(runtime.replay.isEnabled(), false); + assert.equal(runtime.checkpointIntervalMs, 20_000); + assert.equal(replayFromFunction.maxEnvelopes, 128); + assert.equal( + runtimeFromFunction.replay.replayDirectory, + derivedStateReplayDirectory(".sof-function"), + ); + assert.equal( + observer.derivedState.replay.replayDirectory, + derivedStateReplayDirectory(".sof-function"), + ); +}); + test("environment helpers ignore inherited env values and use a null-prototype record", () => { const inherited = Object.create({ [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.balanced, diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index 433810d3..535ff29d 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -38,6 +38,7 @@ import { type DerivedStateReplayDirectory, type DerivedStateReplayDurabilityEnvValue, DerivedStateRuntimeConfig, + derivedStateRuntimeConfig, type DerivedStateRuntimeConfigInit, type NonNegativeIntegerEnvValue, nonNegativeIntegerToEnvValue, @@ -76,6 +77,13 @@ export interface ObserverRuntimeConfigInit { readonly derivedState?: DerivedStateRuntimeConfig | DerivedStateRuntimeConfigInit; } +export interface ObserverRuntimeProfileInit + extends Omit {} + +export type ObserverRuntimeConfigInput = + | ObserverRuntimeConfig + | ObserverRuntimeConfigInit; + export interface ObserverRuntimeEnvironmentOptions { readonly includeDefaults?: boolean; } @@ -175,6 +183,24 @@ function shouldIncludeValue( return options.includeDefaults === true || currentValue !== defaultValue; } +export function observerRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, +): ObserverRuntimeConfig { + return init instanceof ObserverRuntimeConfig + ? init + : new ObserverRuntimeConfig(init); +} + +export function observerRuntimeConfigForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, +): ObserverRuntimeConfig { + return new ObserverRuntimeConfig({ + ...init, + runtimeDeliveryProfile: profile, + }); +} + export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; readonly shredTrustMode: ShredTrustMode; @@ -198,10 +224,40 @@ export class ObserverRuntimeConfig { "providerStreamAllowEof", init.providerStreamAllowEof ?? defaultProviderStreamAllowEof, ); - this.derivedState = - init.derivedState instanceof DerivedStateRuntimeConfig - ? init.derivedState - : new DerivedStateRuntimeConfig(init.derivedState); + this.derivedState = derivedStateRuntimeConfig(init.derivedState); + } + + static create(init: ObserverRuntimeConfigInput = {}): ObserverRuntimeConfig { + return observerRuntimeConfig(init); + } + + static forProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, + ): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile(profile, init); + } + + static latencyOptimized( + init: ObserverRuntimeProfileInit = {}, + ): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile( + RuntimeDeliveryProfile.LatencyOptimized, + init, + ); + } + + static balanced(init: ObserverRuntimeProfileInit = {}): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, init); + } + + static deliveryDisciplined( + init: ObserverRuntimeProfileInit = {}, + ): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, + init, + ); } toEnvironment( From 5b8ea872a89e6c52dc7d0a203074b76c61418159 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:37:05 +0200 Subject: [PATCH 07/25] fix(ts-sdk): align profile presets with runtime defaults --- sdks/typescript/README.md | 31 ++++++++++++ .../src/runtime/runtime-config.test.ts | 50 +++++++++++++++++++ sdks/typescript/src/runtime/runtime-config.ts | 24 +++++++++ .../src/runtime/runtime-delivery-profile.ts | 35 +++++++++++++ 4 files changed, 140 insertions(+) diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 88bb9719..26e16ede 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -2,6 +2,13 @@ Unified TypeScript SDK surface for SOF. +## Mental Model + +- Use `ObserverRuntimeConfig` when you want to build or parse the SOF env surface safely. +- Use `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. +- Profile presets in this SDK stamp the profile env plus the derived-state replay retention defaults that SOF applies through env-backed setup. +- Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface, not those in-process host builders. + This initial package slice provides: - checked `Result` primitives @@ -102,6 +109,23 @@ parsed; checkpointOnly; ``` +## Quick Start + +```ts +import { ObserverRuntimeConfig } from "@sof/sdk"; + +const config = ObserverRuntimeConfig.deliveryDisciplined(); +const env = config.toEnvironmentRecord(); + +// { +// SOF_RUNTIME_DELIVERY_PROFILE: "delivery_disciplined", +// SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "32768", +// SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS: "8", +// } + +env; +``` + ## Focused Imports ```ts @@ -136,3 +160,10 @@ const config = observerRuntimeConfigForProfile( ObserverRuntimeConfig.latencyOptimized(); config; ``` + +## Choosing An API + +- Use `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env from files, CI, or process managers. +- Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you want profile-first setup. +- Use `derivedStateRuntimeConfig(...)` or `DerivedStateRuntimeConfig.checkpointOnly()` when your main concern is derived-state recovery behavior. +- Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface in application code. diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 084bd579..ca7820f3 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -46,6 +46,7 @@ import { parseRuntimeBoolean, parseShredTrustMode, RuntimeDeliveryProfile, + runtimeDeliveryProfileEnvDefaults, runtimeBooleanAllowedValues, runtimeBooleanEnvValues, parseRuntimeDeliveryProfile, @@ -91,6 +92,32 @@ test("runtime delivery profile maps to the documented env values", () => { ); }); +test("runtime delivery profiles expose the expected env-backed defaults", () => { + assert.deepEqual( + runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.LatencyOptimized), + { + derivedStateReplayMaxEnvelopes: 8192, + derivedStateReplayMaxSessions: 4, + }, + ); + assert.deepEqual( + runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.Balanced), + { + derivedStateReplayMaxEnvelopes: 16384, + derivedStateReplayMaxSessions: 6, + }, + ); + assert.deepEqual( + runtimeDeliveryProfileEnvDefaults( + RuntimeDeliveryProfile.DeliveryDisciplined, + ), + { + derivedStateReplayMaxEnvelopes: 32768, + derivedStateReplayMaxSessions: 8, + }, + ); +}); + test("runtime policy enums map to the documented env values", () => { assert.equal( shredTrustModeToEnvValue(ShredTrustMode.PublicUntrusted), @@ -426,6 +453,29 @@ test("runtime config preset helpers create one-line common profiles", () => { functionPreset.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined, ); + assert.equal(balanced.derivedState.replay.maxEnvelopes, 16_384); + assert.equal(balanced.derivedState.replay.maxSessions, 6); + assert.equal(functionPreset.derivedState.replay.maxEnvelopes, 32_768); + assert.equal(functionPreset.derivedState.replay.maxSessions, 8); +}); + +test("runtime profile helpers preserve explicit derived-state replay overrides", () => { + const config = ObserverRuntimeConfig.deliveryDisciplined({ + derivedState: { + replay: { + maxEnvelopes: 512, + }, + }, + }); + + assert.equal( + config.derivedState.replay.maxEnvelopes, + 512, + ); + assert.equal( + config.derivedState.replay.maxSessions, + 8, + ); }); test("runtime config supports nested plain-object construction", () => { diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index 535ff29d..545dbf2f 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -12,6 +12,7 @@ import { isRuntimeDeliveryProfile, parseRuntimeDeliveryProfile, RuntimeDeliveryProfile, + runtimeDeliveryProfileEnvDefaults, type RuntimeDeliveryProfileEnvValue, runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, @@ -195,9 +196,32 @@ export function observerRuntimeConfigForProfile( profile: RuntimeDeliveryProfile, init: ObserverRuntimeProfileInit = {}, ): ObserverRuntimeConfig { + const derivedStateDefaults = runtimeDeliveryProfileEnvDefaults(profile); + const derivedState = init.derivedState; + const replay = + derivedState instanceof DerivedStateRuntimeConfig ? derivedState.replay : derivedState?.replay; + return new ObserverRuntimeConfig({ ...init, runtimeDeliveryProfile: profile, + derivedState: + derivedState instanceof DerivedStateRuntimeConfig + ? derivedState + : { + ...derivedState, + replay: + replay instanceof DerivedStateReplayConfig + ? replay + : { + ...replay, + maxEnvelopes: + replay?.maxEnvelopes ?? + derivedStateDefaults.derivedStateReplayMaxEnvelopes, + maxSessions: + replay?.maxSessions ?? + derivedStateDefaults.derivedStateReplayMaxSessions, + }, + }, }); } diff --git a/sdks/typescript/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/src/runtime/runtime-delivery-profile.ts index a889dad6..72209556 100644 --- a/sdks/typescript/src/runtime/runtime-delivery-profile.ts +++ b/sdks/typescript/src/runtime/runtime-delivery-profile.ts @@ -2,6 +2,10 @@ import { brand, type Brand } from "../brand.js"; import { envVarName } from "../environment.js"; import { ValidationErrorKind, type ValidationError } from "../errors.js"; import { err, ok, type Result } from "../result.js"; +import { + defaultDerivedStateReplayMaxEnvelopes, + defaultDerivedStateReplayMaxSessions, +} from "./derived-state.js"; export enum RuntimeDeliveryProfile { LatencyOptimized = 1, @@ -42,6 +46,11 @@ export const runtimeDeliveryProfileAllowedValues: readonly RuntimeDeliveryProfil runtimeDeliveryProfileEnvValues.deliveryDisciplined, ]; +export interface RuntimeDeliveryProfileEnvDefaults { + readonly derivedStateReplayMaxEnvelopes: number; + readonly derivedStateReplayMaxSessions: number; +} + export function isRuntimeDeliveryProfile( value: RuntimeDeliveryProfile, ): value is RuntimeDeliveryProfile { @@ -78,6 +87,32 @@ export function runtimeDeliveryProfileToEnvValue( } } +export function runtimeDeliveryProfileEnvDefaults( + profile: RuntimeDeliveryProfile, +): RuntimeDeliveryProfileEnvDefaults { + switch (requireRuntimeDeliveryProfile(profile)) { + case RuntimeDeliveryProfile.LatencyOptimized: + return { + derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes, + derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions, + }; + case RuntimeDeliveryProfile.Balanced: + return { + derivedStateReplayMaxEnvelopes: + defaultDerivedStateReplayMaxEnvelopes * 2, + derivedStateReplayMaxSessions: + defaultDerivedStateReplayMaxSessions + 2, + }; + case RuntimeDeliveryProfile.DeliveryDisciplined: + return { + derivedStateReplayMaxEnvelopes: + defaultDerivedStateReplayMaxEnvelopes * 4, + derivedStateReplayMaxSessions: + defaultDerivedStateReplayMaxSessions * 2, + }; + } +} + export function parseRuntimeDeliveryProfile( input: string, ): Result< From 3764093bff620f58d3474a5d8993ac6b98940bcd Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 12:41:07 +0200 Subject: [PATCH 08/25] build(ts-sdk): separate package and test outputs --- .gitignore | 1 + sdks/typescript/package.json | 18 +++++++++++++++--- sdks/typescript/tsconfig.build.json | 6 ++++++ sdks/typescript/tsconfig.test.json | 11 +++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 sdks/typescript/tsconfig.build.json create mode 100644 sdks/typescript/tsconfig.test.json diff --git a/.gitignore b/.gitignore index 0fc1846a..d70a2d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target/ **/node_modules/ **/dist/ +**/dist-test/ # Editor/OS noise .DS_Store diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 6e388162..9ceaa0f0 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,8 +1,17 @@ { "name": "@sof/sdk", "version": "0.1.0", - "private": true, "description": "Unified SOF TypeScript SDK", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -36,9 +45,12 @@ "dist" ], "scripts": { - "build": "tsc -p tsconfig.json", + "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true }); rmSync('dist-test', { recursive: true, force: true });\"", + "build": "npm run clean && tsc -p tsconfig.build.json", + "build:test": "tsc -p tsconfig.test.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && node --test dist/*.test.js dist/**/*.test.js" + "test": "npm run build && npm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", + "prepack": "npm run build" }, "devDependencies": { "@types/node": "^24.6.0", diff --git a/sdks/typescript/tsconfig.build.json b/sdks/typescript/tsconfig.build.json new file mode 100644 index 00000000..c2f08f79 --- /dev/null +++ b/sdks/typescript/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/sdks/typescript/tsconfig.test.json b/sdks/typescript/tsconfig.test.json new file mode 100644 index 00000000..44e7ac15 --- /dev/null +++ b/sdks/typescript/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist-test", + "declaration": false, + "sourceMap": false + }, + "include": [ + "src/**/*.test.ts" + ] +} From 756ee56f62720ab557763ce82629fad94ce7fb4e Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 13:04:14 +0200 Subject: [PATCH 09/25] build(ts-sdk): migrate to pnpm and harden runtime tooling --- sdks/typescript/.oxlintrc.json | 69 ++ sdks/typescript/README.md | 25 +- sdks/typescript/package-lock.json | 47 - sdks/typescript/package.json | 31 +- sdks/typescript/pnpm-lock.yaml | 957 ++++++++++++++++++ sdks/typescript/src/environment.ts | 55 +- sdks/typescript/src/errors.ts | 1 + sdks/typescript/src/package-exports.test.ts | 48 +- sdks/typescript/src/runtime/derived-state.ts | 399 +++++++- .../src/runtime/runtime-config.test.ts | 229 ++++- sdks/typescript/src/runtime/runtime-config.ts | 267 +++-- .../src/runtime/runtime-delivery-profile.ts | 106 +- sdks/typescript/src/runtime/runtime-policy.ts | 127 ++- sdks/typescript/tsconfig.build.json | 6 - sdks/typescript/tsconfig.json | 3 + sdks/typescript/tsdown.config.ts | 21 + 16 files changed, 2105 insertions(+), 286 deletions(-) create mode 100644 sdks/typescript/.oxlintrc.json delete mode 100644 sdks/typescript/package-lock.json create mode 100644 sdks/typescript/pnpm-lock.yaml delete mode 100644 sdks/typescript/tsconfig.build.json create mode 100644 sdks/typescript/tsdown.config.ts diff --git a/sdks/typescript/.oxlintrc.json b/sdks/typescript/.oxlintrc.json new file mode 100644 index 00000000..fe3f90f5 --- /dev/null +++ b/sdks/typescript/.oxlintrc.json @@ -0,0 +1,69 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "import", + "node", + "promise", + "typescript", + "unicorn" + ], + "categories": { + "correctness": "error", + "suspicious": "error", + "pedantic": "error", + "perf": "error" + }, + "ignorePatterns": [ + "dist", + "dist-test", + "node_modules" + ], + "rules": { + "import/no-duplicates": "error", + "max-classes-per-file": "off", + "max-lines": "off", + "max-lines-per-function": "off", + "no-alert": "error", + "no-console": "error", + "no-debugger": "error", + "no-unused-vars": "off", + "typescript/consistent-type-imports": "error", + "typescript/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "unicorn/error-message": "error", + "unicorn/filename-case": "off", + "unicorn/no-null": "off", + "unicorn/no-process-exit": "error" + }, + "overrides": [ + { + "files": [ + "src/brand.ts" + ], + "rules": { + "typescript/no-unsafe-type-assertion": "off" + } + }, + { + "files": [ + "src/**/*.test.ts" + ], + "rules": { + "typescript/no-floating-promises": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + "typescript/no-unsafe-type-assertion": "off" + } + } + ] +} diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 26e16ede..0b141202 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -2,9 +2,17 @@ Unified TypeScript SDK surface for SOF. +## Tooling + +- Use `pnpm` for this package. +- `pnpm run build` produces minified ESM library output plus `.d.ts` files. +- `pnpm run lint` runs the `oxlint` production lint profile. +- `pnpm run check` runs lint, typecheck, tests, and package-shape validation. + ## Mental Model - Use `ObserverRuntimeConfig` when you want to build or parse the SOF env surface safely. +- Prefer `ObserverRuntimeConfig.tryCreate(...)`, `.tryBalanced(...)`, and `fromEnvironmentRecord(...)` when you want validation errors as `Result` values instead of thrown exceptions. - Use `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. - Profile presets in this SDK stamp the profile env plus the derived-state replay retention defaults that SOF applies through env-backed setup. - Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface, not those in-process host builders. @@ -30,6 +38,7 @@ This initial package slice provides: - typed environment entry helpers instead of only raw string maps - plain-object nested config construction, so common cases do not require chained `new` calls - one-line runtime profile presets such as `ObserverRuntimeConfig.balanced()` +- result-return factory and serialization helpers for programmatic validation, so SDK consumers do not need to rely on exceptions for normal invalid-input handling - focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` ## Example @@ -112,16 +121,15 @@ checkpointOnly; ## Quick Start ```ts -import { ObserverRuntimeConfig } from "@sof/sdk"; +import { isErr, ObserverRuntimeConfig } from "@sof/sdk"; -const config = ObserverRuntimeConfig.deliveryDisciplined(); -const env = config.toEnvironmentRecord(); +const config = ObserverRuntimeConfig.tryDeliveryDisciplined(); -// { -// SOF_RUNTIME_DELIVERY_PROFILE: "delivery_disciplined", -// SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "32768", -// SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS: "8", -// } +if (isErr(config)) { + throw new Error(config.error.message); +} + +const env = config.value.toEnvironmentRecord(); env; ``` @@ -164,6 +172,7 @@ config; ## Choosing An API - Use `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env from files, CI, or process managers. +- Use `ObserverRuntimeConfig.tryCreate(...)` or `tryObserverRuntimeConfigForProfile(...)` when invalid programmatic input should stay in `Result` form. - Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you want profile-first setup. - Use `derivedStateRuntimeConfig(...)` or `DerivedStateRuntimeConfig.checkpointOnly()` when your main concern is derived-state recovery behavior. - Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface in application code. diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json deleted file mode 100644 index a88db4a3..00000000 --- a/sdks/typescript/package-lock.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@sof/sdk", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@sof/sdk", - "version": "0.1.0", - "devDependencies": { - "@types/node": "^24.6.0", - "typescript": "^5.9.3" - } - }, - "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 9ceaa0f0..964dc327 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "description": "Unified SOF TypeScript SDK", "license": "MIT OR Apache-2.0", + "sideEffects": false, + "packageManager": "pnpm@10.28.2", "repository": { "type": "git", "url": "git+https://github.com/Lythaeon/sof.git", @@ -12,6 +14,9 @@ "url": "https://github.com/Lythaeon/sof/issues" }, "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -25,20 +30,20 @@ "import": "./dist/runtime.js" }, "./runtime/config": { - "types": "./dist/runtime/runtime-config.d.ts", - "import": "./dist/runtime/runtime-config.js" + "types": "./dist/runtime/config.d.ts", + "import": "./dist/runtime/config.js" }, "./runtime/policy": { - "types": "./dist/runtime/runtime-policy.d.ts", - "import": "./dist/runtime/runtime-policy.js" + "types": "./dist/runtime/policy.d.ts", + "import": "./dist/runtime/policy.js" }, "./runtime/derived-state": { "types": "./dist/runtime/derived-state.d.ts", "import": "./dist/runtime/derived-state.js" }, "./runtime/delivery-profile": { - "types": "./dist/runtime/runtime-delivery-profile.d.ts", - "import": "./dist/runtime/runtime-delivery-profile.js" + "types": "./dist/runtime/delivery-profile.d.ts", + "import": "./dist/runtime/delivery-profile.js" } }, "files": [ @@ -46,14 +51,22 @@ ], "scripts": { "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true }); rmSync('dist-test', { recursive: true, force: true });\"", - "build": "npm run clean && tsc -p tsconfig.build.json", + "build": "pnpm run clean && tsdown --config tsdown.config.ts", "build:test": "tsc -p tsconfig.test.json", + "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", + "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --fix --fix-suggestions src tsdown.config.ts", + "check:package": "publint run --strict --pack pnpm", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && npm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", - "prepack": "npm run build" + "test": "pnpm run build && pnpm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", + "prepack": "pnpm run build" }, "devDependencies": { "@types/node": "^24.6.0", + "oxlint": "^1.59.0", + "oxlint-tsgolint": "^0.20.0", + "publint": "^0.3.18", + "tsdown": "^0.21.7", "typescript": "^5.9.3" } } diff --git a/sdks/typescript/pnpm-lock.yaml b/sdks/typescript/pnpm-lock.yaml new file mode 100644 index 00000000..3a7ed291 --- /dev/null +++ b/sdks/typescript/pnpm-lock.yaml @@ -0,0 +1,957 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.2 + oxlint: + specifier: ^1.59.0 + version: 1.59.0(oxlint-tsgolint@0.20.0) + oxlint-tsgolint: + specifier: ^0.20.0 + version: 0.20.0 + publint: + specifier: ^0.3.18 + version: 0.3.18 + tsdown: + specifier: ^0.21.7 + version: 0.21.7(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(publint@0.3.18)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/generator@8.0.0-rc.3': + resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-string-parser@8.0.0-rc.3': + resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-validator-identifier@8.0.0-rc.3': + resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/parser@8.0.0-rc.3': + resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + '@babel/types@8.0.0-rc.3': + resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@oxlint-tsgolint/darwin-arm64@0.20.0': + resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.20.0': + resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.20.0': + resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.20.0': + resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.20.0': + resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.20.0': + resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.59.0': + resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.59.0': + resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.59.0': + resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.59.0': + resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.59.0': + resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.59.0': + resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.59.0': + resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.59.0': + resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.59.0': + resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.59.0': + resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.59.0': + resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.59.0': + resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.59.0': + resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.59.0': + resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.59.0': + resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.59.0': + resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.59.0': + resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.59.0': + resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.59.0': + resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} + engines: {node: '>=18'} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + ast-kit@3.0.0-beta.1: + resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} + engines: {node: '>=20.19.0'} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + hookable@6.1.0: + resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oxlint-tsgolint@0.20.0: + resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==} + hasBin: true + + oxlint@1.59.0: + resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.18.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + publint@0.3.18: + resolution: {integrity: sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==} + engines: {node: '>=18'} + hasBin: true + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.23.2: + resolution: {integrity: sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0-rc.12 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.21.7: + resolution: {integrity: sha512-ukKIxKQzngkWvOYJAyptudclkm4VQqbjq+9HF5K5qDO8GJsYtMh8gIRwicbnZEnvFPr6mquFwYAVZ8JKt3rY2g==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.7 + '@tsdown/exe': 0.21.7 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unrun@0.2.34: + resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + +snapshots: + + '@babel/generator@8.0.0-rc.3': + dependencies: + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@8.0.0-rc.3': {} + + '@babel/helper-validator-identifier@8.0.0-rc.3': {} + + '@babel/parser@8.0.0-rc.3': + dependencies: + '@babel/types': 8.0.0-rc.3 + + '@babel/types@8.0.0-rc.3': + dependencies: + '@babel/helper-string-parser': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@oxlint-tsgolint/darwin-arm64@0.20.0': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.20.0': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.20.0': + optional: true + + '@oxlint-tsgolint/linux-x64@0.20.0': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.20.0': + optional: true + + '@oxlint-tsgolint/win32-x64@0.20.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.59.0': + optional: true + + '@oxlint/binding-android-arm64@1.59.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.59.0': + optional: true + + '@oxlint/binding-darwin-x64@1.59.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.59.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.59.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.59.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.59.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.59.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.59.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.59.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.59.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.59.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.59.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.59.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.59.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.59.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.59.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.59.0': + optional: true + + '@publint/pack@0.1.4': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/jsesc@2.5.1': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + ansis@4.2.0: {} + + ast-kit@3.0.0-beta.1: + dependencies: + '@babel/parser': 8.0.0-rc.3 + estree-walker: 3.0.3 + pathe: 2.0.3 + + birpc@4.0.0: {} + + cac@7.0.0: {} + + defu@6.1.7: {} + + dts-resolver@2.1.3: {} + + empathic@2.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.0: {} + + import-without-cache@0.2.5: {} + + jsesc@3.1.0: {} + + mri@1.2.0: {} + + obug@2.1.1: {} + + oxlint-tsgolint@0.20.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.20.0 + '@oxlint-tsgolint/darwin-x64': 0.20.0 + '@oxlint-tsgolint/linux-arm64': 0.20.0 + '@oxlint-tsgolint/linux-x64': 0.20.0 + '@oxlint-tsgolint/win32-arm64': 0.20.0 + '@oxlint-tsgolint/win32-x64': 0.20.0 + + oxlint@1.59.0(oxlint-tsgolint@0.20.0): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.59.0 + '@oxlint/binding-android-arm64': 1.59.0 + '@oxlint/binding-darwin-arm64': 1.59.0 + '@oxlint/binding-darwin-x64': 1.59.0 + '@oxlint/binding-freebsd-x64': 1.59.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.59.0 + '@oxlint/binding-linux-arm-musleabihf': 1.59.0 + '@oxlint/binding-linux-arm64-gnu': 1.59.0 + '@oxlint/binding-linux-arm64-musl': 1.59.0 + '@oxlint/binding-linux-ppc64-gnu': 1.59.0 + '@oxlint/binding-linux-riscv64-gnu': 1.59.0 + '@oxlint/binding-linux-riscv64-musl': 1.59.0 + '@oxlint/binding-linux-s390x-gnu': 1.59.0 + '@oxlint/binding-linux-x64-gnu': 1.59.0 + '@oxlint/binding-linux-x64-musl': 1.59.0 + '@oxlint/binding-openharmony-arm64': 1.59.0 + '@oxlint/binding-win32-arm64-msvc': 1.59.0 + '@oxlint/binding-win32-ia32-msvc': 1.59.0 + '@oxlint/binding-win32-x64-msvc': 1.59.0 + oxlint-tsgolint: 0.20.0 + + package-manager-detector@1.6.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + publint@0.3.18: + dependencies: + '@publint/pack': 0.1.4 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + sade: 1.8.1 + + quansync@1.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(typescript@5.9.3): + dependencies: + '@babel/generator': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + ast-kit: 3.0.0-beta.1 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.7 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + semver@7.7.4: {} + + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + tsdown@0.21.7(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(publint@0.3.18)(typescript@5.9.3): + dependencies: + ansis: 4.2.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.0 + hookable: 6.1.0 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(typescript@5.9.3) + semver: 7.7.4 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.34(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optionalDependencies: + publint: 0.3.18 + typescript: 5.9.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@7.16.0: {} + + unrun@0.2.34(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + dependencies: + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' diff --git a/sdks/typescript/src/environment.ts b/sdks/typescript/src/environment.ts index 5546f0d1..5b171f53 100644 --- a/sdks/typescript/src/environment.ts +++ b/sdks/typescript/src/environment.ts @@ -14,6 +14,12 @@ export type EnvironmentInput = | Readonly> | readonly EnvironmentVariable[]; +function isEnvironmentVariableList( + input: EnvironmentInput, +): input is readonly EnvironmentVariable[] { + return Array.isArray(input); +} + export function envVarName(value: Name): EnvVarName { return brand(value); } @@ -28,36 +34,47 @@ export function environmentVariable< export function environmentVariablesToRecord( variables: readonly EnvironmentVariable[], ): Readonly> { - const record: Record = Object.create(null) as Record< - string, - string - >; + const record = Object.fromEntries( + variables.map((variable) => [variable.name, variable.value] as const), + ); - for (const variable of variables) { - record[variable.name] = variable.value; - } + Object.setPrototypeOf(record, null); return record; } -export function readEnvironmentVariable( - input: EnvironmentInput, +function readEnvironmentVariableFromList( + variables: readonly EnvironmentVariable[], name: EnvVarName, ): string | undefined { - if (Array.isArray(input)) { - for (let index = input.length - 1; index >= 0; index -= 1) { - const variable = input[index]; - if (variable?.name === name) { - return variable.value; - } + for (let index = variables.length - 1; index >= 0; index -= 1) { + const variable = variables[index]; + if (variable?.name === name) { + return variable.value; } - return undefined; } - const record = input as Readonly>; - if (!Object.prototype.hasOwnProperty.call(record, name)) { + return undefined; +} + +function readEnvironmentVariableFromRecord( + input: Readonly>, + name: EnvVarName, +): string | undefined { + if (!Object.prototype.hasOwnProperty.call(input, name)) { return undefined; } - return record[name]; + return input[name]; +} + +export function readEnvironmentVariable( + input: EnvironmentInput, + name: EnvVarName, +): string | undefined { + if (isEnvironmentVariableList(input)) { + return readEnvironmentVariableFromList(input, name); + } + + return readEnvironmentVariableFromRecord(input, name); } diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 4c71469a..29b23bd6 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -8,6 +8,7 @@ export enum ValidationErrorKind { InvalidDerivedStateReplayBackend = 5, InvalidDerivedStateReplayDurability = 6, InvalidNonNegativeInteger = 7, + InvalidDerivedStateReplayDirectory = 8, } export interface ValidationError { diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index ab0511dc..0584bfb8 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -1,24 +1,44 @@ import assert from "node:assert/strict"; import test from "node:test"; +function importPackageEntry(moduleName: string): Promise { + return import(moduleName); +} + test("package exports resolve the documented public entry points", async () => { - const root = await import("@sof/sdk"); - const runtime = await import("@sof/sdk/runtime"); - const config = await import("@sof/sdk/runtime/config"); - const policy = await import("@sof/sdk/runtime/policy"); - const derivedState = await import("@sof/sdk/runtime/derived-state"); - const deliveryProfile = await import("@sof/sdk/runtime/delivery-profile"); + const root = await importPackageEntry("@sof/sdk"); + const runtime = await importPackageEntry("@sof/sdk/runtime"); + const config = await importPackageEntry("@sof/sdk/runtime/config"); + const policy = await importPackageEntry("@sof/sdk/runtime/policy"); + const derivedState = await importPackageEntry("@sof/sdk/runtime/derived-state"); + const deliveryProfile = await importPackageEntry("@sof/sdk/runtime/delivery-profile"); - assert.equal(root.ObserverRuntimeConfig, config.ObserverRuntimeConfig); - assert.equal(root.observerRuntimeConfig, config.observerRuntimeConfig); - assert.equal(runtime.ObserverRuntimeConfig, config.ObserverRuntimeConfig); - assert.equal(runtime.ShredTrustMode, policy.ShredTrustMode); assert.equal( - runtime.DerivedStateReplayConfig, - derivedState.DerivedStateReplayConfig, + (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, + (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, + ); + assert.equal( + (root as { observerRuntimeConfig: unknown }).observerRuntimeConfig, + (config as { observerRuntimeConfig: unknown }).observerRuntimeConfig, + ); + assert.equal( + (root as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, + (config as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, + ); + assert.equal( + (runtime as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, + (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, + ); + assert.equal( + (runtime as { ShredTrustMode: unknown }).ShredTrustMode, + (policy as { ShredTrustMode: unknown }).ShredTrustMode, + ); + assert.equal( + (runtime as { DerivedStateReplayConfig: unknown }).DerivedStateReplayConfig, + (derivedState as { DerivedStateReplayConfig: unknown }).DerivedStateReplayConfig, ); assert.equal( - runtime.RuntimeDeliveryProfile, - deliveryProfile.RuntimeDeliveryProfile, + (runtime as { RuntimeDeliveryProfile: unknown }).RuntimeDeliveryProfile, + (deliveryProfile as { RuntimeDeliveryProfile: unknown }).RuntimeDeliveryProfile, ); }); diff --git a/sdks/typescript/src/runtime/derived-state.ts b/sdks/typescript/src/runtime/derived-state.ts index e4c7cad9..88dae88f 100644 --- a/sdks/typescript/src/runtime/derived-state.ts +++ b/sdks/typescript/src/runtime/derived-state.ts @@ -1,7 +1,7 @@ import { brand, type Brand } from "../brand.js"; import { envVarName } from "../environment.js"; import { ValidationErrorKind, type ValidationError } from "../errors.js"; -import { err, ok, type Result } from "../result.js"; +import { err, isErr, ok, type Result } from "../result.js"; export enum DerivedStateReplayBackend { Memory = 1, @@ -110,17 +110,38 @@ export const defaultDerivedStateReplayDirectory = asDerivedStateReplayDirectory( ".sof-derived-state-replay", ); -export function derivedStateReplayDirectory( +export function parseDerivedStateReplayDirectory( value: string, -): DerivedStateReplayDirectory { +): Result { if (value.trim() === "") { - throw new RangeError("replayDirectory must not be empty"); + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayDirectory, + field: derivedStateReplayDirEnvVarName, + received: value, + message: "replayDirectory must not be empty", + }); } if (value.includes("\u0000")) { - throw new RangeError("replayDirectory must not contain NUL bytes"); + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayDirectory, + field: derivedStateReplayDirEnvVarName, + received: value, + message: "replayDirectory must not contain NUL bytes", + }); } - return asDerivedStateReplayDirectory(value); + return ok(asDerivedStateReplayDirectory(value)); +} + +export function derivedStateReplayDirectory( + value: string, +): DerivedStateReplayDirectory { + const result = parseDerivedStateReplayDirectory(value); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(result.error.message); } export function isDerivedStateReplayBackend( @@ -147,48 +168,161 @@ export function isDerivedStateReplayDurability( } } +export function validateDerivedStateReplayBackend( + value: DerivedStateReplayBackend, +): Result< + DerivedStateReplayBackend, + ValidationError +> { + if (!isDerivedStateReplayBackend(value)) { + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayBackend, + field: derivedStateReplayBackendEnvVarName, + received: String(value), + message: "derived-state replay backend must be memory or disk", + allowedValues: derivedStateReplayBackendAllowedValues, + }); + } + + return ok(value); +} + +export function validateDerivedStateReplayDurability( + value: DerivedStateReplayDurability, +): Result< + DerivedStateReplayDurability, + ValidationError +> { + if (!isDerivedStateReplayDurability(value)) { + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayDurability, + field: derivedStateReplayDurabilityEnvVarName, + received: String(value), + message: "derived-state replay durability must be flush or fsync", + allowedValues: derivedStateReplayDurabilityAllowedValues, + }); + } + + return ok(value); +} + +export function validateNonNegativeIntegerInput( + value: number, + field: ReturnType, + propertyName: string, +): Result { + if (!Number.isInteger(value) || value < 0) { + return err({ + kind: ValidationErrorKind.InvalidNonNegativeInteger, + field, + received: String(value), + message: `${propertyName} must be a non-negative integer`, + }); + } + + return ok(value); +} + function requireDerivedStateReplayBackend( value: DerivedStateReplayBackend, ): DerivedStateReplayBackend { - if (!isDerivedStateReplayBackend(value)) { - throw new RangeError(`unknown derived-state replay backend: ${String(value)}`); + const validated = validateDerivedStateReplayBackend(value); + if (!isErr(validated)) { + return validated.value; } - return value; + throw new RangeError(`unknown derived-state replay backend: ${String(value)}`); } function requireDerivedStateReplayDurability( value: DerivedStateReplayDurability, ): DerivedStateReplayDurability { - if (!isDerivedStateReplayDurability(value)) { - throw new RangeError( - `unknown derived-state replay durability: ${String(value)}`, - ); + const validated = validateDerivedStateReplayDurability(value); + if (!isErr(validated)) { + return validated.value; } - return value; + throw new RangeError( + `unknown derived-state replay durability: ${String(value)}`, + ); } -export function derivedStateReplayBackendToEnvValue( +export function tryDerivedStateReplayBackendToEnvValue( backend: DerivedStateReplayBackend, -): DerivedStateReplayBackendEnvValue { - switch (requireDerivedStateReplayBackend(backend)) { +): Result< + DerivedStateReplayBackendEnvValue, + ValidationError +> { + const validated = validateDerivedStateReplayBackend(backend); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case DerivedStateReplayBackend.Memory: - return derivedStateReplayBackendEnvValues.memory; + return ok(derivedStateReplayBackendEnvValues.memory); case DerivedStateReplayBackend.Disk: - return derivedStateReplayBackendEnvValues.disk; + return ok(derivedStateReplayBackendEnvValues.disk); } + + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayBackend, + field: derivedStateReplayBackendEnvVarName, + received: String(backend), + message: "derived-state replay backend must be memory or disk", + allowedValues: derivedStateReplayBackendAllowedValues, + }); } -export function derivedStateReplayDurabilityToEnvValue( +export function derivedStateReplayBackendToEnvValue( + backend: DerivedStateReplayBackend, +): DerivedStateReplayBackendEnvValue { + const result = tryDerivedStateReplayBackendToEnvValue(backend); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(`unknown derived-state replay backend: ${String(backend)}`); +} + +export function tryDerivedStateReplayDurabilityToEnvValue( durability: DerivedStateReplayDurability, -): DerivedStateReplayDurabilityEnvValue { - switch (requireDerivedStateReplayDurability(durability)) { +): Result< + DerivedStateReplayDurabilityEnvValue, + ValidationError +> { + const validated = validateDerivedStateReplayDurability(durability); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case DerivedStateReplayDurability.Flush: - return derivedStateReplayDurabilityEnvValues.flush; + return ok(derivedStateReplayDurabilityEnvValues.flush); case DerivedStateReplayDurability.Fsync: - return derivedStateReplayDurabilityEnvValues.fsync; + return ok(derivedStateReplayDurabilityEnvValues.fsync); } + + return err({ + kind: ValidationErrorKind.InvalidDerivedStateReplayDurability, + field: derivedStateReplayDurabilityEnvVarName, + received: String(durability), + message: "derived-state replay durability must be flush or fsync", + allowedValues: derivedStateReplayDurabilityAllowedValues, + }); +} + +export function derivedStateReplayDurabilityToEnvValue( + durability: DerivedStateReplayDurability, +): DerivedStateReplayDurabilityEnvValue { + const result = tryDerivedStateReplayDurabilityToEnvValue(durability); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError( + `unknown derived-state replay durability: ${String(durability)}`, + ); } export function parseDerivedStateReplayBackend( @@ -265,12 +399,17 @@ export function parseNonNegativeInteger( return ok(parsed); } -function requireNonNegativeInteger(field: string, value: number): number { - if (!Number.isInteger(value) || value < 0) { - throw new RangeError(`${field} must be a non-negative integer`); +function requireNonNegativeInteger( + field: ReturnType, + propertyName: string, + value: number, +): number { + const validated = validateNonNegativeIntegerInput(value, field, propertyName); + if (!isErr(validated)) { + return validated.value; } - return value; + throw new RangeError(validated.error.message); } function normalizeReplayDirectory( @@ -283,10 +422,28 @@ function normalizeReplayDirectory( return derivedStateReplayDirectory(value); } +export function tryNonNegativeIntegerToEnvValue( + value: number, + field: ReturnType = derivedStateReplayMaxEnvelopesEnvVarName, + propertyName = "value", +): Result { + const validated = validateNonNegativeIntegerInput(value, field, propertyName); + if (isErr(validated)) { + return validated; + } + + return ok(asNonNegativeIntegerEnvValue(`${validated.value}`)); +} + export function nonNegativeIntegerToEnvValue( value: number, ): NonNegativeIntegerEnvValue { - return asNonNegativeIntegerEnvValue(`${requireNonNegativeInteger("value", value)}`); + const result = tryNonNegativeIntegerToEnvValue(value); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(result.error.message); } export interface DerivedStateReplayConfigInit { @@ -301,6 +458,11 @@ export type DerivedStateReplayConfigInput = | DerivedStateReplayConfig | DerivedStateReplayConfigInit; +export type DerivedStateValidationError = + | ValidationError + | ValidationError + | ValidationError; + export class DerivedStateReplayConfig { readonly backend: DerivedStateReplayBackend; readonly replayDirectory: DerivedStateReplayDirectory; @@ -317,10 +479,12 @@ export class DerivedStateReplayConfig { init.durability ?? defaultDerivedStateReplayDurability, ); this.maxEnvelopes = requireNonNegativeInteger( + derivedStateReplayMaxEnvelopesEnvVarName, "maxEnvelopes", init.maxEnvelopes ?? defaultDerivedStateReplayMaxEnvelopes, ); this.maxSessions = requireNonNegativeInteger( + derivedStateReplayMaxSessionsEnvVarName, "maxSessions", init.maxSessions ?? defaultDerivedStateReplayMaxSessions, ); @@ -339,6 +503,12 @@ export class DerivedStateReplayConfig { return derivedStateReplayConfig(init); } + static tryCreate( + init: DerivedStateReplayConfigInput = {}, + ): Result { + return tryDerivedStateReplayConfig(init); + } + static memory( init: Omit = {}, ): DerivedStateReplayConfig { @@ -360,6 +530,24 @@ export class DerivedStateReplayConfig { isEnabled(): boolean { return this.maxEnvelopes > 0; } + + static tryMemory( + init: Omit = {}, + ): Result { + return tryDerivedStateReplayConfig({ + ...init, + backend: DerivedStateReplayBackend.Memory, + }); + } + + static tryDisk( + init: Omit = {}, + ): Result { + return tryDerivedStateReplayConfig({ + ...init, + backend: DerivedStateReplayBackend.Disk, + }); + } } export interface DerivedStateRuntimeConfigInit { @@ -379,10 +567,12 @@ export class DerivedStateRuntimeConfig { constructor(init: DerivedStateRuntimeConfigInit = {}) { this.checkpointIntervalMs = requireNonNegativeInteger( + derivedStateCheckpointIntervalEnvVarName, "checkpointIntervalMs", init.checkpointIntervalMs ?? defaultDerivedStateCheckpointIntervalMs, ); this.recoveryIntervalMs = requireNonNegativeInteger( + derivedStateRecoveryIntervalEnvVarName, "recoveryIntervalMs", init.recoveryIntervalMs ?? defaultDerivedStateRecoveryIntervalMs, ); @@ -398,6 +588,12 @@ export class DerivedStateRuntimeConfig { return derivedStateRuntimeConfig(init); } + static tryCreate( + init: DerivedStateRuntimeConfigInput = {}, + ): Result { + return tryDerivedStateRuntimeConfig(init); + } + static checkpointOnly( init: Omit = {}, ): DerivedStateRuntimeConfig { @@ -406,6 +602,113 @@ export class DerivedStateRuntimeConfig { replay: DerivedStateReplayConfig.checkpointOnly(), }); } + + static tryCheckpointOnly( + init: Omit = {}, + ): Result { + return tryDerivedStateRuntimeConfig({ + ...init, + replay: DerivedStateReplayConfig.checkpointOnly(), + }); + } +} + +function validateDerivedStateReplayConfigInit( + init: DerivedStateReplayConfigInit, +): Result< + Required, + DerivedStateValidationError +> { + const backend = validateDerivedStateReplayBackend( + init.backend ?? defaultDerivedStateReplayBackend, + ); + if (isErr(backend)) { + return backend; + } + + const replayDirectory = + init.replayDirectory === undefined + ? ok(defaultDerivedStateReplayDirectory) + : parseDerivedStateReplayDirectory(init.replayDirectory); + if (isErr(replayDirectory)) { + return replayDirectory; + } + + const durability = validateDerivedStateReplayDurability( + init.durability ?? defaultDerivedStateReplayDurability, + ); + if (isErr(durability)) { + return durability; + } + + const maxEnvelopes = validateNonNegativeIntegerInput( + init.maxEnvelopes ?? defaultDerivedStateReplayMaxEnvelopes, + derivedStateReplayMaxEnvelopesEnvVarName, + "maxEnvelopes", + ); + if (isErr(maxEnvelopes)) { + return maxEnvelopes; + } + + const maxSessions = validateNonNegativeIntegerInput( + init.maxSessions ?? defaultDerivedStateReplayMaxSessions, + derivedStateReplayMaxSessionsEnvVarName, + "maxSessions", + ); + if (isErr(maxSessions)) { + return maxSessions; + } + + return ok({ + backend: backend.value, + replayDirectory: replayDirectory.value, + durability: durability.value, + maxEnvelopes: maxEnvelopes.value, + maxSessions: maxSessions.value, + }); +} + +function validateDerivedStateRuntimeConfigInit( + init: DerivedStateRuntimeConfigInit, +): Result< + { + readonly checkpointIntervalMs: number; + readonly recoveryIntervalMs: number; + readonly replay: DerivedStateReplayConfig; + }, + DerivedStateValidationError +> { + const checkpointIntervalMs = validateNonNegativeIntegerInput( + init.checkpointIntervalMs ?? defaultDerivedStateCheckpointIntervalMs, + derivedStateCheckpointIntervalEnvVarName, + "checkpointIntervalMs", + ); + if (isErr(checkpointIntervalMs)) { + return checkpointIntervalMs; + } + + const recoveryIntervalMs = validateNonNegativeIntegerInput( + init.recoveryIntervalMs ?? defaultDerivedStateRecoveryIntervalMs, + derivedStateRecoveryIntervalEnvVarName, + "recoveryIntervalMs", + ); + if (isErr(recoveryIntervalMs)) { + return recoveryIntervalMs; + } + + const replay = + init.replay instanceof DerivedStateReplayConfig + ? ok(init.replay) + : tryDerivedStateReplayConfig(init.replay); + if (isErr(replay)) { + return replay; + } + + return ok({ + checkpointIntervalMs: checkpointIntervalMs.value, + recoveryIntervalMs: recoveryIntervalMs.value, + replay: replay.value, + }); } export function derivedStateReplayConfig( @@ -420,6 +723,25 @@ export function derivedStateReplayConfig( : new DerivedStateReplayConfig(init); } +export function tryDerivedStateReplayConfig( + init?: DerivedStateReplayConfigInput, +): Result { + if (init === undefined) { + return ok(new DerivedStateReplayConfig()); + } + + if (init instanceof DerivedStateReplayConfig) { + return ok(init); + } + + const validated = validateDerivedStateReplayConfigInit(init); + if (isErr(validated)) { + return validated; + } + + return ok(new DerivedStateReplayConfig(validated.value)); +} + export function derivedStateRuntimeConfig( init?: DerivedStateRuntimeConfigInput, ): DerivedStateRuntimeConfig { @@ -431,3 +753,22 @@ export function derivedStateRuntimeConfig( ? init : new DerivedStateRuntimeConfig(init); } + +export function tryDerivedStateRuntimeConfig( + init?: DerivedStateRuntimeConfigInput, +): Result { + if (init === undefined) { + return ok(new DerivedStateRuntimeConfig()); + } + + if (init instanceof DerivedStateRuntimeConfig) { + return ok(init); + } + + const validated = validateDerivedStateRuntimeConfigInit(init); + if (isErr(validated)) { + return validated; + } + + return ok(new DerivedStateRuntimeConfig(validated.value)); +} diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index ca7820f3..26602b67 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -15,6 +15,7 @@ import { derivedStateReplayBackendEnvValues, derivedStateReplayBackendEnvVarName, derivedStateReplayBackendToEnvValue, + parseDerivedStateReplayDirectory, derivedStateReplayDirectory, derivedStateReplayDirEnvVarName, derivedStateRecoveryIntervalEnvVarName, @@ -46,6 +47,15 @@ import { parseRuntimeBoolean, parseShredTrustMode, RuntimeDeliveryProfile, + tryDerivedStateReplayBackendToEnvValue, + tryDerivedStateReplayDurabilityToEnvValue, + tryNonNegativeIntegerToEnvValue, + tryObserverRuntimeConfig, + tryObserverRuntimeConfigForProfile, + tryProviderStreamCapabilityPolicyToEnvValue, + tryRuntimeDeliveryProfileEnvDefaults, + tryRuntimeDeliveryProfileToEnvValue, + tryShredTrustModeToEnvValue, runtimeDeliveryProfileEnvDefaults, runtimeBooleanAllowedValues, runtimeBooleanEnvValues, @@ -694,64 +704,171 @@ test("derived-state replay config exposes checkpoint-only helper", () => { assert.equal(replay.isEnabled(), false); }); -test("derived-state configs reject invalid programmatic numeric values", () => { - assert.throws( - () => - new DerivedStateRuntimeConfig({ - checkpointIntervalMs: -1, - }), - /checkpointIntervalMs must be a non-negative integer/, - ); - assert.throws( - () => - new DerivedStateReplayConfig({ - maxSessions: -1, - }), - /maxSessions must be a non-negative integer/, - ); - assert.throws( - () => nonNegativeIntegerToEnvValue(-1), - /value must be a non-negative integer/, +test("result-return helpers validate programmatic numeric and path values", () => { + const invalidRuntime = DerivedStateRuntimeConfig.tryCreate({ + checkpointIntervalMs: -1, + }); + const invalidReplay = DerivedStateReplayConfig.tryCreate({ + maxSessions: -1, + }); + const invalidInteger = tryNonNegativeIntegerToEnvValue( + -1, + derivedStateReplayMaxEnvelopesEnvVarName, + "value", ); + const invalidDirectory = parseDerivedStateReplayDirectory(" "); + + assert.equal(isErr(invalidRuntime), true); + assert.equal(isErr(invalidReplay), true); + assert.equal(isErr(invalidInteger), true); + assert.equal(isErr(invalidDirectory), true); + + if (isErr(invalidRuntime)) { + assert.equal( + invalidRuntime.error.kind, + ValidationErrorKind.InvalidNonNegativeInteger, + ); + assert.equal( + invalidRuntime.error.field, + derivedStateCheckpointIntervalEnvVarName, + ); + assert.equal( + invalidRuntime.error.message, + "checkpointIntervalMs must be a non-negative integer", + ); + } + + if (isErr(invalidReplay)) { + assert.equal( + invalidReplay.error.kind, + ValidationErrorKind.InvalidNonNegativeInteger, + ); + assert.equal( + invalidReplay.error.field, + derivedStateReplayMaxSessionsEnvVarName, + ); + assert.equal( + invalidReplay.error.message, + "maxSessions must be a non-negative integer", + ); + } + + if (isErr(invalidInteger)) { + assert.equal( + invalidInteger.error.kind, + ValidationErrorKind.InvalidNonNegativeInteger, + ); + assert.equal( + invalidInteger.error.field, + derivedStateReplayMaxEnvelopesEnvVarName, + ); + assert.equal( + invalidInteger.error.message, + "value must be a non-negative integer", + ); + } + + if (isErr(invalidDirectory)) { + assert.equal( + invalidDirectory.error.kind, + ValidationErrorKind.InvalidDerivedStateReplayDirectory, + ); + assert.equal(invalidDirectory.error.field, derivedStateReplayDirEnvVarName); + assert.equal(invalidDirectory.error.message, "replayDirectory must not be empty"); + } }); -test("runtime config helpers reject invalid programmatic enum and path values", () => { - assert.throws( - () => runtimeDeliveryProfileToEnvValue(99 as RuntimeDeliveryProfile), - /unknown runtime delivery profile/, - ); - assert.throws( - () => shredTrustModeToEnvValue(99 as ShredTrustMode), - /unknown shred trust mode/, - ); - assert.throws( - () => - providerStreamCapabilityPolicyToEnvValue( - 99 as ProviderStreamCapabilityPolicy, - ), - /unknown provider stream capability policy/, - ); - assert.throws( - () => - derivedStateReplayBackendToEnvValue(99 as DerivedStateReplayBackend), - /unknown derived-state replay backend/, - ); - assert.throws( - () => - derivedStateReplayDurabilityToEnvValue( - 99 as DerivedStateReplayDurability, - ), - /unknown derived-state replay durability/, - ); - assert.throws( - () => derivedStateReplayDirectory(" "), - /replayDirectory must not be empty/, - ); - assert.throws( - () => - new ObserverRuntimeConfig({ - providerStreamAllowEof: "true" as unknown as boolean, - }), - /providerStreamAllowEof must be a boolean/, +test("result-return helpers reject invalid programmatic enum and profile values", () => { + const invalidProfile = tryRuntimeDeliveryProfileToEnvValue( + 99 as RuntimeDeliveryProfile, + ); + const invalidProfileDefaults = tryRuntimeDeliveryProfileEnvDefaults( + 99 as RuntimeDeliveryProfile, + ); + const invalidShredTrustMode = tryShredTrustModeToEnvValue(99 as ShredTrustMode); + const invalidCapabilityPolicy = tryProviderStreamCapabilityPolicyToEnvValue( + 99 as ProviderStreamCapabilityPolicy, ); + const invalidReplayBackend = tryDerivedStateReplayBackendToEnvValue( + 99 as DerivedStateReplayBackend, + ); + const invalidReplayDurability = tryDerivedStateReplayDurabilityToEnvValue( + 99 as DerivedStateReplayDurability, + ); + const invalidRuntimeConfig = tryObserverRuntimeConfig({ + providerStreamAllowEof: "true" as unknown as boolean, + }); + const invalidProfileConfig = tryObserverRuntimeConfigForProfile( + 99 as RuntimeDeliveryProfile, + ); + + assert.equal(isErr(invalidProfile), true); + assert.equal(isErr(invalidProfileDefaults), true); + assert.equal(isErr(invalidShredTrustMode), true); + assert.equal(isErr(invalidCapabilityPolicy), true); + assert.equal(isErr(invalidReplayBackend), true); + assert.equal(isErr(invalidReplayDurability), true); + assert.equal(isErr(invalidRuntimeConfig), true); + assert.equal(isErr(invalidProfileConfig), true); + + if (isErr(invalidProfile)) { + assert.equal( + invalidProfile.error.kind, + ValidationErrorKind.InvalidRuntimeDeliveryProfile, + ); + assert.equal(invalidProfile.error.field, runtimeDeliveryProfileEnvVarName); + } + + if (isErr(invalidProfileDefaults)) { + assert.equal( + invalidProfileDefaults.error.kind, + ValidationErrorKind.InvalidRuntimeDeliveryProfile, + ); + } + + if (isErr(invalidShredTrustMode)) { + assert.equal( + invalidShredTrustMode.error.kind, + ValidationErrorKind.InvalidShredTrustMode, + ); + } + + if (isErr(invalidCapabilityPolicy)) { + assert.equal( + invalidCapabilityPolicy.error.kind, + ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, + ); + } + + if (isErr(invalidReplayBackend)) { + assert.equal( + invalidReplayBackend.error.kind, + ValidationErrorKind.InvalidDerivedStateReplayBackend, + ); + } + + if (isErr(invalidReplayDurability)) { + assert.equal( + invalidReplayDurability.error.kind, + ValidationErrorKind.InvalidDerivedStateReplayDurability, + ); + } + + if (isErr(invalidRuntimeConfig)) { + assert.equal( + invalidRuntimeConfig.error.kind, + ValidationErrorKind.InvalidProviderStreamAllowEof, + ); + assert.equal( + invalidRuntimeConfig.error.message, + "providerStreamAllowEof must be a boolean", + ); + } + + if (isErr(invalidProfileConfig)) { + assert.equal( + invalidProfileConfig.error.kind, + ValidationErrorKind.InvalidRuntimeDeliveryProfile, + ); + } }); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index 545dbf2f..bc3a355e 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -9,10 +9,10 @@ import type { ValidationError } from "../errors.js"; import { isErr, ok, type Result } from "../result.js"; import { defaultRuntimeDeliveryProfile, - isRuntimeDeliveryProfile, parseRuntimeDeliveryProfile, RuntimeDeliveryProfile, - runtimeDeliveryProfileEnvDefaults, + tryRuntimeDeliveryProfileEnvDefaults, + validateRuntimeDeliveryProfile, type RuntimeDeliveryProfileEnvValue, runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileToEnvValue, @@ -30,10 +30,12 @@ import { derivedStateReplayBackendEnvVarName, derivedStateReplayBackendToEnvValue, derivedStateReplayDirEnvVarName, + parseDerivedStateReplayDirectory, derivedStateReplayDurabilityEnvVarName, derivedStateReplayDurabilityToEnvValue, derivedStateReplayMaxEnvelopesEnvVarName, derivedStateReplayMaxSessionsEnvVarName, + type DerivedStateValidationError, DerivedStateReplayConfig, type DerivedStateReplayBackendEnvValue, type DerivedStateReplayDirectory, @@ -46,25 +48,26 @@ import { parseDerivedStateReplayBackend, parseDerivedStateReplayDurability, parseNonNegativeInteger, - derivedStateReplayDirectory, + tryDerivedStateRuntimeConfig, } from "./derived-state.js"; import { defaultProviderStreamAllowEof, defaultProviderStreamCapabilityPolicy, defaultShredTrustMode, - isProviderStreamCapabilityPolicy, - isShredTrustMode, parseProviderStreamCapabilityPolicy, parseRuntimeBoolean, parseShredTrustMode, - ProviderStreamCapabilityPolicy, + type ProviderStreamCapabilityPolicy, providerStreamAllowEofEnvVarName, providerStreamCapabilityPolicyEnvVarName, providerStreamCapabilityPolicyToEnvValue, type ProviderStreamCapabilityPolicyEnvValue, runtimeBooleanToEnvValue, type RuntimeBooleanEnvValue, - ShredTrustMode, + type ShredTrustMode, + validateProviderStreamCapabilityPolicy, + validateRuntimeBooleanInput, + validateShredTrustMode, shredTrustModeEnvVarName, shredTrustModeToEnvValue, type ShredTrustModeEnvValue, @@ -136,44 +139,56 @@ export type ObserverRuntimeValidationError = | ValidationError | ValidationError | ValidationError - | ValidationError; + | DerivedStateValidationError; + +function throwObserverRuntimeValidationError( + error: ObserverRuntimeValidationError, +): never { + throw new RangeError(error.message); +} function requireBoolean(field: string, value: boolean): boolean { - if (typeof value !== "boolean") { - throw new TypeError(`${field} must be a boolean`); + const validated = validateRuntimeBooleanInput( + value, + providerStreamAllowEofEnvVarName, + field, + ); + if (isErr(validated)) { + throw new TypeError(validated.error.message); } - return value; + return validated.value; } function requireObserverRuntimeDeliveryProfile( value: RuntimeDeliveryProfile, ): RuntimeDeliveryProfile { - if (!isRuntimeDeliveryProfile(value)) { - throw new RangeError(`unknown runtime delivery profile: ${String(value)}`); + const validated = validateRuntimeDeliveryProfile(value); + if (isErr(validated)) { + throw new RangeError(validated.error.message); } - return value; + return validated.value; } function requireObserverShredTrustMode(value: ShredTrustMode): ShredTrustMode { - if (!isShredTrustMode(value)) { - throw new RangeError(`unknown shred trust mode: ${String(value)}`); + const validated = validateShredTrustMode(value); + if (isErr(validated)) { + throw new RangeError(validated.error.message); } - return value; + return validated.value; } function requireObserverProviderStreamCapabilityPolicy( value: ProviderStreamCapabilityPolicy, ): ProviderStreamCapabilityPolicy { - if (!isProviderStreamCapabilityPolicy(value)) { - throw new RangeError( - `unknown provider stream capability policy: ${String(value)}`, - ); + const validated = validateProviderStreamCapabilityPolicy(value); + if (isErr(validated)) { + throw new RangeError(validated.error.message); } - return value; + return validated.value; } function shouldIncludeValue( @@ -184,24 +199,25 @@ function shouldIncludeValue( return options.includeDefaults === true || currentValue !== defaultValue; } -export function observerRuntimeConfig( - init: ObserverRuntimeConfigInput = {}, -): ObserverRuntimeConfig { - return init instanceof ObserverRuntimeConfig - ? init - : new ObserverRuntimeConfig(init); -} - -export function observerRuntimeConfigForProfile( +function applyRuntimeProfileDerivedStateDefaults( profile: RuntimeDeliveryProfile, - init: ObserverRuntimeProfileInit = {}, -): ObserverRuntimeConfig { - const derivedStateDefaults = runtimeDeliveryProfileEnvDefaults(profile); + init: ObserverRuntimeProfileInit, +): Result< + ObserverRuntimeConfigInit, + ValidationError +> { + const derivedStateDefaults = tryRuntimeDeliveryProfileEnvDefaults(profile); + if (isErr(derivedStateDefaults)) { + return derivedStateDefaults; + } + const derivedState = init.derivedState; const replay = - derivedState instanceof DerivedStateRuntimeConfig ? derivedState.replay : derivedState?.replay; + derivedState instanceof DerivedStateRuntimeConfig + ? derivedState.replay + : derivedState?.replay; - return new ObserverRuntimeConfig({ + return ok({ ...init, runtimeDeliveryProfile: profile, derivedState: @@ -216,15 +232,108 @@ export function observerRuntimeConfigForProfile( ...replay, maxEnvelopes: replay?.maxEnvelopes ?? - derivedStateDefaults.derivedStateReplayMaxEnvelopes, + derivedStateDefaults.value.derivedStateReplayMaxEnvelopes, maxSessions: replay?.maxSessions ?? - derivedStateDefaults.derivedStateReplayMaxSessions, + derivedStateDefaults.value.derivedStateReplayMaxSessions, }, }, }); } +export function tryObserverRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, +): Result { + if (init instanceof ObserverRuntimeConfig) { + return ok(init); + } + + const runtimeDeliveryProfile = validateRuntimeDeliveryProfile( + init.runtimeDeliveryProfile ?? defaultRuntimeDeliveryProfile, + ); + if (isErr(runtimeDeliveryProfile)) { + return runtimeDeliveryProfile; + } + + const shredTrustMode = validateShredTrustMode( + init.shredTrustMode ?? defaultShredTrustMode, + ); + if (isErr(shredTrustMode)) { + return shredTrustMode; + } + + const providerStreamCapabilityPolicy = + validateProviderStreamCapabilityPolicy( + init.providerStreamCapabilityPolicy ?? + defaultProviderStreamCapabilityPolicy, + ); + if (isErr(providerStreamCapabilityPolicy)) { + return providerStreamCapabilityPolicy; + } + + const providerStreamAllowEof = validateRuntimeBooleanInput( + init.providerStreamAllowEof ?? defaultProviderStreamAllowEof, + providerStreamAllowEofEnvVarName, + "providerStreamAllowEof", + ); + if (isErr(providerStreamAllowEof)) { + return providerStreamAllowEof; + } + + const derivedState = tryDerivedStateRuntimeConfig(init.derivedState); + if (isErr(derivedState)) { + return derivedState; + } + + return ok( + new ObserverRuntimeConfig({ + runtimeDeliveryProfile: runtimeDeliveryProfile.value, + shredTrustMode: shredTrustMode.value, + providerStreamCapabilityPolicy: providerStreamCapabilityPolicy.value, + providerStreamAllowEof: providerStreamAllowEof.value, + derivedState: derivedState.value, + }), + ); +} + +export function tryObserverRuntimeConfigForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, +): Result { + const withProfileDefaults = applyRuntimeProfileDerivedStateDefaults( + profile, + init, + ); + if (isErr(withProfileDefaults)) { + return withProfileDefaults; + } + + return tryObserverRuntimeConfig(withProfileDefaults.value); +} + +export function observerRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, +): ObserverRuntimeConfig { + const result = tryObserverRuntimeConfig(init); + if (isErr(result)) { + throwObserverRuntimeValidationError(result.error); + } + + return result.value; +} + +export function observerRuntimeConfigForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, +): ObserverRuntimeConfig { + const result = tryObserverRuntimeConfigForProfile(profile, init); + if (isErr(result)) { + throwObserverRuntimeValidationError(result.error); + } + + return result.value; +} + export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; readonly shredTrustMode: ShredTrustMode; @@ -255,6 +364,12 @@ export class ObserverRuntimeConfig { return observerRuntimeConfig(init); } + static tryCreate( + init: ObserverRuntimeConfigInput = {}, + ): Result { + return tryObserverRuntimeConfig(init); + } + static forProfile( profile: RuntimeDeliveryProfile, init: ObserverRuntimeProfileInit = {}, @@ -262,6 +377,13 @@ export class ObserverRuntimeConfig { return observerRuntimeConfigForProfile(profile, init); } + static tryForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, + ): Result { + return tryObserverRuntimeConfigForProfile(profile, init); + } + static latencyOptimized( init: ObserverRuntimeProfileInit = {}, ): ObserverRuntimeConfig { @@ -284,6 +406,33 @@ export class ObserverRuntimeConfig { ); } + static tryLatencyOptimized( + init: ObserverRuntimeProfileInit = {}, + ): Result { + return tryObserverRuntimeConfigForProfile( + RuntimeDeliveryProfile.LatencyOptimized, + init, + ); + } + + static tryBalanced( + init: ObserverRuntimeProfileInit = {}, + ): Result { + return tryObserverRuntimeConfigForProfile( + RuntimeDeliveryProfile.Balanced, + init, + ); + } + + static tryDeliveryDisciplined( + init: ObserverRuntimeProfileInit = {}, + ): Result { + return tryObserverRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, + init, + ); + } + toEnvironment( options: ObserverRuntimeEnvironmentOptions = {}, ): readonly ObserverRuntimeEnvironmentVariable[] { @@ -603,9 +752,13 @@ export class ObserverRuntimeConfig { let parsedDerivedStateReplayDirectory = defaultDerivedStateReplayDirectory; if (derivedStateReplayDir !== undefined && derivedStateReplayDir.trim() !== "") { - parsedDerivedStateReplayDirectory = derivedStateReplayDirectory( + const parsed = parseDerivedStateReplayDirectory( derivedStateReplayDir, ); + if (isErr(parsed)) { + return parsed; + } + parsedDerivedStateReplayDirectory = parsed.value; } let parsedDerivedStateReplayDurability = defaultDerivedStateReplayDurability; @@ -654,25 +807,23 @@ export class ObserverRuntimeConfig { parsedDerivedStateReplayMaxSessions = parsed.value; } - return ok( - new ObserverRuntimeConfig({ - runtimeDeliveryProfile: parsedRuntimeDeliveryProfile, - shredTrustMode: parsedShredTrustMode, - providerStreamCapabilityPolicy: parsedProviderStreamCapabilityPolicy, - providerStreamAllowEof: parsedProviderStreamAllowEof, - derivedState: new DerivedStateRuntimeConfig({ - checkpointIntervalMs: parsedCheckpointIntervalMs, - recoveryIntervalMs: parsedRecoveryIntervalMs, - replay: new DerivedStateReplayConfig({ - backend: parsedDerivedStateReplayBackend, - replayDirectory: parsedDerivedStateReplayDirectory, - durability: parsedDerivedStateReplayDurability, - maxEnvelopes: parsedDerivedStateReplayMaxEnvelopes, - maxSessions: parsedDerivedStateReplayMaxSessions, - }), - }), - }), - ); + return tryObserverRuntimeConfig({ + runtimeDeliveryProfile: parsedRuntimeDeliveryProfile, + shredTrustMode: parsedShredTrustMode, + providerStreamCapabilityPolicy: parsedProviderStreamCapabilityPolicy, + providerStreamAllowEof: parsedProviderStreamAllowEof, + derivedState: { + checkpointIntervalMs: parsedCheckpointIntervalMs, + recoveryIntervalMs: parsedRecoveryIntervalMs, + replay: { + backend: parsedDerivedStateReplayBackend, + replayDirectory: parsedDerivedStateReplayDirectory, + durability: parsedDerivedStateReplayDurability, + maxEnvelopes: parsedDerivedStateReplayMaxEnvelopes, + maxSessions: parsedDerivedStateReplayMaxSessions, + }, + }, + }); } static fromEnvironmentRecord( diff --git a/sdks/typescript/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/src/runtime/runtime-delivery-profile.ts index 72209556..94aca5fa 100644 --- a/sdks/typescript/src/runtime/runtime-delivery-profile.ts +++ b/sdks/typescript/src/runtime/runtime-delivery-profile.ts @@ -1,7 +1,7 @@ import { brand, type Brand } from "../brand.js"; import { envVarName } from "../environment.js"; import { ValidationErrorKind, type ValidationError } from "../errors.js"; -import { err, ok, type Result } from "../result.js"; +import { err, isErr, ok, type Result } from "../result.js"; import { defaultDerivedStateReplayMaxEnvelopes, defaultDerivedStateReplayMaxSessions, @@ -64,53 +64,119 @@ export function isRuntimeDeliveryProfile( } } -function requireRuntimeDeliveryProfile( +export function validateRuntimeDeliveryProfile( value: RuntimeDeliveryProfile, -): RuntimeDeliveryProfile { +): Result< + RuntimeDeliveryProfile, + ValidationError +> { if (!isRuntimeDeliveryProfile(value)) { - throw new RangeError(`unknown runtime delivery profile: ${String(value)}`); + return err({ + kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, + field: runtimeDeliveryProfileEnvVarName, + received: String(value), + message: + "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", + allowedValues: runtimeDeliveryProfileAllowedValues, + }); } - return value; + return ok(value); } -export function runtimeDeliveryProfileToEnvValue( +export function tryRuntimeDeliveryProfileToEnvValue( profile: RuntimeDeliveryProfile, -): RuntimeDeliveryProfileEnvValue { - switch (requireRuntimeDeliveryProfile(profile)) { +): Result< + RuntimeDeliveryProfileEnvValue, + ValidationError +> { + const validated = validateRuntimeDeliveryProfile(profile); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case RuntimeDeliveryProfile.LatencyOptimized: - return runtimeDeliveryProfileEnvValues.latencyOptimized; + return ok(runtimeDeliveryProfileEnvValues.latencyOptimized); case RuntimeDeliveryProfile.Balanced: - return runtimeDeliveryProfileEnvValues.balanced; + return ok(runtimeDeliveryProfileEnvValues.balanced); case RuntimeDeliveryProfile.DeliveryDisciplined: - return runtimeDeliveryProfileEnvValues.deliveryDisciplined; + return ok(runtimeDeliveryProfileEnvValues.deliveryDisciplined); } + + return err({ + kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, + field: runtimeDeliveryProfileEnvVarName, + received: String(profile), + message: + "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", + allowedValues: runtimeDeliveryProfileAllowedValues, + }); } -export function runtimeDeliveryProfileEnvDefaults( +export function runtimeDeliveryProfileToEnvValue( profile: RuntimeDeliveryProfile, -): RuntimeDeliveryProfileEnvDefaults { - switch (requireRuntimeDeliveryProfile(profile)) { +): RuntimeDeliveryProfileEnvValue { + const result = tryRuntimeDeliveryProfileToEnvValue(profile); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(`unknown runtime delivery profile: ${String(profile)}`); +} + +export function tryRuntimeDeliveryProfileEnvDefaults( + profile: RuntimeDeliveryProfile, +): Result< + RuntimeDeliveryProfileEnvDefaults, + ValidationError +> { + const validated = validateRuntimeDeliveryProfile(profile); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case RuntimeDeliveryProfile.LatencyOptimized: - return { + return ok({ derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes, derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions, - }; + }); case RuntimeDeliveryProfile.Balanced: - return { + return ok({ derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes * 2, derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions + 2, - }; + }); case RuntimeDeliveryProfile.DeliveryDisciplined: - return { + return ok({ derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes * 4, derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions * 2, - }; + }); } + + return err({ + kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, + field: runtimeDeliveryProfileEnvVarName, + received: String(profile), + message: + "runtime delivery profile must be latency_optimized, balanced, or delivery_disciplined", + allowedValues: runtimeDeliveryProfileAllowedValues, + }); +} + +export function runtimeDeliveryProfileEnvDefaults( + profile: RuntimeDeliveryProfile, +): RuntimeDeliveryProfileEnvDefaults { + const result = tryRuntimeDeliveryProfileEnvDefaults(profile); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(`unknown runtime delivery profile: ${String(profile)}`); } export function parseRuntimeDeliveryProfile( diff --git a/sdks/typescript/src/runtime/runtime-policy.ts b/sdks/typescript/src/runtime/runtime-policy.ts index 48f5ad32..0f9d0119 100644 --- a/sdks/typescript/src/runtime/runtime-policy.ts +++ b/sdks/typescript/src/runtime/runtime-policy.ts @@ -1,7 +1,7 @@ import { brand, type Brand } from "../brand.js"; import { envVarName, type EnvVarName } from "../environment.js"; import { ValidationErrorKind, type ValidationError } from "../errors.js"; -import { err, ok, type Result } from "../result.js"; +import { err, isErr, ok, type Result } from "../result.js"; export enum ShredTrustMode { PublicUntrusted = 1, @@ -106,46 +106,133 @@ export function isProviderStreamCapabilityPolicy( } } -function requireShredTrustMode(value: ShredTrustMode): ShredTrustMode { +export function validateShredTrustMode( + value: ShredTrustMode, +): Result> { if (!isShredTrustMode(value)) { - throw new RangeError(`unknown shred trust mode: ${String(value)}`); + return err({ + kind: ValidationErrorKind.InvalidShredTrustMode, + field: shredTrustModeEnvVarName, + received: String(value), + message: + "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + allowedValues: shredTrustModeAllowedValues, + }); } - return value; + return ok(value); } -function requireProviderStreamCapabilityPolicy( +export function validateProviderStreamCapabilityPolicy( value: ProviderStreamCapabilityPolicy, -): ProviderStreamCapabilityPolicy { +): Result< + ProviderStreamCapabilityPolicy, + ValidationError +> { if (!isProviderStreamCapabilityPolicy(value)) { - throw new RangeError( - `unknown provider stream capability policy: ${String(value)}`, - ); + return err({ + kind: ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, + field: providerStreamCapabilityPolicyEnvVarName, + received: String(value), + message: "provider stream capability policy must be warn or strict", + allowedValues: providerStreamCapabilityPolicyAllowedValues, + }); } - return value; + return ok(value); } -export function shredTrustModeToEnvValue( +export function validateRuntimeBooleanInput( + value: unknown, + field: EnvVarName, + propertyName: string, +): Result> { + if (typeof value !== "boolean") { + return err({ + kind: ValidationErrorKind.InvalidProviderStreamAllowEof, + field, + received: String(value), + message: `${propertyName} must be a boolean`, + }); + } + + return ok(value); +} + +export function tryShredTrustModeToEnvValue( mode: ShredTrustMode, -): ShredTrustModeEnvValue { - switch (requireShredTrustMode(mode)) { +): Result> { + const validated = validateShredTrustMode(mode); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case ShredTrustMode.PublicUntrusted: - return shredTrustModeEnvValues.publicUntrusted; + return ok(shredTrustModeEnvValues.publicUntrusted); case ShredTrustMode.TrustedRawShredProvider: - return shredTrustModeEnvValues.trustedRawShredProvider; + return ok(shredTrustModeEnvValues.trustedRawShredProvider); } + + return err({ + kind: ValidationErrorKind.InvalidShredTrustMode, + field: shredTrustModeEnvVarName, + received: String(mode), + message: + "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + allowedValues: shredTrustModeAllowedValues, + }); } -export function providerStreamCapabilityPolicyToEnvValue( +export function tryProviderStreamCapabilityPolicyToEnvValue( policy: ProviderStreamCapabilityPolicy, -): ProviderStreamCapabilityPolicyEnvValue { - switch (requireProviderStreamCapabilityPolicy(policy)) { +): Result< + ProviderStreamCapabilityPolicyEnvValue, + ValidationError +> { + const validated = validateProviderStreamCapabilityPolicy(policy); + if (isErr(validated)) { + return validated; + } + + switch (validated.value) { case ProviderStreamCapabilityPolicy.Warn: - return providerStreamCapabilityPolicyEnvValues.warn; + return ok(providerStreamCapabilityPolicyEnvValues.warn); case ProviderStreamCapabilityPolicy.Strict: - return providerStreamCapabilityPolicyEnvValues.strict; + return ok(providerStreamCapabilityPolicyEnvValues.strict); } + + return err({ + kind: ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, + field: providerStreamCapabilityPolicyEnvVarName, + received: String(policy), + message: "provider stream capability policy must be warn or strict", + allowedValues: providerStreamCapabilityPolicyAllowedValues, + }); +} + +export function shredTrustModeToEnvValue( + mode: ShredTrustMode, +): ShredTrustModeEnvValue { + const result = tryShredTrustModeToEnvValue(mode); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError(`unknown shred trust mode: ${String(mode)}`); +} + +export function providerStreamCapabilityPolicyToEnvValue( + policy: ProviderStreamCapabilityPolicy, +): ProviderStreamCapabilityPolicyEnvValue { + const result = tryProviderStreamCapabilityPolicyToEnvValue(policy); + if (!isErr(result)) { + return result.value; + } + + throw new RangeError( + `unknown provider stream capability policy: ${String(policy)}`, + ); } export function runtimeBooleanToEnvValue(value: boolean): RuntimeBooleanEnvValue { diff --git a/sdks/typescript/tsconfig.build.json b/sdks/typescript/tsconfig.build.json deleted file mode 100644 index c2f08f79..00000000 --- a/sdks/typescript/tsconfig.build.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": [ - "src/**/*.test.ts" - ] -} diff --git a/sdks/typescript/tsconfig.json b/sdks/typescript/tsconfig.json index 93b36ef3..a2a957a2 100644 --- a/sdks/typescript/tsconfig.json +++ b/sdks/typescript/tsconfig.json @@ -12,6 +12,9 @@ "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, + "types": [ + "node" + ], "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, diff --git a/sdks/typescript/tsdown.config.ts b/sdks/typescript/tsdown.config.ts new file mode 100644 index 00000000..7be8f4b9 --- /dev/null +++ b/sdks/typescript/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + dts: true, + entry: { + index: "src/index.ts", + runtime: "src/runtime.ts", + "runtime/config": "src/runtime/runtime-config.ts", + "runtime/policy": "src/runtime/runtime-policy.ts", + "runtime/derived-state": "src/runtime/derived-state.ts", + "runtime/delivery-profile": "src/runtime/runtime-delivery-profile.ts", + }, + format: ["esm"], + minify: true, + outDir: "dist", + platform: "neutral", + sourcemap: true, + target: "es2022", + unbundle: true, +}); From 0612655b06a8360dc427b17102bb957bce29fbe1 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 13:08:46 +0200 Subject: [PATCH 10/25] refactor(ts-sdk): add simpler runtime config facade --- sdks/typescript/README.md | 109 +++++++++--------- sdks/typescript/src/package-exports.test.ts | 8 ++ .../src/runtime/runtime-config.test.ts | 104 +++++++++++++++++ sdks/typescript/src/runtime/runtime-config.ts | 73 ++++++++++++ 4 files changed, 238 insertions(+), 56 deletions(-) diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 0b141202..5443a2e0 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -11,9 +11,10 @@ Unified TypeScript SDK surface for SOF. ## Mental Model -- Use `ObserverRuntimeConfig` when you want to build or parse the SOF env surface safely. -- Prefer `ObserverRuntimeConfig.tryCreate(...)`, `.tryBalanced(...)`, and `fromEnvironmentRecord(...)` when you want validation errors as `Result` values instead of thrown exceptions. -- Use `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. +- Prefer the functional runtime-config helpers first: `createRuntimeConfigForProfile(...)`, `serializeRuntimeConfigRecord(...)`, and `parseRuntimeConfig(...)`. +- Use `ObserverRuntimeConfig` when you want an explicit config object with instance methods and class-based presets. +- Prefer `tryCreateRuntimeConfig(...)`, `tryCreateRuntimeConfigForProfile(...)`, and `parseRuntimeConfig(...)` when you want validation errors as `Result` values instead of thrown exceptions. +- Use `createRuntimeConfigForProfile(...)` or `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. - Profile presets in this SDK stamp the profile env plus the derived-state replay retention defaults that SOF applies through env-backed setup. - Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface, not those in-process host builders. @@ -38,60 +39,42 @@ This initial package slice provides: - typed environment entry helpers instead of only raw string maps - plain-object nested config construction, so common cases do not require chained `new` calls - one-line runtime profile presets such as `ObserverRuntimeConfig.balanced()` +- small functional helpers for the common create/serialize/parse path, so most consumers do not need to learn the class API first - result-return factory and serialization helpers for programmatic validation, so SDK consumers do not need to rely on exceptions for normal invalid-input handling - focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` -## Example +## Quick Start ```ts import { + createRuntimeConfigForProfile, DerivedStateReplayBackend, - DerivedStateReplayConfig, DerivedStateReplayDurability, - ObserverRuntimeConfig, + parseRuntimeConfig, ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, + serializeRuntimeConfigRecord, ShredTrustMode, - providerStreamAllowEofEnvVarName, - providerStreamCapabilityPolicyEnvVarName, - runtimeDeliveryProfileEnvValues, - runtimeDeliveryProfileEnvVarName, - shredTrustModeEnvVarName, } from "@sof/sdk"; -const config = ObserverRuntimeConfig.balanced({ - shredTrustMode: ShredTrustMode.TrustedRawShredProvider, - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - providerStreamAllowEof: true, - derivedState: { - checkpointIntervalMs: 60_000, - recoveryIntervalMs: 10_000, - replay: { - backend: DerivedStateReplayBackend.Disk, - replayDirectory: ".sof-replay", - durability: DerivedStateReplayDurability.Fsync, - maxEnvelopes: 1024, - maxSessions: 2, +const config = createRuntimeConfigForProfile( + RuntimeDeliveryProfile.Balanced, + { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + providerStreamAllowEof: true, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, + }, }, }, -}); - -const env = config.toEnvironment(); -// [ -// { name: "SOF_RUNTIME_DELIVERY_PROFILE", value: "balanced" }, -// { name: "SOF_SHRED_TRUST_MODE", value: "trusted_raw_shred_provider" }, -// { name: "SOF_PROVIDER_STREAM_CAPABILITY_POLICY", value: "strict" }, -// { name: "SOF_PROVIDER_STREAM_ALLOW_EOF", value: "true" }, -// { name: "SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS", value: "60000" }, -// { name: "SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS", value: "10000" }, -// { name: "SOF_DERIVED_STATE_REPLAY_BACKEND", value: "disk" }, -// { name: "SOF_DERIVED_STATE_REPLAY_DIR", value: ".sof-replay" }, -// { name: "SOF_DERIVED_STATE_REPLAY_DURABILITY", value: "fsync" }, -// { name: "SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES", value: "1024" }, -// { name: "SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS", value: "2" }, -// ] - -const envRecord = config.toEnvironmentRecord(); +); + +const envRecord = serializeRuntimeConfigRecord(config); // { // SOF_RUNTIME_DELIVERY_PROFILE: "balanced", // SOF_SHRED_TRUST_MODE: "trusted_raw_shred_provider", @@ -106,30 +89,42 @@ const envRecord = config.toEnvironmentRecord(); // SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS: "2", // } -const parsed = ObserverRuntimeConfig.fromEnvironmentRecord(envRecord); -const checkpointOnly = DerivedStateReplayConfig.checkpointOnly(); +const parsed = parseRuntimeConfig(envRecord); -runtimeDeliveryProfileEnvVarName; -runtimeDeliveryProfileEnvValues.deliveryDisciplined; -shredTrustModeEnvVarName; -providerStreamCapabilityPolicyEnvVarName; -providerStreamAllowEofEnvVarName; +config; parsed; -checkpointOnly; ``` -## Quick Start +## Result Path ```ts -import { isErr, ObserverRuntimeConfig } from "@sof/sdk"; +import { + isErr, + RuntimeDeliveryProfile, + tryCreateRuntimeConfigForProfile, + trySerializeRuntimeConfigRecord, +} from "@sof/sdk"; -const config = ObserverRuntimeConfig.tryDeliveryDisciplined(); +const config = tryCreateRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, +); if (isErr(config)) { throw new Error(config.error.message); } -const env = config.value.toEnvironmentRecord(); +const env = trySerializeRuntimeConfigRecord(config.value); + +env; +``` + +## Class API + +```ts +import { ObserverRuntimeConfig } from "@sof/sdk"; + +const config = ObserverRuntimeConfig.deliveryDisciplined(); +const env = config.toEnvironmentRecord(); env; ``` @@ -172,7 +167,9 @@ config; ## Choosing An API - Use `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env from files, CI, or process managers. -- Use `ObserverRuntimeConfig.tryCreate(...)` or `tryObserverRuntimeConfigForProfile(...)` when invalid programmatic input should stay in `Result` form. -- Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you want profile-first setup. +- Use `parseRuntimeConfig(...)` for env parsing. It accepts either env records or typed environment-variable lists. +- Use `tryCreateRuntimeConfig(...)`, `tryCreateRuntimeConfigForProfile(...)`, or `trySerializeRuntimeConfigRecord(...)` when invalid programmatic input should stay in `Result` form. +- Use `createRuntimeConfigForProfile(...)` or `serializeRuntimeConfigRecord(...)` for the simplest create-and-emit workflow. +- Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you explicitly want the class-oriented surface. - Use `derivedStateRuntimeConfig(...)` or `DerivedStateRuntimeConfig.checkpointOnly()` when your main concern is derived-state recovery behavior. - Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface in application code. diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index 0584bfb8..0a044c99 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -17,6 +17,10 @@ test("package exports resolve the documented public entry points", async () => { (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, ); + assert.equal( + (root as { createRuntimeConfig: unknown }).createRuntimeConfig, + (config as { createRuntimeConfig: unknown }).createRuntimeConfig, + ); assert.equal( (root as { observerRuntimeConfig: unknown }).observerRuntimeConfig, (config as { observerRuntimeConfig: unknown }).observerRuntimeConfig, @@ -25,6 +29,10 @@ test("package exports resolve the documented public entry points", async () => { (root as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, (config as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, ); + assert.equal( + (root as { tryCreateRuntimeConfig: unknown }).tryCreateRuntimeConfig, + (config as { tryCreateRuntimeConfig: unknown }).tryCreateRuntimeConfig, + ); assert.equal( (runtime as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 26602b67..500d5120 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -8,6 +8,8 @@ import { import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { + createRuntimeConfig, + createRuntimeConfigForProfile, defaultDerivedStateReplayDirectory, derivedStateCheckpointIntervalEnvVarName, DerivedStateReplayBackend, @@ -36,6 +38,7 @@ import { parseDerivedStateReplayBackend, parseDerivedStateReplayDurability, parseNonNegativeInteger, + parseRuntimeConfig, ObserverRuntimeConfig, ProviderStreamCapabilityPolicy, providerStreamAllowEofEnvVarName, @@ -49,6 +52,8 @@ import { RuntimeDeliveryProfile, tryDerivedStateReplayBackendToEnvValue, tryDerivedStateReplayDurabilityToEnvValue, + tryCreateRuntimeConfig, + tryCreateRuntimeConfigForProfile, tryNonNegativeIntegerToEnvValue, tryObserverRuntimeConfig, tryObserverRuntimeConfigForProfile, @@ -56,10 +61,14 @@ import { tryRuntimeDeliveryProfileEnvDefaults, tryRuntimeDeliveryProfileToEnvValue, tryShredTrustModeToEnvValue, + trySerializeRuntimeConfig, + trySerializeRuntimeConfigRecord, runtimeDeliveryProfileEnvDefaults, runtimeBooleanAllowedValues, runtimeBooleanEnvValues, parseRuntimeDeliveryProfile, + serializeRuntimeConfig, + serializeRuntimeConfigRecord, shredTrustModeAllowedValues, shredTrustModeEnvValues, shredTrustModeEnvVarName, @@ -422,6 +431,48 @@ test("runtime config parses typed environment variables into typed config", () = } }); +test("functional runtime config facade keeps common env workflows simple", () => { + const created = createRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + }); + const config = createRuntimeConfigForProfile( + RuntimeDeliveryProfile.Balanced, + { + providerStreamAllowEof: true, + derivedState: { + replay: { + maxEnvelopes: 512, + }, + }, + }, + ); + const serialized = serializeRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + providerStreamAllowEof: true, + derivedState: { + replay: { + maxEnvelopes: 512, + }, + }, + }); + const record = serializeRuntimeConfigRecord(config); + const parsed = parseRuntimeConfig(record); + + assert.equal(created.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); + assert.equal(config.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); + assert.equal(config.providerStreamAllowEof, true); + assert.equal(config.derivedState.replay.maxEnvelopes, 512); + assert.equal(Array.isArray(serialized), true); + assert.equal(record[runtimeDeliveryProfileEnvVarName], "balanced"); + assert.equal(isOk(parsed), true); + + if (isOk(parsed)) { + assert.equal(parsed.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); + assert.equal(parsed.value.providerStreamAllowEof, true); + assert.equal(parsed.value.derivedState.replay.maxEnvelopes, 512); + } +}); + test("runtime config preset helpers create one-line common profiles", () => { const latency = ObserverRuntimeConfig.latencyOptimized(); const balanced = ObserverRuntimeConfig.balanced({ @@ -469,6 +520,37 @@ test("runtime config preset helpers create one-line common profiles", () => { assert.equal(functionPreset.derivedState.replay.maxSessions, 8); }); +test("functional runtime config facade exposes result-return creation and serialization", () => { + const created = tryCreateRuntimeConfig({ + providerStreamAllowEof: true, + }); + const profiled = tryCreateRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + const serialized = trySerializeRuntimeConfig({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + }); + const record = trySerializeRuntimeConfigRecord({ + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + }); + + assert.equal(isOk(created), true); + assert.equal(isOk(profiled), true); + assert.equal(isOk(serialized), true); + assert.equal(isOk(record), true); + + if (isOk(profiled)) { + assert.equal( + profiled.value.runtimeDeliveryProfile, + RuntimeDeliveryProfile.DeliveryDisciplined, + ); + } + + if (isOk(record)) { + assert.equal(record.value[runtimeDeliveryProfileEnvVarName], "balanced"); + } +}); + test("runtime profile helpers preserve explicit derived-state replay overrides", () => { const config = ObserverRuntimeConfig.deliveryDisciplined({ derivedState: { @@ -801,6 +883,12 @@ test("result-return helpers reject invalid programmatic enum and profile values" const invalidProfileConfig = tryObserverRuntimeConfigForProfile( 99 as RuntimeDeliveryProfile, ); + const invalidCreated = tryCreateRuntimeConfig({ + providerStreamAllowEof: "true" as unknown as boolean, + }); + const invalidSerialized = trySerializeRuntimeConfigRecord({ + providerStreamAllowEof: "true" as unknown as boolean, + }); assert.equal(isErr(invalidProfile), true); assert.equal(isErr(invalidProfileDefaults), true); @@ -810,6 +898,8 @@ test("result-return helpers reject invalid programmatic enum and profile values" assert.equal(isErr(invalidReplayDurability), true); assert.equal(isErr(invalidRuntimeConfig), true); assert.equal(isErr(invalidProfileConfig), true); + assert.equal(isErr(invalidCreated), true); + assert.equal(isErr(invalidSerialized), true); if (isErr(invalidProfile)) { assert.equal( @@ -871,4 +961,18 @@ test("result-return helpers reject invalid programmatic enum and profile values" ValidationErrorKind.InvalidRuntimeDeliveryProfile, ); } + + if (isErr(invalidCreated)) { + assert.equal( + invalidCreated.error.kind, + ValidationErrorKind.InvalidProviderStreamAllowEof, + ); + } + + if (isErr(invalidSerialized)) { + assert.equal( + invalidSerialized.error.kind, + ValidationErrorKind.InvalidProviderStreamAllowEof, + ); + } }); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index bc3a355e..ad115215 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -334,6 +334,79 @@ export function observerRuntimeConfigForProfile( return result.value; } +export function createRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, +): ObserverRuntimeConfig { + return observerRuntimeConfig(init); +} + +export function tryCreateRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, +): Result { + return tryObserverRuntimeConfig(init); +} + +export function createRuntimeConfigForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, +): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile(profile, init); +} + +export function tryCreateRuntimeConfigForProfile( + profile: RuntimeDeliveryProfile, + init: ObserverRuntimeProfileInit = {}, +): Result { + return tryObserverRuntimeConfigForProfile(profile, init); +} + +export function parseRuntimeConfig( + env: EnvironmentInput, +): Result { + return ObserverRuntimeConfig.fromEnvironment(env); +} + +export function serializeRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, + options: ObserverRuntimeEnvironmentOptions = {}, +): readonly ObserverRuntimeEnvironmentVariable[] { + return observerRuntimeConfig(init).toEnvironment(options); +} + +export function trySerializeRuntimeConfig( + init: ObserverRuntimeConfigInput = {}, + options: ObserverRuntimeEnvironmentOptions = {}, +): Result< + readonly ObserverRuntimeEnvironmentVariable[], + ObserverRuntimeValidationError +> { + const config = tryObserverRuntimeConfig(init); + if (isErr(config)) { + return config; + } + + return ok(config.value.toEnvironment(options)); +} + +export function serializeRuntimeConfigRecord( + init: ObserverRuntimeConfigInput = {}, + options: ObserverRuntimeEnvironmentOptions = {}, +): Readonly> { + return observerRuntimeConfig(init).toEnvironmentRecord(options); +} + +export function trySerializeRuntimeConfigRecord( + init: ObserverRuntimeConfigInput = {}, + options: ObserverRuntimeEnvironmentOptions = {}, +): Result>, ObserverRuntimeValidationError> { + const config = tryObserverRuntimeConfig(init); + if (isErr(config)) { + return config; + } + + return ok(config.value.toEnvironmentRecord(options)); +} + export class ObserverRuntimeConfig { readonly runtimeDeliveryProfile: RuntimeDeliveryProfile; readonly shredTrustMode: ShredTrustMode; From a7603a80115cb5f6367849516e5a7bbbd88c9ae6 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 13:28:10 +0200 Subject: [PATCH 11/25] feat(ts-sdk): add extension runtime authoring and examples --- .gitignore | 1 + sdks/typescript/README.md | 34 + .../examples/runtime-config-balanced.ts | 36 + .../examples/runtime-config-parse.ts | 21 + .../examples/runtime-extension-manifest.ts | 78 ++ .../examples/runtime-extension-worker.ts | 110 +++ sdks/typescript/package.json | 10 +- sdks/typescript/src/package-exports.test.ts | 7 + sdks/typescript/src/runtime.ts | 1 + .../src/runtime/runtime-extension.test.ts | 215 +++++ .../src/runtime/runtime-extension.ts | 839 ++++++++++++++++++ sdks/typescript/tsconfig.examples.json | 12 + sdks/typescript/tsdown.config.ts | 1 + 13 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 sdks/typescript/examples/runtime-config-balanced.ts create mode 100644 sdks/typescript/examples/runtime-config-parse.ts create mode 100644 sdks/typescript/examples/runtime-extension-manifest.ts create mode 100644 sdks/typescript/examples/runtime-extension-worker.ts create mode 100644 sdks/typescript/src/runtime/runtime-extension.test.ts create mode 100644 sdks/typescript/src/runtime/runtime-extension.ts create mode 100644 sdks/typescript/tsconfig.examples.json diff --git a/.gitignore b/.gitignore index d70a2d5c..4e287798 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/node_modules/ **/dist/ **/dist-test/ +**/dist-examples/ # Editor/OS noise .DS_Store diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 5443a2e0..6f414c9a 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -41,6 +41,7 @@ This initial package slice provides: - one-line runtime profile presets such as `ObserverRuntimeConfig.balanced()` - small functional helpers for the common create/serialize/parse path, so most consumers do not need to learn the class API first - result-return factory and serialization helpers for programmatic validation, so SDK consumers do not need to rely on exceptions for normal invalid-input handling +- typed runtime-extension manifest and worker-authoring primitives for TS-side extension contracts - focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` ## Quick Start @@ -129,6 +130,39 @@ const env = config.toEnvironmentRecord(); env; ``` +## Extension Runtime + +The TS SDK now includes a typed extension-worker authoring surface under +`@sof/sdk/runtime/extension`. + +Use it for: + +- typed extension manifests +- typed packet-subscription matching +- typed in-memory worker lifecycle/runtime +- runnable TS examples for future Rust-host integration + +Important boundary: + +- Rust still owns the actual runtime, queues, sockets, and packet dispatch. +- The current TS SDK extension surface is the TS-side contract and worker model. +- It does not yet mean the Rust binary can already spawn TS workers directly. + +## Examples + +Runnable examples live in `sdks/typescript/examples`: + +- `runtime-config-balanced.ts` +- `runtime-config-parse.ts` +- `runtime-extension-manifest.ts` +- `runtime-extension-worker.ts` + +Verify them with: + +```bash +pnpm run check:examples +``` + ## Focused Imports ```ts diff --git a/sdks/typescript/examples/runtime-config-balanced.ts b/sdks/typescript/examples/runtime-config-balanced.ts new file mode 100644 index 00000000..f8119c69 --- /dev/null +++ b/sdks/typescript/examples/runtime-config-balanced.ts @@ -0,0 +1,36 @@ +import { + DerivedStateReplayBackend, + DerivedStateReplayDurability, + ProviderStreamCapabilityPolicy, + RuntimeDeliveryProfile, + ShredTrustMode, + isErr, + tryCreateRuntimeConfigForProfile, + trySerializeRuntimeConfigRecord, +} from "../dist/index.js"; + +const config = tryCreateRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, + }, + }, +}); + +if (isErr(config)) { + process.stderr.write(`${config.error.message}\n`); + process.exit(1); +} + +const serialized = trySerializeRuntimeConfigRecord(config.value); +if (isErr(serialized)) { + process.stderr.write(`${serialized.error.message}\n`); + process.exit(1); +} + +process.stdout.write(`${JSON.stringify(serialized.value, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-config-parse.ts b/sdks/typescript/examples/runtime-config-parse.ts new file mode 100644 index 00000000..148af5a2 --- /dev/null +++ b/sdks/typescript/examples/runtime-config-parse.ts @@ -0,0 +1,21 @@ +import { + isErr, + parseRuntimeConfig, + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileEnvValues, +} from "../dist/index.js"; + +const parsed = parseRuntimeConfig({ + [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.balanced, + SOF_PROVIDER_STREAM_ALLOW_EOF: "true", + SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "2048", +}); + +if (isErr(parsed)) { + process.stderr.write(`${parsed.error.message}\n`); + process.exit(1); +} + +process.stdout.write( + `${JSON.stringify(parsed.value.toEnvironmentRecord(), undefined, 2)}\n`, +); diff --git a/sdks/typescript/examples/runtime-extension-manifest.ts b/sdks/typescript/examples/runtime-extension-manifest.ts new file mode 100644 index 00000000..ad0e5171 --- /dev/null +++ b/sdks/typescript/examples/runtime-extension-manifest.ts @@ -0,0 +1,78 @@ +import { + ExtensionCapability, + ExtensionStreamVisibilityTag, + RuntimePacketSourceKind, + RuntimePacketTransport, + extensionName, + extensionResourceId, + isErr, + sharedExtensionStream, + socketAddress, + tryCreateRuntimeExtensionWorkerManifest, + udpListenerResource, +} from "../dist/index.js"; + +const extension = extensionName("demo-shared-udp-extension"); +if (isErr(extension)) { + process.stderr.write(`${extension.error.message}\n`); + process.exit(1); +} + +const resourceId = extensionResourceId("demo-udp"); +if (isErr(resourceId)) { + process.stderr.write(`${resourceId.error.message}\n`); + process.exit(1); +} + +const bindAddress = socketAddress("127.0.0.1:21011"); +if (isErr(bindAddress)) { + process.stderr.write(`${bindAddress.error.message}\n`); + process.exit(1); +} + +const sharedVisibility = sharedExtensionStream("demo-stream"); +if (isErr(sharedVisibility)) { + process.stderr.write(`${sharedVisibility.error.message}\n`); + process.exit(1); +} + +const udpResource = udpListenerResource( + resourceId.value, + bindAddress.value, + sharedVisibility.value, +); +if (isErr(udpResource)) { + process.stderr.write(`${udpResource.error.message}\n`); + process.exit(1); +} + +const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: extension.value, + capabilities: [ + ExtensionCapability.BindUdp, + ExtensionCapability.ObserveSharedExtensionStream, + ], + resources: [udpResource.value], + subscriptions: [ + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + ownerExtension: extension.value, + resourceId: resourceId.value, + }, + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + ...(sharedVisibility.value.tag === ExtensionStreamVisibilityTag.Shared + ? { sharedTag: sharedVisibility.value.sharedTag } + : {}), + }, + ], +}); + +if (isErr(manifest)) { + process.stderr.write(`${manifest.error.message}\n`); + process.exit(1); +} + +process.stdout.write(`${JSON.stringify(manifest.value, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-extension-worker.ts b/sdks/typescript/examples/runtime-extension-worker.ts new file mode 100644 index 00000000..816e2c00 --- /dev/null +++ b/sdks/typescript/examples/runtime-extension-worker.ts @@ -0,0 +1,110 @@ +import { + ExtensionCapability, + RuntimeExtensionWorkerHostMessageTag, + RuntimePacketEventClass, + RuntimePacketSourceKind, + RuntimePacketTransport, + SdkLanguage, + createRuntimePacketEvent, + extensionName, + isErr, + ok, + runtimeExtensionAck, + socketAddress, + tryDefineRuntimeExtension, + tryCreateRuntimeExtensionWorkerManifest, + tryCreateRuntimeExtensionWorkerRuntime, +} from "../dist/index.js"; + +const extension = extensionName("demo-extension-worker"); +if (isErr(extension)) { + process.stderr.write(`${extension.error.message}\n`); + process.exit(1); +} + +const localAddress = socketAddress("127.0.0.1:21011"); +if (isErr(localAddress)) { + process.stderr.write(`${localAddress.error.message}\n`); + process.exit(1); +} + +const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: extension.value, + capabilities: [ExtensionCapability.ObserveObserverIngress], + subscriptions: [ + { + sourceKind: RuntimePacketSourceKind.ObserverIngress, + transport: RuntimePacketTransport.Udp, + eventClass: RuntimePacketEventClass.Packet, + localAddress: localAddress.value, + }, + ], +}); +if (isErr(manifest)) { + process.stderr.write(`${manifest.error.message}\n`); + process.exit(1); +} + +const definition = tryDefineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: (event) => { + process.stdout.write( + `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}\n`, + ); + return ok(runtimeExtensionAck()); + }, + onShutdown: () => ok(runtimeExtensionAck()), +}); +if (isErr(definition)) { + process.stderr.write(`${definition.error.message}\n`); + process.exit(1); +} + +const worker = tryCreateRuntimeExtensionWorkerRuntime(definition.value); +if (isErr(worker)) { + process.stderr.write(`${worker.error.message}\n`); + process.exit(1); +} + +const started = await worker.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: extension.value, + }, +}); + +const delivered = await worker.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: createRuntimePacketEvent( + { + kind: RuntimePacketSourceKind.ObserverIngress, + transport: RuntimePacketTransport.Udp, + eventClass: RuntimePacketEventClass.Packet, + localAddress: localAddress.value, + }, + Uint8Array.from([1, 2, 3, 4]), + Date.now(), + ), +}); + +const shutdown = await worker.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: extension.value, + }, +}); + +process.stdout.write( + `${JSON.stringify( + { + sdkLanguage: SdkLanguage.TypeScript, + started, + delivered, + shutdown, + }, + undefined, + 2, + )}\n`, +); diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 964dc327..49af9754 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -44,19 +44,25 @@ "./runtime/delivery-profile": { "types": "./dist/runtime/delivery-profile.d.ts", "import": "./dist/runtime/delivery-profile.js" + }, + "./runtime/extension": { + "types": "./dist/runtime/extension.d.ts", + "import": "./dist/runtime/extension.js" } }, "files": [ "dist" ], "scripts": { - "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true }); rmSync('dist-test', { recursive: true, force: true });\"", + "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true }); rmSync('dist-test', { recursive: true, force: true }); rmSync('dist-examples', { recursive: true, force: true });\"", "build": "pnpm run clean && tsdown --config tsdown.config.ts", "build:test": "tsc -p tsconfig.test.json", + "build:examples": "pnpm run build && tsc -p tsconfig.examples.json", "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --fix --fix-suggestions src tsdown.config.ts", + "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js", "check:package": "publint run --strict --pack pnpm", - "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:package", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm run build && pnpm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", "prepack": "pnpm run build" diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index 0a044c99..5336cab5 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -12,6 +12,7 @@ test("package exports resolve the documented public entry points", async () => { const policy = await importPackageEntry("@sof/sdk/runtime/policy"); const derivedState = await importPackageEntry("@sof/sdk/runtime/derived-state"); const deliveryProfile = await importPackageEntry("@sof/sdk/runtime/delivery-profile"); + const extension = await importPackageEntry("@sof/sdk/runtime/extension"); assert.equal( (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, @@ -49,4 +50,10 @@ test("package exports resolve the documented public entry points", async () => { (runtime as { RuntimeDeliveryProfile: unknown }).RuntimeDeliveryProfile, (deliveryProfile as { RuntimeDeliveryProfile: unknown }).RuntimeDeliveryProfile, ); + assert.equal( + (runtime as { createRuntimeExtensionWorkerManifest: unknown }) + .createRuntimeExtensionWorkerManifest, + (extension as { createRuntimeExtensionWorkerManifest: unknown }) + .createRuntimeExtensionWorkerManifest, + ); }); diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index da4414da..be82788c 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -1,4 +1,5 @@ export * from "./runtime/derived-state.js"; export * from "./runtime/runtime-config.js"; export * from "./runtime/runtime-delivery-profile.js"; +export * from "./runtime/runtime-extension.js"; export * from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/runtime-extension.test.ts b/sdks/typescript/src/runtime/runtime-extension.test.ts new file mode 100644 index 00000000..8dcf833e --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-extension.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isErr, isOk, ok } from "../result.js"; +import { + ExtensionCapability, + ExtensionStreamVisibilityTag, + ForeignWorkerKind, + RuntimeExtensionErrorKind, + RuntimeExtensionWorkerHostMessageTag, + RuntimeExtensionWorkerResponseTag, + RuntimePacketEventClass, + RuntimePacketSourceKind, + RuntimePacketTransport, + SdkLanguage, + createRuntimeExtensionWorkerManifest, + createRuntimePacketEvent, + defineRuntimeExtension, + extensionName, + extensionResourceId, + packetSubscriptionMatches, + runtimeExtensionAck, + sharedExtensionStream, + socketAddress, + tryCreateRuntimeExtensionWorkerManifest, + tryCreateRuntimeExtensionWorkerRuntime, + udpListenerResource, +} from "../runtime.js"; + +test("runtime extension manifest creation validates stable typed metadata", () => { + const parsedExtensionName = extensionName("demo-extension"); + const parsedResourceId = extensionResourceId("demo-udp"); + const bindAddress = socketAddress("127.0.0.1:21011"); + const visibility = sharedExtensionStream("demo-stream"); + + assert.equal(isOk(parsedExtensionName), true); + assert.equal(isOk(parsedResourceId), true); + assert.equal(isOk(bindAddress), true); + assert.equal(isOk(visibility), true); + + if ( + isOk(parsedExtensionName) && + isOk(parsedResourceId) && + isOk(bindAddress) && + isOk(visibility) + ) { + const resource = udpListenerResource( + parsedResourceId.value, + bindAddress.value, + visibility.value, + ); + + assert.equal(isOk(resource), true); + if (isOk(resource)) { + const manifest = createRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: parsedExtensionName.value, + capabilities: [ + ExtensionCapability.BindUdp, + ExtensionCapability.ObserveSharedExtensionStream, + ], + resources: [resource.value], + subscriptions: [ + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + eventClass: RuntimePacketEventClass.Packet, + ownerExtension: parsedExtensionName.value, + resourceId: parsedResourceId.value, + ...(visibility.value.tag === ExtensionStreamVisibilityTag.Shared + ? { sharedTag: visibility.value.sharedTag } + : {}), + }, + ], + }); + + assert.deepEqual(manifest.protocolVersion, { major: 1, minor: 0 }); + assert.equal(manifest.sdkLanguage, SdkLanguage.TypeScript); + assert.equal(manifest.workerKind, ForeignWorkerKind.RuntimeExtension); + assert.equal(manifest.manifest.capabilities.length, 2); + assert.equal(manifest.manifest.resources.length, 1); + assert.equal(manifest.manifest.subscriptions.length, 1); + } + } +}); + +test("runtime extension manifest rejects invalid worker metadata", () => { + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: " ", + extensionName: "", + }); + + assert.equal(isErr(manifest), true); + if (isErr(manifest)) { + assert.equal(manifest.error.kind, RuntimeExtensionErrorKind.ValidationError); + assert.equal(manifest.error.field, "extensionName"); + } +}); + +test("packet subscription matching mirrors rust-side metadata filters", () => { + const parsedExtensionName = extensionName("udp-demo"); + const parsedResourceId = extensionResourceId("udp-resource"); + const localAddress = socketAddress("127.0.0.1:21011"); + + assert.equal(isOk(parsedExtensionName), true); + assert.equal(isOk(parsedResourceId), true); + assert.equal(isOk(localAddress), true); + + if (isOk(parsedExtensionName) && isOk(parsedResourceId) && isOk(localAddress)) { + const event = createRuntimePacketEvent( + { + kind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + eventClass: RuntimePacketEventClass.Packet, + ownerExtension: parsedExtensionName.value, + resourceId: parsedResourceId.value, + localAddress: localAddress.value, + }, + [1, 2, 3], + 42, + ); + + assert.equal( + packetSubscriptionMatches( + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + ownerExtension: parsedExtensionName.value, + resourceId: parsedResourceId.value, + localPort: 21011, + }, + event, + ), + true, + ); + assert.equal( + packetSubscriptionMatches( + { + sourceKind: RuntimePacketSourceKind.ObserverIngress, + }, + event, + ), + false, + ); + } +}); + +test("runtime extension worker runtime handles manifest lifecycle and exceptions", async () => { + const parsedExtensionName = extensionName("worker-demo"); + assert.equal(isOk(parsedExtensionName), true); + + if (!isOk(parsedExtensionName)) { + return; + } + + const runtime = tryCreateRuntimeExtensionWorkerRuntime( + defineRuntimeExtension({ + manifest: createRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: parsedExtensionName.value, + capabilities: [ExtensionCapability.ObserveObserverIngress], + }), + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: () => { + throw new Error("boom"); + }, + onShutdown: () => ok(runtimeExtensionAck()), + }), + ); + + assert.equal(isOk(runtime), true); + if (!isOk(runtime)) { + return; + } + + const manifest = await runtime.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.GetManifest, + }); + const started = await runtime.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { extensionName: parsedExtensionName.value }, + }); + const delivered = await runtime.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: createRuntimePacketEvent( + { + kind: RuntimePacketSourceKind.ObserverIngress, + transport: RuntimePacketTransport.Udp, + eventClass: RuntimePacketEventClass.Packet, + }, + [1], + 1, + ), + }); + const shutdown = await runtime.value.handleMessage({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { extensionName: parsedExtensionName.value }, + }); + + assert.equal(manifest.tag, RuntimeExtensionWorkerResponseTag.Manifest); + assert.equal(started.tag, RuntimeExtensionWorkerResponseTag.Started); + assert.equal(delivered.tag, RuntimeExtensionWorkerResponseTag.EventHandled); + assert.equal(shutdown.tag, RuntimeExtensionWorkerResponseTag.ShutdownComplete); + assert.equal(isOk(started.result), true); + assert.equal(isErr(delivered.result), true); + assert.equal(isOk(shutdown.result), true); + + if (isErr(delivered.result)) { + assert.equal( + delivered.result.error.kind, + RuntimeExtensionErrorKind.UnhandledException, + ); + assert.equal(delivered.result.error.field, "onPacketReceived"); + } +}); diff --git a/sdks/typescript/src/runtime/runtime-extension.ts b/sdks/typescript/src/runtime/runtime-extension.ts new file mode 100644 index 00000000..e97fbaf1 --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-extension.ts @@ -0,0 +1,839 @@ +import { brand, type Brand } from "../brand.js"; +import { err, isErr, ok, type Result } from "../result.js"; + +export enum RuntimeExtensionErrorKind { + ValidationError = 1, + CapabilityError = 2, + CompatibilityError = 3, + ProtocolError = 4, + UnhandledException = 5, +} + +export interface RuntimeExtensionError { + readonly kind: RuntimeExtensionErrorKind; + readonly field: string; + readonly message: string; + readonly received?: string; + readonly cause?: string; +} + +export interface RuntimeExtensionAck { + readonly acknowledged: true; +} + +export type ExtensionName = Brand; +export type ExtensionResourceId = Brand; +export type SharedStreamTag = Brand; +export type SocketAddress = Brand; +export type WebSocketUrl = Brand; + +export enum ExtensionCapability { + BindUdp = 1, + BindTcp = 2, + ConnectTcp = 3, + ConnectWebSocket = 4, + ObserveObserverIngress = 5, + ObserveSharedExtensionStream = 6, +} + +export enum ExtensionStreamVisibilityTag { + Private = 1, + Shared = 2, +} + +export type ExtensionStreamVisibility = + | { + readonly tag: ExtensionStreamVisibilityTag.Private; + } + | { + readonly tag: ExtensionStreamVisibilityTag.Shared; + readonly sharedTag: SharedStreamTag; + }; + +export enum ExtensionResourceKind { + UdpListener = 1, + TcpListener = 2, + TcpConnector = 3, + WsConnector = 4, +} + +interface ExtensionResourceBase { + readonly resourceId: ExtensionResourceId; + readonly visibility: ExtensionStreamVisibility; + readonly readBufferBytes: number; +} + +export interface UdpListenerResourceSpec extends ExtensionResourceBase { + readonly kind: ExtensionResourceKind.UdpListener; + readonly bindAddress: SocketAddress; +} + +export interface TcpListenerResourceSpec extends ExtensionResourceBase { + readonly kind: ExtensionResourceKind.TcpListener; + readonly bindAddress: SocketAddress; +} + +export interface TcpConnectorResourceSpec extends ExtensionResourceBase { + readonly kind: ExtensionResourceKind.TcpConnector; + readonly remoteAddress: SocketAddress; +} + +export interface WebSocketConnectorResourceSpec extends ExtensionResourceBase { + readonly kind: ExtensionResourceKind.WsConnector; + readonly url: WebSocketUrl; +} + +export type ExtensionResourceSpec = + | UdpListenerResourceSpec + | TcpListenerResourceSpec + | TcpConnectorResourceSpec + | WebSocketConnectorResourceSpec; + +export enum RuntimePacketSourceKind { + ObserverIngress = 1, + ExtensionResource = 2, +} + +export enum RuntimePacketTransport { + Udp = 1, + Tcp = 2, + WebSocket = 3, +} + +export enum RuntimePacketEventClass { + Packet = 1, + ConnectionClosed = 2, +} + +export enum RuntimeWebSocketFrameType { + Text = 1, + Binary = 2, + Ping = 3, + Pong = 4, +} + +export interface RuntimePacketSource { + readonly kind: RuntimePacketSourceKind; + readonly transport: RuntimePacketTransport; + readonly eventClass: RuntimePacketEventClass; + readonly ownerExtension?: ExtensionName; + readonly resourceId?: ExtensionResourceId; + readonly sharedTag?: SharedStreamTag; + readonly webSocketFrameType?: RuntimeWebSocketFrameType; + readonly localAddress?: SocketAddress; + readonly remoteAddress?: SocketAddress; +} + +export interface RuntimePacketEvent { + readonly source: RuntimePacketSource; + readonly bytes: Uint8Array; + readonly observedUnixMs: number; +} + +export interface PacketSubscription { + readonly sourceKind?: RuntimePacketSourceKind; + readonly transport?: RuntimePacketTransport; + readonly eventClass?: RuntimePacketEventClass; + readonly localAddress?: SocketAddress; + readonly localPort?: number; + readonly remoteAddress?: SocketAddress; + readonly remotePort?: number; + readonly ownerExtension?: ExtensionName; + readonly resourceId?: ExtensionResourceId; + readonly sharedTag?: SharedStreamTag; + readonly webSocketFrameType?: RuntimeWebSocketFrameType; +} + +export interface ExtensionManifest { + readonly capabilities: readonly ExtensionCapability[]; + readonly resources: readonly ExtensionResourceSpec[]; + readonly subscriptions: readonly PacketSubscription[]; +} + +export interface ExtensionContext { + readonly extensionName: ExtensionName; +} + +export enum SdkLanguage { + TypeScript = 1, +} + +export enum ForeignWorkerKind { + RuntimeExtension = 1, +} + +export interface RuntimeExtensionProtocolVersion { + readonly major: number; + readonly minor: number; +} + +export const defaultRuntimeExtensionProtocolVersion: RuntimeExtensionProtocolVersion = + { + major: 1, + minor: 0, + }; + +export interface RuntimeExtensionWorkerManifest { + readonly protocolVersion: RuntimeExtensionProtocolVersion; + readonly sdkLanguage: SdkLanguage.TypeScript; + readonly sdkVersion: string; + readonly manifestVersion: number; + readonly workerKind: ForeignWorkerKind.RuntimeExtension; + readonly extensionName: ExtensionName; + readonly manifest: ExtensionManifest; +} + +export type MaybePromise = T | Promise; + +export interface RuntimeExtensionDefinition { + readonly manifest: RuntimeExtensionWorkerManifest; + readonly onReady?: ( + context: ExtensionContext, + ) => MaybePromise>; + readonly onPacketReceived?: ( + event: RuntimePacketEvent, + ) => MaybePromise>; + readonly onShutdown?: ( + context: ExtensionContext, + ) => MaybePromise>; +} + +export enum RuntimeExtensionWorkerHostMessageTag { + GetManifest = 1, + Start = 2, + DeliverPacket = 3, + Shutdown = 4, +} + +export type RuntimeExtensionWorkerHostMessage = + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.GetManifest; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.Start; + readonly context: ExtensionContext; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket; + readonly event: RuntimePacketEvent; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.Shutdown; + readonly context: ExtensionContext; + }; + +export enum RuntimeExtensionWorkerResponseTag { + Manifest = 1, + Started = 2, + EventHandled = 3, + ShutdownComplete = 4, +} + +export type RuntimeExtensionWorkerResponse = + | { + readonly tag: RuntimeExtensionWorkerResponseTag.Manifest; + readonly result: Result< + RuntimeExtensionWorkerManifest, + RuntimeExtensionError + >; + } + | { + readonly tag: RuntimeExtensionWorkerResponseTag.Started; + readonly result: Result; + } + | { + readonly tag: RuntimeExtensionWorkerResponseTag.EventHandled; + readonly result: Result; + } + | { + readonly tag: RuntimeExtensionWorkerResponseTag.ShutdownComplete; + readonly result: Result; + }; + +export interface RuntimeExtensionWorkerManifestInit { + readonly protocolVersion?: RuntimeExtensionProtocolVersion; + readonly sdkVersion: string; + readonly manifestVersion?: number; + readonly extensionName: string | ExtensionName; + readonly capabilities?: readonly ExtensionCapability[]; + readonly resources?: readonly ExtensionResourceSpec[]; + readonly subscriptions?: readonly PacketSubscription[]; +} + +const defaultResourceReadBufferBytes = 2_048; +const maxResourceReadBufferBytes = 1024 * 1024; + +function runtimeExtensionError( + kind: RuntimeExtensionErrorKind, + field: string, + message: string, + received?: string, + cause?: string, +): RuntimeExtensionError { + const runtimeError: RuntimeExtensionError = { + kind, + field, + message, + }; + + if (received !== undefined) { + return { + ...runtimeError, + received, + ...(cause === undefined ? {} : { cause }), + }; + } + + if (cause !== undefined) { + return { + ...runtimeError, + cause, + }; + } + + return runtimeError; +} + +function asExtensionName(value: Value): ExtensionName { + return brand(value); +} + +function asExtensionResourceId( + value: Value, +): ExtensionResourceId { + return brand(value); +} + +function asSharedStreamTag(value: Value): SharedStreamTag { + return brand(value); +} + +function asSocketAddress(value: Value): SocketAddress { + return brand(value); +} + +function asWebSocketUrl(value: Value): WebSocketUrl { + return brand(value); +} + +function parseNonEmptyValueObject( + value: string, + field: string, + wrap: (normalized: string) => T, +): Result { + const normalized = value.trim(); + if (normalized === "") { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + field, + `${field} must not be empty`, + value, + ), + ); + } + if (normalized.includes("\u0000")) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + field, + `${field} must not contain NUL bytes`, + value, + ), + ); + } + + return ok(wrap(normalized)); +} + +export function extensionName(value: string): Result { + return parseNonEmptyValueObject(value, "extensionName", asExtensionName); +} + +export function extensionResourceId( + value: string, +): Result { + return parseNonEmptyValueObject(value, "resourceId", asExtensionResourceId); +} + +export function sharedStreamTag( + value: string, +): Result { + return parseNonEmptyValueObject(value, "sharedTag", asSharedStreamTag); +} + +export function socketAddress( + value: string, +): Result { + return parseNonEmptyValueObject(value, "socketAddress", asSocketAddress); +} + +export function webSocketUrl( + value: string, +): Result { + const parsed = parseNonEmptyValueObject(value, "url", asWebSocketUrl); + if (isErr(parsed)) { + return parsed; + } + if ( + !parsed.value.startsWith("ws://") && + !parsed.value.startsWith("wss://") + ) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + "url", + "url must start with ws:// or wss://", + value, + ), + ); + } + + return parsed; +} + +export function privateExtensionStream(): ExtensionStreamVisibility { + return { + tag: ExtensionStreamVisibilityTag.Private, + }; +} + +export function sharedExtensionStream( + tag: SharedStreamTag | string, +): Result { + const parsed = typeof tag === "string" ? sharedStreamTag(tag) : ok(tag); + if (isErr(parsed)) { + return parsed; + } + + return ok({ + tag: ExtensionStreamVisibilityTag.Shared, + sharedTag: parsed.value, + }); +} + +function validatePositiveInteger( + value: number, + field: string, +): Result { + if (!Number.isInteger(value) || value <= 0) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + field, + `${field} must be a positive integer`, + String(value), + ), + ); + } + + return ok(value); +} + +function validateResourceReadBufferBytes( + value: number, +): Result { + const parsed = validatePositiveInteger(value, "readBufferBytes"); + if (isErr(parsed)) { + return parsed; + } + if (parsed.value > maxResourceReadBufferBytes) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + "readBufferBytes", + `readBufferBytes must be <= ${maxResourceReadBufferBytes}`, + String(value), + ), + ); + } + + return parsed; +} + +function validateRuntimeExtensionProtocolVersion( + value: RuntimeExtensionProtocolVersion, +): Result { + const major = validatePositiveInteger(value.major, "protocolVersion.major"); + if (isErr(major)) { + return major; + } + if (!Number.isInteger(value.minor) || value.minor < 0) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + "protocolVersion.minor", + "protocolVersion.minor must be a non-negative integer", + String(value.minor), + ), + ); + } + + return ok({ + major: major.value, + minor: value.minor, + }); +} + +function validateRuntimeExtensionWorkerManifest( + manifest: RuntimeExtensionWorkerManifest, +): Result { + const protocolVersion = validateRuntimeExtensionProtocolVersion( + manifest.protocolVersion, + ); + if (isErr(protocolVersion)) { + return protocolVersion; + } + + const version = manifest.sdkVersion.trim(); + if (version === "") { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + "sdkVersion", + "sdkVersion must not be empty", + manifest.sdkVersion, + ), + ); + } + + const manifestVersion = validatePositiveInteger( + manifest.manifestVersion, + "manifestVersion", + ); + if (isErr(manifestVersion)) { + return manifestVersion; + } + + const duplicateResourceIds = new Set(); + for (const resource of manifest.manifest.resources) { + const validatedReadBuffer = validateResourceReadBufferBytes( + resource.readBufferBytes, + ); + if (isErr(validatedReadBuffer)) { + return validatedReadBuffer; + } + + const resourceId = resource.resourceId as string; + if (duplicateResourceIds.has(resourceId)) { + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ValidationError, + "resourceId", + `duplicate resourceId ${resourceId}`, + resourceId, + ), + ); + } + duplicateResourceIds.add(resourceId); + + if (resource.visibility.tag === ExtensionStreamVisibilityTag.Shared) { + const sharedTagValue = sharedStreamTag(resource.visibility.sharedTag); + if (isErr(sharedTagValue)) { + return sharedTagValue; + } + } + } + + return ok(manifest); +} + +export function createRuntimeExtensionWorkerManifest( + init: RuntimeExtensionWorkerManifestInit, +): RuntimeExtensionWorkerManifest { + const result = tryCreateRuntimeExtensionWorkerManifest(init); + if (isErr(result)) { + throw new RangeError(result.error.message); + } + + return result.value; +} + +export function tryCreateRuntimeExtensionWorkerManifest( + init: RuntimeExtensionWorkerManifestInit, +): Result { + const parsedName = + typeof init.extensionName === "string" + ? extensionName(init.extensionName) + : ok(init.extensionName); + if (isErr(parsedName)) { + return parsedName; + } + + const manifest: RuntimeExtensionWorkerManifest = { + protocolVersion: + init.protocolVersion ?? defaultRuntimeExtensionProtocolVersion, + sdkLanguage: SdkLanguage.TypeScript, + sdkVersion: init.sdkVersion, + manifestVersion: init.manifestVersion ?? 1, + workerKind: ForeignWorkerKind.RuntimeExtension, + extensionName: parsedName.value, + manifest: { + capabilities: init.capabilities ?? [], + resources: init.resources ?? [], + subscriptions: init.subscriptions ?? [], + }, + }; + + return validateRuntimeExtensionWorkerManifest(manifest); +} + +export function packetSubscriptionMatches( + subscription: PacketSubscription, + event: RuntimePacketEvent, +): boolean { + if ( + subscription.sourceKind !== undefined && + subscription.sourceKind !== event.source.kind + ) { + return false; + } + if ( + subscription.transport !== undefined && + subscription.transport !== event.source.transport + ) { + return false; + } + if ( + subscription.eventClass !== undefined && + subscription.eventClass !== event.source.eventClass + ) { + return false; + } + if ( + subscription.localAddress !== undefined && + subscription.localAddress !== event.source.localAddress + ) { + return false; + } + if ( + subscription.localPort !== undefined && + event.source.localAddress?.split(":").at(-1) !== + String(subscription.localPort) + ) { + return false; + } + if ( + subscription.remoteAddress !== undefined && + subscription.remoteAddress !== event.source.remoteAddress + ) { + return false; + } + if ( + subscription.remotePort !== undefined && + event.source.remoteAddress?.split(":").at(-1) !== + String(subscription.remotePort) + ) { + return false; + } + if ( + subscription.ownerExtension !== undefined && + subscription.ownerExtension !== event.source.ownerExtension + ) { + return false; + } + if ( + subscription.resourceId !== undefined && + subscription.resourceId !== event.source.resourceId + ) { + return false; + } + if ( + subscription.sharedTag !== undefined && + subscription.sharedTag !== event.source.sharedTag + ) { + return false; + } + if ( + subscription.webSocketFrameType !== undefined && + subscription.webSocketFrameType !== event.source.webSocketFrameType + ) { + return false; + } + + return true; +} + +export function runtimeExtensionAck(): RuntimeExtensionAck { + return { + acknowledged: true, + }; +} + +async function settleExtensionHook( + field: string, + callback: () => MaybePromise>, +): Promise> { + try { + return await callback(); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + return err( + runtimeExtensionError( + RuntimeExtensionErrorKind.UnhandledException, + field, + `${field} threw an unhandled exception`, + undefined, + cause, + ), + ); + } +} + +export function defineRuntimeExtension( + definition: RuntimeExtensionDefinition, +): RuntimeExtensionDefinition { + const result = tryDefineRuntimeExtension(definition); + if (isErr(result)) { + throw new RangeError(result.error.message); + } + + return result.value; +} + +export function tryDefineRuntimeExtension( + definition: RuntimeExtensionDefinition, +): Result { + const manifest = validateRuntimeExtensionWorkerManifest(definition.manifest); + if (isErr(manifest)) { + return manifest; + } + + return ok({ + ...definition, + manifest: manifest.value, + }); +} + +export class RuntimeExtensionWorkerRuntime { + readonly definition: RuntimeExtensionDefinition; + + constructor(definition: RuntimeExtensionDefinition) { + this.definition = defineRuntimeExtension(definition); + } + + async handleMessage( + message: RuntimeExtensionWorkerHostMessage, + ): Promise { + switch (message.tag) { + case RuntimeExtensionWorkerHostMessageTag.GetManifest: + return { + tag: RuntimeExtensionWorkerResponseTag.Manifest, + result: ok(this.definition.manifest), + }; + case RuntimeExtensionWorkerHostMessageTag.Start: + return { + tag: RuntimeExtensionWorkerResponseTag.Started, + result: + this.definition.onReady === undefined + ? ok(runtimeExtensionAck()) + : await settleExtensionHook("onReady", () => + this.definition.onReady?.(message.context) ?? + ok(runtimeExtensionAck()), + ), + }; + case RuntimeExtensionWorkerHostMessageTag.DeliverPacket: + return { + tag: RuntimeExtensionWorkerResponseTag.EventHandled, + result: + this.definition.onPacketReceived === undefined + ? ok(runtimeExtensionAck()) + : await settleExtensionHook("onPacketReceived", () => + this.definition.onPacketReceived?.(message.event) ?? + ok(runtimeExtensionAck()), + ), + }; + case RuntimeExtensionWorkerHostMessageTag.Shutdown: + return { + tag: RuntimeExtensionWorkerResponseTag.ShutdownComplete, + result: + this.definition.onShutdown === undefined + ? ok(runtimeExtensionAck()) + : await settleExtensionHook("onShutdown", () => + this.definition.onShutdown?.(message.context) ?? + ok(runtimeExtensionAck()), + ), + }; + } + + return { + tag: RuntimeExtensionWorkerResponseTag.ShutdownComplete, + result: err( + runtimeExtensionError( + RuntimeExtensionErrorKind.ProtocolError, + "message.tag", + "unknown runtime extension worker message tag", + ), + ), + }; + } +} + +export function createRuntimeExtensionWorkerRuntime( + definition: RuntimeExtensionDefinition, +): RuntimeExtensionWorkerRuntime { + return new RuntimeExtensionWorkerRuntime(definition); +} + +export function tryCreateRuntimeExtensionWorkerRuntime( + definition: RuntimeExtensionDefinition, +): Result { + const validated = tryDefineRuntimeExtension(definition); + if (isErr(validated)) { + return validated; + } + + return ok(new RuntimeExtensionWorkerRuntime(validated.value)); +} + +export function createRuntimePacketEvent( + source: RuntimePacketSource, + bytes: Uint8Array | readonly number[], + observedUnixMs: number, +): RuntimePacketEvent { + return { + source, + bytes: bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes), + observedUnixMs, + }; +} + +export function udpListenerResource( + resourceId: ExtensionResourceId, + bindAddress: SocketAddress, + visibility: ExtensionStreamVisibility = privateExtensionStream(), + readBufferBytes = defaultResourceReadBufferBytes, +): Result { + const validatedReadBuffer = validateResourceReadBufferBytes(readBufferBytes); + if (isErr(validatedReadBuffer)) { + return validatedReadBuffer; + } + + return ok({ + kind: ExtensionResourceKind.UdpListener, + resourceId, + bindAddress, + visibility, + readBufferBytes: validatedReadBuffer.value, + }); +} + +export function webSocketConnectorResource( + resourceId: ExtensionResourceId, + url: WebSocketUrl, + visibility: ExtensionStreamVisibility = privateExtensionStream(), + readBufferBytes = defaultResourceReadBufferBytes, +): Result { + const validatedReadBuffer = validateResourceReadBufferBytes(readBufferBytes); + if (isErr(validatedReadBuffer)) { + return validatedReadBuffer; + } + + return ok({ + kind: ExtensionResourceKind.WsConnector, + resourceId, + url, + visibility, + readBufferBytes: validatedReadBuffer.value, + }); +} diff --git a/sdks/typescript/tsconfig.examples.json b/sdks/typescript/tsconfig.examples.json new file mode 100644 index 00000000..bd63d748 --- /dev/null +++ b/sdks/typescript/tsconfig.examples.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "examples", + "outDir": "dist-examples", + "declaration": false, + "sourceMap": false + }, + "include": [ + "examples/**/*.ts" + ] +} diff --git a/sdks/typescript/tsdown.config.ts b/sdks/typescript/tsdown.config.ts index 7be8f4b9..84db20d3 100644 --- a/sdks/typescript/tsdown.config.ts +++ b/sdks/typescript/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "runtime/policy": "src/runtime/runtime-policy.ts", "runtime/derived-state": "src/runtime/derived-state.ts", "runtime/delivery-profile": "src/runtime/runtime-delivery-profile.ts", + "runtime/extension": "src/runtime/runtime-extension.ts", }, format: ["esm"], minify: true, From bccf3d5f69c156da2175504664eebcbfca33ee20 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 13:43:18 +0200 Subject: [PATCH 12/25] feat(ts-sdk): add stdio worker runtime and ci checks --- .github/workflows/ci.yml | 26 + sdks/typescript/README.md | 49 +- sdks/typescript/biome.json | 15 + .../examples/runtime-config-balanced.ts | 46 +- .../examples/runtime-config-parse.ts | 26 +- .../examples/runtime-extension-manifest.ts | 96 +-- .../examples/runtime-extension-worker.ts | 170 +++-- sdks/typescript/package.json | 13 +- sdks/typescript/pnpm-lock.yaml | 95 +++ sdks/typescript/src/environment.ts | 8 +- sdks/typescript/src/package-exports.test.ts | 5 + sdks/typescript/src/runtime.ts | 1 + sdks/typescript/src/runtime/derived-state.ts | 110 +-- .../src/runtime/runtime-config.test.ts | 367 +++------- sdks/typescript/src/runtime/runtime-config.ts | 214 ++---- .../src/runtime/runtime-delivery-profile.ts | 59 +- .../runtime/runtime-extension-stdio.test.ts | 214 ++++++ .../src/runtime/runtime-extension-stdio.ts | 670 ++++++++++++++++++ .../src/runtime/runtime-extension.test.ts | 5 +- .../src/runtime/runtime-extension.ts | 95 +-- sdks/typescript/src/runtime/runtime-policy.ts | 43 +- sdks/typescript/tsconfig.lint.json | 12 + sdks/typescript/tsdown.config.ts | 4 + 23 files changed, 1500 insertions(+), 843 deletions(-) create mode 100644 sdks/typescript/biome.json create mode 100644 sdks/typescript/src/runtime/runtime-extension-stdio.test.ts create mode 100644 sdks/typescript/src/runtime/runtime-extension-stdio.ts create mode 100644 sdks/typescript/tsconfig.lint.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45dab360..daec3585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,32 @@ jobs: - name: Build and verify publishable crate archives run: bash scripts/verify-publishable-archives.sh + typescript-sdk: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: sdks/typescript + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: sdks/typescript/pnpm-lock.yaml + + - name: Install SDK dependencies + run: pnpm install --frozen-lockfile + + - name: Run TypeScript SDK checks + run: pnpm run check + fuzz-smoke: runs-on: ubuntu-latest timeout-minutes: 90 diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 6f414c9a..642b480d 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -6,6 +6,7 @@ Unified TypeScript SDK surface for SOF. - Use `pnpm` for this package. - `pnpm run build` produces minified ESM library output plus `.d.ts` files. +- `pnpm run format:check` verifies Biome formatting for the SDK TS surface. - `pnpm run lint` runs the `oxlint` production lint profile. - `pnpm run check` runs lint, typecheck, tests, and package-shape validation. @@ -16,7 +17,7 @@ Unified TypeScript SDK surface for SOF. - Prefer `tryCreateRuntimeConfig(...)`, `tryCreateRuntimeConfigForProfile(...)`, and `parseRuntimeConfig(...)` when you want validation errors as `Result` values instead of thrown exceptions. - Use `createRuntimeConfigForProfile(...)` or `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. - Profile presets in this SDK stamp the profile env plus the derived-state replay retention defaults that SOF applies through env-backed setup. -- Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface, not those in-process host builders. +- Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface and ships a TS-side extension worker runtime, not the Rust host builders themselves. This initial package slice provides: @@ -42,6 +43,7 @@ This initial package slice provides: - small functional helpers for the common create/serialize/parse path, so most consumers do not need to learn the class API first - result-return factory and serialization helpers for programmatic validation, so SDK consumers do not need to rely on exceptions for normal invalid-input handling - typed runtime-extension manifest and worker-authoring primitives for TS-side extension contracts +- a ready-to-run Node stdio worker loop for runtime-extension processes, so the SDK is not only a DTO wrapper - focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` ## Quick Start @@ -133,19 +135,21 @@ env; ## Extension Runtime The TS SDK now includes a typed extension-worker authoring surface under -`@sof/sdk/runtime/extension`. +`@sof/sdk/runtime/extension` plus a ready-to-run Node worker loop under +`@sof/sdk/runtime/extension-stdio`. Use it for: - typed extension manifests - typed packet-subscription matching - typed in-memory worker lifecycle/runtime +- newline-delimited JSON stdio worker processes with no custom transport loop - runnable TS examples for future Rust-host integration Important boundary: - Rust still owns the actual runtime, queues, sockets, and packet dispatch. -- The current TS SDK extension surface is the TS-side contract and worker model. +- The current TS SDK extension surface is the TS-side contract plus a real TS worker runtime. - It does not yet mean the Rust binary can already spawn TS workers directly. ## Examples @@ -198,6 +202,44 @@ ObserverRuntimeConfig.latencyOptimized(); config; ``` +## Stdio Worker Runtime + +```ts +import { + ExtensionCapability, + RuntimeExtensionWorkerHostMessageTag, + isErr, + ok, + runRuntimeExtensionWorkerStdio, + runtimeExtensionAck, + tryCreateRuntimeExtensionWorkerManifest, + tryDefineRuntimeExtension, +} from "@sof/sdk/runtime/extension-stdio"; + +const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "demo-worker", + capabilities: [ExtensionCapability.ObserveObserverIngress], +}); + +if (isErr(manifest)) { + throw new Error(manifest.error.message); +} + +const worker = tryDefineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: () => ok(runtimeExtensionAck()), + onShutdown: () => ok(runtimeExtensionAck()), +}); + +if (isErr(worker)) { + throw new Error(worker.error.message); +} + +await runRuntimeExtensionWorkerStdio(worker.value); +``` + ## Choosing An API - Use `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env from files, CI, or process managers. @@ -206,4 +248,5 @@ config; - Use `createRuntimeConfigForProfile(...)` or `serializeRuntimeConfigRecord(...)` for the simplest create-and-emit workflow. - Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you explicitly want the class-oriented surface. - Use `derivedStateRuntimeConfig(...)` or `DerivedStateRuntimeConfig.checkpointOnly()` when your main concern is derived-state recovery behavior. +- Use `runRuntimeExtensionWorkerStdio(...)` when you want an actual Node worker process instead of only in-memory runtime objects. - Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface in application code. diff --git a/sdks/typescript/biome.json b/sdks/typescript/biome.json new file mode 100644 index 00000000..06bdf12e --- /dev/null +++ b/sdks/typescript/biome.json @@ -0,0 +1,15 @@ +{ + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/sdks/typescript/examples/runtime-config-balanced.ts b/sdks/typescript/examples/runtime-config-balanced.ts index f8119c69..b8db9b73 100644 --- a/sdks/typescript/examples/runtime-config-balanced.ts +++ b/sdks/typescript/examples/runtime-config-balanced.ts @@ -4,33 +4,37 @@ import { ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, ShredTrustMode, + type Result, isErr, tryCreateRuntimeConfigForProfile, trySerializeRuntimeConfigRecord, } from "../dist/index.js"; -const config = tryCreateRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { - shredTrustMode: ShredTrustMode.TrustedRawShredProvider, - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - derivedState: { - replay: { - backend: DerivedStateReplayBackend.Disk, - durability: DerivedStateReplayDurability.Fsync, - maxEnvelopes: 1024, - maxSessions: 2, - }, - }, -}); +function expectOk( + result: Result, +): Value { + if (isErr(result)) { + throw new Error(result.error.message); + } -if (isErr(config)) { - process.stderr.write(`${config.error.message}\n`); - process.exit(1); + return result.value; } -const serialized = trySerializeRuntimeConfigRecord(config.value); -if (isErr(serialized)) { - process.stderr.write(`${serialized.error.message}\n`); - process.exit(1); -} +const config = expectOk( + tryCreateRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, + }, + }, + }), +); + +const serialized = expectOk(trySerializeRuntimeConfigRecord(config)); -process.stdout.write(`${JSON.stringify(serialized.value, undefined, 2)}\n`); +process.stdout.write(`${JSON.stringify(serialized, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-config-parse.ts b/sdks/typescript/examples/runtime-config-parse.ts index 148af5a2..e199cfb4 100644 --- a/sdks/typescript/examples/runtime-config-parse.ts +++ b/sdks/typescript/examples/runtime-config-parse.ts @@ -1,21 +1,27 @@ import { + type Result, isErr, parseRuntimeConfig, runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues, } from "../dist/index.js"; -const parsed = parseRuntimeConfig({ - [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.balanced, - SOF_PROVIDER_STREAM_ALLOW_EOF: "true", - SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "2048", -}); +function expectOk( + result: Result, +): Value { + if (isErr(result)) { + throw new Error(result.error.message); + } -if (isErr(parsed)) { - process.stderr.write(`${parsed.error.message}\n`); - process.exit(1); + return result.value; } -process.stdout.write( - `${JSON.stringify(parsed.value.toEnvironmentRecord(), undefined, 2)}\n`, +const parsed = expectOk( + parseRuntimeConfig({ + [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.balanced, + SOF_PROVIDER_STREAM_ALLOW_EOF: "true", + SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "2048", + }), ); + +process.stdout.write(`${JSON.stringify(parsed.toEnvironmentRecord(), undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-extension-manifest.ts b/sdks/typescript/examples/runtime-extension-manifest.ts index ad0e5171..c038c687 100644 --- a/sdks/typescript/examples/runtime-extension-manifest.ts +++ b/sdks/typescript/examples/runtime-extension-manifest.ts @@ -3,6 +3,7 @@ import { ExtensionStreamVisibilityTag, RuntimePacketSourceKind, RuntimePacketTransport, + type Result, extensionName, extensionResourceId, isErr, @@ -12,67 +13,44 @@ import { udpListenerResource, } from "../dist/index.js"; -const extension = extensionName("demo-shared-udp-extension"); -if (isErr(extension)) { - process.stderr.write(`${extension.error.message}\n`); - process.exit(1); -} - -const resourceId = extensionResourceId("demo-udp"); -if (isErr(resourceId)) { - process.stderr.write(`${resourceId.error.message}\n`); - process.exit(1); -} - -const bindAddress = socketAddress("127.0.0.1:21011"); -if (isErr(bindAddress)) { - process.stderr.write(`${bindAddress.error.message}\n`); - process.exit(1); -} +function expectOk( + result: Result, +): Value { + if (isErr(result)) { + throw new Error(result.error.message); + } -const sharedVisibility = sharedExtensionStream("demo-stream"); -if (isErr(sharedVisibility)) { - process.stderr.write(`${sharedVisibility.error.message}\n`); - process.exit(1); + return result.value; } -const udpResource = udpListenerResource( - resourceId.value, - bindAddress.value, - sharedVisibility.value, +const extension = expectOk(extensionName("demo-shared-udp-extension")); +const resourceId = expectOk(extensionResourceId("demo-udp")); +const bindAddress = expectOk(socketAddress("127.0.0.1:21011")); +const sharedVisibility = expectOk(sharedExtensionStream("demo-stream")); + +const udpResource = expectOk(udpListenerResource(resourceId, bindAddress, sharedVisibility)); + +const manifest = expectOk( + tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: extension, + capabilities: [ExtensionCapability.BindUdp, ExtensionCapability.ObserveSharedExtensionStream], + resources: [udpResource], + subscriptions: [ + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + ownerExtension: extension, + resourceId, + }, + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + ...(sharedVisibility.tag === ExtensionStreamVisibilityTag.Shared + ? { sharedTag: sharedVisibility.sharedTag } + : {}), + }, + ], + }), ); -if (isErr(udpResource)) { - process.stderr.write(`${udpResource.error.message}\n`); - process.exit(1); -} - -const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: extension.value, - capabilities: [ - ExtensionCapability.BindUdp, - ExtensionCapability.ObserveSharedExtensionStream, - ], - resources: [udpResource.value], - subscriptions: [ - { - sourceKind: RuntimePacketSourceKind.ExtensionResource, - transport: RuntimePacketTransport.Udp, - ownerExtension: extension.value, - resourceId: resourceId.value, - }, - { - sourceKind: RuntimePacketSourceKind.ExtensionResource, - ...(sharedVisibility.value.tag === ExtensionStreamVisibilityTag.Shared - ? { sharedTag: sharedVisibility.value.sharedTag } - : {}), - }, - ], -}); - -if (isErr(manifest)) { - process.stderr.write(`${manifest.error.message}\n`); - process.exit(1); -} -process.stdout.write(`${JSON.stringify(manifest.value, undefined, 2)}\n`); +process.stdout.write(`${JSON.stringify(manifest, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-extension-worker.ts b/sdks/typescript/examples/runtime-extension-worker.ts index 816e2c00..8c80cd4b 100644 --- a/sdks/typescript/examples/runtime-extension-worker.ts +++ b/sdks/typescript/examples/runtime-extension-worker.ts @@ -1,108 +1,126 @@ +import { PassThrough } from "node:stream"; + import { ExtensionCapability, RuntimeExtensionWorkerHostMessageTag, - RuntimePacketEventClass, - RuntimePacketSourceKind, - RuntimePacketTransport, SdkLanguage, - createRuntimePacketEvent, + type Result, extensionName, isErr, ok, runtimeExtensionAck, + runRuntimeExtensionWorkerStdio, + serializeRuntimeExtensionWorkerHostMessageWire, socketAddress, tryDefineRuntimeExtension, tryCreateRuntimeExtensionWorkerManifest, - tryCreateRuntimeExtensionWorkerRuntime, } from "../dist/index.js"; -const extension = extensionName("demo-extension-worker"); -if (isErr(extension)) { - process.stderr.write(`${extension.error.message}\n`); - process.exit(1); -} +function expectOk( + result: Result, +): Value { + if (isErr(result)) { + throw new Error(result.error.message); + } -const localAddress = socketAddress("127.0.0.1:21011"); -if (isErr(localAddress)) { - process.stderr.write(`${localAddress.error.message}\n`); - process.exit(1); + return result.value; } -const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: extension.value, - capabilities: [ExtensionCapability.ObserveObserverIngress], - subscriptions: [ - { - sourceKind: RuntimePacketSourceKind.ObserverIngress, - transport: RuntimePacketTransport.Udp, - eventClass: RuntimePacketEventClass.Packet, - localAddress: localAddress.value, +const extension = expectOk(extensionName("demo-extension-worker")); +const localAddress = expectOk(socketAddress("127.0.0.1:21011")); + +const manifest = expectOk( + tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: extension, + capabilities: [ExtensionCapability.ObserveObserverIngress], + }), +); + +let observedPacketLog = ""; +const definition = expectOk( + tryDefineRuntimeExtension({ + manifest, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: (event) => { + observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; + return ok(runtimeExtensionAck()); }, - ], -}); -if (isErr(manifest)) { - process.stderr.write(`${manifest.error.message}\n`); - process.exit(1); -} + onShutdown: () => ok(runtimeExtensionAck()), + }), +); -const definition = tryDefineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), - onPacketReceived: (event) => { - process.stdout.write( - `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}\n`, - ); - return ok(runtimeExtensionAck()); - }, - onShutdown: () => ok(runtimeExtensionAck()), -}); -if (isErr(definition)) { - process.stderr.write(`${definition.error.message}\n`); - process.exit(1); -} +const input = new PassThrough(); +const output = new PassThrough(); +const errorOutput = new PassThrough(); -const worker = tryCreateRuntimeExtensionWorkerRuntime(definition.value); -if (isErr(worker)) { - process.stderr.write(`${worker.error.message}\n`); - process.exit(1); -} +let protocolOutput = ""; +let protocolErrors = ""; +output.setEncoding("utf8"); +errorOutput.setEncoding("utf8"); +output.on("data", (chunk: string) => { + protocolOutput += chunk; +}); +errorOutput.on("data", (chunk: string) => { + protocolErrors += chunk; +}); -const started = await worker.value.handleMessage({ - tag: RuntimeExtensionWorkerHostMessageTag.Start, - context: { - extensionName: extension.value, - }, +const runner = runRuntimeExtensionWorkerStdio(definition, { + input, + output, + error: errorOutput, }); -const delivered = await worker.value.handleMessage({ - tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, - event: createRuntimePacketEvent( - { - kind: RuntimePacketSourceKind.ObserverIngress, - transport: RuntimePacketTransport.Udp, - eventClass: RuntimePacketEventClass.Packet, - localAddress: localAddress.value, +input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: extension, + }, + }), + )}\n`, +); +input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + localAddress, + }, + bytes: [1, 2, 3, 4], + observedUnixMs: Date.now(), }, - Uint8Array.from([1, 2, 3, 4]), - Date.now(), - ), -}); + })}\n`, +); +input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: extension, + }, + }), + )}\n`, +); +input.end(); -const shutdown = await worker.value.handleMessage({ - tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, - context: { - extensionName: extension.value, - }, -}); +expectOk(await runner); process.stdout.write( `${JSON.stringify( { sdkLanguage: SdkLanguage.TypeScript, - started, - delivered, - shutdown, + observedPacketLog, + protocolErrors: protocolErrors.trim(), + responses: protocolOutput + .trim() + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as unknown), }, undefined, 2, diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 49af9754..e0c8aaef 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -48,6 +48,10 @@ "./runtime/extension": { "types": "./dist/runtime/extension.d.ts", "import": "./dist/runtime/extension.js" + }, + "./runtime/extension-stdio": { + "types": "./dist/runtime/extension-stdio.d.ts", + "import": "./dist/runtime/extension-stdio.js" } }, "files": [ @@ -58,16 +62,19 @@ "build": "pnpm run clean && tsdown --config tsdown.config.ts", "build:test": "tsc -p tsconfig.test.json", "build:examples": "pnpm run build && tsc -p tsconfig.examples.json", - "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", - "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.json --type-aware --fix --fix-suggestions src tsdown.config.ts", + "format": "biome format --config-path biome.json --write src examples tsdown.config.ts", + "format:check": "biome format --config-path biome.json src examples tsdown.config.ts", + "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", + "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts", "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js", "check:package": "publint run --strict --pack pnpm", - "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", + "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm run build && pnpm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", "prepack": "pnpm run build" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@types/node": "^24.6.0", "oxlint": "^1.59.0", "oxlint-tsgolint": "^0.20.0", diff --git a/sdks/typescript/pnpm-lock.yaml b/sdks/typescript/pnpm-lock.yaml index 3a7ed291..f717109e 100644 --- a/sdks/typescript/pnpm-lock.yaml +++ b/sdks/typescript/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 '@types/node': specifier: ^24.6.0 version: 24.12.2 @@ -50,6 +53,63 @@ packages: resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} engines: {node: ^20.19.0 || >=22.12.0} + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -574,6 +634,41 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/sdks/typescript/src/environment.ts b/sdks/typescript/src/environment.ts index 5b171f53..17531f6b 100644 --- a/sdks/typescript/src/environment.ts +++ b/sdks/typescript/src/environment.ts @@ -24,10 +24,10 @@ export function envVarName(value: Name): EnvVarName { return brand(value); } -export function environmentVariable< - Name extends EnvVarName, - Value extends string, ->(name: Name, value: Value): EnvironmentVariable { +export function environmentVariable( + name: Name, + value: Value, +): EnvironmentVariable { return { name, value }; } diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index 5336cab5..89465d5a 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -13,6 +13,7 @@ test("package exports resolve the documented public entry points", async () => { const derivedState = await importPackageEntry("@sof/sdk/runtime/derived-state"); const deliveryProfile = await importPackageEntry("@sof/sdk/runtime/delivery-profile"); const extension = await importPackageEntry("@sof/sdk/runtime/extension"); + const extensionStdio = await importPackageEntry("@sof/sdk/runtime/extension-stdio"); assert.equal( (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, @@ -56,4 +57,8 @@ test("package exports resolve the documented public entry points", async () => { (extension as { createRuntimeExtensionWorkerManifest: unknown }) .createRuntimeExtensionWorkerManifest, ); + assert.equal( + (root as { runRuntimeExtensionWorkerStdio: unknown }).runRuntimeExtensionWorkerStdio, + (extensionStdio as { runRuntimeExtensionWorkerStdio: unknown }).runRuntimeExtensionWorkerStdio, + ); }); diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index be82788c..074bb4fc 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -2,4 +2,5 @@ export * from "./runtime/derived-state.js"; export * from "./runtime/runtime-config.js"; export * from "./runtime/runtime-delivery-profile.js"; export * from "./runtime/runtime-extension.js"; +export * from "./runtime/runtime-extension-stdio.js"; export * from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/derived-state.ts b/sdks/typescript/src/runtime/derived-state.ts index 88dae88f..87f3a949 100644 --- a/sdks/typescript/src/runtime/derived-state.ts +++ b/sdks/typescript/src/runtime/derived-state.ts @@ -16,27 +16,17 @@ export enum DerivedStateReplayDurability { export const defaultDerivedStateCheckpointIntervalMs = 30_000; export const defaultDerivedStateRecoveryIntervalMs = 5_000; export const defaultDerivedStateReplayBackend = DerivedStateReplayBackend.Memory; -export const defaultDerivedStateReplayDurability = - DerivedStateReplayDurability.Flush; +export const defaultDerivedStateReplayDurability = DerivedStateReplayDurability.Flush; export const defaultDerivedStateReplayMaxEnvelopes = 8_192; export const defaultDerivedStateReplayMaxSessions = 4; -export type DerivedStateReplayBackendEnvValue = Brand< - string, - "DerivedStateReplayBackendEnvValue" ->; +export type DerivedStateReplayBackendEnvValue = Brand; export type DerivedStateReplayDurabilityEnvValue = Brand< string, "DerivedStateReplayDurabilityEnvValue" >; -export type NonNegativeIntegerEnvValue = Brand< - string, - "NonNegativeIntegerEnvValue" ->; -export type DerivedStateReplayDirectory = Brand< - string, - "DerivedStateReplayDirectory" ->; +export type NonNegativeIntegerEnvValue = Brand; +export type DerivedStateReplayDirectory = Brand; function asDerivedStateReplayBackendEnvValue( value: Value, @@ -68,12 +58,8 @@ export const derivedStateCheckpointIntervalEnvVarName = envVarName( export const derivedStateRecoveryIntervalEnvVarName = envVarName( "SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS", ); -export const derivedStateReplayBackendEnvVarName = envVarName( - "SOF_DERIVED_STATE_REPLAY_BACKEND", -); -export const derivedStateReplayDirEnvVarName = envVarName( - "SOF_DERIVED_STATE_REPLAY_DIR", -); +export const derivedStateReplayBackendEnvVarName = envVarName("SOF_DERIVED_STATE_REPLAY_BACKEND"); +export const derivedStateReplayDirEnvVarName = envVarName("SOF_DERIVED_STATE_REPLAY_DIR"); export const derivedStateReplayDurabilityEnvVarName = envVarName( "SOF_DERIVED_STATE_REPLAY_DURABILITY", ); @@ -95,16 +81,10 @@ export const derivedStateReplayDurabilityEnvValues = { } as const; export const derivedStateReplayBackendAllowedValues: readonly DerivedStateReplayBackendEnvValue[] = - [ - derivedStateReplayBackendEnvValues.memory, - derivedStateReplayBackendEnvValues.disk, - ]; + [derivedStateReplayBackendEnvValues.memory, derivedStateReplayBackendEnvValues.disk]; export const derivedStateReplayDurabilityAllowedValues: readonly DerivedStateReplayDurabilityEnvValue[] = - [ - derivedStateReplayDurabilityEnvValues.flush, - derivedStateReplayDurabilityEnvValues.fsync, - ]; + [derivedStateReplayDurabilityEnvValues.flush, derivedStateReplayDurabilityEnvValues.fsync]; export const defaultDerivedStateReplayDirectory = asDerivedStateReplayDirectory( ".sof-derived-state-replay", @@ -133,9 +113,7 @@ export function parseDerivedStateReplayDirectory( return ok(asDerivedStateReplayDirectory(value)); } -export function derivedStateReplayDirectory( - value: string, -): DerivedStateReplayDirectory { +export function derivedStateReplayDirectory(value: string): DerivedStateReplayDirectory { const result = parseDerivedStateReplayDirectory(value); if (!isErr(result)) { return result.value; @@ -170,10 +148,7 @@ export function isDerivedStateReplayDurability( export function validateDerivedStateReplayBackend( value: DerivedStateReplayBackend, -): Result< - DerivedStateReplayBackend, - ValidationError -> { +): Result> { if (!isDerivedStateReplayBackend(value)) { return err({ kind: ValidationErrorKind.InvalidDerivedStateReplayBackend, @@ -189,10 +164,7 @@ export function validateDerivedStateReplayBackend( export function validateDerivedStateReplayDurability( value: DerivedStateReplayDurability, -): Result< - DerivedStateReplayDurability, - ValidationError -> { +): Result> { if (!isDerivedStateReplayDurability(value)) { return err({ kind: ValidationErrorKind.InvalidDerivedStateReplayDurability, @@ -242,17 +214,12 @@ function requireDerivedStateReplayDurability( return validated.value; } - throw new RangeError( - `unknown derived-state replay durability: ${String(value)}`, - ); + throw new RangeError(`unknown derived-state replay durability: ${String(value)}`); } export function tryDerivedStateReplayBackendToEnvValue( backend: DerivedStateReplayBackend, -): Result< - DerivedStateReplayBackendEnvValue, - ValidationError -> { +): Result> { const validated = validateDerivedStateReplayBackend(backend); if (isErr(validated)) { return validated; @@ -320,17 +287,12 @@ export function derivedStateReplayDurabilityToEnvValue( return result.value; } - throw new RangeError( - `unknown derived-state replay durability: ${String(durability)}`, - ); + throw new RangeError(`unknown derived-state replay durability: ${String(durability)}`); } export function parseDerivedStateReplayBackend( input: string, -): Result< - DerivedStateReplayBackend, - ValidationError -> { +): Result> { const normalized = input.trim().toLowerCase(); switch (normalized) { @@ -351,10 +313,7 @@ export function parseDerivedStateReplayBackend( export function parseDerivedStateReplayDurability( input: string, -): Result< - DerivedStateReplayDurability, - ValidationError -> { +): Result> { const normalized = input.trim().toLowerCase(); switch (normalized) { @@ -435,9 +394,7 @@ export function tryNonNegativeIntegerToEnvValue( return ok(asNonNegativeIntegerEnvValue(`${validated.value}`)); } -export function nonNegativeIntegerToEnvValue( - value: number, -): NonNegativeIntegerEnvValue { +export function nonNegativeIntegerToEnvValue(value: number): NonNegativeIntegerEnvValue { const result = tryNonNegativeIntegerToEnvValue(value); if (!isErr(result)) { return result.value; @@ -454,9 +411,7 @@ export interface DerivedStateReplayConfigInit { readonly maxSessions?: number; } -export type DerivedStateReplayConfigInput = - | DerivedStateReplayConfig - | DerivedStateReplayConfigInit; +export type DerivedStateReplayConfigInput = DerivedStateReplayConfig | DerivedStateReplayConfigInit; export type DerivedStateValidationError = | ValidationError @@ -497,9 +452,7 @@ export class DerivedStateReplayConfig { }); } - static create( - init: DerivedStateReplayConfigInput = {}, - ): DerivedStateReplayConfig { + static create(init: DerivedStateReplayConfigInput = {}): DerivedStateReplayConfig { return derivedStateReplayConfig(init); } @@ -518,9 +471,7 @@ export class DerivedStateReplayConfig { }); } - static disk( - init: Omit = {}, - ): DerivedStateReplayConfig { + static disk(init: Omit = {}): DerivedStateReplayConfig { return new DerivedStateReplayConfig({ ...init, backend: DerivedStateReplayBackend.Disk, @@ -582,9 +533,7 @@ export class DerivedStateRuntimeConfig { : new DerivedStateReplayConfig(init.replay); } - static create( - init: DerivedStateRuntimeConfigInput = {}, - ): DerivedStateRuntimeConfig { + static create(init: DerivedStateRuntimeConfigInput = {}): DerivedStateRuntimeConfig { return derivedStateRuntimeConfig(init); } @@ -615,10 +564,7 @@ export class DerivedStateRuntimeConfig { function validateDerivedStateReplayConfigInit( init: DerivedStateReplayConfigInit, -): Result< - Required, - DerivedStateValidationError -> { +): Result, DerivedStateValidationError> { const backend = validateDerivedStateReplayBackend( init.backend ?? defaultDerivedStateReplayBackend, ); @@ -668,9 +614,7 @@ function validateDerivedStateReplayConfigInit( }); } -function validateDerivedStateRuntimeConfigInit( - init: DerivedStateRuntimeConfigInit, -): Result< +function validateDerivedStateRuntimeConfigInit(init: DerivedStateRuntimeConfigInit): Result< { readonly checkpointIntervalMs: number; readonly recoveryIntervalMs: number; @@ -718,9 +662,7 @@ export function derivedStateReplayConfig( return new DerivedStateReplayConfig(); } - return init instanceof DerivedStateReplayConfig - ? init - : new DerivedStateReplayConfig(init); + return init instanceof DerivedStateReplayConfig ? init : new DerivedStateReplayConfig(init); } export function tryDerivedStateReplayConfig( @@ -749,9 +691,7 @@ export function derivedStateRuntimeConfig( return new DerivedStateRuntimeConfig(); } - return init instanceof DerivedStateRuntimeConfig - ? init - : new DerivedStateRuntimeConfig(init); + return init instanceof DerivedStateRuntimeConfig ? init : new DerivedStateRuntimeConfig(init); } export function tryDerivedStateRuntimeConfig( diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 500d5120..931798fb 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - environmentVariable, - environmentVariablesToRecord, -} from "../environment.js"; +import { environmentVariable, environmentVariablesToRecord } from "../environment.js"; import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { @@ -112,29 +109,18 @@ test("runtime delivery profile maps to the documented env values", () => { }); test("runtime delivery profiles expose the expected env-backed defaults", () => { - assert.deepEqual( - runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.LatencyOptimized), - { - derivedStateReplayMaxEnvelopes: 8192, - derivedStateReplayMaxSessions: 4, - }, - ); - assert.deepEqual( - runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.Balanced), - { - derivedStateReplayMaxEnvelopes: 16384, - derivedStateReplayMaxSessions: 6, - }, - ); - assert.deepEqual( - runtimeDeliveryProfileEnvDefaults( - RuntimeDeliveryProfile.DeliveryDisciplined, - ), - { - derivedStateReplayMaxEnvelopes: 32768, - derivedStateReplayMaxSessions: 8, - }, - ); + assert.deepEqual(runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.LatencyOptimized), { + derivedStateReplayMaxEnvelopes: 8192, + derivedStateReplayMaxSessions: 4, + }); + assert.deepEqual(runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.Balanced), { + derivedStateReplayMaxEnvelopes: 16384, + derivedStateReplayMaxSessions: 6, + }); + assert.deepEqual(runtimeDeliveryProfileEnvDefaults(RuntimeDeliveryProfile.DeliveryDisciplined), { + derivedStateReplayMaxEnvelopes: 32768, + derivedStateReplayMaxSessions: 8, + }); }); test("runtime policy enums map to the documented env values", () => { @@ -147,15 +133,11 @@ test("runtime policy enums map to the documented env values", () => { shredTrustModeEnvValues.trustedRawShredProvider, ); assert.equal( - providerStreamCapabilityPolicyToEnvValue( - ProviderStreamCapabilityPolicy.Warn, - ), + providerStreamCapabilityPolicyToEnvValue(ProviderStreamCapabilityPolicy.Warn), providerStreamCapabilityPolicyEnvValues.warn, ); assert.equal( - providerStreamCapabilityPolicyToEnvValue( - ProviderStreamCapabilityPolicy.Strict, - ), + providerStreamCapabilityPolicyToEnvValue(ProviderStreamCapabilityPolicy.Strict), providerStreamCapabilityPolicyEnvValues.strict, ); assert.equal( @@ -188,10 +170,7 @@ test("runtime delivery profile parser accepts documented aliases", () => { assert.equal(balanced.value, RuntimeDeliveryProfile.Balanced); } if (isOk(disciplined)) { - assert.equal( - disciplined.value, - RuntimeDeliveryProfile.DeliveryDisciplined, - ); + assert.equal(disciplined.value, RuntimeDeliveryProfile.DeliveryDisciplined); } }); @@ -239,23 +218,22 @@ test("runtime config omits default policy values unless requested", () => { assert.deepEqual(config.toEnvironment(), []); assert.equal(Object.getPrototypeOf(envRecord), null); - assert.deepEqual({ ...envRecord }, { - [runtimeDeliveryProfileEnvVarName]: - runtimeDeliveryProfileEnvValues.latencyOptimized, - [shredTrustModeEnvVarName]: shredTrustModeEnvValues.publicUntrusted, - [providerStreamCapabilityPolicyEnvVarName]: - providerStreamCapabilityPolicyEnvValues.warn, - [providerStreamAllowEofEnvVarName]: runtimeBooleanEnvValues.false, - [derivedStateCheckpointIntervalEnvVarName]: "30000", - [derivedStateRecoveryIntervalEnvVarName]: "5000", - [derivedStateReplayBackendEnvVarName]: - derivedStateReplayBackendEnvValues.memory, - [derivedStateReplayDirEnvVarName]: defaultDerivedStateReplayDirectory, - [derivedStateReplayDurabilityEnvVarName]: - derivedStateReplayDurabilityEnvValues.flush, - [derivedStateReplayMaxEnvelopesEnvVarName]: "8192", - [derivedStateReplayMaxSessionsEnvVarName]: "4", - }); + assert.deepEqual( + { ...envRecord }, + { + [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.latencyOptimized, + [shredTrustModeEnvVarName]: shredTrustModeEnvValues.publicUntrusted, + [providerStreamCapabilityPolicyEnvVarName]: providerStreamCapabilityPolicyEnvValues.warn, + [providerStreamAllowEofEnvVarName]: runtimeBooleanEnvValues.false, + [derivedStateCheckpointIntervalEnvVarName]: "30000", + [derivedStateRecoveryIntervalEnvVarName]: "5000", + [derivedStateReplayBackendEnvVarName]: derivedStateReplayBackendEnvValues.memory, + [derivedStateReplayDirEnvVarName]: defaultDerivedStateReplayDirectory, + [derivedStateReplayDurabilityEnvVarName]: derivedStateReplayDurabilityEnvValues.flush, + [derivedStateReplayMaxEnvelopesEnvVarName]: "8192", + [derivedStateReplayMaxSessionsEnvVarName]: "4", + }, + ); }); test("runtime config serializes explicit runtime policy selection", () => { @@ -278,22 +256,13 @@ test("runtime config serializes explicit runtime policy selection", () => { }); assert.deepEqual(config.toEnvironment(), [ - environmentVariable( - runtimeDeliveryProfileEnvVarName, - runtimeDeliveryProfileEnvValues.balanced, - ), - environmentVariable( - shredTrustModeEnvVarName, - shredTrustModeEnvValues.trustedRawShredProvider, - ), + environmentVariable(runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues.balanced), + environmentVariable(shredTrustModeEnvVarName, shredTrustModeEnvValues.trustedRawShredProvider), environmentVariable( providerStreamCapabilityPolicyEnvVarName, providerStreamCapabilityPolicyEnvValues.strict, ), - environmentVariable( - providerStreamAllowEofEnvVarName, - runtimeBooleanEnvValues.true, - ), + environmentVariable(providerStreamAllowEofEnvVarName, runtimeBooleanEnvValues.true), environmentVariable(derivedStateCheckpointIntervalEnvVarName, "60000"), environmentVariable(derivedStateRecoveryIntervalEnvVarName, "10000"), environmentVariable( @@ -315,11 +284,9 @@ test("runtime config serializes explicit runtime policy selection", () => { test("runtime config parses environment values into typed runtime policy config", () => { const config = ObserverRuntimeConfig.fromEnvironment({ - [runtimeDeliveryProfileEnvVarName]: - runtimeDeliveryProfileEnvValues.deliveryDisciplined, + [runtimeDeliveryProfileEnvVarName]: runtimeDeliveryProfileEnvValues.deliveryDisciplined, [shredTrustModeEnvVarName]: shredTrustModeEnvValues.trustedRawShredProvider, - [providerStreamCapabilityPolicyEnvVarName]: - providerStreamCapabilityPolicyEnvValues.strict, + [providerStreamCapabilityPolicyEnvVarName]: providerStreamCapabilityPolicyEnvValues.strict, [providerStreamAllowEofEnvVarName]: "on", [derivedStateCheckpointIntervalEnvVarName]: "45000", [derivedStateRecoveryIntervalEnvVarName]: "7000", @@ -332,14 +299,8 @@ test("runtime config parses environment values into typed runtime policy config" assert.equal(isOk(config), true); if (isOk(config)) { - assert.equal( - config.value.runtimeDeliveryProfile, - RuntimeDeliveryProfile.DeliveryDisciplined, - ); - assert.equal( - config.value.shredTrustMode, - ShredTrustMode.TrustedRawShredProvider, - ); + assert.equal(config.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined); + assert.equal(config.value.shredTrustMode, ShredTrustMode.TrustedRawShredProvider); assert.equal( config.value.providerStreamCapabilityPolicy, ProviderStreamCapabilityPolicy.Strict, @@ -347,18 +308,12 @@ test("runtime config parses environment values into typed runtime policy config" assert.equal(config.value.providerStreamAllowEof, true); assert.equal(config.value.derivedState.checkpointIntervalMs, 45_000); assert.equal(config.value.derivedState.recoveryIntervalMs, 7_000); - assert.equal( - config.value.derivedState.replay.backend, - DerivedStateReplayBackend.Disk, - ); + assert.equal(config.value.derivedState.replay.backend, DerivedStateReplayBackend.Disk); assert.equal( config.value.derivedState.replay.replayDirectory, derivedStateReplayDirectory(".sof-disk-tail"), ); - assert.equal( - config.value.derivedState.replay.durability, - DerivedStateReplayDurability.Fsync, - ); + assert.equal(config.value.derivedState.replay.durability, DerivedStateReplayDurability.Fsync); assert.equal(config.value.derivedState.replay.maxEnvelopes, 2048); assert.equal(config.value.derivedState.replay.maxSessions, 6); } @@ -366,22 +321,13 @@ test("runtime config parses environment values into typed runtime policy config" test("runtime config parses typed environment variables into typed config", () => { const config = ObserverRuntimeConfig.fromEnvironmentVariables([ - environmentVariable( - runtimeDeliveryProfileEnvVarName, - runtimeDeliveryProfileEnvValues.balanced, - ), - environmentVariable( - shredTrustModeEnvVarName, - shredTrustModeEnvValues.publicUntrusted, - ), + environmentVariable(runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues.balanced), + environmentVariable(shredTrustModeEnvVarName, shredTrustModeEnvValues.publicUntrusted), environmentVariable( providerStreamCapabilityPolicyEnvVarName, providerStreamCapabilityPolicyEnvValues.warn, ), - environmentVariable( - providerStreamAllowEofEnvVarName, - runtimeBooleanEnvValues.false, - ), + environmentVariable(providerStreamAllowEofEnvVarName, runtimeBooleanEnvValues.false), environmentVariable( derivedStateCheckpointIntervalEnvVarName, nonNegativeIntegerToEnvValue(30_000), @@ -394,10 +340,7 @@ test("runtime config parses typed environment variables into typed config", () = derivedStateReplayBackendEnvVarName, derivedStateReplayBackendEnvValues.memory, ), - environmentVariable( - derivedStateReplayDirEnvVarName, - defaultDerivedStateReplayDirectory, - ), + environmentVariable(derivedStateReplayDirEnvVarName, defaultDerivedStateReplayDirectory), environmentVariable( derivedStateReplayDurabilityEnvVarName, derivedStateReplayDurabilityEnvValues.flush, @@ -406,28 +349,16 @@ test("runtime config parses typed environment variables into typed config", () = derivedStateReplayMaxEnvelopesEnvVarName, nonNegativeIntegerToEnvValue(8_192), ), - environmentVariable( - derivedStateReplayMaxSessionsEnvVarName, - nonNegativeIntegerToEnvValue(4), - ), + environmentVariable(derivedStateReplayMaxSessionsEnvVarName, nonNegativeIntegerToEnvValue(4)), ]); assert.equal(isOk(config), true); if (isOk(config)) { - assert.equal( - config.value.runtimeDeliveryProfile, - RuntimeDeliveryProfile.Balanced, - ); + assert.equal(config.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); assert.equal(config.value.shredTrustMode, ShredTrustMode.PublicUntrusted); - assert.equal( - config.value.providerStreamCapabilityPolicy, - ProviderStreamCapabilityPolicy.Warn, - ); + assert.equal(config.value.providerStreamCapabilityPolicy, ProviderStreamCapabilityPolicy.Warn); assert.equal(config.value.providerStreamAllowEof, false); - assert.equal( - config.value.derivedState.replay.backend, - DerivedStateReplayBackend.Memory, - ); + assert.equal(config.value.derivedState.replay.backend, DerivedStateReplayBackend.Memory); } }); @@ -435,17 +366,14 @@ test("functional runtime config facade keeps common env workflows simple", () => const created = createRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, }); - const config = createRuntimeConfigForProfile( - RuntimeDeliveryProfile.Balanced, - { - providerStreamAllowEof: true, - derivedState: { - replay: { - maxEnvelopes: 512, - }, + const config = createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { + providerStreamAllowEof: true, + derivedState: { + replay: { + maxEnvelopes: 512, }, }, - ); + }); const serialized = serializeRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, providerStreamAllowEof: true, @@ -481,39 +409,21 @@ test("runtime config preset helpers create one-line common profiles", () => { const disciplined = ObserverRuntimeConfig.deliveryDisciplined({ shredTrustMode: ShredTrustMode.TrustedRawShredProvider, }); - const explicit = ObserverRuntimeConfig.forProfile( - RuntimeDeliveryProfile.Balanced, - { - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - }, - ); + const explicit = ObserverRuntimeConfig.forProfile(RuntimeDeliveryProfile.Balanced, { + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + }); const functionPreset = observerRuntimeConfigForProfile( RuntimeDeliveryProfile.DeliveryDisciplined, ); - assert.equal( - latency.runtimeDeliveryProfile, - RuntimeDeliveryProfile.LatencyOptimized, - ); + assert.equal(latency.runtimeDeliveryProfile, RuntimeDeliveryProfile.LatencyOptimized); assert.equal(balanced.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); assert.equal(balanced.providerStreamAllowEof, true); - assert.equal( - disciplined.runtimeDeliveryProfile, - RuntimeDeliveryProfile.DeliveryDisciplined, - ); - assert.equal( - disciplined.shredTrustMode, - ShredTrustMode.TrustedRawShredProvider, - ); + assert.equal(disciplined.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined); + assert.equal(disciplined.shredTrustMode, ShredTrustMode.TrustedRawShredProvider); assert.equal(explicit.runtimeDeliveryProfile, RuntimeDeliveryProfile.Balanced); - assert.equal( - explicit.providerStreamCapabilityPolicy, - ProviderStreamCapabilityPolicy.Strict, - ); - assert.equal( - functionPreset.runtimeDeliveryProfile, - RuntimeDeliveryProfile.DeliveryDisciplined, - ); + assert.equal(explicit.providerStreamCapabilityPolicy, ProviderStreamCapabilityPolicy.Strict); + assert.equal(functionPreset.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined); assert.equal(balanced.derivedState.replay.maxEnvelopes, 16_384); assert.equal(balanced.derivedState.replay.maxSessions, 6); assert.equal(functionPreset.derivedState.replay.maxEnvelopes, 32_768); @@ -524,9 +434,7 @@ test("functional runtime config facade exposes result-return creation and serial const created = tryCreateRuntimeConfig({ providerStreamAllowEof: true, }); - const profiled = tryCreateRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, - ); + const profiled = tryCreateRuntimeConfigForProfile(RuntimeDeliveryProfile.DeliveryDisciplined); const serialized = trySerializeRuntimeConfig({ runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, }); @@ -540,10 +448,7 @@ test("functional runtime config facade exposes result-return creation and serial assert.equal(isOk(record), true); if (isOk(profiled)) { - assert.equal( - profiled.value.runtimeDeliveryProfile, - RuntimeDeliveryProfile.DeliveryDisciplined, - ); + assert.equal(profiled.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.DeliveryDisciplined); } if (isOk(record)) { @@ -560,14 +465,8 @@ test("runtime profile helpers preserve explicit derived-state replay overrides", }, }); - assert.equal( - config.derivedState.replay.maxEnvelopes, - 512, - ); - assert.equal( - config.derivedState.replay.maxSessions, - 8, - ); + assert.equal(config.derivedState.replay.maxEnvelopes, 512); + assert.equal(config.derivedState.replay.maxSessions, 8); }); test("runtime config supports nested plain-object construction", () => { @@ -643,17 +542,11 @@ test("environment helpers ignore inherited env values and use a null-prototype r assert.equal(isOk(parsed), true); if (isOk(parsed)) { - assert.equal( - parsed.value.runtimeDeliveryProfile, - RuntimeDeliveryProfile.LatencyOptimized, - ); + assert.equal(parsed.value.runtimeDeliveryProfile, RuntimeDeliveryProfile.LatencyOptimized); } const record = environmentVariablesToRecord([ - environmentVariable( - runtimeDeliveryProfileEnvVarName, - runtimeDeliveryProfileEnvValues.balanced, - ), + environmentVariable(runtimeDeliveryProfileEnvVarName, runtimeDeliveryProfileEnvValues.balanced), ]); assert.equal(Object.getPrototypeOf(record), null); @@ -667,16 +560,10 @@ test("runtime config rejects invalid delivery profile values", () => { assert.equal(isErr(config), true); if (isErr(config)) { - assert.equal( - config.error.kind, - ValidationErrorKind.InvalidRuntimeDeliveryProfile, - ); + assert.equal(config.error.kind, ValidationErrorKind.InvalidRuntimeDeliveryProfile); assert.equal(config.error.field, runtimeDeliveryProfileEnvVarName); assert.equal(config.error.received, "fastest"); - assert.deepEqual( - config.error.allowedValues, - runtimeDeliveryProfileAllowedValues, - ); + assert.deepEqual(config.error.allowedValues, runtimeDeliveryProfileAllowedValues); } }); @@ -700,15 +587,9 @@ test("runtime config rejects invalid provider stream capability policy values", assert.equal(isErr(config), true); if (isErr(config)) { - assert.equal( - config.error.kind, - ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, - ); + assert.equal(config.error.kind, ValidationErrorKind.InvalidProviderStreamCapabilityPolicy); assert.equal(config.error.field, providerStreamCapabilityPolicyEnvVarName); - assert.deepEqual( - config.error.allowedValues, - providerStreamCapabilityPolicyAllowedValues, - ); + assert.deepEqual(config.error.allowedValues, providerStreamCapabilityPolicyAllowedValues); } }); @@ -719,10 +600,7 @@ test("runtime config rejects invalid provider stream eof values", () => { assert.equal(isErr(config), true); if (isErr(config)) { - assert.equal( - config.error.kind, - ValidationErrorKind.InvalidProviderStreamAllowEof, - ); + assert.equal(config.error.kind, ValidationErrorKind.InvalidProviderStreamAllowEof); assert.equal(config.error.field, providerStreamAllowEofEnvVarName); assert.deepEqual(config.error.allowedValues, runtimeBooleanAllowedValues); } @@ -735,15 +613,9 @@ test("runtime config rejects invalid derived-state replay backend values", () => assert.equal(isErr(config), true); if (isErr(config)) { - assert.equal( - config.error.kind, - ValidationErrorKind.InvalidDerivedStateReplayBackend, - ); + assert.equal(config.error.kind, ValidationErrorKind.InvalidDerivedStateReplayBackend); assert.equal(config.error.field, derivedStateReplayBackendEnvVarName); - assert.deepEqual( - config.error.allowedValues, - derivedStateReplayBackendAllowedValues, - ); + assert.deepEqual(config.error.allowedValues, derivedStateReplayBackendAllowedValues); } }); @@ -754,15 +626,9 @@ test("runtime config rejects invalid derived-state replay durability values", () assert.equal(isErr(config), true); if (isErr(config)) { - assert.equal( - config.error.kind, - ValidationErrorKind.InvalidDerivedStateReplayDurability, - ); + assert.equal(config.error.kind, ValidationErrorKind.InvalidDerivedStateReplayDurability); assert.equal(config.error.field, derivedStateReplayDurabilityEnvVarName); - assert.deepEqual( - config.error.allowedValues, - derivedStateReplayDurabilityAllowedValues, - ); + assert.deepEqual(config.error.allowedValues, derivedStateReplayDurabilityAllowedValues); } }); @@ -806,14 +672,8 @@ test("result-return helpers validate programmatic numeric and path values", () = assert.equal(isErr(invalidDirectory), true); if (isErr(invalidRuntime)) { - assert.equal( - invalidRuntime.error.kind, - ValidationErrorKind.InvalidNonNegativeInteger, - ); - assert.equal( - invalidRuntime.error.field, - derivedStateCheckpointIntervalEnvVarName, - ); + assert.equal(invalidRuntime.error.kind, ValidationErrorKind.InvalidNonNegativeInteger); + assert.equal(invalidRuntime.error.field, derivedStateCheckpointIntervalEnvVarName); assert.equal( invalidRuntime.error.message, "checkpointIntervalMs must be a non-negative integer", @@ -821,33 +681,15 @@ test("result-return helpers validate programmatic numeric and path values", () = } if (isErr(invalidReplay)) { - assert.equal( - invalidReplay.error.kind, - ValidationErrorKind.InvalidNonNegativeInteger, - ); - assert.equal( - invalidReplay.error.field, - derivedStateReplayMaxSessionsEnvVarName, - ); - assert.equal( - invalidReplay.error.message, - "maxSessions must be a non-negative integer", - ); + assert.equal(invalidReplay.error.kind, ValidationErrorKind.InvalidNonNegativeInteger); + assert.equal(invalidReplay.error.field, derivedStateReplayMaxSessionsEnvVarName); + assert.equal(invalidReplay.error.message, "maxSessions must be a non-negative integer"); } if (isErr(invalidInteger)) { - assert.equal( - invalidInteger.error.kind, - ValidationErrorKind.InvalidNonNegativeInteger, - ); - assert.equal( - invalidInteger.error.field, - derivedStateReplayMaxEnvelopesEnvVarName, - ); - assert.equal( - invalidInteger.error.message, - "value must be a non-negative integer", - ); + assert.equal(invalidInteger.error.kind, ValidationErrorKind.InvalidNonNegativeInteger); + assert.equal(invalidInteger.error.field, derivedStateReplayMaxEnvelopesEnvVarName); + assert.equal(invalidInteger.error.message, "value must be a non-negative integer"); } if (isErr(invalidDirectory)) { @@ -861,12 +703,8 @@ test("result-return helpers validate programmatic numeric and path values", () = }); test("result-return helpers reject invalid programmatic enum and profile values", () => { - const invalidProfile = tryRuntimeDeliveryProfileToEnvValue( - 99 as RuntimeDeliveryProfile, - ); - const invalidProfileDefaults = tryRuntimeDeliveryProfileEnvDefaults( - 99 as RuntimeDeliveryProfile, - ); + const invalidProfile = tryRuntimeDeliveryProfileToEnvValue(99 as RuntimeDeliveryProfile); + const invalidProfileDefaults = tryRuntimeDeliveryProfileEnvDefaults(99 as RuntimeDeliveryProfile); const invalidShredTrustMode = tryShredTrustModeToEnvValue(99 as ShredTrustMode); const invalidCapabilityPolicy = tryProviderStreamCapabilityPolicyToEnvValue( 99 as ProviderStreamCapabilityPolicy, @@ -880,9 +718,7 @@ test("result-return helpers reject invalid programmatic enum and profile values" const invalidRuntimeConfig = tryObserverRuntimeConfig({ providerStreamAllowEof: "true" as unknown as boolean, }); - const invalidProfileConfig = tryObserverRuntimeConfigForProfile( - 99 as RuntimeDeliveryProfile, - ); + const invalidProfileConfig = tryObserverRuntimeConfigForProfile(99 as RuntimeDeliveryProfile); const invalidCreated = tryCreateRuntimeConfig({ providerStreamAllowEof: "true" as unknown as boolean, }); @@ -902,10 +738,7 @@ test("result-return helpers reject invalid programmatic enum and profile values" assert.equal(isErr(invalidSerialized), true); if (isErr(invalidProfile)) { - assert.equal( - invalidProfile.error.kind, - ValidationErrorKind.InvalidRuntimeDeliveryProfile, - ); + assert.equal(invalidProfile.error.kind, ValidationErrorKind.InvalidRuntimeDeliveryProfile); assert.equal(invalidProfile.error.field, runtimeDeliveryProfileEnvVarName); } @@ -917,10 +750,7 @@ test("result-return helpers reject invalid programmatic enum and profile values" } if (isErr(invalidShredTrustMode)) { - assert.equal( - invalidShredTrustMode.error.kind, - ValidationErrorKind.InvalidShredTrustMode, - ); + assert.equal(invalidShredTrustMode.error.kind, ValidationErrorKind.InvalidShredTrustMode); } if (isErr(invalidCapabilityPolicy)) { @@ -949,10 +779,7 @@ test("result-return helpers reject invalid programmatic enum and profile values" invalidRuntimeConfig.error.kind, ValidationErrorKind.InvalidProviderStreamAllowEof, ); - assert.equal( - invalidRuntimeConfig.error.message, - "providerStreamAllowEof must be a boolean", - ); + assert.equal(invalidRuntimeConfig.error.message, "providerStreamAllowEof must be a boolean"); } if (isErr(invalidProfileConfig)) { @@ -963,16 +790,10 @@ test("result-return helpers reject invalid programmatic enum and profile values" } if (isErr(invalidCreated)) { - assert.equal( - invalidCreated.error.kind, - ValidationErrorKind.InvalidProviderStreamAllowEof, - ); + assert.equal(invalidCreated.error.kind, ValidationErrorKind.InvalidProviderStreamAllowEof); } if (isErr(invalidSerialized)) { - assert.equal( - invalidSerialized.error.kind, - ValidationErrorKind.InvalidProviderStreamAllowEof, - ); + assert.equal(invalidSerialized.error.kind, ValidationErrorKind.InvalidProviderStreamAllowEof); } }); diff --git a/sdks/typescript/src/runtime/runtime-config.ts b/sdks/typescript/src/runtime/runtime-config.ts index ad115215..849e26d4 100644 --- a/sdks/typescript/src/runtime/runtime-config.ts +++ b/sdks/typescript/src/runtime/runtime-config.ts @@ -84,36 +84,22 @@ export interface ObserverRuntimeConfigInit { export interface ObserverRuntimeProfileInit extends Omit {} -export type ObserverRuntimeConfigInput = - | ObserverRuntimeConfig - | ObserverRuntimeConfigInit; +export type ObserverRuntimeConfigInput = ObserverRuntimeConfig | ObserverRuntimeConfigInit; export interface ObserverRuntimeEnvironmentOptions { readonly includeDefaults?: boolean; } export type ObserverRuntimeEnvironmentVariable = - | EnvironmentVariable< - typeof runtimeDeliveryProfileEnvVarName, - RuntimeDeliveryProfileEnvValue - > + | EnvironmentVariable | EnvironmentVariable | EnvironmentVariable< typeof providerStreamCapabilityPolicyEnvVarName, ProviderStreamCapabilityPolicyEnvValue > - | EnvironmentVariable< - typeof providerStreamAllowEofEnvVarName, - RuntimeBooleanEnvValue - > - | EnvironmentVariable< - typeof derivedStateCheckpointIntervalEnvVarName, - NonNegativeIntegerEnvValue - > - | EnvironmentVariable< - typeof derivedStateRecoveryIntervalEnvVarName, - NonNegativeIntegerEnvValue - > + | EnvironmentVariable + | EnvironmentVariable + | EnvironmentVariable | EnvironmentVariable< typeof derivedStateReplayBackendEnvVarName, DerivedStateReplayBackendEnvValue @@ -123,14 +109,8 @@ export type ObserverRuntimeEnvironmentVariable = typeof derivedStateReplayDurabilityEnvVarName, DerivedStateReplayDurabilityEnvValue > - | EnvironmentVariable< - typeof derivedStateReplayMaxEnvelopesEnvVarName, - NonNegativeIntegerEnvValue - > - | EnvironmentVariable< - typeof derivedStateReplayMaxSessionsEnvVarName, - NonNegativeIntegerEnvValue - >; + | EnvironmentVariable + | EnvironmentVariable; export type ObserverRuntimeValidationError = | ValidationError @@ -141,18 +121,12 @@ export type ObserverRuntimeValidationError = | ValidationError | DerivedStateValidationError; -function throwObserverRuntimeValidationError( - error: ObserverRuntimeValidationError, -): never { +function throwObserverRuntimeValidationError(error: ObserverRuntimeValidationError): never { throw new RangeError(error.message); } function requireBoolean(field: string, value: boolean): boolean { - const validated = validateRuntimeBooleanInput( - value, - providerStreamAllowEofEnvVarName, - field, - ); + const validated = validateRuntimeBooleanInput(value, providerStreamAllowEofEnvVarName, field); if (isErr(validated)) { throw new TypeError(validated.error.message); } @@ -202,10 +176,7 @@ function shouldIncludeValue( function applyRuntimeProfileDerivedStateDefaults( profile: RuntimeDeliveryProfile, init: ObserverRuntimeProfileInit, -): Result< - ObserverRuntimeConfigInit, - ValidationError -> { +): Result> { const derivedStateDefaults = tryRuntimeDeliveryProfileEnvDefaults(profile); if (isErr(derivedStateDefaults)) { return derivedStateDefaults; @@ -213,9 +184,7 @@ function applyRuntimeProfileDerivedStateDefaults( const derivedState = init.derivedState; const replay = - derivedState instanceof DerivedStateRuntimeConfig - ? derivedState.replay - : derivedState?.replay; + derivedState instanceof DerivedStateRuntimeConfig ? derivedState.replay : derivedState?.replay; return ok({ ...init, @@ -255,18 +224,14 @@ export function tryObserverRuntimeConfig( return runtimeDeliveryProfile; } - const shredTrustMode = validateShredTrustMode( - init.shredTrustMode ?? defaultShredTrustMode, - ); + const shredTrustMode = validateShredTrustMode(init.shredTrustMode ?? defaultShredTrustMode); if (isErr(shredTrustMode)) { return shredTrustMode; } - const providerStreamCapabilityPolicy = - validateProviderStreamCapabilityPolicy( - init.providerStreamCapabilityPolicy ?? - defaultProviderStreamCapabilityPolicy, - ); + const providerStreamCapabilityPolicy = validateProviderStreamCapabilityPolicy( + init.providerStreamCapabilityPolicy ?? defaultProviderStreamCapabilityPolicy, + ); if (isErr(providerStreamCapabilityPolicy)) { return providerStreamCapabilityPolicy; } @@ -300,10 +265,7 @@ export function tryObserverRuntimeConfigForProfile( profile: RuntimeDeliveryProfile, init: ObserverRuntimeProfileInit = {}, ): Result { - const withProfileDefaults = applyRuntimeProfileDerivedStateDefaults( - profile, - init, - ); + const withProfileDefaults = applyRuntimeProfileDerivedStateDefaults(profile, init); if (isErr(withProfileDefaults)) { return withProfileDefaults; } @@ -334,9 +296,7 @@ export function observerRuntimeConfigForProfile( return result.value; } -export function createRuntimeConfig( - init: ObserverRuntimeConfigInput = {}, -): ObserverRuntimeConfig { +export function createRuntimeConfig(init: ObserverRuntimeConfigInput = {}): ObserverRuntimeConfig { return observerRuntimeConfig(init); } @@ -376,10 +336,7 @@ export function serializeRuntimeConfig( export function trySerializeRuntimeConfig( init: ObserverRuntimeConfigInput = {}, options: ObserverRuntimeEnvironmentOptions = {}, -): Result< - readonly ObserverRuntimeEnvironmentVariable[], - ObserverRuntimeValidationError -> { +): Result { const config = tryObserverRuntimeConfig(init); if (isErr(config)) { return config; @@ -421,11 +378,9 @@ export class ObserverRuntimeConfig { this.shredTrustMode = requireObserverShredTrustMode( init.shredTrustMode ?? defaultShredTrustMode, ); - this.providerStreamCapabilityPolicy = - requireObserverProviderStreamCapabilityPolicy( - init.providerStreamCapabilityPolicy ?? - defaultProviderStreamCapabilityPolicy, - ); + this.providerStreamCapabilityPolicy = requireObserverProviderStreamCapabilityPolicy( + init.providerStreamCapabilityPolicy ?? defaultProviderStreamCapabilityPolicy, + ); this.providerStreamAllowEof = requireBoolean( "providerStreamAllowEof", init.providerStreamAllowEof ?? defaultProviderStreamAllowEof, @@ -457,53 +412,34 @@ export class ObserverRuntimeConfig { return tryObserverRuntimeConfigForProfile(profile, init); } - static latencyOptimized( - init: ObserverRuntimeProfileInit = {}, - ): ObserverRuntimeConfig { - return observerRuntimeConfigForProfile( - RuntimeDeliveryProfile.LatencyOptimized, - init, - ); + static latencyOptimized(init: ObserverRuntimeProfileInit = {}): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile(RuntimeDeliveryProfile.LatencyOptimized, init); } static balanced(init: ObserverRuntimeProfileInit = {}): ObserverRuntimeConfig { return observerRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, init); } - static deliveryDisciplined( - init: ObserverRuntimeProfileInit = {}, - ): ObserverRuntimeConfig { - return observerRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, - init, - ); + static deliveryDisciplined(init: ObserverRuntimeProfileInit = {}): ObserverRuntimeConfig { + return observerRuntimeConfigForProfile(RuntimeDeliveryProfile.DeliveryDisciplined, init); } static tryLatencyOptimized( init: ObserverRuntimeProfileInit = {}, ): Result { - return tryObserverRuntimeConfigForProfile( - RuntimeDeliveryProfile.LatencyOptimized, - init, - ); + return tryObserverRuntimeConfigForProfile(RuntimeDeliveryProfile.LatencyOptimized, init); } static tryBalanced( init: ObserverRuntimeProfileInit = {}, ): Result { - return tryObserverRuntimeConfigForProfile( - RuntimeDeliveryProfile.Balanced, - init, - ); + return tryObserverRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, init); } static tryDeliveryDisciplined( init: ObserverRuntimeProfileInit = {}, ): Result { - return tryObserverRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, - init, - ); + return tryObserverRuntimeConfigForProfile(RuntimeDeliveryProfile.DeliveryDisciplined, init); } toEnvironment( @@ -511,13 +447,7 @@ export class ObserverRuntimeConfig { ): readonly ObserverRuntimeEnvironmentVariable[] { const environment: ObserverRuntimeEnvironmentVariable[] = []; - if ( - shouldIncludeValue( - this.runtimeDeliveryProfile, - defaultRuntimeDeliveryProfile, - options, - ) - ) { + if (shouldIncludeValue(this.runtimeDeliveryProfile, defaultRuntimeDeliveryProfile, options)) { environment.push( environmentVariable( runtimeDeliveryProfileEnvVarName, @@ -526,9 +456,7 @@ export class ObserverRuntimeConfig { ); } - if ( - shouldIncludeValue(this.shredTrustMode, defaultShredTrustMode, options) - ) { + if (shouldIncludeValue(this.shredTrustMode, defaultShredTrustMode, options)) { environment.push( environmentVariable( shredTrustModeEnvVarName, @@ -547,20 +475,12 @@ export class ObserverRuntimeConfig { environment.push( environmentVariable( providerStreamCapabilityPolicyEnvVarName, - providerStreamCapabilityPolicyToEnvValue( - this.providerStreamCapabilityPolicy, - ), + providerStreamCapabilityPolicyToEnvValue(this.providerStreamCapabilityPolicy), ), ); } - if ( - shouldIncludeValue( - this.providerStreamAllowEof, - defaultProviderStreamAllowEof, - options, - ) - ) { + if (shouldIncludeValue(this.providerStreamAllowEof, defaultProviderStreamAllowEof, options)) { environment.push( environmentVariable( providerStreamAllowEofEnvVarName, @@ -639,9 +559,7 @@ export class ObserverRuntimeConfig { environment.push( environmentVariable( derivedStateReplayDurabilityEnvVarName, - derivedStateReplayDurabilityToEnvValue( - this.derivedState.replay.durability, - ), + derivedStateReplayDurabilityToEnvValue(this.derivedState.replay.durability), ), ); } @@ -688,19 +606,13 @@ export class ObserverRuntimeConfig { static fromEnvironment( env: EnvironmentInput, ): Result { - const runtimeDeliveryProfile = readEnvironmentVariable( - env, - runtimeDeliveryProfileEnvVarName, - ); + const runtimeDeliveryProfile = readEnvironmentVariable(env, runtimeDeliveryProfileEnvVarName); const shredTrustMode = readEnvironmentVariable(env, shredTrustModeEnvVarName); const providerStreamCapabilityPolicy = readEnvironmentVariable( env, providerStreamCapabilityPolicyEnvVarName, ); - const providerStreamAllowEof = readEnvironmentVariable( - env, - providerStreamAllowEofEnvVarName, - ); + const providerStreamAllowEof = readEnvironmentVariable(env, providerStreamAllowEofEnvVarName); const derivedStateCheckpointInterval = readEnvironmentVariable( env, derivedStateCheckpointIntervalEnvVarName, @@ -713,10 +625,7 @@ export class ObserverRuntimeConfig { env, derivedStateReplayBackendEnvVarName, ); - const derivedStateReplayDir = readEnvironmentVariable( - env, - derivedStateReplayDirEnvVarName, - ); + const derivedStateReplayDir = readEnvironmentVariable(env, derivedStateReplayDirEnvVarName); const derivedStateReplayDurability = readEnvironmentVariable( env, derivedStateReplayDurabilityEnvVarName, @@ -731,10 +640,7 @@ export class ObserverRuntimeConfig { ); let parsedRuntimeDeliveryProfile = defaultRuntimeDeliveryProfile; - if ( - runtimeDeliveryProfile !== undefined && - runtimeDeliveryProfile.trim() !== "" - ) { + if (runtimeDeliveryProfile !== undefined && runtimeDeliveryProfile.trim() !== "") { const parsed = parseRuntimeDeliveryProfile(runtimeDeliveryProfile); if (isErr(parsed)) { return parsed; @@ -751,15 +657,12 @@ export class ObserverRuntimeConfig { parsedShredTrustMode = parsed.value; } - let parsedProviderStreamCapabilityPolicy = - defaultProviderStreamCapabilityPolicy; + let parsedProviderStreamCapabilityPolicy = defaultProviderStreamCapabilityPolicy; if ( providerStreamCapabilityPolicy !== undefined && providerStreamCapabilityPolicy.trim() !== "" ) { - const parsed = parseProviderStreamCapabilityPolicy( - providerStreamCapabilityPolicy, - ); + const parsed = parseProviderStreamCapabilityPolicy(providerStreamCapabilityPolicy); if (isErr(parsed)) { return parsed; } @@ -767,14 +670,8 @@ export class ObserverRuntimeConfig { } let parsedProviderStreamAllowEof = defaultProviderStreamAllowEof; - if ( - providerStreamAllowEof !== undefined && - providerStreamAllowEof.trim() !== "" - ) { - const parsed = parseRuntimeBoolean( - providerStreamAllowEof, - providerStreamAllowEofEnvVarName, - ); + if (providerStreamAllowEof !== undefined && providerStreamAllowEof.trim() !== "") { + const parsed = parseRuntimeBoolean(providerStreamAllowEof, providerStreamAllowEofEnvVarName); if (isErr(parsed)) { return parsed; } @@ -797,10 +694,7 @@ export class ObserverRuntimeConfig { } let parsedRecoveryIntervalMs = defaultDerivedStateRecoveryIntervalMs; - if ( - derivedStateRecoveryInterval !== undefined && - derivedStateRecoveryInterval.trim() !== "" - ) { + if (derivedStateRecoveryInterval !== undefined && derivedStateRecoveryInterval.trim() !== "") { const parsed = parseNonNegativeInteger( derivedStateRecoveryInterval, derivedStateRecoveryIntervalEnvVarName, @@ -812,10 +706,7 @@ export class ObserverRuntimeConfig { } let parsedDerivedStateReplayBackend = defaultDerivedStateReplayBackend; - if ( - derivedStateReplayBackend !== undefined && - derivedStateReplayBackend.trim() !== "" - ) { + if (derivedStateReplayBackend !== undefined && derivedStateReplayBackend.trim() !== "") { const parsed = parseDerivedStateReplayBackend(derivedStateReplayBackend); if (isErr(parsed)) { return parsed; @@ -825,9 +716,7 @@ export class ObserverRuntimeConfig { let parsedDerivedStateReplayDirectory = defaultDerivedStateReplayDirectory; if (derivedStateReplayDir !== undefined && derivedStateReplayDir.trim() !== "") { - const parsed = parseDerivedStateReplayDirectory( - derivedStateReplayDir, - ); + const parsed = parseDerivedStateReplayDirectory(derivedStateReplayDir); if (isErr(parsed)) { return parsed; } @@ -835,21 +724,15 @@ export class ObserverRuntimeConfig { } let parsedDerivedStateReplayDurability = defaultDerivedStateReplayDurability; - if ( - derivedStateReplayDurability !== undefined && - derivedStateReplayDurability.trim() !== "" - ) { - const parsed = parseDerivedStateReplayDurability( - derivedStateReplayDurability, - ); + if (derivedStateReplayDurability !== undefined && derivedStateReplayDurability.trim() !== "") { + const parsed = parseDerivedStateReplayDurability(derivedStateReplayDurability); if (isErr(parsed)) { return parsed; } parsedDerivedStateReplayDurability = parsed.value; } - let parsedDerivedStateReplayMaxEnvelopes = - defaultDerivedStateReplayMaxEnvelopes; + let parsedDerivedStateReplayMaxEnvelopes = defaultDerivedStateReplayMaxEnvelopes; if ( derivedStateReplayMaxEnvelopes !== undefined && derivedStateReplayMaxEnvelopes.trim() !== "" @@ -864,8 +747,7 @@ export class ObserverRuntimeConfig { parsedDerivedStateReplayMaxEnvelopes = parsed.value; } - let parsedDerivedStateReplayMaxSessions = - defaultDerivedStateReplayMaxSessions; + let parsedDerivedStateReplayMaxSessions = defaultDerivedStateReplayMaxSessions; if ( derivedStateReplayMaxSessions !== undefined && derivedStateReplayMaxSessions.trim() !== "" diff --git a/sdks/typescript/src/runtime/runtime-delivery-profile.ts b/sdks/typescript/src/runtime/runtime-delivery-profile.ts index 94aca5fa..26f58fcc 100644 --- a/sdks/typescript/src/runtime/runtime-delivery-profile.ts +++ b/sdks/typescript/src/runtime/runtime-delivery-profile.ts @@ -13,13 +13,9 @@ export enum RuntimeDeliveryProfile { DeliveryDisciplined = 3, } -export const defaultRuntimeDeliveryProfile = - RuntimeDeliveryProfile.LatencyOptimized; +export const defaultRuntimeDeliveryProfile = RuntimeDeliveryProfile.LatencyOptimized; -export type RuntimeDeliveryProfileEnvValue = Brand< - string, - "RuntimeDeliveryProfileEnvValue" ->; +export type RuntimeDeliveryProfileEnvValue = Brand; function asRuntimeDeliveryProfileEnvValue( value: Value, @@ -27,24 +23,19 @@ function asRuntimeDeliveryProfileEnvValue( return brand(value); } -export const runtimeDeliveryProfileEnvVarName = envVarName( - "SOF_RUNTIME_DELIVERY_PROFILE", -); +export const runtimeDeliveryProfileEnvVarName = envVarName("SOF_RUNTIME_DELIVERY_PROFILE"); export const runtimeDeliveryProfileEnvValues = { latencyOptimized: asRuntimeDeliveryProfileEnvValue("latency_optimized"), balanced: asRuntimeDeliveryProfileEnvValue("balanced"), - deliveryDisciplined: asRuntimeDeliveryProfileEnvValue( - "delivery_disciplined", - ), + deliveryDisciplined: asRuntimeDeliveryProfileEnvValue("delivery_disciplined"), } as const; -export const runtimeDeliveryProfileAllowedValues: readonly RuntimeDeliveryProfileEnvValue[] = - [ - runtimeDeliveryProfileEnvValues.latencyOptimized, - runtimeDeliveryProfileEnvValues.balanced, - runtimeDeliveryProfileEnvValues.deliveryDisciplined, - ]; +export const runtimeDeliveryProfileAllowedValues: readonly RuntimeDeliveryProfileEnvValue[] = [ + runtimeDeliveryProfileEnvValues.latencyOptimized, + runtimeDeliveryProfileEnvValues.balanced, + runtimeDeliveryProfileEnvValues.deliveryDisciplined, +]; export interface RuntimeDeliveryProfileEnvDefaults { readonly derivedStateReplayMaxEnvelopes: number; @@ -66,10 +57,7 @@ export function isRuntimeDeliveryProfile( export function validateRuntimeDeliveryProfile( value: RuntimeDeliveryProfile, -): Result< - RuntimeDeliveryProfile, - ValidationError -> { +): Result> { if (!isRuntimeDeliveryProfile(value)) { return err({ kind: ValidationErrorKind.InvalidRuntimeDeliveryProfile, @@ -86,10 +74,7 @@ export function validateRuntimeDeliveryProfile( export function tryRuntimeDeliveryProfileToEnvValue( profile: RuntimeDeliveryProfile, -): Result< - RuntimeDeliveryProfileEnvValue, - ValidationError -> { +): Result> { const validated = validateRuntimeDeliveryProfile(profile); if (isErr(validated)) { return validated; @@ -127,10 +112,7 @@ export function runtimeDeliveryProfileToEnvValue( export function tryRuntimeDeliveryProfileEnvDefaults( profile: RuntimeDeliveryProfile, -): Result< - RuntimeDeliveryProfileEnvDefaults, - ValidationError -> { +): Result> { const validated = validateRuntimeDeliveryProfile(profile); if (isErr(validated)) { return validated; @@ -144,17 +126,13 @@ export function tryRuntimeDeliveryProfileEnvDefaults( }); case RuntimeDeliveryProfile.Balanced: return ok({ - derivedStateReplayMaxEnvelopes: - defaultDerivedStateReplayMaxEnvelopes * 2, - derivedStateReplayMaxSessions: - defaultDerivedStateReplayMaxSessions + 2, + derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes * 2, + derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions + 2, }); case RuntimeDeliveryProfile.DeliveryDisciplined: return ok({ - derivedStateReplayMaxEnvelopes: - defaultDerivedStateReplayMaxEnvelopes * 4, - derivedStateReplayMaxSessions: - defaultDerivedStateReplayMaxSessions * 2, + derivedStateReplayMaxEnvelopes: defaultDerivedStateReplayMaxEnvelopes * 4, + derivedStateReplayMaxSessions: defaultDerivedStateReplayMaxSessions * 2, }); } @@ -181,10 +159,7 @@ export function runtimeDeliveryProfileEnvDefaults( export function parseRuntimeDeliveryProfile( input: string, -): Result< - RuntimeDeliveryProfile, - ValidationError -> { +): Result> { const normalized = input.trim().toLowerCase().replaceAll("-", "_"); switch (normalized) { diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts new file mode 100644 index 00000000..bd7746c6 --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { isErr, isOk, ok } from "../result.js"; +import { + ExtensionCapability, + RuntimeExtensionWorkerHostMessageTag, + RuntimeExtensionWorkerResponseTag, + extensionName, + runtimeExtensionAck, + runRuntimeExtensionWorkerStdio, + serializeRuntimeExtensionWorkerHostMessageWire, + serializeRuntimePacketEventWire, + socketAddress, + tryParseRuntimeExtensionWorkerHostMessageWire, + tryParseRuntimePacketEventWire, + tryCreateRuntimeExtensionWorkerManifest, + tryDefineRuntimeExtension, +} from "../runtime.js"; + +test("runtime extension wire helpers round-trip packet delivery messages", () => { + const parsedExtensionName = extensionName("wire-demo"); + const localAddress = socketAddress("127.0.0.1:21011"); + + assert.equal(isOk(parsedExtensionName), true); + assert.equal(isOk(localAddress), true); + if (!isOk(parsedExtensionName) || !isOk(localAddress)) { + return; + } + + const wireMessage = serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + ownerExtension: parsedExtensionName.value, + localAddress: localAddress.value, + }, + bytes: Uint8Array.from([1, 2, 3]), + observedUnixMs: 42, + }, + }); + + const parsedWireMessage = tryParseRuntimeExtensionWorkerHostMessageWire(wireMessage); + assert.equal(isOk(parsedWireMessage), true); + if (!isOk(parsedWireMessage)) { + return; + } + + assert.equal(parsedWireMessage.value.tag, RuntimeExtensionWorkerHostMessageTag.DeliverPacket); + if (parsedWireMessage.value.tag !== RuntimeExtensionWorkerHostMessageTag.DeliverPacket) { + return; + } + + assert.deepEqual(Array.from(parsedWireMessage.value.event.bytes), [1, 2, 3]); + assert.equal(parsedWireMessage.value.event.source.localAddress, localAddress.value); + + const parsedEvent = tryParseRuntimePacketEventWire( + serializeRuntimePacketEventWire(parsedWireMessage.value.event), + ); + assert.equal(isOk(parsedEvent), true); +}); + +test("runtime extension stdio worker processes newline-delimited protocol messages", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); + let outputText = ""; + let errorText = ""; + + output.setEncoding("utf8"); + errorOutput.setEncoding("utf8"); + output.on("data", (chunk: string) => { + outputText += chunk; + }); + errorOutput.on("data", (chunk: string) => { + errorText += chunk; + }); + + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "stdio-demo", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + assert.equal(isOk(manifest), true); + if (!isOk(manifest)) { + return; + } + + const definition = tryDefineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: () => ok(runtimeExtensionAck()), + onShutdown: () => ok(runtimeExtensionAck()), + }); + assert.equal(isOk(definition), true); + if (!isOk(definition)) { + return; + } + + const runner = runRuntimeExtensionWorkerStdio(definition.value, { + input, + output, + error: errorOutput, + }); + + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.GetManifest, + }), + )}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: manifest.value.extensionName, + }, + }), + )}\n`, + ); + input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + }, + bytes: [1, 2, 3, 4], + observedUnixMs: 100, + }, + })}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: manifest.value.extensionName, + }, + }), + )}\n`, + ); + input.end(); + + const runnerResult = await runner; + assert.equal(isOk(runnerResult), true); + assert.equal(errorText, ""); + + const responses = outputText + .trim() + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as { tag: number }); + + assert.equal(responses.length, 4); + assert.equal(responses[0]?.tag, RuntimeExtensionWorkerResponseTag.Manifest); + assert.equal(responses[1]?.tag, RuntimeExtensionWorkerResponseTag.Started); + assert.equal(responses[2]?.tag, RuntimeExtensionWorkerResponseTag.EventHandled); + assert.equal(responses[3]?.tag, RuntimeExtensionWorkerResponseTag.ShutdownComplete); +}); + +test("runtime extension stdio worker rejects malformed protocol messages", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); + let errorText = ""; + + errorOutput.setEncoding("utf8"); + errorOutput.on("data", (chunk: string) => { + errorText += chunk; + }); + + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "bad-wire-demo", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + assert.equal(isOk(manifest), true); + if (!isOk(manifest)) { + return; + } + + const definition = tryDefineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: () => ok(runtimeExtensionAck()), + onShutdown: () => ok(runtimeExtensionAck()), + }); + assert.equal(isOk(definition), true); + if (!isOk(definition)) { + return; + } + + const runner = runRuntimeExtensionWorkerStdio(definition.value, { + input, + output, + error: errorOutput, + }); + + input.write('{"tag":3,"event":{"source":{"kind":99},"bytes":[1],"observedUnixMs":1}}\n'); + input.end(); + + const runnerResult = await runner; + assert.equal(isErr(runnerResult), true); + assert.match(errorText, /event\.source\.kind/); +}); diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.ts new file mode 100644 index 00000000..bfde2073 --- /dev/null +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.ts @@ -0,0 +1,670 @@ +import { once } from "node:events"; +import { createInterface } from "node:readline"; + +import { ResultTag, err, isErr, ok, type Result } from "../result.js"; +import { + RuntimeExtensionErrorKind, + RuntimeExtensionWorkerHostMessageTag, + type ExtensionContext, + type ExtensionName, + type ExtensionResourceId, + type SharedStreamTag, + type SocketAddress, + type RuntimeExtensionAck, + type RuntimeExtensionDefinition, + type RuntimeExtensionError, + type RuntimeExtensionWorkerHostMessage, + type RuntimeExtensionWorkerResponse, + type RuntimePacketEvent, + type RuntimePacketEventClass, + type RuntimePacketSource, + type RuntimePacketSourceKind, + type RuntimePacketTransport, + type RuntimeWebSocketFrameType, + extensionName, + extensionResourceId, + runtimeExtensionAck, + socketAddress, + sharedStreamTag, + tryCreateRuntimeExtensionWorkerRuntime, +} from "./runtime-extension.js"; + +export * from "./runtime-extension.js"; + +const runtimePacketSourceKinds = [1, 2] as const satisfies readonly RuntimePacketSourceKind[]; +const runtimePacketTransports = [1, 2, 3] as const satisfies readonly RuntimePacketTransport[]; +const runtimePacketEventClasses = [1, 2] as const satisfies readonly RuntimePacketEventClass[]; +const runtimeWebSocketFrameTypes = [ + 1, 2, 3, 4, +] as const satisfies readonly RuntimeWebSocketFrameType[]; +const maxPacketByteValue = 255; + +type JsonRecord = Record; + +export interface RuntimePacketEventWire { + readonly source: RuntimePacketSource; + readonly bytes: readonly number[]; + readonly observedUnixMs: number; +} + +export type RuntimeExtensionWorkerWireHostMessage = + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.GetManifest; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.Start; + readonly context: ExtensionContext; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket; + readonly event: RuntimePacketEventWire; + } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.Shutdown; + readonly context: ExtensionContext; + }; + +export type RuntimeExtensionWorkerWireResponse = RuntimeExtensionWorkerResponse; + +export interface RuntimeExtensionWorkerStdioOptions { + readonly input?: NodeJS.ReadableStream; + readonly output?: NodeJS.WritableStream; + readonly error?: NodeJS.WritableStream; +} + +function runtimeExtensionProtocolError( + field: string, + message: string, + received?: string, + cause?: string, +): RuntimeExtensionError { + const error: RuntimeExtensionError = { + kind: RuntimeExtensionErrorKind.ProtocolError, + field, + message, + }; + + if (received !== undefined) { + return { + ...error, + received, + ...(cause === undefined ? {} : { cause }), + }; + } + + if (cause !== undefined) { + return { + ...error, + cause, + }; + } + + return error; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonRecord(value: unknown, field: string): Result { + if (!isJsonRecord(value)) { + return err( + runtimeExtensionProtocolError(field, `${field} must be a JSON object`, JSON.stringify(value)), + ); + } + + return ok(value); +} + +function okMissing(): Result { + return { + tag: ResultTag.Ok, + value: void 0, + }; +} + +function parseOptionalString( + value: unknown, + field: string, +): Result { + if (value === undefined) { + return okMissing(); + } + if (typeof value !== "string") { + return err( + runtimeExtensionProtocolError(field, `${field} must be a string`, JSON.stringify(value)), + ); + } + + return ok(value); +} + +function parseNumericEnumValue( + value: unknown, + field: string, + allowed: readonly number[], +): Result { + if (typeof value !== "number" || !Number.isInteger(value) || !allowed.includes(value)) { + return err( + runtimeExtensionProtocolError( + field, + `${field} must be one of ${allowed.join(", ")}`, + JSON.stringify(value), + ), + ); + } + + return ok(value); +} + +function parseRuntimeExtensionWorkerHostMessageTag( + value: unknown, +): Result { + const parsed = parseNumericEnumValue(value, "message.tag", [ + RuntimeExtensionWorkerHostMessageTag.GetManifest, + RuntimeExtensionWorkerHostMessageTag.Start, + RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + RuntimeExtensionWorkerHostMessageTag.Shutdown, + ]); + if (isErr(parsed)) { + return parsed; + } + + if (parsed.value === 1) { + return ok(RuntimeExtensionWorkerHostMessageTag.GetManifest); + } + if (parsed.value === 2) { + return ok(RuntimeExtensionWorkerHostMessageTag.Start); + } + if (parsed.value === 3) { + return ok(RuntimeExtensionWorkerHostMessageTag.DeliverPacket); + } + if (parsed.value === 4) { + return ok(RuntimeExtensionWorkerHostMessageTag.Shutdown); + } + + return err( + runtimeExtensionProtocolError( + "message.tag", + "message.tag must be a supported runtime extension worker message tag", + JSON.stringify(parsed.value), + ), + ); +} + +function parseContext(value: unknown): Result { + const contextRecord = parseJsonRecord(value, "context"); + if (isErr(contextRecord)) { + return contextRecord; + } + + const parsedName = parseOptionalString( + contextRecord.value.extensionName, + "context.extensionName", + ); + if (isErr(parsedName) || parsedName.value === undefined) { + return isErr(parsedName) + ? parsedName + : err( + runtimeExtensionProtocolError( + "context.extensionName", + "context.extensionName must be provided", + ), + ); + } + + const extension = extensionName(parsedName.value); + if (isErr(extension)) { + return err({ + ...extension.error, + field: "context.extensionName", + }); + } + + return ok({ + extensionName: extension.value, + }); +} + +function parseOptionalExtensionName( + value: unknown, + field: string, +): Result { + const parsed = parseOptionalString(value, field); + if (isErr(parsed)) { + return parsed; + } + if (parsed.value === undefined) { + return okMissing(); + } + + const extension = extensionName(parsed.value); + if (isErr(extension)) { + return err({ + ...extension.error, + field, + }); + } + + return ok(extension.value); +} + +function parseOptionalResourceId( + value: unknown, + field: string, +): Result { + const parsed = parseOptionalString(value, field); + if (isErr(parsed)) { + return parsed; + } + if (parsed.value === undefined) { + return okMissing(); + } + + const resourceId = extensionResourceId(parsed.value); + if (isErr(resourceId)) { + return err({ + ...resourceId.error, + field, + }); + } + + return ok(resourceId.value); +} + +function parseOptionalSocketAddress( + value: unknown, + field: string, +): Result { + const parsed = parseOptionalString(value, field); + if (isErr(parsed)) { + return parsed; + } + if (parsed.value === undefined) { + return okMissing(); + } + + const address = socketAddress(parsed.value); + if (isErr(address)) { + return err({ + ...address.error, + field, + }); + } + + return ok(address.value); +} + +function parseOptionalSharedStreamTag( + value: unknown, + field: string, +): Result { + const parsed = parseOptionalString(value, field); + if (isErr(parsed)) { + return parsed; + } + if (parsed.value === undefined) { + return okMissing(); + } + + const tag = sharedStreamTag(parsed.value); + if (isErr(tag)) { + return err({ + ...tag.error, + field, + }); + } + + return ok(tag.value); +} + +function parsePacketBytes(value: unknown): Result { + if (!Array.isArray(value)) { + return err( + runtimeExtensionProtocolError( + "event.bytes", + "event.bytes must be an array of byte values", + JSON.stringify(value), + ), + ); + } + + const bytes = new Uint8Array(value.length); + for (const [index, packetByte] of value.entries()) { + if ( + typeof packetByte !== "number" || + !Number.isInteger(packetByte) || + packetByte < 0 || + packetByte > maxPacketByteValue + ) { + return err( + runtimeExtensionProtocolError( + `event.bytes[${index}]`, + `event.bytes[${index}] must be an integer between 0 and ${maxPacketByteValue}`, + JSON.stringify(packetByte), + ), + ); + } + bytes[index] = packetByte; + } + + return ok(bytes); +} + +function parseRuntimePacketSource( + value: unknown, +): Result { + const sourceRecord = parseJsonRecord(value, "event.source"); + if (isErr(sourceRecord)) { + return sourceRecord; + } + + const kind = parseNumericEnumValue( + sourceRecord.value.kind, + "event.source.kind", + runtimePacketSourceKinds, + ); + if (isErr(kind)) { + return kind; + } + + const transport = parseNumericEnumValue( + sourceRecord.value.transport, + "event.source.transport", + runtimePacketTransports, + ); + if (isErr(transport)) { + return transport; + } + + const eventClass = parseNumericEnumValue( + sourceRecord.value.eventClass, + "event.source.eventClass", + runtimePacketEventClasses, + ); + if (isErr(eventClass)) { + return eventClass; + } + + const ownerExtension = parseOptionalExtensionName( + sourceRecord.value.ownerExtension, + "event.source.ownerExtension", + ); + if (isErr(ownerExtension)) { + return ownerExtension; + } + + const resourceId = parseOptionalResourceId( + sourceRecord.value.resourceId, + "event.source.resourceId", + ); + if (isErr(resourceId)) { + return resourceId; + } + + const sharedTag = parseOptionalSharedStreamTag( + sourceRecord.value.sharedTag, + "event.source.sharedTag", + ); + if (isErr(sharedTag)) { + return sharedTag; + } + + const webSocketFrameType = + sourceRecord.value.webSocketFrameType === undefined + ? okMissing() + : parseNumericEnumValue( + sourceRecord.value.webSocketFrameType, + "event.source.webSocketFrameType", + runtimeWebSocketFrameTypes, + ); + if (isErr(webSocketFrameType)) { + return webSocketFrameType; + } + + const localAddress = parseOptionalSocketAddress( + sourceRecord.value.localAddress, + "event.source.localAddress", + ); + if (isErr(localAddress)) { + return localAddress; + } + + const remoteAddress = parseOptionalSocketAddress( + sourceRecord.value.remoteAddress, + "event.source.remoteAddress", + ); + if (isErr(remoteAddress)) { + return remoteAddress; + } + + return ok({ + kind: kind.value, + transport: transport.value, + eventClass: eventClass.value, + ...(ownerExtension.value === undefined ? {} : { ownerExtension: ownerExtension.value }), + ...(resourceId.value === undefined ? {} : { resourceId: resourceId.value }), + ...(sharedTag.value === undefined ? {} : { sharedTag: sharedTag.value }), + ...(webSocketFrameType.value === undefined + ? {} + : { webSocketFrameType: webSocketFrameType.value }), + ...(localAddress.value === undefined ? {} : { localAddress: localAddress.value }), + ...(remoteAddress.value === undefined ? {} : { remoteAddress: remoteAddress.value }), + }); +} + +export function serializeRuntimePacketEventWire(event: RuntimePacketEvent): RuntimePacketEventWire { + return { + source: event.source, + bytes: Array.from(event.bytes), + observedUnixMs: event.observedUnixMs, + }; +} + +export function tryParseRuntimePacketEventWire( + value: unknown, +): Result { + const eventRecord = parseJsonRecord(value, "event"); + if (isErr(eventRecord)) { + return eventRecord; + } + + const source = parseRuntimePacketSource(eventRecord.value.source); + if (isErr(source)) { + return source; + } + + const bytes = parsePacketBytes(eventRecord.value.bytes); + if (isErr(bytes)) { + return bytes; + } + + if ( + typeof eventRecord.value.observedUnixMs !== "number" || + !Number.isSafeInteger(eventRecord.value.observedUnixMs) || + eventRecord.value.observedUnixMs < 0 + ) { + return err( + runtimeExtensionProtocolError( + "event.observedUnixMs", + "event.observedUnixMs must be a non-negative safe integer", + JSON.stringify(eventRecord.value.observedUnixMs), + ), + ); + } + + return ok({ + source: source.value, + bytes: bytes.value, + observedUnixMs: eventRecord.value.observedUnixMs, + }); +} + +export function serializeRuntimeExtensionWorkerHostMessageWire( + message: RuntimeExtensionWorkerHostMessage, +): RuntimeExtensionWorkerWireHostMessage { + switch (message.tag) { + case RuntimeExtensionWorkerHostMessageTag.GetManifest: + return { tag: RuntimeExtensionWorkerHostMessageTag.GetManifest }; + case RuntimeExtensionWorkerHostMessageTag.Start: + return { + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: message.context, + }; + case RuntimeExtensionWorkerHostMessageTag.DeliverPacket: + return { + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: serializeRuntimePacketEventWire(message.event), + }; + case RuntimeExtensionWorkerHostMessageTag.Shutdown: + return { + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: message.context, + }; + } + + return { + tag: RuntimeExtensionWorkerHostMessageTag.GetManifest, + }; +} + +export function tryParseRuntimeExtensionWorkerHostMessageWire( + value: unknown, +): Result { + const messageRecord = parseJsonRecord(value, "message"); + if (isErr(messageRecord)) { + return messageRecord; + } + + const tag = parseRuntimeExtensionWorkerHostMessageTag(messageRecord.value.tag); + if (isErr(tag)) { + return tag; + } + + switch (tag.value) { + case RuntimeExtensionWorkerHostMessageTag.GetManifest: + return ok({ tag: RuntimeExtensionWorkerHostMessageTag.GetManifest }); + case RuntimeExtensionWorkerHostMessageTag.Start: { + const context = parseContext(messageRecord.value.context); + if (isErr(context)) { + return context; + } + + return ok({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: context.value, + }); + } + case RuntimeExtensionWorkerHostMessageTag.DeliverPacket: { + const event = tryParseRuntimePacketEventWire(messageRecord.value.event); + if (isErr(event)) { + return event; + } + + return ok({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: event.value, + }); + } + case RuntimeExtensionWorkerHostMessageTag.Shutdown: { + const context = parseContext(messageRecord.value.context); + if (isErr(context)) { + return context; + } + + return ok({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: context.value, + }); + } + } + + return err( + runtimeExtensionProtocolError( + "message.tag", + "message.tag must be a supported runtime extension worker message tag", + JSON.stringify(tag.value), + ), + ); +} + +export function serializeRuntimeExtensionWorkerResponseWire( + response: RuntimeExtensionWorkerResponse, +): RuntimeExtensionWorkerWireResponse { + return response; +} + +async function writeText(output: NodeJS.WritableStream, text: string): Promise { + if (output.write(text)) { + return; + } + + await once(output, "drain"); +} + +export async function runRuntimeExtensionWorkerStdio( + definition: RuntimeExtensionDefinition, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise> { + const runtime = tryCreateRuntimeExtensionWorkerRuntime(definition); + if (isErr(runtime)) { + return runtime; + } + + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const errorOutput = options.error ?? process.stderr; + const lineReader = createInterface({ + input, + crlfDelay: Infinity, + }); + + try { + for await (const line of lineReader) { + const trimmed = line.trim(); + if (trimmed === "") { + continue; + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(trimmed); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + return err( + runtimeExtensionProtocolError( + "message", + "worker stdin line must be valid JSON", + trimmed, + cause, + ), + ); + } + + const message = tryParseRuntimeExtensionWorkerHostMessageWire(parsedJson); + if (isErr(message)) { + await writeText(errorOutput, `${message.error.message}\n`); + return message; + } + + const response = await runtime.value.handleMessage(message.value); + await writeText( + output, + `${JSON.stringify(serializeRuntimeExtensionWorkerResponseWire(response))}\n`, + ); + + if (message.value.tag === RuntimeExtensionWorkerHostMessageTag.Shutdown) { + return ok(runtimeExtensionAck()); + } + } + } finally { + lineReader.close(); + } + + return err( + runtimeExtensionProtocolError( + "message", + "worker stdin closed before a shutdown message was received", + ), + ); +} diff --git a/sdks/typescript/src/runtime/runtime-extension.test.ts b/sdks/typescript/src/runtime/runtime-extension.test.ts index 8dcf833e..4a56f5e6 100644 --- a/sdks/typescript/src/runtime/runtime-extension.test.ts +++ b/sdks/typescript/src/runtime/runtime-extension.test.ts @@ -206,10 +206,7 @@ test("runtime extension worker runtime handles manifest lifecycle and exceptions assert.equal(isOk(shutdown.result), true); if (isErr(delivered.result)) { - assert.equal( - delivered.result.error.kind, - RuntimeExtensionErrorKind.UnhandledException, - ); + assert.equal(delivered.result.error.kind, RuntimeExtensionErrorKind.UnhandledException); assert.equal(delivered.result.error.field, "onPacketReceived"); } }); diff --git a/sdks/typescript/src/runtime/runtime-extension.ts b/sdks/typescript/src/runtime/runtime-extension.ts index e97fbaf1..89aa5a8e 100644 --- a/sdks/typescript/src/runtime/runtime-extension.ts +++ b/sdks/typescript/src/runtime/runtime-extension.ts @@ -167,11 +167,10 @@ export interface RuntimeExtensionProtocolVersion { readonly minor: number; } -export const defaultRuntimeExtensionProtocolVersion: RuntimeExtensionProtocolVersion = - { - major: 1, - minor: 0, - }; +export const defaultRuntimeExtensionProtocolVersion: RuntimeExtensionProtocolVersion = { + major: 1, + minor: 0, +}; export interface RuntimeExtensionWorkerManifest { readonly protocolVersion: RuntimeExtensionProtocolVersion; @@ -232,10 +231,7 @@ export enum RuntimeExtensionWorkerResponseTag { export type RuntimeExtensionWorkerResponse = | { readonly tag: RuntimeExtensionWorkerResponseTag.Manifest; - readonly result: Result< - RuntimeExtensionWorkerManifest, - RuntimeExtensionError - >; + readonly result: Result; } | { readonly tag: RuntimeExtensionWorkerResponseTag.Started; @@ -298,9 +294,7 @@ function asExtensionName(value: Value): ExtensionNam return brand(value); } -function asExtensionResourceId( - value: Value, -): ExtensionResourceId { +function asExtensionResourceId(value: Value): ExtensionResourceId { return brand(value); } @@ -356,29 +350,20 @@ export function extensionResourceId( return parseNonEmptyValueObject(value, "resourceId", asExtensionResourceId); } -export function sharedStreamTag( - value: string, -): Result { +export function sharedStreamTag(value: string): Result { return parseNonEmptyValueObject(value, "sharedTag", asSharedStreamTag); } -export function socketAddress( - value: string, -): Result { +export function socketAddress(value: string): Result { return parseNonEmptyValueObject(value, "socketAddress", asSocketAddress); } -export function webSocketUrl( - value: string, -): Result { +export function webSocketUrl(value: string): Result { const parsed = parseNonEmptyValueObject(value, "url", asWebSocketUrl); if (isErr(parsed)) { return parsed; } - if ( - !parsed.value.startsWith("ws://") && - !parsed.value.startsWith("wss://") - ) { + if (!parsed.value.startsWith("ws://") && !parsed.value.startsWith("wss://")) { return err( runtimeExtensionError( RuntimeExtensionErrorKind.ValidationError, @@ -430,9 +415,7 @@ function validatePositiveInteger( return ok(value); } -function validateResourceReadBufferBytes( - value: number, -): Result { +function validateResourceReadBufferBytes(value: number): Result { const parsed = validatePositiveInteger(value, "readBufferBytes"); if (isErr(parsed)) { return parsed; @@ -478,9 +461,7 @@ function validateRuntimeExtensionProtocolVersion( function validateRuntimeExtensionWorkerManifest( manifest: RuntimeExtensionWorkerManifest, ): Result { - const protocolVersion = validateRuntimeExtensionProtocolVersion( - manifest.protocolVersion, - ); + const protocolVersion = validateRuntimeExtensionProtocolVersion(manifest.protocolVersion); if (isErr(protocolVersion)) { return protocolVersion; } @@ -497,19 +478,14 @@ function validateRuntimeExtensionWorkerManifest( ); } - const manifestVersion = validatePositiveInteger( - manifest.manifestVersion, - "manifestVersion", - ); + const manifestVersion = validatePositiveInteger(manifest.manifestVersion, "manifestVersion"); if (isErr(manifestVersion)) { return manifestVersion; } const duplicateResourceIds = new Set(); for (const resource of manifest.manifest.resources) { - const validatedReadBuffer = validateResourceReadBufferBytes( - resource.readBufferBytes, - ); + const validatedReadBuffer = validateResourceReadBufferBytes(resource.readBufferBytes); if (isErr(validatedReadBuffer)) { return validatedReadBuffer; } @@ -561,8 +537,7 @@ export function tryCreateRuntimeExtensionWorkerManifest( } const manifest: RuntimeExtensionWorkerManifest = { - protocolVersion: - init.protocolVersion ?? defaultRuntimeExtensionProtocolVersion, + protocolVersion: init.protocolVersion ?? defaultRuntimeExtensionProtocolVersion, sdkLanguage: SdkLanguage.TypeScript, sdkVersion: init.sdkVersion, manifestVersion: init.manifestVersion ?? 1, @@ -582,16 +557,10 @@ export function packetSubscriptionMatches( subscription: PacketSubscription, event: RuntimePacketEvent, ): boolean { - if ( - subscription.sourceKind !== undefined && - subscription.sourceKind !== event.source.kind - ) { + if (subscription.sourceKind !== undefined && subscription.sourceKind !== event.source.kind) { return false; } - if ( - subscription.transport !== undefined && - subscription.transport !== event.source.transport - ) { + if (subscription.transport !== undefined && subscription.transport !== event.source.transport) { return false; } if ( @@ -608,8 +577,7 @@ export function packetSubscriptionMatches( } if ( subscription.localPort !== undefined && - event.source.localAddress?.split(":").at(-1) !== - String(subscription.localPort) + event.source.localAddress?.split(":").at(-1) !== String(subscription.localPort) ) { return false; } @@ -621,8 +589,7 @@ export function packetSubscriptionMatches( } if ( subscription.remotePort !== undefined && - event.source.remoteAddress?.split(":").at(-1) !== - String(subscription.remotePort) + event.source.remoteAddress?.split(":").at(-1) !== String(subscription.remotePort) ) { return false; } @@ -638,10 +605,7 @@ export function packetSubscriptionMatches( ) { return false; } - if ( - subscription.sharedTag !== undefined && - subscription.sharedTag !== event.source.sharedTag - ) { + if (subscription.sharedTag !== undefined && subscription.sharedTag !== event.source.sharedTag) { return false; } if ( @@ -727,9 +691,9 @@ export class RuntimeExtensionWorkerRuntime { result: this.definition.onReady === undefined ? ok(runtimeExtensionAck()) - : await settleExtensionHook("onReady", () => - this.definition.onReady?.(message.context) ?? - ok(runtimeExtensionAck()), + : await settleExtensionHook( + "onReady", + () => this.definition.onReady?.(message.context) ?? ok(runtimeExtensionAck()), ), }; case RuntimeExtensionWorkerHostMessageTag.DeliverPacket: @@ -738,9 +702,10 @@ export class RuntimeExtensionWorkerRuntime { result: this.definition.onPacketReceived === undefined ? ok(runtimeExtensionAck()) - : await settleExtensionHook("onPacketReceived", () => - this.definition.onPacketReceived?.(message.event) ?? - ok(runtimeExtensionAck()), + : await settleExtensionHook( + "onPacketReceived", + () => + this.definition.onPacketReceived?.(message.event) ?? ok(runtimeExtensionAck()), ), }; case RuntimeExtensionWorkerHostMessageTag.Shutdown: @@ -749,9 +714,9 @@ export class RuntimeExtensionWorkerRuntime { result: this.definition.onShutdown === undefined ? ok(runtimeExtensionAck()) - : await settleExtensionHook("onShutdown", () => - this.definition.onShutdown?.(message.context) ?? - ok(runtimeExtensionAck()), + : await settleExtensionHook( + "onShutdown", + () => this.definition.onShutdown?.(message.context) ?? ok(runtimeExtensionAck()), ), }; } diff --git a/sdks/typescript/src/runtime/runtime-policy.ts b/sdks/typescript/src/runtime/runtime-policy.ts index 0f9d0119..c22daa2a 100644 --- a/sdks/typescript/src/runtime/runtime-policy.ts +++ b/sdks/typescript/src/runtime/runtime-policy.ts @@ -14,8 +14,7 @@ export enum ProviderStreamCapabilityPolicy { } export const defaultShredTrustMode = ShredTrustMode.PublicUntrusted; -export const defaultProviderStreamCapabilityPolicy = - ProviderStreamCapabilityPolicy.Warn; +export const defaultProviderStreamCapabilityPolicy = ProviderStreamCapabilityPolicy.Warn; export const defaultProviderStreamAllowEof = false; export type ShredTrustModeEnvValue = Brand; @@ -47,15 +46,11 @@ export const shredTrustModeEnvVarName = envVarName("SOF_SHRED_TRUST_MODE"); export const providerStreamCapabilityPolicyEnvVarName = envVarName( "SOF_PROVIDER_STREAM_CAPABILITY_POLICY", ); -export const providerStreamAllowEofEnvVarName = envVarName( - "SOF_PROVIDER_STREAM_ALLOW_EOF", -); +export const providerStreamAllowEofEnvVarName = envVarName("SOF_PROVIDER_STREAM_ALLOW_EOF"); export const shredTrustModeEnvValues = { publicUntrusted: asShredTrustModeEnvValue("public_untrusted"), - trustedRawShredProvider: asShredTrustModeEnvValue( - "trusted_raw_shred_provider", - ), + trustedRawShredProvider: asShredTrustModeEnvValue("trusted_raw_shred_provider"), } as const; export const providerStreamCapabilityPolicyEnvValues = { @@ -74,10 +69,7 @@ export const shredTrustModeAllowedValues: readonly ShredTrustModeEnvValue[] = [ ]; export const providerStreamCapabilityPolicyAllowedValues: readonly ProviderStreamCapabilityPolicyEnvValue[] = - [ - providerStreamCapabilityPolicyEnvValues.warn, - providerStreamCapabilityPolicyEnvValues.strict, - ]; + [providerStreamCapabilityPolicyEnvValues.warn, providerStreamCapabilityPolicyEnvValues.strict]; export const runtimeBooleanAllowedValues: readonly RuntimeBooleanEnvValue[] = [ runtimeBooleanEnvValues.true, @@ -114,8 +106,7 @@ export function validateShredTrustMode( kind: ValidationErrorKind.InvalidShredTrustMode, field: shredTrustModeEnvVarName, received: String(value), - message: - "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + message: "shred trust mode must be public_untrusted or trusted_raw_shred_provider", allowedValues: shredTrustModeAllowedValues, }); } @@ -125,10 +116,7 @@ export function validateShredTrustMode( export function validateProviderStreamCapabilityPolicy( value: ProviderStreamCapabilityPolicy, -): Result< - ProviderStreamCapabilityPolicy, - ValidationError -> { +): Result> { if (!isProviderStreamCapabilityPolicy(value)) { return err({ kind: ValidationErrorKind.InvalidProviderStreamCapabilityPolicy, @@ -178,8 +166,7 @@ export function tryShredTrustModeToEnvValue( kind: ValidationErrorKind.InvalidShredTrustMode, field: shredTrustModeEnvVarName, received: String(mode), - message: - "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + message: "shred trust mode must be public_untrusted or trusted_raw_shred_provider", allowedValues: shredTrustModeAllowedValues, }); } @@ -211,9 +198,7 @@ export function tryProviderStreamCapabilityPolicyToEnvValue( }); } -export function shredTrustModeToEnvValue( - mode: ShredTrustMode, -): ShredTrustModeEnvValue { +export function shredTrustModeToEnvValue(mode: ShredTrustMode): ShredTrustModeEnvValue { const result = tryShredTrustModeToEnvValue(mode); if (!isErr(result)) { return result.value; @@ -230,9 +215,7 @@ export function providerStreamCapabilityPolicyToEnvValue( return result.value; } - throw new RangeError( - `unknown provider stream capability policy: ${String(policy)}`, - ); + throw new RangeError(`unknown provider stream capability policy: ${String(policy)}`); } export function runtimeBooleanToEnvValue(value: boolean): RuntimeBooleanEnvValue { @@ -254,8 +237,7 @@ export function parseShredTrustMode( kind: ValidationErrorKind.InvalidShredTrustMode, field: shredTrustModeEnvVarName, received: input, - message: - "shred trust mode must be public_untrusted or trusted_raw_shred_provider", + message: "shred trust mode must be public_untrusted or trusted_raw_shred_provider", allowedValues: shredTrustModeAllowedValues, }); } @@ -263,10 +245,7 @@ export function parseShredTrustMode( export function parseProviderStreamCapabilityPolicy( input: string, -): Result< - ProviderStreamCapabilityPolicy, - ValidationError -> { +): Result> { const normalized = input.trim().toLowerCase(); switch (normalized) { diff --git a/sdks/typescript/tsconfig.lint.json b/sdks/typescript/tsconfig.lint.json new file mode 100644 index 00000000..2914714e --- /dev/null +++ b/sdks/typescript/tsconfig.lint.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "examples/**/*.ts", + "tsdown.config.ts" + ] +} diff --git a/sdks/typescript/tsdown.config.ts b/sdks/typescript/tsdown.config.ts index 84db20d3..a40db89a 100644 --- a/sdks/typescript/tsdown.config.ts +++ b/sdks/typescript/tsdown.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, + deps: { + neverBundle: [/^node:/], + }, entry: { index: "src/index.ts", runtime: "src/runtime.ts", @@ -11,6 +14,7 @@ export default defineConfig({ "runtime/derived-state": "src/runtime/derived-state.ts", "runtime/delivery-profile": "src/runtime/runtime-delivery-profile.ts", "runtime/extension": "src/runtime/runtime-extension.ts", + "runtime/extension-stdio": "src/runtime/runtime-extension-stdio.ts", }, format: ["esm"], minify: true, From b8e08ac26b0a3e5a546e67f9f96f1fe54cae0ff0 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 14:07:27 +0200 Subject: [PATCH 13/25] feat(ts-sdk): add app-first application api --- sdks/typescript/README.md | 287 ++++---- .../examples/runtime-extension-worker.ts | 210 +++--- .../typescript/examples/sof-app-entrypoint.ts | 147 ++++ .../examples/sof-app-launch-spec.ts | 40 ++ sdks/typescript/package.json | 6 +- sdks/typescript/src/app.test.ts | 208 ++++++ sdks/typescript/src/app.ts | 651 ++++++++++++++++++ sdks/typescript/src/index.ts | 1 + sdks/typescript/src/package-exports.test.ts | 39 ++ sdks/typescript/tsdown.config.ts | 1 + 10 files changed, 1328 insertions(+), 262 deletions(-) create mode 100644 sdks/typescript/examples/sof-app-entrypoint.ts create mode 100644 sdks/typescript/examples/sof-app-launch-spec.ts create mode 100644 sdks/typescript/src/app.test.ts create mode 100644 sdks/typescript/src/app.ts diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 642b480d..4de0dad2 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -1,175 +1,158 @@ # `@sof/sdk` -Unified TypeScript SDK surface for SOF. +Unified TypeScript SDK surface for SOF apps and plugins. ## Tooling - Use `pnpm` for this package. -- `pnpm run build` produces minified ESM library output plus `.d.ts` files. -- `pnpm run format:check` verifies Biome formatting for the SDK TS surface. -- `pnpm run lint` runs the `oxlint` production lint profile. -- `pnpm run check` runs lint, typecheck, tests, and package-shape validation. +- `pnpm run build` produces minified ESM output plus `.d.ts` files. +- `pnpm run format:check` verifies Biome formatting. +- `pnpm run lint` runs the type-aware `oxlint` profile. +- `pnpm run check` runs format, lint, typecheck, tests, examples, and package validation. ## Mental Model -- Prefer the functional runtime-config helpers first: `createRuntimeConfigForProfile(...)`, `serializeRuntimeConfigRecord(...)`, and `parseRuntimeConfig(...)`. -- Use `ObserverRuntimeConfig` when you want an explicit config object with instance methods and class-based presets. -- Prefer `tryCreateRuntimeConfig(...)`, `tryCreateRuntimeConfigForProfile(...)`, and `parseRuntimeConfig(...)` when you want validation errors as `Result` values instead of thrown exceptions. -- Use `createRuntimeConfigForProfile(...)` or `ObserverRuntimeConfig.balanced()` / `.deliveryDisciplined()` when you want one-line profile presets. -- Profile presets in this SDK stamp the profile env plus the derived-state replay retention defaults that SOF applies through env-backed setup. -- Rust still owns host-builder dispatch defaults such as plugin-host and runtime-extension-host queue and timeout wiring. This SDK currently models the env/config surface and ships a TS-side extension worker runtime, not the Rust host builders themselves. +- Start with the app-facing APIs: `tryDefinePlugin(...)`, `tryDefineApp(...)`, `tryCreateAppLaunch(...)`, and `runSelectedPlugin(...)`. +- Use runtime-config helpers such as `createRuntimeConfigForProfile(...)`, `serializeRuntimeConfigRecord(...)`, and `parseRuntimeConfig(...)` when you need to build or validate env-backed config. +- Prefer `try...` helpers when invalid input should stay in `Result` form instead of throwing. +- Use subpath imports such as `@sof/sdk/app` or `@sof/sdk/runtime/config` when you want a smaller import surface. -This initial package slice provides: +The current package provides: - checked `Result` primitives - branded/value-object types for domain strings - enum-backed runtime policy types -- typed SOF runtime config serialization and parsing for: - - `SOF_RUNTIME_DELIVERY_PROFILE` - - `SOF_SHRED_TRUST_MODE` - - `SOF_PROVIDER_STREAM_CAPABILITY_POLICY` - - `SOF_PROVIDER_STREAM_ALLOW_EOF` - - `SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS` - - `SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS` - - `SOF_DERIVED_STATE_REPLAY_BACKEND` - - `SOF_DERIVED_STATE_REPLAY_DIR` - - `SOF_DERIVED_STATE_REPLAY_DURABILITY` - - `SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES` - - `SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS` -- nested derived-state runtime config with safe defaults and checkpoint-only replay helper -- typed environment entry helpers instead of only raw string maps -- plain-object nested config construction, so common cases do not require chained `new` calls -- one-line runtime profile presets such as `ObserverRuntimeConfig.balanced()` -- small functional helpers for the common create/serialize/parse path, so most consumers do not need to learn the class API first -- result-return factory and serialization helpers for programmatic validation, so SDK consumers do not need to rely on exceptions for normal invalid-input handling -- typed runtime-extension manifest and worker-authoring primitives for TS-side extension contracts -- a ready-to-run Node stdio worker loop for runtime-extension processes, so the SDK is not only a DTO wrapper -- focused subpath imports when you only want one SDK slice, for example `@sof/sdk/runtime/config` +- typed runtime config parsing and serialization +- nested derived-state config with safe defaults +- app-first TS APIs for defining apps and plugins +- launch-spec generation for Node-based app entrypoints +- plugin entrypoint helpers for selected-plugin execution ## Quick Start ```ts import { - createRuntimeConfigForProfile, - DerivedStateReplayBackend, - DerivedStateReplayDurability, - parseRuntimeConfig, - ProviderStreamCapabilityPolicy, + ExtensionCapability, RuntimeDeliveryProfile, - serializeRuntimeConfigRecord, - ShredTrustMode, + createRuntimeConfigForProfile, + isErr, + tryCreateAppLaunch, + tryDefineApp, + tryDefinePlugin, } from "@sof/sdk"; -const config = createRuntimeConfigForProfile( - RuntimeDeliveryProfile.Balanced, - { - shredTrustMode: ShredTrustMode.TrustedRawShredProvider, - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - providerStreamAllowEof: true, - derivedState: { - replay: { - backend: DerivedStateReplayBackend.Disk, - durability: DerivedStateReplayDurability.Fsync, - maxEnvelopes: 1024, - maxSessions: 2, - }, - }, - }, -); - -const envRecord = serializeRuntimeConfigRecord(config); -// { -// SOF_RUNTIME_DELIVERY_PROFILE: "balanced", -// SOF_SHRED_TRUST_MODE: "trusted_raw_shred_provider", -// SOF_PROVIDER_STREAM_CAPABILITY_POLICY: "strict", -// SOF_PROVIDER_STREAM_ALLOW_EOF: "true", -// SOF_DERIVED_STATE_CHECKPOINT_INTERVAL_MS: "60000", -// SOF_DERIVED_STATE_RECOVERY_INTERVAL_MS: "10000", -// SOF_DERIVED_STATE_REPLAY_BACKEND: "disk", -// SOF_DERIVED_STATE_REPLAY_DIR: ".sof-replay", -// SOF_DERIVED_STATE_REPLAY_DURABILITY: "fsync", -// SOF_DERIVED_STATE_REPLAY_MAX_ENVELOPES: "1024", -// SOF_DERIVED_STATE_REPLAY_MAX_SESSIONS: "2", -// } - -const parsed = parseRuntimeConfig(envRecord); +const plugin = tryDefinePlugin({ + name: "demo-plugin", + capabilities: [ExtensionCapability.ObserveObserverIngress], +}); -config; -parsed; +if (!isErr(plugin)) { + const app = tryDefineApp({ + name: "demo-sof-app", + runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), + plugins: [plugin.value], + }); + + if (!isErr(app)) { + const launch = tryCreateAppLaunch(app.value, { + workerEntrypoint: "./dist/app.js", + }); + + if (!isErr(launch)) { + launch.value.runtimeEnvironment; + launch.value.plugins; + } + } +} ``` -## Result Path +## Plugin API ```ts import { + ExtensionCapability, isErr, - RuntimeDeliveryProfile, - tryCreateRuntimeConfigForProfile, - trySerializeRuntimeConfigRecord, + ok, + runtimeExtensionAck, + tryDefinePlugin, } from "@sof/sdk"; -const config = tryCreateRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, -); +const plugin = tryDefinePlugin({ + name: "demo-plugin", + capabilities: [ExtensionCapability.ObserveObserverIngress], + onStart: () => ok(runtimeExtensionAck()), + onPacket: () => ok(runtimeExtensionAck()), + onStop: () => ok(runtimeExtensionAck()), +}); -if (isErr(config)) { - throw new Error(config.error.message); +if (!isErr(plugin)) { + plugin.value.manifest.extensionName; } - -const env = trySerializeRuntimeConfigRecord(config.value); - -env; ``` -## Class API +## App Entrypoint ```ts -import { ObserverRuntimeConfig } from "@sof/sdk"; - -const config = ObserverRuntimeConfig.deliveryDisciplined(); -const env = config.toEnvironmentRecord(); - -env; -``` - -## Extension Runtime - -The TS SDK now includes a typed extension-worker authoring surface under -`@sof/sdk/runtime/extension` plus a ready-to-run Node worker loop under -`@sof/sdk/runtime/extension-stdio`. - -Use it for: +import { + ExtensionCapability, + isErr, + ok, + runSelectedPlugin, + runtimeExtensionAck, + tryDefineApp, + tryDefinePlugin, +} from "@sof/sdk"; -- typed extension manifests -- typed packet-subscription matching -- typed in-memory worker lifecycle/runtime -- newline-delimited JSON stdio worker processes with no custom transport loop -- runnable TS examples for future Rust-host integration +const plugin = tryDefinePlugin({ + name: "demo-plugin", + capabilities: [ExtensionCapability.ObserveObserverIngress], + onStart: () => ok(runtimeExtensionAck()), + onPacket: () => ok(runtimeExtensionAck()), + onStop: () => ok(runtimeExtensionAck()), +}); -Important boundary: +if (!isErr(plugin)) { + const app = tryDefineApp({ + name: "demo-sof-app", + plugins: [plugin.value], + }); -- Rust still owns the actual runtime, queues, sockets, and packet dispatch. -- The current TS SDK extension surface is the TS-side contract plus a real TS worker runtime. -- It does not yet mean the Rust binary can already spawn TS workers directly. + if (!isErr(app)) { + await runSelectedPlugin(app.value); + } +} +``` -## Examples +## Runtime Config -Runnable examples live in `sdks/typescript/examples`: +```ts +import { + RuntimeDeliveryProfile, + isErr, + tryCreateRuntimeConfigForProfile, + trySerializeRuntimeConfigRecord, +} from "@sof/sdk"; -- `runtime-config-balanced.ts` -- `runtime-config-parse.ts` -- `runtime-extension-manifest.ts` -- `runtime-extension-worker.ts` +const config = tryCreateRuntimeConfigForProfile( + RuntimeDeliveryProfile.DeliveryDisciplined, +); -Verify them with: +if (!isErr(config)) { + const env = trySerializeRuntimeConfigRecord(config.value); -```bash -pnpm run check:examples + env; +} ``` ## Focused Imports ```ts +import { + createAppLaunch, + runSelectedPlugin, + tryDefineApp, + tryDefinePlugin, +} from "@sof/sdk/app"; import { ObserverRuntimeConfig, observerRuntimeConfigForProfile, @@ -198,55 +181,35 @@ const config = observerRuntimeConfigForProfile( }, ); +tryDefinePlugin; +tryDefineApp; +createAppLaunch; +runSelectedPlugin; ObserverRuntimeConfig.latencyOptimized(); config; ``` -## Stdio Worker Runtime - -```ts -import { - ExtensionCapability, - RuntimeExtensionWorkerHostMessageTag, - isErr, - ok, - runRuntimeExtensionWorkerStdio, - runtimeExtensionAck, - tryCreateRuntimeExtensionWorkerManifest, - tryDefineRuntimeExtension, -} from "@sof/sdk/runtime/extension-stdio"; - -const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: "demo-worker", - capabilities: [ExtensionCapability.ObserveObserverIngress], -}); +## Examples -if (isErr(manifest)) { - throw new Error(manifest.error.message); -} +Runnable examples live in `sdks/typescript/examples`: -const worker = tryDefineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), - onPacketReceived: () => ok(runtimeExtensionAck()), - onShutdown: () => ok(runtimeExtensionAck()), -}); +- `sof-app-entrypoint.ts` +- `sof-app-launch-spec.ts` +- `runtime-config-balanced.ts` +- `runtime-config-parse.ts` +- `runtime-extension-manifest.ts` +- `runtime-extension-worker.ts` -if (isErr(worker)) { - throw new Error(worker.error.message); -} +Run them with: -await runRuntimeExtensionWorkerStdio(worker.value); +```bash +pnpm run check:examples ``` ## Choosing An API -- Use `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env from files, CI, or process managers. -- Use `parseRuntimeConfig(...)` for env parsing. It accepts either env records or typed environment-variable lists. -- Use `tryCreateRuntimeConfig(...)`, `tryCreateRuntimeConfigForProfile(...)`, or `trySerializeRuntimeConfigRecord(...)` when invalid programmatic input should stay in `Result` form. -- Use `createRuntimeConfigForProfile(...)` or `serializeRuntimeConfigRecord(...)` for the simplest create-and-emit workflow. -- Use `ObserverRuntimeConfig.balanced(...)` or `observerRuntimeConfigForProfile(...)` when you explicitly want the class-oriented surface. -- Use `derivedStateRuntimeConfig(...)` or `DerivedStateRuntimeConfig.checkpointOnly()` when your main concern is derived-state recovery behavior. -- Use `runRuntimeExtensionWorkerStdio(...)` when you want an actual Node worker process instead of only in-memory runtime objects. -- Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface in application code. +- Use `tryDefinePlugin(...)`, `tryDefineApp(...)`, and `tryCreateAppLaunch(...)` for the normal app authoring flow. +- Use `runSelectedPlugin(...)` in your plugin entrypoint when the app should select the plugin to run from its environment. +- Use `parseRuntimeConfig(...)` or `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env input. +- Use `createRuntimeConfigForProfile(...)` for the simplest runtime-profile workflow. +- Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface. diff --git a/sdks/typescript/examples/runtime-extension-worker.ts b/sdks/typescript/examples/runtime-extension-worker.ts index 8c80cd4b..f9732cd1 100644 --- a/sdks/typescript/examples/runtime-extension-worker.ts +++ b/sdks/typescript/examples/runtime-extension-worker.ts @@ -4,125 +4,137 @@ import { ExtensionCapability, RuntimeExtensionWorkerHostMessageTag, SdkLanguage, - type Result, extensionName, isErr, ok, runtimeExtensionAck, - runRuntimeExtensionWorkerStdio, serializeRuntimeExtensionWorkerHostMessageWire, socketAddress, - tryDefineRuntimeExtension, - tryCreateRuntimeExtensionWorkerManifest, + tryDefineApp, + tryDefinePlugin, + tryRunPlugin, } from "../dist/index.js"; -function expectOk( - result: Result, -): Value { - if (isErr(result)) { - throw new Error(result.error.message); +async function main(): Promise { + const extension = extensionName("demo-extension-worker"); + if (isErr(extension)) { + process.stderr.write(`${extension.error.message}\n`); + return 1; } - return result.value; -} - -const extension = expectOk(extensionName("demo-extension-worker")); -const localAddress = expectOk(socketAddress("127.0.0.1:21011")); + const localAddress = socketAddress("127.0.0.1:21011"); + if (isErr(localAddress)) { + process.stderr.write(`${localAddress.error.message}\n`); + return 1; + } -const manifest = expectOk( - tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: extension, + let observedPacketLog = ""; + const plugin = tryDefinePlugin({ + name: extension.value, capabilities: [ExtensionCapability.ObserveObserverIngress], - }), -); - -let observedPacketLog = ""; -const definition = expectOk( - tryDefineRuntimeExtension({ - manifest, - onReady: () => ok(runtimeExtensionAck()), - onPacketReceived: (event) => { + onStart: () => ok(runtimeExtensionAck()), + onPacket: (event) => { observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; return ok(runtimeExtensionAck()); }, - onShutdown: () => ok(runtimeExtensionAck()), - }), -); + onStop: () => ok(runtimeExtensionAck()), + }); + if (isErr(plugin)) { + process.stderr.write(`${plugin.error.message}\n`); + return 1; + } -const input = new PassThrough(); -const output = new PassThrough(); -const errorOutput = new PassThrough(); + const app = tryDefineApp({ + name: "demo-sof-app", + plugins: [plugin.value], + }); + if (isErr(app)) { + process.stderr.write(`${app.error.message}\n`); + return 1; + } -let protocolOutput = ""; -let protocolErrors = ""; -output.setEncoding("utf8"); -errorOutput.setEncoding("utf8"); -output.on("data", (chunk: string) => { - protocolOutput += chunk; -}); -errorOutput.on("data", (chunk: string) => { - protocolErrors += chunk; -}); + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); -const runner = runRuntimeExtensionWorkerStdio(definition, { - input, - output, - error: errorOutput, -}); + let protocolOutput = ""; + let protocolErrors = ""; + output.setEncoding("utf8"); + errorOutput.setEncoding("utf8"); + output.on("data", (chunk: string) => { + protocolOutput += chunk; + }); + errorOutput.on("data", (chunk: string) => { + protocolErrors += chunk; + }); -input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Start, - context: { - extensionName: extension, - }, - }), - )}\n`, -); -input.write( - `${JSON.stringify({ - tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, - event: { - source: { - kind: 1, - transport: 1, - eventClass: 1, - localAddress, + const runner = tryRunPlugin(app.value, extension.value, { + input, + output, + error: errorOutput, + }); + + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: extension.value, + }, + }), + )}\n`, + ); + input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + localAddress: localAddress.value, + }, + bytes: [1, 2, 3, 4], + observedUnixMs: Date.now(), }, - bytes: [1, 2, 3, 4], - observedUnixMs: Date.now(), - }, - })}\n`, -); -input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, - context: { - extensionName: extension, + })}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: extension.value, + }, + }), + )}\n`, + ); + input.end(); + + const result = await runner; + if (isErr(result)) { + process.stderr.write(`${result.error.message}\n`); + return 1; + } + + process.stdout.write( + `${JSON.stringify( + { + sdkLanguage: SdkLanguage.TypeScript, + observedPacketLog, + protocolErrors: protocolErrors.trim(), + responses: protocolOutput + .trim() + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as unknown), }, - }), - )}\n`, -); -input.end(); + undefined, + 2, + )}\n`, + ); -expectOk(await runner); + return 0; +} -process.stdout.write( - `${JSON.stringify( - { - sdkLanguage: SdkLanguage.TypeScript, - observedPacketLog, - protocolErrors: protocolErrors.trim(), - responses: protocolOutput - .trim() - .split("\n") - .filter((line) => line !== "") - .map((line) => JSON.parse(line) as unknown), - }, - undefined, - 2, - )}\n`, -); +process.exitCode = await main(); diff --git a/sdks/typescript/examples/sof-app-entrypoint.ts b/sdks/typescript/examples/sof-app-entrypoint.ts new file mode 100644 index 00000000..ac458fe4 --- /dev/null +++ b/sdks/typescript/examples/sof-app-entrypoint.ts @@ -0,0 +1,147 @@ +import { PassThrough } from "node:stream"; + +import { + ExtensionCapability, + RuntimeExtensionWorkerHostMessageTag, + SdkLanguage, + extensionName, + isErr, + ok, + runtimeExtensionAck, + serializeRuntimeExtensionWorkerHostMessageWire, + sofRuntimeExtensionNameEnvVarName, + socketAddress, + tryDefineApp, + tryDefinePlugin, + tryRunSelectedPlugin, +} from "../dist/index.js"; + +async function main(): Promise { + const pluginName = extensionName("demo-plugin"); + if (isErr(pluginName)) { + process.stderr.write(`${pluginName.error.message}\n`); + return 1; + } + + const localAddress = socketAddress("127.0.0.1:21011"); + if (isErr(localAddress)) { + process.stderr.write(`${localAddress.error.message}\n`); + return 1; + } + + let observedPacketLog = ""; + const plugin = tryDefinePlugin({ + name: pluginName.value, + capabilities: [ExtensionCapability.ObserveObserverIngress], + onStart: () => ok(runtimeExtensionAck()), + onPacket: (event) => { + observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; + return ok(runtimeExtensionAck()); + }, + onStop: () => ok(runtimeExtensionAck()), + }); + if (isErr(plugin)) { + process.stderr.write(`${plugin.error.message}\n`); + return 1; + } + + const app = tryDefineApp({ + name: "demo-sof-app", + plugins: [plugin.value], + }); + if (isErr(app)) { + process.stderr.write(`${app.error.message}\n`); + return 1; + } + + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); + + let protocolOutput = ""; + let protocolErrors = ""; + output.setEncoding("utf8"); + errorOutput.setEncoding("utf8"); + output.on("data", (chunk: string) => { + protocolOutput += chunk; + }); + errorOutput.on("data", (chunk: string) => { + protocolErrors += chunk; + }); + + const runner = tryRunSelectedPlugin( + app.value, + { + [sofRuntimeExtensionNameEnvVarName]: pluginName.value, + }, + { + input, + output, + error: errorOutput, + }, + ); + + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: pluginName.value, + }, + }), + )}\n`, + ); + input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + localAddress: localAddress.value, + }, + bytes: [1, 2, 3, 4], + observedUnixMs: Date.now(), + }, + })}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: pluginName.value, + }, + }), + )}\n`, + ); + input.end(); + + const result = await runner; + if (isErr(result)) { + process.stderr.write(`${result.error.message}\n`); + return 1; + } + + process.stdout.write( + `${JSON.stringify( + { + sdkLanguage: SdkLanguage.TypeScript, + observedPacketLog, + protocolErrors: protocolErrors.trim(), + responses: protocolOutput + .trim() + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as unknown), + }, + undefined, + 2, + )}\n`, + ); + + return 0; +} + +process.exitCode = await main(); diff --git a/sdks/typescript/examples/sof-app-launch-spec.ts b/sdks/typescript/examples/sof-app-launch-spec.ts new file mode 100644 index 00000000..3b6deb1d --- /dev/null +++ b/sdks/typescript/examples/sof-app-launch-spec.ts @@ -0,0 +1,40 @@ +import { + ExtensionCapability, + RuntimeDeliveryProfile, + tryCreateAppLaunch, + createRuntimeConfigForProfile, + tryDefineApp, + tryDefinePlugin, + isErr, +} from "../dist/index.js"; + +const plugin = tryDefinePlugin({ + name: "app-launch-extension", + capabilities: [ExtensionCapability.ObserveObserverIngress], +}); + +if (isErr(plugin)) { + process.stderr.write(`${plugin.error.message}\n`); + process.exitCode = 1; +} else { + const app = tryDefineApp({ + name: "demo-sof-app", + runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), + plugins: [plugin.value], + }); + if (isErr(app)) { + process.stderr.write(`${app.error.message}\n`); + process.exitCode = 1; + } else { + const launchSpec = tryCreateAppLaunch(app.value, { + workerEntrypoint: "./dist/worker.js", + }); + + if (isErr(launchSpec)) { + process.stderr.write(`${launchSpec.error.message}\n`); + process.exitCode = 1; + } else { + process.stdout.write(`${JSON.stringify(launchSpec.value, undefined, 2)}\n`); + } + } +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index e0c8aaef..66b4f2ee 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -25,6 +25,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./app": { + "types": "./dist/app.d.ts", + "import": "./dist/app.js" + }, "./runtime": { "types": "./dist/runtime.d.ts", "import": "./dist/runtime.js" @@ -66,7 +70,7 @@ "format:check": "biome format --config-path biome.json src examples tsdown.config.ts", "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts", - "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js", + "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js && node dist-examples/sof-app-entrypoint.js && node dist-examples/sof-app-launch-spec.js", "check:package": "publint run --strict --pack pnpm", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/sdks/typescript/src/app.test.ts b/sdks/typescript/src/app.test.ts new file mode 100644 index 00000000..41fcbff9 --- /dev/null +++ b/sdks/typescript/src/app.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { isErr, isOk, ok } from "./result.js"; +import { + ExtensionCapability, + RuntimeDeliveryProfile, + RuntimeExtensionWorkerHostMessageTag, + SofApplication, + createAppLaunch, + createRuntimeConfigForProfile, + defineRuntimeExtension, + defineSofApplication, + runtimeExtensionAck, + sofApplicationNameEnvVarName, + serializeRuntimeExtensionWorkerHostMessageWire, + sofRuntimeExtensionNameEnvVarName, + tryDefineApp, + tryDefinePlugin, + tryCreateRuntimeExtensionWorkerManifest, + tryRunSelectedPlugin, +} from "./index.js"; + +test("sof application produces node launch specs from ts-authored runtime and extensions", () => { + const plugin = tryDefinePlugin({ + name: "launch-spec-extension", + capabilities: [ExtensionCapability.ObserveObserverIngress], + onStart: () => ok(runtimeExtensionAck()), + onPacket: () => ok(runtimeExtensionAck()), + onStop: () => ok(runtimeExtensionAck()), + }); + + assert.equal(isOk(plugin), true); + if (!isOk(plugin)) { + return; + } + + const app = tryDefineApp({ + name: "demo-sof-app", + runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), + plugins: [plugin.value], + }); + assert.equal(isOk(app), true); + if (!isOk(app)) { + return; + } + + const launchSpec = createAppLaunch(app.value, { + workerEntrypoint: "./dist/worker.js", + workerCommand: "node", + workerArgs: ["--enable-source-maps"], + }); + + assert.equal(launchSpec.appName, "demo-sof-app"); + assert.equal(launchSpec.runtimeEnvironment.SOF_RUNTIME_DELIVERY_PROFILE, "balanced"); + assert.equal(launchSpec.plugins.length, 1); + assert.equal(launchSpec.runtimeExtensions.length, 1); + assert.deepEqual(launchSpec.runtimeExtensions[0]?.args, [ + "--enable-source-maps", + "./dist/worker.js", + ]); + assert.equal( + launchSpec.runtimeExtensions[0]?.environment[sofApplicationNameEnvVarName], + "demo-sof-app", + ); + assert.equal( + launchSpec.runtimeExtensions[0]?.environment[sofRuntimeExtensionNameEnvVarName], + "launch-spec-extension", + ); +}); + +test("sof application rejects duplicate runtime extension names", () => { + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "duplicate-extension", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + + assert.equal(isOk(manifest), true); + if (!isOk(manifest)) { + return; + } + + const duplicateApp = SofApplication.tryCreate({ + name: "duplicate-app", + runtimeExtensions: [ + defineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + }), + defineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + }), + ], + }); + + assert.equal(isErr(duplicateApp), true); + if (isErr(duplicateApp)) { + assert.match(duplicateApp.error.message, /registered more than once/); + } +}); + +test("sof application runs one plugin selected from environment", async () => { + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "selected-extension", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + + assert.equal(isOk(manifest), true); + if (!isOk(manifest)) { + return; + } + + const app = defineSofApplication({ + name: "selection-app", + runtimeExtensions: [ + defineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: () => ok(runtimeExtensionAck()), + onShutdown: () => ok(runtimeExtensionAck()), + }), + ], + }); + + const input = new PassThrough(); + const output = new PassThrough(); + let responseText = ""; + + output.setEncoding("utf8"); + output.on("data", (chunk: string) => { + responseText += chunk; + }); + + const runner = tryRunSelectedPlugin( + app, + { + [sofRuntimeExtensionNameEnvVarName]: "selected-extension", + }, + { + input, + output, + }, + ); + + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Start, + context: { + extensionName: manifest.value.extensionName, + }, + }), + )}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: manifest.value.extensionName, + }, + }), + )}\n`, + ); + input.end(); + + const result = await runner; + assert.equal(isOk(result), true); + assert.match(responseText, /"tag":2/); + assert.match(responseText, /"tag":4/); +}); + +test("sof application reports missing runtime extension selection with available names", async () => { + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "available-extension", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + + assert.equal(isOk(manifest), true); + if (!isOk(manifest)) { + return; + } + + const app = defineSofApplication({ + name: "selection-app", + runtimeExtensions: [ + defineRuntimeExtension({ + manifest: manifest.value, + onReady: () => ok(runtimeExtensionAck()), + }), + ], + }); + + const result = await tryRunSelectedPlugin(app, {}); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, sofRuntimeExtensionNameEnvVarName); + if ("availableExtensionNames" in result.error) { + assert.deepEqual(result.error.availableExtensionNames, ["available-extension"]); + } + } +}); diff --git a/sdks/typescript/src/app.ts b/sdks/typescript/src/app.ts new file mode 100644 index 00000000..6cf04ad5 --- /dev/null +++ b/sdks/typescript/src/app.ts @@ -0,0 +1,651 @@ +import { brand, type Brand } from "./brand.js"; +import { + envVarName, + environmentVariablesToRecord, + readEnvironmentVariable, + type EnvironmentInput, +} from "./environment.js"; +import { err, isErr, ok, type Result } from "./result.js"; +import { + type ExtensionCapability, + type ExtensionContext, + type ExtensionResourceSpec, + type ObserverRuntimeConfigInput, + type ObserverRuntimeEnvironmentOptions, + type ObserverRuntimeConfig, + type PacketSubscription, + runRuntimeExtensionWorkerStdio, + type RuntimeExtensionAck, + type RuntimeExtensionDefinition, + type RuntimeExtensionError, + type RuntimePacketEvent, + type RuntimeExtensionWorkerStdioOptions, + extensionName, + tryCreateRuntimeExtensionWorkerManifest, + tryCreateRuntimeConfig, + tryDefineRuntimeExtension, + type ExtensionName, +} from "./runtime.js"; + +export enum SofApplicationErrorKind { + ValidationError = 1, + DuplicateRuntimeExtension = 2, + MissingRuntimeExtension = 3, + MissingRuntimeExtensionSelection = 4, +} + +export interface SofApplicationError { + readonly kind: SofApplicationErrorKind; + readonly field: string; + readonly message: string; + readonly received?: string; + readonly availableExtensionNames?: readonly string[]; +} + +export type SofApplicationName = Brand; + +export interface SofApplicationInit { + readonly name: string | SofApplicationName; + readonly runtime?: ObserverRuntimeConfigInput; + readonly runtimeExtensions?: readonly RuntimeExtensionDefinition[]; + readonly plugins?: readonly SofPluginInput[]; +} + +export type SofApplicationInput = SofApplication | SofApplicationInit; +export type SofPlugin = RuntimeExtensionDefinition; +export type SofPluginError = RuntimeExtensionError; +export type SofPluginInput = SofPlugin | SofPluginInit; + +export interface SofPluginInit { + readonly name: string | ExtensionName; + readonly capabilities?: readonly ExtensionCapability[]; + readonly resources?: readonly ExtensionResourceSpec[]; + readonly subscriptions?: readonly PacketSubscription[]; + readonly onStart?: ( + context: ExtensionContext, + ) => + | Promise> + | Result; + readonly onPacket?: ( + event: RuntimePacketEvent, + ) => + | Promise> + | Result; + readonly onStop?: ( + context: ExtensionContext, + ) => + | Promise> + | Result; +} + +export interface SofNodeLaunchSpecInit { + readonly workerEntrypoint: string; + readonly workerCommand?: string; + readonly workerArgs?: readonly string[]; + readonly workerEnvironment?: Readonly>; + readonly runtimeEnvironment?: ObserverRuntimeEnvironmentOptions; +} + +export interface SofNodeRuntimeExtensionLaunchSpec { + readonly extensionName: ExtensionName; + readonly transport: "stdio"; + readonly command: string; + readonly args: readonly string[]; + readonly environment: Readonly>; +} + +export type SofPluginLaunchSpec = SofNodeRuntimeExtensionLaunchSpec; + +export interface SofNodeLaunchSpec { + readonly appName: SofApplicationName; + readonly runtimeEnvironment: Readonly>; + readonly plugins: readonly SofPluginLaunchSpec[]; + readonly runtimeExtensions: readonly SofNodeRuntimeExtensionLaunchSpec[]; +} + +export type SofApplicationWorkerRunError = SofApplicationError | RuntimeExtensionError; + +export const sofApplicationNameEnvVarName = envVarName("SOF_SDK_APPLICATION_NAME"); +export const sofRuntimeExtensionNameEnvVarName = envVarName("SOF_SDK_RUNTIME_EXTENSION_NAME"); +export const sofTypeScriptSdkVersion = "0.1.0"; + +const defaultNodeCommand = "node"; +const sofApplicationValidatedInitTag = Symbol("SofApplicationValidatedInit"); + +interface SofApplicationValidatedInit { + readonly [sofApplicationValidatedInitTag]: true; + readonly name: SofApplicationName; + readonly runtime: ObserverRuntimeConfig; + readonly plugins: readonly SofPlugin[]; + readonly pluginsByName: ReadonlyMap; + readonly runtimeExtensions: readonly RuntimeExtensionDefinition[]; + readonly runtimeExtensionsByName: ReadonlyMap; +} + +function sofApplicationError( + kind: SofApplicationErrorKind, + field: string, + message: string, + received?: string, + availableExtensionNames?: readonly string[], +): SofApplicationError { + const error: SofApplicationError = { + kind, + field, + message, + }; + + if (received !== undefined) { + return { + ...error, + received, + ...(availableExtensionNames === undefined ? {} : { availableExtensionNames }), + }; + } + + if (availableExtensionNames !== undefined) { + return { + ...error, + availableExtensionNames, + }; + } + + return error; +} + +function parseNonEmptyAppValue( + value: string, + field: string, + wrap: (normalized: string) => T, +): Result { + const normalized = value.trim(); + if (normalized === "") { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + field, + `${field} must not be empty`, + value, + ), + ); + } + if (normalized.includes("\u0000")) { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + field, + `${field} must not contain NUL bytes`, + value, + ), + ); + } + + return ok(wrap(normalized)); +} + +function asSofApplicationName(value: Value): SofApplicationName { + return brand(value); +} + +function parseSofApplicationName(value: string): Result { + return parseNonEmptyAppValue(value, "name", asSofApplicationName); +} + +function mergeEnvironmentRecords( + base: Readonly> = {}, + overlay: Readonly> = {}, +): Readonly> { + const variables: Array<{ readonly name: string; readonly value: string }> = []; + + for (const source of [base, overlay]) { + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + variables.push({ + name: key, + value, + }); + } + } + } + + return environmentVariablesToRecord( + variables.map((variable) => ({ + name: envVarName(variable.name), + value: variable.value, + })), + ); +} + +function throwSofApplicationError(error: SofApplicationError): never { + throw new RangeError(error.message); +} + +function toRuntimeExtensionNameKey(value: ExtensionName): string { + return value; +} + +export class SofApplication { + readonly name!: SofApplicationName; + readonly runtime!: ObserverRuntimeConfig; + readonly plugins!: readonly SofPlugin[]; + readonly pluginsByName!: ReadonlyMap; + readonly runtimeExtensions!: readonly RuntimeExtensionDefinition[]; + readonly runtimeExtensionsByName!: ReadonlyMap; + + constructor(init: SofApplicationInit | SofApplicationValidatedInit) { + if (sofApplicationValidatedInitTag in init) { + this.name = init.name; + this.runtime = init.runtime; + this.plugins = init.plugins; + this.pluginsByName = init.pluginsByName; + this.runtimeExtensions = init.runtimeExtensions; + this.runtimeExtensionsByName = init.runtimeExtensionsByName; + return; + } + + const result = tryDefineSofApplication(init); + if (isErr(result)) { + throwSofApplicationError(result.error); + } + + this.name = result.value.name; + this.runtime = result.value.runtime; + this.plugins = result.value.plugins; + this.pluginsByName = result.value.pluginsByName; + this.runtimeExtensions = result.value.runtimeExtensions; + this.runtimeExtensionsByName = result.value.runtimeExtensionsByName; + } + + toRuntimeEnvironmentRecord( + options: ObserverRuntimeEnvironmentOptions = {}, + ): Readonly> { + return this.runtime.toEnvironmentRecord(options); + } + + getRuntimeExtension( + name: string | ExtensionName, + ): Result { + const parsedName = typeof name === "string" ? extensionName(name) : ok(name); + if (isErr(parsedName)) { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + "runtimeExtensionName", + parsedName.error.message, + typeof name === "string" ? name : String(name), + ), + ); + } + + const found = this.runtimeExtensionsByName.get(toRuntimeExtensionNameKey(parsedName.value)); + if (found !== undefined) { + return ok(found); + } + + return err( + sofApplicationError( + SofApplicationErrorKind.MissingRuntimeExtension, + "runtimeExtensionName", + `runtime extension ${String(parsedName.value)} is not registered in application ${String(this.name)}`, + String(parsedName.value), + this.runtimeExtensions.map((definition) => String(definition.manifest.extensionName)), + ), + ); + } + + getPlugin(name: string | ExtensionName): Result { + return this.getRuntimeExtension(name); + } + + toNodeLaunchSpec(init: SofNodeLaunchSpecInit): Result { + const workerEntrypoint = parseNonEmptyAppValue( + init.workerEntrypoint, + "workerEntrypoint", + (value) => value, + ); + if (isErr(workerEntrypoint)) { + return workerEntrypoint; + } + + const command = parseNonEmptyAppValue( + init.workerCommand ?? defaultNodeCommand, + "workerCommand", + (value) => value, + ); + if (isErr(command)) { + return command; + } + + const runtimeEnvironment = this.runtime.toEnvironmentRecord(init.runtimeEnvironment); + const runtimeExtensions = this.runtimeExtensions.map((definition) => ({ + extensionName: definition.manifest.extensionName, + transport: "stdio" as const, + command: command.value, + args: [...(init.workerArgs ?? []), workerEntrypoint.value], + environment: mergeEnvironmentRecords(init.workerEnvironment, { + [sofApplicationNameEnvVarName]: this.name, + [sofRuntimeExtensionNameEnvVarName]: definition.manifest.extensionName, + }), + })); + + return ok({ + appName: this.name, + runtimeEnvironment, + plugins: runtimeExtensions, + runtimeExtensions, + }); + } + + static create(init: SofApplicationInit): SofApplication { + return defineSofApplication(init); + } + + static tryCreate(init: SofApplicationInit): Result { + return tryDefineSofApplication(init); + } +} + +function createValidatedSofApplication( + init: Omit, +): SofApplication { + return new SofApplication({ + [sofApplicationValidatedInitTag]: true, + ...init, + }); +} + +export function tryDefineSofApplication( + init: SofApplicationInput, +): Result { + if (init instanceof SofApplication) { + return ok(init); + } + + const name = typeof init.name === "string" ? parseSofApplicationName(init.name) : ok(init.name); + if (isErr(name)) { + return name; + } + + const runtime = tryCreateRuntimeConfig(init.runtime); + if (isErr(runtime)) { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + "runtime", + runtime.error.message, + ), + ); + } + + const validatedRuntimeExtensions: RuntimeExtensionDefinition[] = []; + const runtimeExtensionsByName = new Map(); + const pluginDefinitions: RuntimeExtensionDefinition[] = []; + for (const plugin of init.plugins ?? []) { + const validatedPlugin = tryDefineSofPlugin(plugin); + if (isErr(validatedPlugin)) { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + "plugins", + validatedPlugin.error.message, + ), + ); + } + + pluginDefinitions.push(validatedPlugin.value); + } + + for (const runtimeExtension of [...(init.runtimeExtensions ?? []), ...pluginDefinitions]) { + const validatedRuntimeExtension = tryDefineRuntimeExtension(runtimeExtension); + if (isErr(validatedRuntimeExtension)) { + return err( + sofApplicationError( + SofApplicationErrorKind.ValidationError, + "runtimeExtensions", + validatedRuntimeExtension.error.message, + ), + ); + } + + const runtimeExtensionNameKey = toRuntimeExtensionNameKey( + validatedRuntimeExtension.value.manifest.extensionName, + ); + if (runtimeExtensionsByName.has(runtimeExtensionNameKey)) { + return err( + sofApplicationError( + SofApplicationErrorKind.DuplicateRuntimeExtension, + "runtimeExtensions", + `runtime extension ${runtimeExtensionNameKey} is registered more than once`, + runtimeExtensionNameKey, + ), + ); + } + + validatedRuntimeExtensions.push(validatedRuntimeExtension.value); + runtimeExtensionsByName.set(runtimeExtensionNameKey, validatedRuntimeExtension.value); + } + + return ok( + createValidatedSofApplication({ + name: name.value, + runtime: runtime.value, + plugins: validatedRuntimeExtensions, + pluginsByName: runtimeExtensionsByName, + runtimeExtensions: validatedRuntimeExtensions, + runtimeExtensionsByName, + }), + ); +} + +export function tryDefineSofPlugin(init: SofPluginInput): Result { + if ("manifest" in init) { + return tryDefineRuntimeExtension(init); + } + + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: sofTypeScriptSdkVersion, + extensionName: init.name, + ...(init.capabilities === undefined ? {} : { capabilities: init.capabilities }), + ...(init.resources === undefined ? {} : { resources: init.resources }), + ...(init.subscriptions === undefined ? {} : { subscriptions: init.subscriptions }), + }); + if (isErr(manifest)) { + return manifest; + } + + return tryDefineRuntimeExtension({ + manifest: manifest.value, + ...(init.onStart === undefined ? {} : { onReady: init.onStart }), + ...(init.onPacket === undefined ? {} : { onPacketReceived: init.onPacket }), + ...(init.onStop === undefined ? {} : { onShutdown: init.onStop }), + }); +} + +export function defineSofPlugin(init: SofPluginInput): SofPlugin { + const result = tryDefineSofPlugin(init); + if (isErr(result)) { + throw new RangeError(result.error.message); + } + + return result.value; +} + +export function defineSofApplication(init: SofApplicationInit): SofApplication { + const result = tryDefineSofApplication(init); + if (isErr(result)) { + throwSofApplicationError(result.error); + } + + return result.value; +} + +export function tryCreateSofNodeLaunchSpec( + app: SofApplicationInput, + init: SofNodeLaunchSpecInit, +): Result { + const application = tryDefineSofApplication(app); + if (isErr(application)) { + return application; + } + + return application.value.toNodeLaunchSpec(init); +} + +export function createSofNodeLaunchSpec( + app: SofApplicationInput, + init: SofNodeLaunchSpecInit, +): SofNodeLaunchSpec { + const result = tryCreateSofNodeLaunchSpec(app, init); + if (isErr(result)) { + throwSofApplicationError(result.error); + } + + return result.value; +} + +export function tryRunSofApplicationRuntimeExtensionWorker( + app: SofApplicationInput, + name: string | ExtensionName, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise> { + const application = tryDefineSofApplication(app); + if (isErr(application)) { + return Promise.resolve(application); + } + + const runtimeExtension = application.value.getRuntimeExtension(name); + if (isErr(runtimeExtension)) { + return Promise.resolve(runtimeExtension); + } + + return runRuntimeExtensionWorkerStdio(runtimeExtension.value, options); +} + +export async function runSofApplicationRuntimeExtensionWorker( + app: SofApplicationInput, + name: string | ExtensionName, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise { + const result = await tryRunSofApplicationRuntimeExtensionWorker(app, name, options); + if (isErr(result)) { + throw new RangeError(result.error.message); + } + + return result.value; +} + +export function tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment( + app: SofApplicationInput, + env: EnvironmentInput = process.env, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise> { + const selectedRuntimeExtensionName = readEnvironmentVariable( + env, + sofRuntimeExtensionNameEnvVarName, + ); + if (selectedRuntimeExtensionName === undefined || selectedRuntimeExtensionName.trim() === "") { + const application = tryDefineSofApplication(app); + const availableExtensionNames = isErr(application) + ? undefined + : application.value.runtimeExtensions.map((definition) => + String(definition.manifest.extensionName), + ); + + return Promise.resolve( + err( + sofApplicationError( + SofApplicationErrorKind.MissingRuntimeExtensionSelection, + String(sofRuntimeExtensionNameEnvVarName), + `${String(sofRuntimeExtensionNameEnvVarName)} must be set to select one runtime extension worker`, + selectedRuntimeExtensionName, + availableExtensionNames, + ), + ), + ); + } + + return tryRunSofApplicationRuntimeExtensionWorker(app, selectedRuntimeExtensionName, options); +} + +export async function runSofApplicationRuntimeExtensionWorkerFromEnvironment( + app: SofApplicationInput, + env: EnvironmentInput = process.env, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise { + const result = await tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); + if (isErr(result)) { + throw new RangeError(result.error.message); + } + + return result.value; +} + +export type SofApp = SofApplication; +export type SofAppError = SofApplicationError; +export type SofAppInit = SofApplicationInit; +export type SofAppInput = SofApplicationInput; +export type SofAppLaunchSpec = SofNodeLaunchSpec; +export type SofAppLaunchSpecInit = SofNodeLaunchSpecInit; +export type SofPluginName = ExtensionName; + +export function tryDefineSofApp(init: SofAppInput): Result { + return tryDefineSofApplication(init); +} + +export function defineSofApp(init: SofAppInit): SofApp { + return defineSofApplication(init); +} + +export function tryCreateSofAppLaunch( + app: SofAppInput, + init: SofAppLaunchSpecInit, +): Result { + return tryCreateSofNodeLaunchSpec(app, init); +} + +export function createSofAppLaunch(app: SofAppInput, init: SofAppLaunchSpecInit): SofAppLaunchSpec { + return createSofNodeLaunchSpec(app, init); +} + +export function tryRunSofPlugin( + app: SofAppInput, + name: string | SofPluginName, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise> { + return tryRunSofApplicationRuntimeExtensionWorker(app, name, options); +} + +export function runSofPlugin( + app: SofAppInput, + name: string | SofPluginName, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise { + return runSofApplicationRuntimeExtensionWorker(app, name, options); +} + +export function tryRunSelectedSofPlugin( + app: SofAppInput, + env: EnvironmentInput = process.env, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise> { + return tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); +} + +export function runSelectedSofPlugin( + app: SofAppInput, + env: EnvironmentInput = process.env, + options: RuntimeExtensionWorkerStdioOptions = {}, +): Promise { + return runSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); +} + +export const defineApp = defineSofApp; +export const definePlugin = defineSofPlugin; +export const tryDefineApp = tryDefineSofApp; +export const tryDefinePlugin = tryDefineSofPlugin; +export const createAppLaunch = createSofAppLaunch; +export const tryCreateAppLaunch = tryCreateSofAppLaunch; +export const runPlugin = runSofPlugin; +export const tryRunPlugin = tryRunSofPlugin; +export const runSelectedPlugin = runSelectedSofPlugin; +export const tryRunSelectedPlugin = tryRunSelectedSofPlugin; diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 9302d627..477de1f2 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,3 +1,4 @@ +export * from "./app.js"; export * from "./brand.js"; export * from "./environment.js"; export * from "./errors.js"; diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index 89465d5a..bd594330 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -7,6 +7,7 @@ function importPackageEntry(moduleName: string): Promise { test("package exports resolve the documented public entry points", async () => { const root = await importPackageEntry("@sof/sdk"); + const app = await importPackageEntry("@sof/sdk/app"); const runtime = await importPackageEntry("@sof/sdk/runtime"); const config = await importPackageEntry("@sof/sdk/runtime/config"); const policy = await importPackageEntry("@sof/sdk/runtime/policy"); @@ -15,6 +16,44 @@ test("package exports resolve the documented public entry points", async () => { const extension = await importPackageEntry("@sof/sdk/runtime/extension"); const extensionStdio = await importPackageEntry("@sof/sdk/runtime/extension-stdio"); + assert.equal( + (root as { defineSofApplication: unknown }).defineSofApplication, + (app as { defineSofApplication: unknown }).defineSofApplication, + ); + assert.equal( + (root as { defineApp: unknown }).defineApp, + (app as { defineApp: unknown }).defineApp, + ); + assert.equal( + (root as { tryDefineApp: unknown }).tryDefineApp, + (app as { tryDefineApp: unknown }).tryDefineApp, + ); + assert.equal( + (root as { definePlugin: unknown }).definePlugin, + (app as { definePlugin: unknown }).definePlugin, + ); + assert.equal( + (root as { tryDefinePlugin: unknown }).tryDefinePlugin, + (app as { tryDefinePlugin: unknown }).tryDefinePlugin, + ); + assert.equal( + (root as { createAppLaunch: unknown }).createAppLaunch, + (app as { createAppLaunch: unknown }).createAppLaunch, + ); + assert.equal( + (root as { runSelectedPlugin: unknown }).runSelectedPlugin, + (app as { runSelectedPlugin: unknown }).runSelectedPlugin, + ); + assert.equal( + (root as { createSofNodeLaunchSpec: unknown }).createSofNodeLaunchSpec, + (app as { createSofNodeLaunchSpec: unknown }).createSofNodeLaunchSpec, + ); + assert.equal( + (root as { runSofApplicationRuntimeExtensionWorkerFromEnvironment: unknown }) + .runSofApplicationRuntimeExtensionWorkerFromEnvironment, + (app as { runSofApplicationRuntimeExtensionWorkerFromEnvironment: unknown }) + .runSofApplicationRuntimeExtensionWorkerFromEnvironment, + ); assert.equal( (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, diff --git a/sdks/typescript/tsdown.config.ts b/sdks/typescript/tsdown.config.ts index a40db89a..cea74e96 100644 --- a/sdks/typescript/tsdown.config.ts +++ b/sdks/typescript/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ neverBundle: [/^node:/], }, entry: { + app: "src/app.ts", index: "src/index.ts", runtime: "src/runtime.ts", "runtime/config": "src/runtime/runtime-config.ts", From 18dd8488c8686391c725391f985616c91e27065e Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 17:09:01 +0200 Subject: [PATCH 14/25] feat(ts-sdk): align runtime host with rust ingress model --- crates/sof-observer/Cargo.toml | 6 +- .../src/bin/sof_ts_runtime_host.rs | 1850 ++++++++++++++ .../src/bin/sof_ts_runtime_host/af_xdp.rs | 297 +++ sdks/typescript/README.md | 367 ++- sdks/typescript/examples/app-config.ts | 43 + sdks/typescript/examples/app-entrypoint.ts | 38 + .../examples/runtime-extension-worker.ts | 53 +- .../typescript/examples/sof-app-entrypoint.ts | 147 -- .../examples/sof-app-launch-spec.ts | 40 - sdks/typescript/package.json | 15 +- sdks/typescript/scripts/build-native-host.mjs | 52 + sdks/typescript/src/app.test.ts | 588 +++-- sdks/typescript/src/app.ts | 2208 +++++++++++++---- sdks/typescript/src/package-exports.test.ts | 46 +- sdks/typescript/src/runtime.ts | 1 - .../runtime/runtime-extension-stdio.test.ts | 91 +- .../src/runtime/runtime-extension-stdio.ts | 62 + .../src/runtime/runtime-extension.ts | 155 ++ 18 files changed, 5024 insertions(+), 1035 deletions(-) create mode 100644 crates/sof-observer/src/bin/sof_ts_runtime_host.rs create mode 100644 crates/sof-observer/src/bin/sof_ts_runtime_host/af_xdp.rs create mode 100644 sdks/typescript/examples/app-config.ts create mode 100644 sdks/typescript/examples/app-entrypoint.ts delete mode 100644 sdks/typescript/examples/sof-app-entrypoint.ts delete mode 100644 sdks/typescript/examples/sof-app-launch-spec.ts create mode 100644 sdks/typescript/scripts/build-native-host.mjs diff --git a/crates/sof-observer/Cargo.toml b/crates/sof-observer/Cargo.toml index 28cc7457..e33bbc40 100644 --- a/crates/sof-observer/Cargo.toml +++ b/crates/sof-observer/Cargo.toml @@ -56,7 +56,7 @@ solana-vote = "3.1.11" solana-vote-program = { version = "3.1.11", features = ["agave-unstable-api"] } reed-solomon-erasure = { version = "6.0.0", features = ["simd-accel"] } thiserror = "2.0" -tokio = { version = "1.48", features = ["io-util", "macros", "rt-multi-thread", "net", "sync", "time"] } +tokio = { version = "1.48", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } wincode = "=0.1.2" @@ -167,6 +167,10 @@ name = "gossip_protocol_profile" path = "src/bin/gossip_protocol_profile.rs" required-features = ["gossip-bootstrap"] +[[bin]] +name = "sof_ts_runtime_host" +path = "src/bin/sof_ts_runtime_host.rs" + [[bench]] name = "hot_paths" harness = false diff --git a/crates/sof-observer/src/bin/sof_ts_runtime_host.rs b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs new file mode 100644 index 00000000..e870c570 --- /dev/null +++ b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs @@ -0,0 +1,1850 @@ +//! Native runtime host used by the TypeScript SDK `App.run()` handoff. + +#[cfg(all(target_os = "linux", feature = "kernel-bypass"))] +#[path = "sof_ts_runtime_host/af_xdp.rs"] +mod af_xdp; + +#[cfg(feature = "provider-grpc")] +use std::str::FromStr; +#[cfg(all(target_os = "linux", feature = "kernel-bypass"))] +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +#[cfg(all(target_os = "linux", feature = "kernel-bypass"))] +use std::time::Duration; +use std::{collections::HashMap, env, fs, net::SocketAddr, path::PathBuf, sync::Mutex}; + +use async_trait::async_trait; +#[cfg(feature = "provider-grpc")] +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +#[cfg(feature = "provider-grpc")] +use sof::provider_stream::yellowstone::{ + YellowstoneGrpcCommitment, YellowstoneGrpcConfig, YellowstoneGrpcError, + YellowstoneGrpcSlotsConfig, YellowstoneGrpcStream, spawn_yellowstone_grpc_slot_source, + spawn_yellowstone_grpc_source, +}; +#[cfg(feature = "provider-grpc")] +use sof::provider_stream::{ + ProviderStreamMode, ProviderStreamSender, create_provider_stream_queue, +}; +#[cfg(feature = "gossip-bootstrap")] +use sof::runtime::GossipRuntimeMode; +#[cfg(feature = "provider-grpc")] +use sof::{ + event::{ForkSlotStatus, TxCommitmentStatus, TxKind}, + framework::{ + AccountUpdateEvent, BlockMetaEvent, ObservedRecentBlockhashEvent, ObserverPlugin, + PluginConfig, PluginContext, PluginHost, PluginSetupError, SlotStatusEvent, + TransactionEvent, TransactionLogEvent, TransactionStatusEvent, + }, + provider_stream::{ + ProviderSourceArbitrationMode, ProviderSourceReadiness, ProviderSourceRef, + ProviderSourceRole, + }, +}; +use sof::{ + framework::{ + ExtensionCapability, ExtensionContext, ExtensionManifest, ExtensionResourceSpec, + ExtensionSetupError, ExtensionStreamVisibility, PacketSubscription, RuntimeExtension, + RuntimeExtensionHost, RuntimePacketEvent, RuntimePacketEventClass, RuntimePacketSourceKind, + RuntimePacketTransport, RuntimeWebSocketFrameType, TcpConnectorSpec, TcpListenerSpec, + UdpListenerSpec, WsConnectorSpec, + }, + runtime::{ObserverRuntime, RuntimeError, RuntimeSetup}, +}; +#[cfg(feature = "provider-grpc")] +use solana_pubkey::Pubkey; +#[cfg(feature = "provider-grpc")] +use solana_signature::Signature; +#[cfg(feature = "provider-grpc")] +use tokio::task::JoinHandle; +#[cfg(all(target_os = "linux", feature = "kernel-bypass"))] +use tokio::task::spawn_blocking; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, + process::{Child, ChildStdin, ChildStdout, Command}, +}; + +/// Wire tag for websocket ingress handoff. +const INGRESS_KIND_WEB_SOCKET: u8 = 1; +/// Wire tag for Yellowstone gRPC ingress handoff. +const INGRESS_KIND_GRPC: u8 = 2; +/// Wire tag for gossip bootstrap ingress handoff. +const INGRESS_KIND_GOSSIP: u8 = 3; +/// Wire tag for direct raw-shred ingress handoff. +const INGRESS_KIND_DIRECT_SHREDS: u8 = 4; +#[cfg(feature = "provider-grpc")] +/// Wire tag for Yellowstone transaction stream selection. +const GRPC_STREAM_TRANSACTIONS: u8 = 1; +#[cfg(feature = "provider-grpc")] +/// Wire tag for Yellowstone transaction-status stream selection. +const GRPC_STREAM_TRANSACTION_STATUS: u8 = 2; +#[cfg(feature = "provider-grpc")] +/// Wire tag for Yellowstone account stream selection. +const GRPC_STREAM_ACCOUNTS: u8 = 3; +#[cfg(feature = "provider-grpc")] +/// Wire tag for Yellowstone block-meta stream selection. +const GRPC_STREAM_BLOCK_META: u8 = 4; +#[cfg(feature = "provider-grpc")] +/// Wire tag for Yellowstone slot stream selection. +const GRPC_STREAM_SLOTS: u8 = 5; +#[cfg(feature = "provider-grpc")] +/// Provider-stream channel capacity used by the native host. +const PROVIDER_STREAM_QUEUE_CAPACITY: usize = 4096; +/// Worker response tag for manifest delivery. +const RESPONSE_TAG_MANIFEST: u8 = 1; +/// Worker response tag for startup acknowledgement. +const RESPONSE_TAG_STARTED: u8 = 2; +/// Worker response tag for packet delivery acknowledgement. +const RESPONSE_TAG_EVENT_HANDLED: u8 = 3; +/// Worker response tag for shutdown acknowledgement. +const RESPONSE_TAG_SHUTDOWN_COMPLETE: u8 = 4; +#[cfg(feature = "provider-grpc")] +/// Worker response tag for provider-event acknowledgement. +const RESPONSE_TAG_PROVIDER_EVENT_HANDLED: u8 = 5; +/// Successful worker result tag. +const RESULT_TAG_OK: u8 = 1; +/// Error worker result tag. +const RESULT_TAG_ERR: u8 = 2; + +/// Errors returned by the native TypeScript runtime host. +#[derive(Debug, thiserror::Error)] +enum HostError { + /// No config path argument was supplied on the command line. + #[error("usage: sof_ts_runtime_host ")] + MissingConfigPath, + /// Reading the host config file failed. + #[error("failed to read runtime host config {path}: {source}")] + ReadConfig { + /// Path that failed to load. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + /// Parsing the host config JSON failed. + #[error("failed to parse runtime host config {path}: {source}")] + ParseConfig { + /// Path that failed to parse. + path: PathBuf, + /// Underlying JSON parse failure. + source: serde_json::Error, + }, + /// The provided config is structurally valid JSON but semantically invalid. + #[error("{0}")] + InvalidConfig(String), + #[cfg(all(target_os = "linux", feature = "kernel-bypass"))] + /// The AF_XDP kernel-bypass producer failed. + #[error("kernel-bypass producer failed: {0}")] + KernelBypass(String), + /// The observer runtime failed while running. + #[error("runtime failed: {0}")] + Runtime(#[from] RuntimeError), +} + +/// Top-level JSON payload handed from the TypeScript SDK into the native host. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeHostConfig { + /// Stable app name used for environment and observability. + app_name: String, + /// Runtime environment variables derived from the TypeScript runtime config. + runtime_environment: HashMap, + /// Ingress sources delegated to the native host. + ingress: Vec, + #[cfg(feature = "provider-grpc")] + /// Provider fan-in policy for multi-source gRPC ingress. + fan_in: Option, + /// Plugin workers launched through the stdio worker bridge. + plugin_workers: Vec, +} + +#[cfg(feature = "provider-grpc")] +/// JSON wire representation of provider fan-in arbitration. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FanInConfig { + /// Arbitration strategy selected by the TypeScript SDK. + strategy: u8, +} + +/// JSON wire representation for one ingress source. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IngressConfig { + /// Ingress kind discriminator. + kind: u8, + /// Stable ingress instance name. + name: String, + /// Optional bind address for raw ingress runtime setup. + bind_address: Option, + #[cfg(feature = "provider-grpc")] + /// Provider endpoint URL. + endpoint: Option, + #[cfg(feature = "provider-grpc")] + /// Provider stream family discriminator. + stream: Option, + #[cfg(feature = "provider-grpc")] + /// Optional Yellowstone x-token. + x_token: Option, + #[cfg(feature = "provider-grpc")] + /// Optional provider commitment policy. + commitment: Option, + #[cfg(feature = "provider-grpc")] + /// Optional Yellowstone vote filter. + vote: Option, + #[cfg(feature = "provider-grpc")] + /// Optional Yellowstone failed filter. + failed: Option, + #[cfg(feature = "provider-grpc")] + /// Optional signature filter. + signature: Option, + #[cfg(feature = "provider-grpc")] + /// Account-include filter list. + account_include: Option>, + #[cfg(feature = "provider-grpc")] + /// Account-exclude filter list. + account_exclude: Option>, + #[cfg(feature = "provider-grpc")] + /// Account-required filter list. + account_required: Option>, + #[cfg(feature = "provider-grpc")] + /// Explicit account selector list. + accounts: Option>, + #[cfg(feature = "provider-grpc")] + /// Explicit owner selector list. + owners: Option>, + #[cfg(feature = "provider-grpc")] + /// Whether a transaction signature must be present before dispatch. + require_transaction_signature: Option, + #[cfg(feature = "provider-grpc")] + /// Source readiness policy inside provider fan-in. + readiness: Option, + #[cfg(feature = "provider-grpc")] + /// Source role inside provider fan-in. + role: Option, + #[cfg(feature = "provider-grpc")] + /// Explicit source priority inside provider fan-in. + priority: Option, + /// Websocket URL for SDK-side validation errors. + url: Option, + /// Gossip bootstrap entrypoints. + entrypoints: Option>, + /// Gossip runtime mode selector. + runtime_mode: Option, + /// Whether the active gossip entrypoint is pinned. + entrypoint_pinned: Option, + /// Optional kernel-bypass receive configuration. + kernel_bypass: Option, +} + +/// JSON wire representation of direct AF_XDP ingest settings. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KernelBypassConfig { + /// Target interface name. + interface: String, + /// Receive queue id. + queue_id: u32, + /// Packet batch size. + batch_size: usize, + /// Number of UMEM frames to allocate. + umem_frame_count: u32, + /// RX/fill/completion ring depth. + ring_depth: u32, + /// Poll timeout in milliseconds. + poll_timeout_ms: u64, +} + +/// JSON wire representation of one stdio-backed plugin worker. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginWorkerConfig { + /// Stable plugin worker name. + name: String, + /// Worker manifest used to register the extension or plugin bridge. + manifest: RuntimeExtensionWorkerManifestConfig, + /// Executable command used to spawn the worker. + command: String, + /// Command-line arguments passed to the worker. + args: Vec, + /// Environment variables passed to the worker. + environment: HashMap, +} + +/// Worker manifest envelope received from the TypeScript SDK. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeExtensionWorkerManifestConfig { + /// Extension name used by the runtime extension host. + extension_name: String, + /// Runtime extension manifest payload. + manifest: ExtensionManifestConfig, +} + +/// Runtime extension manifest payload serialized from TypeScript. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExtensionManifestConfig { + /// Extension capability discriminators. + capabilities: Vec, + /// Resource declarations owned by the extension. + resources: Vec, + /// Packet subscriptions requested by the extension. + subscriptions: Vec, +} + +/// Extension resource declaration serialized from TypeScript. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExtensionResourceConfig { + /// Resource kind discriminator. + kind: u8, + /// Stable resource id. + resource_id: String, + /// Local bind address for listener resources. + bind_address: Option, + /// Remote address for outbound resources. + remote_address: Option, + /// URL for websocket resources. + url: Option, + /// Stream visibility policy. + visibility: ExtensionStreamVisibilityConfig, + /// Read buffer size in bytes. + read_buffer_bytes: usize, +} + +/// Visibility policy for extension-owned streams. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExtensionStreamVisibilityConfig { + /// Visibility discriminator. + tag: u8, + /// Optional shared visibility tag. + shared_tag: Option, +} + +/// Packet subscription filter serialized from TypeScript. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PacketSubscriptionConfig { + /// Packet source kind selector. + source_kind: Option, + /// Transport selector. + transport: Option, + /// Packet event-class selector. + event_class: Option, + /// Local address filter. + local_address: Option, + /// Local port filter. + local_port: Option, + /// Remote address filter. + remote_address: Option, + /// Remote port filter. + remote_port: Option, + /// Owning extension filter. + owner_extension: Option, + /// Resource id filter. + resource_id: Option, + /// Shared stream tag filter. + shared_tag: Option, + /// Websocket frame-type selector. + web_socket_frame_type: Option, +} + +/// Runtime extension adapter that forwards packet events into one TS worker. +struct TypeScriptRuntimeExtension { + /// Extension name registered with the runtime. + name: &'static str, + /// Worker launch configuration. + config: PluginWorkerConfig, + /// Live worker process handle. + process: Mutex>, +} + +#[cfg(feature = "provider-grpc")] +/// Observer plugin adapter that forwards provider events into one TS worker. +struct TypeScriptObserverPlugin { + /// Plugin name registered with the observer plugin host. + name: &'static str, + /// Worker launch configuration. + config: PluginWorkerConfig, + /// Live worker process handle. + process: Mutex>, +} + +/// Spawned stdio worker process state. +struct TypeScriptWorkerProcess { + /// Child process handle. + child: Child, + /// Worker stdin used for protocol messages. + stdin: ChildStdin, + /// Newline-delimited worker stdout reader. + stdout: Lines>, +} + +/// Lifecycle context passed into the TypeScript worker protocol. +#[derive(Debug, Serialize)] +struct WorkerContext<'name> { + #[serde(rename = "extensionName")] + /// Extension name associated with the lifecycle callback. + extension_name: &'name str, +} + +/// Starts the native TypeScript runtime host. +#[tokio::main] +async fn main() -> Result<(), HostError> { + let config_path = env::args_os() + .nth(1) + .map(PathBuf::from) + .ok_or(HostError::MissingConfigPath)?; + let config_text = fs::read_to_string(&config_path).map_err(|source| HostError::ReadConfig { + path: config_path.clone(), + source, + })?; + let config: RuntimeHostConfig = + serde_json::from_str(&config_text).map_err(|source| HostError::ParseConfig { + path: config_path, + source, + })?; + + run_config(config).await +} + +/// Routes the parsed host config into the raw or provider runtime path. +async fn run_config(config: RuntimeHostConfig) -> Result<(), HostError> { + validate_ingress(&config)?; + + if config + .ingress + .iter() + .any(|ingress| ingress.kind == INGRESS_KIND_GRPC) + { + return run_provider_stream_config(config).await; + } + + run_raw_ingress_config(config).await +} + +/// Runs one raw-ingress observer runtime through the extension host bridge. +async fn run_raw_ingress_config(config: RuntimeHostConfig) -> Result<(), HostError> { + let setup = runtime_setup(&config)?; + let kernel_bypass = direct_shreds_ingress(&config) + .and_then(|ingress| ingress.kernel_bypass.as_ref()) + .cloned(); + let mut extension_host_builder = RuntimeExtensionHost::builder(); + for worker in config.plugin_workers { + let extension_name = leak_extension_name(worker.manifest.extension_name.clone())?; + extension_host_builder = extension_host_builder.add_extension(TypeScriptRuntimeExtension { + name: extension_name, + config: worker, + process: Mutex::new(None), + }); + } + + let runtime = ObserverRuntime::new() + .with_setup(setup) + .with_extension_host(extension_host_builder.build()); + run_raw_runtime_with_optional_kernel_bypass(runtime, kernel_bypass.as_ref()).await?; + + Ok(()) +} + +#[cfg(all(target_os = "linux", feature = "kernel-bypass"))] +/// Runs one raw observer runtime and optionally attaches AF_XDP ingest. +async fn run_raw_runtime_with_optional_kernel_bypass( + runtime: ObserverRuntime, + kernel_bypass: Option<&KernelBypassConfig>, +) -> Result<(), HostError> { + let Some(kernel_bypass) = kernel_bypass else { + runtime.run_until_termination_signal().await?; + return Ok(()); + }; + + let producer_config = af_xdp::AfXdpConfig { + interface: kernel_bypass.interface.clone(), + queue_id: kernel_bypass.queue_id, + batch_size: kernel_bypass.batch_size, + umem_frame_count: kernel_bypass.umem_frame_count, + ring_depth: kernel_bypass.ring_depth, + poll_timeout: Duration::from_millis(kernel_bypass.poll_timeout_ms), + filter: af_xdp::PortFilter::default_sol(), + }; + let stop = Arc::new(AtomicBool::new(false)); + let producer_stop = Arc::clone(&stop); + let (tx, rx) = sof::runtime::create_kernel_bypass_ingress_queue(); + let producer_task = spawn_blocking(move || { + af_xdp::run_af_xdp_producer_until(&tx, &producer_config, &producer_stop) + }); + let runtime_result = runtime + .with_kernel_bypass_ingress(rx) + .run_until_termination_signal() + .await; + + stop.store(true, Ordering::Relaxed); + let producer_result: Result<(), af_xdp::AfXdpError> = producer_task.await.map_err(|error| { + HostError::KernelBypass(format!("AF_XDP producer task join failed: {error}")) + })?; + producer_result.map_err(|error| HostError::KernelBypass(error.to_string()))?; + runtime_result?; + Ok(()) +} + +#[cfg(not(all(target_os = "linux", feature = "kernel-bypass")))] +/// Runs one raw observer runtime when kernel bypass is unavailable. +async fn run_raw_runtime_with_optional_kernel_bypass( + runtime: ObserverRuntime, + kernel_bypass: Option<&KernelBypassConfig>, +) -> Result<(), HostError> { + if kernel_bypass.is_some() { + return Err(HostError::InvalidConfig( + "kernel bypass requires a Linux runtime host built with the kernel-bypass feature" + .to_owned(), + )); + } + + runtime.run_until_termination_signal().await?; + Ok(()) +} + +#[cfg(feature = "provider-grpc")] +/// Runs one provider-stream observer runtime through the plugin host bridge. +async fn run_provider_stream_config(config: RuntimeHostConfig) -> Result<(), HostError> { + let setup = runtime_setup(&config)?; + let (provider_stream_tx, provider_stream_rx) = + create_provider_stream_queue(PROVIDER_STREAM_QUEUE_CAPACITY); + let mut modes = Vec::new(); + let mut source_handles = Vec::new(); + + for ingress in &config.ingress { + if ingress.kind != INGRESS_KIND_GRPC { + return Err(HostError::InvalidConfig(format!( + "ingress `{}` cannot be mixed with gRPC provider-stream ingress in one TypeScript runtime host", + ingress.name + ))); + } + + let source = + spawn_yellowstone_ingress(ingress, config.fan_in.as_ref(), provider_stream_tx.clone()) + .await?; + modes.push(source.mode); + source_handles.push(source.handle); + } + drop(provider_stream_tx); + + let mut plugin_host_builder = PluginHost::builder(); + for worker in config.plugin_workers { + let plugin_name = leak_extension_name(worker.manifest.extension_name.clone())?; + plugin_host_builder = plugin_host_builder.add_plugin(TypeScriptObserverPlugin { + name: plugin_name, + config: worker, + process: Mutex::new(None), + }); + } + + let mode = provider_stream_mode(modes.as_slice()); + let runtime_result = ObserverRuntime::new() + .with_setup(setup) + .with_plugin_host(plugin_host_builder.build()) + .with_provider_stream_ingress(mode, provider_stream_rx) + .run_until_termination_signal() + .await; + + for handle in source_handles { + handle.abort(); + } + + runtime_result?; + Ok(()) +} + +#[cfg(not(feature = "provider-grpc"))] +/// Rejects provider-stream configs when the host was built without gRPC support. +async fn run_provider_stream_config(config: RuntimeHostConfig) -> Result<(), HostError> { + let ingress = config + .ingress + .iter() + .find(|ingress| ingress.kind == INGRESS_KIND_GRPC) + .map(|ingress| ingress.name.as_str()) + .unwrap_or("unknown"); + Err(HostError::InvalidConfig(format!( + "gRPC ingress `{ingress}` requires a runtime host built with the provider-grpc feature" + ))) +} + +/// Validates ingress combinations accepted by the native host. +fn validate_ingress(config: &RuntimeHostConfig) -> Result<(), HostError> { + let mut direct_shreds_count = 0_usize; + #[cfg(feature = "gossip-bootstrap")] + let mut gossip_count = 0_usize; + #[cfg(not(feature = "gossip-bootstrap"))] + let gossip_count = 0_usize; + let mut provider_ingress_count = 0_usize; + for ingress in &config.ingress { + match ingress.kind { + INGRESS_KIND_WEB_SOCKET => { + return Err(HostError::InvalidConfig(format!( + "websocket ingress `{}` should run on the TypeScript websocket path (url={})", + ingress.name, + ingress.url.as_deref().unwrap_or("unknown"), + ))); + } + INGRESS_KIND_GRPC => { + provider_ingress_count = + provider_ingress_count.checked_add(1).ok_or_else(|| { + HostError::InvalidConfig( + "provider ingress count overflowed during validation".to_owned(), + ) + })?; + } + INGRESS_KIND_GOSSIP => { + #[cfg(not(feature = "gossip-bootstrap"))] + { + return Err(HostError::InvalidConfig(format!( + "gossip ingress `{}` requires a runtime host built with the gossip-bootstrap feature", + ingress.name + ))); + } + #[cfg(feature = "gossip-bootstrap")] + { + gossip_count = gossip_count.checked_add(1).ok_or_else(|| { + HostError::InvalidConfig( + "gossip ingress count overflowed during validation".to_owned(), + ) + })?; + if ingress.kernel_bypass.is_some() { + return Err(HostError::InvalidConfig(format!( + "gossip ingress `{}` cannot declare kernel bypass directly; attach kernel-bypass config to direct shred ingress instead", + ingress.name + ))); + } + } + } + INGRESS_KIND_DIRECT_SHREDS => { + direct_shreds_count = direct_shreds_count.checked_add(1).ok_or_else(|| { + HostError::InvalidConfig( + "direct shred ingress count overflowed during validation".to_owned(), + ) + })?; + if let Some(kernel_bypass) = ingress.kernel_bypass.as_ref() { + validate_kernel_bypass_config(kernel_bypass, &ingress.name)?; + #[cfg(not(all(target_os = "linux", feature = "kernel-bypass")))] + return Err(HostError::InvalidConfig(format!( + "ingress `{}` requests kernel bypass; this runtime host must be built on Linux with the kernel-bypass feature", + ingress.name + ))); + } + } + other => { + return Err(HostError::InvalidConfig(format!( + "ingress `{}` has unsupported kind {other}", + ingress.name + ))); + } + } + } + + let raw_ingress_count = direct_shreds_count + .checked_add(gossip_count) + .ok_or_else(|| { + HostError::InvalidConfig("raw ingress count overflowed during validation".to_owned()) + })?; + if provider_ingress_count > 0 && raw_ingress_count > 0 { + return Err(HostError::InvalidConfig( + "TypeScript runtime host does not support mixing provider-stream ingress and raw packet ingress in one app run" + .to_owned(), + )); + } + + if direct_shreds_count > 1 { + return Err(HostError::InvalidConfig( + "TypeScript runtime host currently supports one direct shred ingress source per app run" + .to_owned(), + )); + } + if gossip_count > 1 { + return Err(HostError::InvalidConfig( + "TypeScript runtime host currently supports one gossip ingress source per app run" + .to_owned(), + )); + } + if let (Some(bind_address), Some(gossip_bind_address)) = ( + direct_shreds_ingress(config).and_then(|ingress| ingress.bind_address.as_deref()), + gossip_ingress(config).and_then(|ingress| ingress.bind_address.as_deref()), + ) && bind_address != gossip_bind_address + { + return Err(HostError::InvalidConfig(format!( + "direct shred and gossip ingress bind addresses must match when both are configured ({bind_address} != {gossip_bind_address})" + ))); + } + + Ok(()) +} + +/// Validates one kernel-bypass config block after JSON deserialization. +fn validate_kernel_bypass_config( + config: &KernelBypassConfig, + ingress_name: &str, +) -> Result<(), HostError> { + if config.interface.trim().is_empty() { + return Err(HostError::InvalidConfig(format!( + "ingress `{ingress_name}` requests kernel bypass with an empty network interface" + ))); + } + if config.batch_size == 0 { + return Err(HostError::InvalidConfig(format!( + "ingress `{ingress_name}` requests kernel bypass with batchSize=0" + ))); + } + if config.umem_frame_count == 0 { + return Err(HostError::InvalidConfig(format!( + "ingress `{ingress_name}` requests kernel bypass with umemFrameCount=0" + ))); + } + if config.ring_depth == 0 { + return Err(HostError::InvalidConfig(format!( + "ingress `{ingress_name}` requests kernel bypass with ringDepth=0" + ))); + } + if config.poll_timeout_ms == 0 { + return Err(HostError::InvalidConfig(format!( + "ingress `{ingress_name}` requests kernel bypass with pollTimeoutMs=0" + ))); + } + let _queue_id = config.queue_id; + Ok(()) +} + +/// Builds the runtime setup derived from the TypeScript config. +fn runtime_setup(config: &RuntimeHostConfig) -> Result { + let mut setup = RuntimeSetup::new(); + for (key, value) in &config.runtime_environment { + setup = setup.with_env(key, value); + } + if let Some(bind_address) = effective_bind_address(config) { + setup = setup.with_env("SOF_BIND", bind_address); + } + if let Some(ingress) = gossip_ingress(config) + && let Some(entrypoints) = ingress.entrypoints.as_ref() + { + setup = setup.with_gossip_entrypoints(entrypoints.iter().cloned()); + if let Some(pinned) = ingress.entrypoint_pinned { + setup = setup.with_gossip_entrypoint_pinned(pinned); + } + #[cfg(feature = "gossip-bootstrap")] + if let Some(runtime_mode) = ingress.runtime_mode { + setup = setup.with_gossip_runtime_mode(gossip_runtime_mode_from_wire(runtime_mode)?); + } + } + Ok(setup.with_env("SOF_TS_APP_NAME", &config.app_name)) +} + +#[cfg(feature = "gossip-bootstrap")] +/// Maps the wire gossip runtime mode into the Rust runtime enum. +fn gossip_runtime_mode_from_wire(value: u8) -> Result { + match value { + 1 => Ok(GossipRuntimeMode::Full), + 2 => Ok(GossipRuntimeMode::BootstrapOnly), + 3 => Ok(GossipRuntimeMode::ControlPlaneOnly), + other => Err(HostError::InvalidConfig(format!( + "unsupported gossip runtime mode {other}" + ))), + } +} + +/// Returns the direct-shreds ingress when present. +fn direct_shreds_ingress(config: &RuntimeHostConfig) -> Option<&IngressConfig> { + config + .ingress + .iter() + .find(|ingress| ingress.kind == INGRESS_KIND_DIRECT_SHREDS) +} + +/// Returns the gossip ingress when present. +fn gossip_ingress(config: &RuntimeHostConfig) -> Option<&IngressConfig> { + config + .ingress + .iter() + .find(|ingress| ingress.kind == INGRESS_KIND_GOSSIP) +} + +/// Returns the bind address that should drive raw runtime setup. +fn effective_bind_address(config: &RuntimeHostConfig) -> Option<&str> { + direct_shreds_ingress(config) + .and_then(|ingress| ingress.bind_address.as_deref()) + .or_else(|| gossip_ingress(config).and_then(|ingress| ingress.bind_address.as_deref())) +} + +#[cfg(feature = "provider-grpc")] +/// Yellowstone source task handle plus the runtime mode it drives. +struct ProviderSourceHandle { + /// Runtime mode inferred from the configured source. + mode: ProviderStreamMode, + /// Join handle for the spawned Yellowstone source task. + handle: JoinHandle>, +} + +#[cfg(feature = "provider-grpc")] +/// Spawns one Yellowstone ingress source from the wire config. +async fn spawn_yellowstone_ingress( + ingress: &IngressConfig, + fan_in: Option<&FanInConfig>, + sender: ProviderStreamSender, +) -> Result { + let endpoint = ingress.endpoint.as_deref().ok_or_else(|| { + HostError::InvalidConfig(format!( + "gRPC ingress `{}` is missing endpoint", + ingress.name + )) + })?; + let stream = ingress.stream.unwrap_or(GRPC_STREAM_TRANSACTIONS); + + if stream == GRPC_STREAM_SLOTS { + let mut config = + YellowstoneGrpcSlotsConfig::new(endpoint).with_source_instance(ingress.name.clone()); + config = apply_yellowstone_slot_source_policy(config, ingress)?; + config = apply_yellowstone_slot_fan_in(config, fan_in)?; + if let Some(x_token) = non_empty_optional(ingress.x_token.as_deref()) { + config = config.with_x_token(x_token.to_owned()); + } + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(yellowstone_commitment_from_wire(commitment)?); + } + let mode = config.runtime_mode(); + let handle = spawn_yellowstone_grpc_slot_source(config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + return Ok(ProviderSourceHandle { mode, handle }); + } + + let mut config = YellowstoneGrpcConfig::new(endpoint) + .with_source_instance(ingress.name.clone()) + .with_stream(yellowstone_stream_from_wire(stream)?); + config = apply_yellowstone_source_policy(config, ingress)?; + config = apply_yellowstone_fan_in(config, fan_in)?; + if let Some(x_token) = non_empty_optional(ingress.x_token.as_deref()) { + config = config.with_x_token(x_token.to_owned()); + } + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(yellowstone_commitment_from_wire(commitment)?); + } + if let Some(vote) = ingress.vote { + config = config.with_vote(vote); + } + if let Some(failed) = ingress.failed { + config = config.with_failed(failed); + } + if let Some(signature) = non_empty_optional(ingress.signature.as_deref()) { + config = config.with_signature(parse_signature(signature, "ingress.signature")?); + } + config = config + .with_account_include(parse_pubkeys( + ingress.account_include.as_deref().unwrap_or(&[]), + "ingress.accountInclude", + )?) + .with_account_exclude(parse_pubkeys( + ingress.account_exclude.as_deref().unwrap_or(&[]), + "ingress.accountExclude", + )?) + .with_account_required(parse_pubkeys( + ingress.account_required.as_deref().unwrap_or(&[]), + "ingress.accountRequired", + )?) + .with_accounts(parse_pubkeys( + ingress.accounts.as_deref().unwrap_or(&[]), + "ingress.accounts", + )?) + .with_owners(parse_pubkeys( + ingress.owners.as_deref().unwrap_or(&[]), + "ingress.owners", + )?); + if ingress.require_transaction_signature.unwrap_or(false) { + config = config.require_transaction_signature(); + } + + let mode = config.runtime_mode(); + let handle = spawn_yellowstone_grpc_source(config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + Ok(ProviderSourceHandle { mode, handle }) +} + +#[cfg(feature = "provider-grpc")] +/// Applies per-source readiness, role, and priority to one Yellowstone config. +fn apply_yellowstone_source_policy( + mut config: YellowstoneGrpcConfig, + ingress: &IngressConfig, +) -> Result { + if let Some(readiness) = ingress.readiness { + config = config.with_readiness(provider_readiness_from_wire(readiness)?); + } + if let Some(role) = ingress.role { + config = config.with_source_role(provider_role_from_wire(role)?); + } + if let Some(priority) = ingress.priority { + config = config.with_source_priority(priority); + } + Ok(config) +} + +#[cfg(feature = "provider-grpc")] +/// Applies per-source readiness, role, and priority to one Yellowstone slots config. +fn apply_yellowstone_slot_source_policy( + mut config: YellowstoneGrpcSlotsConfig, + ingress: &IngressConfig, +) -> Result { + if let Some(readiness) = ingress.readiness { + config = config.with_readiness(provider_readiness_from_wire(readiness)?); + } + if let Some(role) = ingress.role { + config = config.with_source_role(provider_role_from_wire(role)?); + } + if let Some(priority) = ingress.priority { + config = config.with_source_priority(priority); + } + Ok(config) +} + +#[cfg(feature = "provider-grpc")] +/// Applies fan-in arbitration to one Yellowstone config. +fn apply_yellowstone_fan_in( + config: YellowstoneGrpcConfig, + fan_in: Option<&FanInConfig>, +) -> Result { + Ok(config.with_source_arbitration(provider_source_arbitration(fan_in)?)) +} + +#[cfg(feature = "provider-grpc")] +/// Applies fan-in arbitration to one Yellowstone slots config. +fn apply_yellowstone_slot_fan_in( + config: YellowstoneGrpcSlotsConfig, + fan_in: Option<&FanInConfig>, +) -> Result { + Ok(config.with_source_arbitration(provider_source_arbitration(fan_in)?)) +} + +#[cfg(feature = "provider-grpc")] +/// Maps the wire fan-in strategy into the Rust arbitration enum. +fn provider_source_arbitration( + fan_in: Option<&FanInConfig>, +) -> Result { + let Some(fan_in) = fan_in else { + return Ok(ProviderSourceArbitrationMode::FirstSeen); + }; + match fan_in.strategy { + 1 => Ok(ProviderSourceArbitrationMode::EmitAll), + 2 => Ok(ProviderSourceArbitrationMode::FirstSeen), + 3 => Ok(ProviderSourceArbitrationMode::FirstSeenThenPromote), + other => Err(HostError::InvalidConfig(format!( + "unsupported fan-in arbitration mode {other}" + ))), + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps the wire provider readiness selector into the Rust enum. +fn provider_readiness_from_wire(value: u8) -> Result { + match value { + 1 => Ok(ProviderSourceReadiness::Required), + 2 => Ok(ProviderSourceReadiness::Optional), + other => Err(HostError::InvalidConfig(format!( + "unsupported provider ingress readiness {other}" + ))), + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps the wire provider role selector into the Rust enum. +fn provider_role_from_wire(value: u8) -> Result { + match value { + 1 => Ok(ProviderSourceRole::Primary), + 2 => Ok(ProviderSourceRole::Secondary), + 3 => Ok(ProviderSourceRole::Fallback), + 4 => Ok(ProviderSourceRole::ConfirmOnly), + other => Err(HostError::InvalidConfig(format!( + "unsupported provider ingress role {other}" + ))), + } +} + +#[cfg(feature = "provider-grpc")] +/// Collapses one or more source runtime modes into the runtime ingress mode. +fn provider_stream_mode(modes: &[ProviderStreamMode]) -> ProviderStreamMode { + match modes { + [mode] => *mode, + _ => ProviderStreamMode::Generic, + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps the wire Yellowstone stream selector into the Rust enum. +fn yellowstone_stream_from_wire(value: u8) -> Result { + match value { + GRPC_STREAM_TRANSACTIONS => Ok(YellowstoneGrpcStream::Transaction), + GRPC_STREAM_TRANSACTION_STATUS => Ok(YellowstoneGrpcStream::TransactionStatus), + GRPC_STREAM_ACCOUNTS => Ok(YellowstoneGrpcStream::Accounts), + GRPC_STREAM_BLOCK_META => Ok(YellowstoneGrpcStream::BlockMeta), + other => Err(HostError::InvalidConfig(format!( + "unsupported gRPC stream kind {other}" + ))), + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps the wire Yellowstone commitment selector into the Rust enum. +fn yellowstone_commitment_from_wire(value: u8) -> Result { + match value { + 1 => Ok(YellowstoneGrpcCommitment::Processed), + 2 => Ok(YellowstoneGrpcCommitment::Confirmed), + 3 => Ok(YellowstoneGrpcCommitment::Finalized), + other => Err(HostError::InvalidConfig(format!( + "unsupported gRPC commitment {other}" + ))), + } +} + +#[cfg(feature = "provider-grpc")] +/// Trims and filters empty optional string values. +fn non_empty_optional(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(feature = "provider-grpc")] +/// Parses one base58 transaction signature from the wire config. +fn parse_signature(value: &str, field: &str) -> Result { + Signature::from_str(value).map_err(|error| { + HostError::InvalidConfig(format!("{field} is not a valid signature: {error}")) + }) +} + +#[cfg(feature = "provider-grpc")] +/// Parses one list of pubkeys from the wire config. +fn parse_pubkeys(values: &[String], field: &str) -> Result, HostError> { + values + .iter() + .map(|value| { + Pubkey::from_str(value).map_err(|error| { + HostError::InvalidConfig(format!( + "{field} contains invalid pubkey `{value}`: {error}" + )) + }) + }) + .collect() +} + +/// Leaks one extension name for the lifetime expected by the host traits. +fn leak_extension_name(name: String) -> Result<&'static str, HostError> { + if name.trim().is_empty() { + return Err(HostError::InvalidConfig( + "plugin worker declares an empty extension name".to_owned(), + )); + } + + Ok(Box::leak(name.into_boxed_str())) +} + +#[async_trait] +impl RuntimeExtension for TypeScriptRuntimeExtension { + fn name(&self) -> &'static str { + self.name + } + + async fn setup( + &self, + _ctx: ExtensionContext, + ) -> Result { + let process = start_worker_process(self.name, &self.config) + .await + .map_err(ExtensionSetupError::new)?; + + let manifest = extension_manifest_from_config(&self.config.manifest.manifest) + .map_err(ExtensionSetupError::new)?; + let mut guard = self + .process + .lock() + .map_err(|_error| ExtensionSetupError::new("worker process lock poisoned"))?; + *guard = Some(process); + Ok(manifest) + } + + async fn on_packet_received(&self, event: RuntimePacketEvent) { + let process = match self.process.lock() { + Ok(mut guard) => guard.take(), + Err(error) => { + tracing::warn!(extension = self.name, error = %error, "worker process lock poisoned"); + None + } + }; + let Some(mut process) = process else { + tracing::warn!( + extension = self.name, + "worker process is not available for packet" + ); + return; + }; + + if let Err(error) = send_worker_message( + &mut process, + json!({ + "tag": 3, + "event": runtime_packet_event_wire(&event), + }), + ) + .await + { + tracing::warn!(extension = self.name, error = %error, "failed to deliver packet to worker"); + } else { + match read_worker_response(&mut process, RESPONSE_TAG_EVENT_HANDLED).await { + Ok(response) => { + if let Err(error) = response_result_ok(self.name, &response) { + tracing::warn!(extension = self.name, error = %error, "worker rejected packet"); + } + } + Err(error) => { + tracing::warn!(extension = self.name, error = %error, "worker did not acknowledge packet"); + } + } + } + + if let Ok(mut guard) = self.process.lock() { + *guard = Some(process); + } + } + + async fn shutdown(&self, _ctx: ExtensionContext) { + let process = match self.process.lock() { + Ok(mut guard) => guard.take(), + Err(error) => { + tracing::warn!(extension = self.name, error = %error, "worker process lock poisoned"); + None + } + }; + let Some(mut process) = process else { + return; + }; + + if let Err(error) = send_worker_message( + &mut process, + json!({ + "tag": 4, + "context": WorkerContext { + extension_name: self.name, + }, + }), + ) + .await + { + tracing::warn!(extension = self.name, error = %error, "failed to request worker shutdown"); + } else if let Err(error) = + read_worker_response(&mut process, RESPONSE_TAG_SHUTDOWN_COMPLETE) + .await + .and_then(|response| response_result_ok(self.name, &response)) + { + tracing::warn!(extension = self.name, error = %error, "worker shutdown was not acknowledged"); + } + + if let Err(error) = process.child.wait().await { + tracing::warn!(extension = self.name, error = %error, "failed to wait for worker process"); + } + } +} + +#[async_trait] +#[cfg(feature = "provider-grpc")] +impl ObserverPlugin for TypeScriptObserverPlugin { + fn name(&self) -> &'static str { + self.name + } + + fn config(&self) -> PluginConfig { + PluginConfig { + transaction_log: true, + ..PluginConfig::new() + .with_transaction() + .with_transaction_status() + .with_account_update() + .with_block_meta() + .with_slot_status() + .with_recent_blockhash() + } + } + + async fn setup(&self, _ctx: PluginContext) -> Result<(), PluginSetupError> { + let process = start_worker_process(self.name, &self.config) + .await + .map_err(PluginSetupError::new)?; + let mut guard = self + .process + .lock() + .map_err(|_error| PluginSetupError::new("worker process lock poisoned"))?; + *guard = Some(process); + Ok(()) + } + + async fn on_transaction(&self, event: &TransactionEvent) { + self.deliver_provider_event(provider_transaction_event_wire(event)) + .await; + } + + async fn on_transaction_log(&self, event: &TransactionLogEvent) { + self.deliver_provider_event(provider_transaction_log_event_wire(event)) + .await; + } + + async fn on_transaction_status(&self, event: &TransactionStatusEvent) { + self.deliver_provider_event(provider_transaction_status_event_wire(event)) + .await; + } + + async fn on_account_update(&self, event: &AccountUpdateEvent) { + self.deliver_provider_event(provider_account_update_event_wire(event)) + .await; + } + + async fn on_block_meta(&self, event: &BlockMetaEvent) { + self.deliver_provider_event(provider_block_meta_event_wire(event)) + .await; + } + + async fn on_slot_status(&self, event: SlotStatusEvent) { + self.deliver_provider_event(provider_slot_status_event_wire(&event)) + .await; + } + + async fn on_recent_blockhash(&self, event: ObservedRecentBlockhashEvent) { + self.deliver_provider_event(provider_recent_blockhash_event_wire(&event)) + .await; + } + + async fn shutdown(&self, _ctx: PluginContext) { + shutdown_worker_process(self.name, &self.process).await; + } +} + +#[cfg(feature = "provider-grpc")] +impl TypeScriptObserverPlugin { + /// Delivers one provider event into the bound TypeScript worker. + async fn deliver_provider_event(&self, event: Value) { + let process = match self.process.lock() { + Ok(mut guard) => guard.take(), + Err(error) => { + tracing::warn!(plugin = self.name, error = %error, "worker process lock poisoned"); + None + } + }; + let Some(mut process) = process else { + tracing::warn!( + plugin = self.name, + "worker process is not available for provider event" + ); + return; + }; + + if let Err(error) = send_worker_message( + &mut process, + json!({ + "tag": 5, + "event": event, + }), + ) + .await + { + tracing::warn!(plugin = self.name, error = %error, "failed to deliver provider event to worker"); + } else { + match read_worker_response(&mut process, RESPONSE_TAG_PROVIDER_EVENT_HANDLED).await { + Ok(response) => { + if let Err(error) = response_result_ok(self.name, &response) { + tracing::warn!(plugin = self.name, error = %error, "worker rejected provider event"); + } + } + Err(error) => { + tracing::warn!(plugin = self.name, error = %error, "worker did not acknowledge provider event"); + } + } + } + + if let Ok(mut guard) = self.process.lock() { + *guard = Some(process); + } + } +} + +/// Spawns one TypeScript worker and completes its manifest and startup handshake. +async fn start_worker_process( + extension_name: &str, + config: &PluginWorkerConfig, +) -> Result { + let mut process = spawn_worker(extension_name, config).await?; + send_worker_message(&mut process, json!({ "tag": 1 })).await?; + let manifest_response = read_worker_response(&mut process, RESPONSE_TAG_MANIFEST).await?; + response_result_ok(extension_name, &manifest_response)?; + + send_worker_message( + &mut process, + json!({ + "tag": 2, + "context": WorkerContext { + extension_name, + }, + }), + ) + .await?; + let start_response = read_worker_response(&mut process, RESPONSE_TAG_STARTED).await?; + response_result_ok(extension_name, &start_response)?; + + Ok(process) +} + +#[cfg(feature = "provider-grpc")] +/// Requests shutdown for one worker process when it is still running. +async fn shutdown_worker_process( + extension_name: &'static str, + process: &Mutex>, +) { + let process = match process.lock() { + Ok(mut guard) => guard.take(), + Err(error) => { + tracing::warn!(extension = extension_name, error = %error, "worker process lock poisoned"); + None + } + }; + let Some(mut process) = process else { + return; + }; + + if let Err(error) = send_worker_message( + &mut process, + json!({ + "tag": 4, + "context": WorkerContext { + extension_name, + }, + }), + ) + .await + { + tracing::warn!(extension = extension_name, error = %error, "failed to request worker shutdown"); + } else if let Err(error) = read_worker_response(&mut process, RESPONSE_TAG_SHUTDOWN_COMPLETE) + .await + .and_then(|response| response_result_ok(extension_name, &response)) + { + tracing::warn!(extension = extension_name, error = %error, "worker shutdown was not acknowledged"); + } + + if let Err(error) = process.child.wait().await { + tracing::warn!(extension = extension_name, error = %error, "failed to wait for worker process"); + } +} + +/// Spawns one stdio worker process from the provided launch config. +async fn spawn_worker( + extension_name: &str, + config: &PluginWorkerConfig, +) -> Result { + if config.name != extension_name { + return Err(format!( + "worker `{}` does not match extension manifest name `{extension_name}`", + config.name + )); + } + + let mut command = Command::new(&config.command); + command + .args(&config.args) + .envs(&config.environment) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()); + let mut child = command + .spawn() + .map_err(|error| format!("failed to spawn worker `{extension_name}`: {error}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| format!("worker `{extension_name}` did not expose stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("worker `{extension_name}` did not expose stdout"))?; + + Ok(TypeScriptWorkerProcess { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + }) +} + +/// Serializes and writes one protocol message to the worker stdin. +async fn send_worker_message( + process: &mut TypeScriptWorkerProcess, + message: Value, +) -> Result<(), String> { + let mut line = serde_json::to_vec(&message) + .map_err(|error| format!("failed to encode worker message: {error}"))?; + line.push(b'\n'); + process + .stdin + .write_all(&line) + .await + .map_err(|error| format!("failed to write worker message: {error}"))?; + process + .stdin + .flush() + .await + .map_err(|error| format!("failed to flush worker message: {error}")) +} + +/// Reads one worker response line and validates its protocol tag. +async fn read_worker_response( + process: &mut TypeScriptWorkerProcess, + expected_tag: u8, +) -> Result { + let line = process + .stdout + .next_line() + .await + .map_err(|error| format!("failed to read worker response: {error}"))? + .ok_or_else(|| "worker stdout closed before response".to_owned())?; + let response: Value = serde_json::from_str(&line) + .map_err(|error| format!("worker response was invalid JSON: {error}; line={line}"))?; + let received_tag = response + .get("tag") + .and_then(Value::as_u64) + .ok_or_else(|| "worker response did not contain numeric tag".to_owned())?; + if received_tag != u64::from(expected_tag) { + return Err(format!( + "worker response tag {received_tag} did not match expected tag {expected_tag}", + )); + } + + Ok(response) +} + +/// Checks whether one worker response contains an OK result tag. +fn response_result_ok(extension_name: &str, response: &Value) -> Result<(), String> { + let result = response + .get("result") + .ok_or_else(|| format!("worker `{extension_name}` response omitted result"))?; + match result.get("tag").and_then(Value::as_u64) { + Some(value) if value == u64::from(RESULT_TAG_OK) => Ok(()), + Some(value) if value == u64::from(RESULT_TAG_ERR) => { + let message = result + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or("worker returned an error result"); + Err(format!( + "worker `{extension_name}` returned error: {message}" + )) + } + Some(other) => Err(format!( + "worker `{extension_name}` response used unsupported result tag {other}", + )), + None => Err(format!( + "worker `{extension_name}` response did not contain result tag", + )), + } +} + +/// Converts the TypeScript manifest wire config into a runtime manifest. +fn extension_manifest_from_config( + config: &ExtensionManifestConfig, +) -> Result { + Ok(ExtensionManifest { + capabilities: config + .capabilities + .iter() + .copied() + .map(extension_capability_from_wire) + .collect::, _>>()?, + resources: config + .resources + .iter() + .map(extension_resource_from_config) + .collect::, _>>()?, + subscriptions: config + .subscriptions + .iter() + .map(packet_subscription_from_config) + .collect::, _>>()?, + }) +} + +/// Converts one wire resource declaration into a runtime resource spec. +fn extension_resource_from_config( + config: &ExtensionResourceConfig, +) -> Result { + match config.kind { + 1 => Ok(ExtensionResourceSpec::UdpListener(UdpListenerSpec { + resource_id: config.resource_id.clone(), + bind_addr: parse_required_addr(config.bind_address.as_deref(), "bindAddress")?, + visibility: stream_visibility_from_config(&config.visibility)?, + read_buffer_bytes: config.read_buffer_bytes, + })), + 2 => Ok(ExtensionResourceSpec::TcpListener(TcpListenerSpec { + resource_id: config.resource_id.clone(), + bind_addr: parse_required_addr(config.bind_address.as_deref(), "bindAddress")?, + visibility: stream_visibility_from_config(&config.visibility)?, + read_buffer_bytes: config.read_buffer_bytes, + })), + 3 => Ok(ExtensionResourceSpec::TcpConnector(TcpConnectorSpec { + resource_id: config.resource_id.clone(), + remote_addr: parse_required_addr(config.remote_address.as_deref(), "remoteAddress")?, + visibility: stream_visibility_from_config(&config.visibility)?, + read_buffer_bytes: config.read_buffer_bytes, + })), + 4 => Ok(ExtensionResourceSpec::WsConnector(WsConnectorSpec { + resource_id: config.resource_id.clone(), + url: config + .url + .clone() + .ok_or_else(|| "websocket connector resource missing url".to_owned())?, + visibility: stream_visibility_from_config(&config.visibility)?, + read_buffer_bytes: config.read_buffer_bytes, + })), + other => Err(format!("unsupported extension resource kind {other}")), + } +} + +/// Converts the wire visibility policy into the runtime visibility enum. +fn stream_visibility_from_config( + config: &ExtensionStreamVisibilityConfig, +) -> Result { + match config.tag { + 1 => Ok(ExtensionStreamVisibility::Private), + 2 => Ok(ExtensionStreamVisibility::Shared { + tag: config + .shared_tag + .clone() + .ok_or_else(|| "shared stream visibility missing sharedTag".to_owned())?, + }), + other => Err(format!("unsupported stream visibility tag {other}")), + } +} + +/// Converts the wire packet subscription into the runtime subscription filter. +fn packet_subscription_from_config( + config: &PacketSubscriptionConfig, +) -> Result { + Ok(PacketSubscription { + source_kind: config + .source_kind + .map(runtime_packet_source_kind_from_wire) + .transpose()?, + transport: config + .transport + .map(runtime_packet_transport_from_wire) + .transpose()?, + event_class: config + .event_class + .map(runtime_packet_event_class_from_wire) + .transpose()?, + local_addr: config + .local_address + .as_deref() + .map(parse_addr) + .transpose()?, + local_port: config.local_port, + remote_addr: config + .remote_address + .as_deref() + .map(parse_addr) + .transpose()?, + remote_port: config.remote_port, + owner_extension: config.owner_extension.clone(), + resource_id: config.resource_id.clone(), + shared_tag: config.shared_tag.clone(), + websocket_frame_type: config + .web_socket_frame_type + .map(runtime_websocket_frame_type_from_wire) + .transpose()?, + }) +} + +/// Parses one required socket address field from a resource config. +fn parse_required_addr(value: Option<&str>, field: &str) -> Result { + value + .ok_or_else(|| format!("resource missing {field}")) + .and_then(parse_addr) +} + +/// Parses one socket address string used by extension resources. +fn parse_addr(value: &str) -> Result { + value + .parse() + .map_err(|error| format!("invalid socket address `{value}`: {error}")) +} + +/// Maps the wire extension capability into the runtime enum. +fn extension_capability_from_wire(value: u8) -> Result { + match value { + 1 => Ok(ExtensionCapability::BindUdp), + 2 => Ok(ExtensionCapability::BindTcp), + 3 => Ok(ExtensionCapability::ConnectTcp), + 4 => Ok(ExtensionCapability::ConnectWebSocket), + 5 => Ok(ExtensionCapability::ObserveObserverIngress), + 6 => Ok(ExtensionCapability::ObserveSharedExtensionStream), + other => Err(format!("unsupported extension capability {other}")), + } +} + +/// Maps the wire packet source-kind selector into the runtime enum. +fn runtime_packet_source_kind_from_wire(value: u8) -> Result { + match value { + 1 => Ok(RuntimePacketSourceKind::ObserverIngress), + 2 => Ok(RuntimePacketSourceKind::ExtensionResource), + other => Err(format!("unsupported runtime packet source kind {other}")), + } +} + +/// Maps the wire packet transport selector into the runtime enum. +fn runtime_packet_transport_from_wire(value: u8) -> Result { + match value { + 1 => Ok(RuntimePacketTransport::Udp), + 2 => Ok(RuntimePacketTransport::Tcp), + 3 => Ok(RuntimePacketTransport::WebSocket), + other => Err(format!("unsupported runtime packet transport {other}")), + } +} + +/// Maps the wire packet event-class selector into the runtime enum. +fn runtime_packet_event_class_from_wire(value: u8) -> Result { + match value { + 1 => Ok(RuntimePacketEventClass::Packet), + 2 => Ok(RuntimePacketEventClass::ConnectionClosed), + other => Err(format!("unsupported runtime packet event class {other}")), + } +} + +/// Maps the wire websocket frame selector into the runtime enum. +fn runtime_websocket_frame_type_from_wire(value: u8) -> Result { + match value { + 1 => Ok(RuntimeWebSocketFrameType::Text), + 2 => Ok(RuntimeWebSocketFrameType::Binary), + 3 => Ok(RuntimeWebSocketFrameType::Ping), + 4 => Ok(RuntimeWebSocketFrameType::Pong), + other => Err(format!("unsupported websocket frame type {other}")), + } +} + +/// Serializes one runtime packet event for the worker protocol. +fn runtime_packet_event_wire(event: &RuntimePacketEvent) -> Value { + json!({ + "source": { + "kind": runtime_packet_source_kind_to_wire(event.source.kind), + "transport": runtime_packet_transport_to_wire(event.source.transport), + "eventClass": runtime_packet_event_class_to_wire(event.source.event_class), + "ownerExtension": event.source.owner_extension, + "resourceId": event.source.resource_id, + "sharedTag": event.source.shared_tag, + "webSocketFrameType": event.source.websocket_frame_type.map(runtime_websocket_frame_type_to_wire), + "localAddress": event.source.local_addr.map(|addr| addr.to_string()), + "remoteAddress": event.source.remote_addr.map(|addr| addr.to_string()), + }, + "bytes": event.bytes.as_ref(), + "observedUnixMs": event.observed_unix_ms, + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one transaction event for the worker protocol. +fn provider_transaction_event_wire(event: &TransactionEvent) -> Value { + json!({ + "kind": 1, + "slot": event.slot, + "commitmentStatus": commitment_status_to_wire(event.commitment_status), + "confirmedSlot": event.confirmed_slot, + "finalizedSlot": event.finalized_slot, + "signature": event.signature.map(|signature| signature.to_base58()), + "providerSource": provider_source_wire(event.provider_source.as_ref()), + "transactionKind": tx_kind_to_wire(event.kind), + "transactionBase64": bincode::serialize(event.tx.as_ref()) + .ok() + .map(|bytes| BASE64_STANDARD.encode(bytes)), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one transaction-log event for the worker protocol. +fn provider_transaction_log_event_wire(event: &TransactionLogEvent) -> Value { + json!({ + "kind": 2, + "slot": event.slot, + "commitmentStatus": commitment_status_to_wire(event.commitment_status), + "signature": event.signature.to_base58(), + "err": event.err, + "logs": event.logs.as_ref(), + "matchedFilter": event.matched_filter.map(|pubkey| pubkey.to_base58()), + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one transaction-status event for the worker protocol. +fn provider_transaction_status_event_wire(event: &TransactionStatusEvent) -> Value { + json!({ + "kind": 3, + "slot": event.slot, + "commitmentStatus": commitment_status_to_wire(event.commitment_status), + "confirmedSlot": event.confirmed_slot, + "finalizedSlot": event.finalized_slot, + "signature": event.signature.to_base58(), + "isVote": event.is_vote, + "index": event.index, + "err": event.err, + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one account-update event for the worker protocol. +fn provider_account_update_event_wire(event: &AccountUpdateEvent) -> Value { + json!({ + "kind": 4, + "slot": event.slot, + "commitmentStatus": commitment_status_to_wire(event.commitment_status), + "confirmedSlot": event.confirmed_slot, + "finalizedSlot": event.finalized_slot, + "pubkey": event.pubkey.to_base58(), + "owner": event.owner.to_base58(), + "lamports": event.lamports, + "executable": event.executable, + "rentEpoch": event.rent_epoch, + "dataBase64": BASE64_STANDARD.encode(event.data.as_ref()), + "writeVersion": event.write_version, + "txnSignature": event.txn_signature.map(|signature| signature.to_base58()), + "isStartup": event.is_startup, + "matchedFilter": event.matched_filter.map(|pubkey| pubkey.to_base58()), + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one block-meta event for the worker protocol. +fn provider_block_meta_event_wire(event: &BlockMetaEvent) -> Value { + json!({ + "kind": 5, + "slot": event.slot, + "commitmentStatus": commitment_status_to_wire(event.commitment_status), + "confirmedSlot": event.confirmed_slot, + "finalizedSlot": event.finalized_slot, + "blockhash": solana_hash::Hash::new_from_array(event.blockhash).to_string(), + "parentSlot": event.parent_slot, + "parentBlockhash": solana_hash::Hash::new_from_array(event.parent_blockhash).to_string(), + "blockTime": event.block_time, + "blockHeight": event.block_height, + "executedTransactionCount": event.executed_transaction_count, + "entriesCount": event.entries_count, + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one slot-status event for the worker protocol. +fn provider_slot_status_event_wire(event: &SlotStatusEvent) -> Value { + json!({ + "kind": 6, + "slot": event.slot, + "parentSlot": event.parent_slot, + "previousStatus": event.previous_status.map(fork_slot_status_to_wire), + "status": fork_slot_status_to_wire(event.status), + "tipSlot": event.tip_slot, + "confirmedSlot": event.confirmed_slot, + "finalizedSlot": event.finalized_slot, + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one recent-blockhash event for the worker protocol. +fn provider_recent_blockhash_event_wire(event: &ObservedRecentBlockhashEvent) -> Value { + json!({ + "kind": 7, + "slot": event.slot, + "recentBlockhash": solana_hash::Hash::new_from_array(event.recent_blockhash).to_string(), + "datasetTxCount": event.dataset_tx_count, + "providerSource": provider_source_wire(event.provider_source.as_ref()), + }) +} + +#[cfg(feature = "provider-grpc")] +/// Serializes one provider-source reference for the worker protocol. +fn provider_source_wire(source: Option<&ProviderSourceRef>) -> Option { + source.map(|source| { + json!({ + "kind": source.kind_str(), + "instance": source.instance_str(), + "priority": source.priority(), + "role": source.role().as_str(), + "arbitration": source.arbitration().as_str(), + }) + }) +} + +#[cfg(feature = "provider-grpc")] +/// Maps one commitment status into the worker wire tag. +const fn commitment_status_to_wire(status: TxCommitmentStatus) -> u8 { + match status { + TxCommitmentStatus::Processed => 1, + TxCommitmentStatus::Confirmed => 2, + TxCommitmentStatus::Finalized => 3, + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps one transaction kind into the worker wire tag. +const fn tx_kind_to_wire(kind: TxKind) -> u8 { + match kind { + TxKind::VoteOnly => 1, + TxKind::Mixed => 2, + TxKind::NonVote => 3, + } +} + +#[cfg(feature = "provider-grpc")] +/// Maps one fork slot status into the worker wire tag. +const fn fork_slot_status_to_wire(status: ForkSlotStatus) -> u8 { + match status { + ForkSlotStatus::Processed => 1, + ForkSlotStatus::Confirmed => 2, + ForkSlotStatus::Finalized => 3, + ForkSlotStatus::Orphaned => 4, + } +} + +/// Maps one runtime packet source kind into the worker wire tag. +const fn runtime_packet_source_kind_to_wire(value: RuntimePacketSourceKind) -> u8 { + match value { + RuntimePacketSourceKind::ObserverIngress => 1, + RuntimePacketSourceKind::ExtensionResource => 2, + } +} + +/// Maps one runtime packet transport into the worker wire tag. +const fn runtime_packet_transport_to_wire(value: RuntimePacketTransport) -> u8 { + match value { + RuntimePacketTransport::Udp => 1, + RuntimePacketTransport::Tcp => 2, + RuntimePacketTransport::WebSocket => 3, + } +} + +/// Maps one runtime packet event class into the worker wire tag. +const fn runtime_packet_event_class_to_wire(value: RuntimePacketEventClass) -> u8 { + match value { + RuntimePacketEventClass::Packet => 1, + RuntimePacketEventClass::ConnectionClosed => 2, + } +} + +/// Maps one websocket frame type into the worker wire tag. +const fn runtime_websocket_frame_type_to_wire(value: RuntimeWebSocketFrameType) -> u8 { + match value { + RuntimeWebSocketFrameType::Text => 1, + RuntimeWebSocketFrameType::Binary => 2, + RuntimeWebSocketFrameType::Ping => 3, + RuntimeWebSocketFrameType::Pong => 4, + } +} diff --git a/crates/sof-observer/src/bin/sof_ts_runtime_host/af_xdp.rs b/crates/sof-observer/src/bin/sof_ts_runtime_host/af_xdp.rs new file mode 100644 index 00000000..87f04444 --- /dev/null +++ b/crates/sof-observer/src/bin/sof_ts_runtime_host/af_xdp.rs @@ -0,0 +1,297 @@ +//! AF_XDP packet producer used by the TypeScript runtime host on Linux. + +use std::{ + ffi::CString, + io, + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; + +use sof::{ + ingest::{RawPacketBatch, RawPacketIngress}, + runtime::KernelBypassIngressSender, +}; +use xdp::{ + RingConfigBuilder, Umem, + slab::{HeapSlab, Slab}, + socket::{PollTimeout, XdpSocket, XdpSocketBuilder}, + umem::{FrameSize, UmemCfgBuilder}, +}; + +/// Error type returned by the AF_XDP helper module. +pub(super) type AfXdpError = Box; + +/// AF_XDP socket and batching settings for direct raw-shred ingest. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) struct AfXdpConfig { + /// Network interface name used for the XDP bind. + pub(super) interface: String, + /// Receive queue id bound on the target interface. + pub(super) queue_id: u32, + /// Maximum number of packets forwarded in one batch. + pub(super) batch_size: usize, + /// Number of UMEM frames allocated for the socket. + pub(super) umem_frame_count: u32, + /// RX/fill/completion ring depth. + pub(super) ring_depth: u32, + /// Poll timeout used while waiting for packets. + pub(super) poll_timeout: Duration, + /// Destination UDP port filter applied before forwarding. + pub(super) filter: PortFilter, +} + +/// UDP destination ports accepted by the AF_XDP producer. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) struct PortFilter { + /// Inclusive lower bound of the accepted port range. + pub(super) range_start: u16, + /// Inclusive upper bound of the accepted port range. + pub(super) range_end: u16, + /// One additional accepted port outside the main range. + pub(super) extra_port: u16, +} + +impl PortFilter { + /// Returns the default Solana TPU and TVU destination port filter. + pub(super) const fn default_sol() -> Self { + Self { + range_start: 12_000, + range_end: 12_100, + extra_port: 8_001, + } + } + + /// Returns whether one UDP destination port should be forwarded. + const fn allows(self, port: u16) -> bool { + (port >= self.range_start && port <= self.range_end) || port == self.extra_port + } +} + +/// Live AF_XDP socket state used while receiving packets. +struct AfXdpSocketState { + /// Bound XDP socket. + socket: XdpSocket, + /// Wakeable RX/fill/completion rings paired with the socket. + rings: xdp::WakableRings, + /// Shared packet memory region used by the rings. + umem: Umem, +} + +/// Internal packet parse outcome while decoding one Ethernet frame. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum FrameParseOutcome { + /// The frame was malformed for the expected IPv4 UDP layout. + ParseError, +} + +/// Runs the AF_XDP receive loop until shutdown is requested. +pub(super) fn run_af_xdp_producer_until( + tx: &KernelBypassIngressSender, + config: &AfXdpConfig, + shutdown: &AtomicBool, +) -> Result<(), AfXdpError> { + let mut state = build_af_xdp_socket(config)?; + let mut slab = HeapSlab::with_capacity(usize::try_from(config.ring_depth).unwrap_or(0)); + let mut batch = RawPacketBatch::with_capacity(config.batch_size); + + while !shutdown.load(Ordering::Relaxed) { + drop( + state + .socket + .poll_read(PollTimeout::new(Some(config.poll_timeout))), + ); + + if shutdown.load(Ordering::Relaxed) { + break; + } + + let Some(rx_ring) = state.rings.rx_ring.as_mut() else { + return Err(io::Error::other("AF_XDP socket has no RX ring configured").into()); + }; + // SAFETY: `rx_ring` is paired with `state.umem` from the same socket setup, + // and `slab` is the destination scratch storage expected by the xdp crate. + let received = unsafe { rx_ring.recv(&state.umem, &mut slab) }; + if received == 0 { + continue; + } + + for _ in 0..received { + let Some(frame) = slab.pop_back() else { + break; + }; + match parse_udp_payload_to_raw_packet(&frame, config.filter) { + Ok(Some((source, payload))) => { + batch.push_packet_bytes(source, RawPacketIngress::Udp, payload)?; + if batch.len() >= config.batch_size { + if !tx.send_batch(std::mem::take(&mut batch), false) { + state.umem.free_packet(frame); + return Ok(()); + } + batch = RawPacketBatch::with_capacity(config.batch_size); + } + } + Ok(None) | Err(FrameParseOutcome::ParseError) => {} + } + state.umem.free_packet(frame); + } + + // SAFETY: Returned RX buffers are recycled into the matching fill ring for the + // same `umem`, bounded by the number of descriptors just received. + unsafe { + state + .rings + .fill_ring + .enqueue(&mut state.umem, received, true)? + }; + } + + if !batch.is_empty() { + let _sent = tx.send_batch(batch, false); + } + + Ok(()) +} + +/// Creates and primes one AF_XDP socket using the requested config. +fn build_af_xdp_socket(config: &AfXdpConfig) -> Result { + let ifname = CString::new(config.interface.clone())?; + let nic = xdp::nic::NicIndex::lookup_by_name(&ifname)?.ok_or_else(|| { + io::Error::other(format!( + "network interface `{}` not found", + config.interface + )) + })?; + + let mut builder = XdpSocketBuilder::new()?; + let umem_cfg = UmemCfgBuilder { + frame_size: FrameSize::TwoK, + frame_count: config.umem_frame_count, + ..Default::default() + } + .build()?; + let mut umem = Umem::map(umem_cfg)?; + let ring_cfg = RingConfigBuilder { + rx_count: config.ring_depth, + tx_count: 0, + fill_count: config.ring_depth, + completion_count: config.ring_depth, + } + .build()?; + let (mut rings, bind_flags) = builder.build_wakable_rings(&umem, ring_cfg)?; + let socket = builder.bind(nic, config.queue_id, bind_flags)?; + + // SAFETY: `umem` and `fill_ring` were created together by `build_wakable_rings`, + // and the requested descriptor count is bounded by the configured ring depth. + let _fill_queued = unsafe { + rings.fill_ring.enqueue( + &mut umem, + usize::try_from(config.ring_depth).unwrap_or(0), + true, + )? + }; + + Ok(AfXdpSocketState { + socket, + rings, + umem, + }) +} + +/// Parses one Ethernet frame into a raw UDP payload and source address. +fn parse_udp_payload_to_raw_packet( + frame: &[u8], + filter: PortFilter, +) -> Result, FrameParseOutcome> { + const ETH_LEN: usize = 14; + const ETH_TYPE_OFFSET: usize = 12; + const ETH_P_IPV4: u16 = 0x0800; + const IP_PROTO_UDP: u8 = 17; + if frame.len() < ETH_LEN + 20 { + return Err(FrameParseOutcome::ParseError); + } + + if read_u16_be(frame, ETH_TYPE_OFFSET)? != ETH_P_IPV4 { + return Ok(None); + } + + let ip_offset = ETH_LEN; + let version_ihl = read_u8(frame, ip_offset)?; + if version_ihl >> 4 != 4 { + return Ok(None); + } + let ihl = usize::from(version_ihl & 0x0F).checked_mul(4).unwrap_or(0); + let udp_header_end = checked_add(checked_add(ip_offset, ihl)?, 8)?; + if ihl < 20 || frame.len() < udp_header_end { + return Err(FrameParseOutcome::ParseError); + } + if read_u8(frame, checked_add(ip_offset, 9)?)? != IP_PROTO_UDP { + return Ok(None); + } + let frag_field = read_u16_be(frame, checked_add(ip_offset, 6)?)?; + if (frag_field & 0x1FFF) != 0 { + return Ok(None); + } + + let udp_offset = checked_add(ip_offset, ihl)?; + let src_port = read_u16_be(frame, udp_offset)?; + let dst_port = read_u16_be(frame, checked_add(udp_offset, 2)?)?; + if !filter.allows(dst_port) { + return Ok(None); + } + let udp_len = usize::from(read_u16_be(frame, checked_add(udp_offset, 4)?)?); + if udp_len < 8 { + return Err(FrameParseOutcome::ParseError); + } + let payload_start = checked_add(udp_offset, 8)?; + let payload_end = checked_add(payload_start, udp_len.saturating_sub(8))?; + if payload_end > frame.len() { + return Err(FrameParseOutcome::ParseError); + } + + let source = SocketAddr::new( + IpAddr::V4(read_ipv4_addr(frame, checked_add(ip_offset, 12)?)?), + src_port, + ); + Ok(Some(( + source, + frame + .get(payload_start..payload_end) + .ok_or(FrameParseOutcome::ParseError)?, + ))) +} + +/// Adds two byte offsets while rejecting overflow. +fn checked_add(lhs: usize, rhs: usize) -> Result { + lhs.checked_add(rhs).ok_or(FrameParseOutcome::ParseError) +} + +/// Reads one byte from the frame at the requested offset. +fn read_u8(frame: &[u8], offset: usize) -> Result { + frame + .get(offset) + .copied() + .ok_or(FrameParseOutcome::ParseError) +} + +/// Reads one big-endian `u16` from the frame at the requested offset. +fn read_u16_be(frame: &[u8], offset: usize) -> Result { + let end = checked_add(offset, 2)?; + let bytes: [u8; 2] = frame + .get(offset..end) + .ok_or(FrameParseOutcome::ParseError)? + .try_into() + .map_err(|_slice_error| FrameParseOutcome::ParseError)?; + Ok(u16::from_be_bytes(bytes)) +} + +/// Reads one IPv4 address from the frame at the requested offset. +fn read_ipv4_addr(frame: &[u8], offset: usize) -> Result { + let end = checked_add(offset, 4)?; + let octets: [u8; 4] = frame + .get(offset..end) + .ok_or(FrameParseOutcome::ParseError)? + .try_into() + .map_err(|_slice_error| FrameParseOutcome::ParseError)?; + Ok(Ipv4Addr::from(octets)) +} diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 4de0dad2..dcd59a38 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -1,6 +1,6 @@ # `@sof/sdk` -Unified TypeScript SDK surface for SOF apps and plugins. +TypeScript SDK for building apps with a typed `App`, `Plugin`, `runtime`, and `derivedState` model. ## Tooling @@ -12,204 +12,309 @@ Unified TypeScript SDK surface for SOF apps and plugins. ## Mental Model -- Start with the app-facing APIs: `tryDefinePlugin(...)`, `tryDefineApp(...)`, `tryCreateAppLaunch(...)`, and `runSelectedPlugin(...)`. -- Use runtime-config helpers such as `createRuntimeConfigForProfile(...)`, `serializeRuntimeConfigRecord(...)`, and `parseRuntimeConfig(...)` when you need to build or validate env-backed config. -- Prefer `try...` helpers when invalid input should stay in `Result` form instead of throwing. -- Use subpath imports such as `@sof/sdk/app` or `@sof/sdk/runtime/config` when you want a smaller import surface. +- Build one `App`. +- Put ingress on the app with `ingress`. +- Put merge behavior on the app with `fanIn` when you have multiple sources. +- Put runtime behavior on the app with `runtime`, including `derivedState`. +- Put app logic in `Plugin`. +- Start the app with `app.run()`. -The current package provides: - -- checked `Result` primitives -- branded/value-object types for domain strings -- enum-backed runtime policy types -- typed runtime config parsing and serialization -- nested derived-state config with safe defaults -- app-first TS APIs for defining apps and plugins -- launch-spec generation for Node-based app entrypoints -- plugin entrypoint helpers for selected-plugin execution +`name` is optional on `App`. If you omit it, the SDK derives one from the first plugin. ## Quick Start +```ts +import { App, IngressKind, Plugin } from "@sof/sdk"; + +const app = new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + name: "solana-websocket", + url: "wss://example.invalid", + }, + ], + plugins: [ + new Plugin({ + name: "tx-logger", + logPackets: true, + }), + ], +}); + +await app.run(); +``` + +`app.run()` is the normal execution path. It runs until the app is stopped. + +Current executable coverage: +- `app.run()` supports one WebSocket ingress source today. +- `DirectShreds` runs through the packaged native runtime host for one raw packet source per app. +- `Grpc` runs through the packaged native runtime host with Yellowstone provider-stream events delivered to `Plugin.onProviderEvent`. +- `Gossip` runs through the packaged native runtime host with gossip-bootstrap support. +- Direct shreds can enable kernel bypass on Linux through a typed `kernelBypass` object. +- One `DirectShreds` ingress plus one `Gossip` ingress run together as one raw runtime composition without `fanIn`. +- Mixed WebSocket/native ingress and multi-WebSocket fan-in still return typed errors instead of silently falling through. +- Multi-gRPC fan-in uses the Rust arbitration model: `EmitAll`, `FirstSeen`, or `FirstSeenThenPromote`. +- The package build includes the native host under `dist/native/-/`; `SOF_SDK_RUNTIME_HOST_BINARY` is only an override for development or custom deployments. +- If the host is missing, `app.run()` returns a typed `Result` error instead of throwing. + +## Runtime + +Use the runtime object when you want to control delivery profile, provider policy, shred trust mode, or derived state. + ```ts import { - ExtensionCapability, - RuntimeDeliveryProfile, - createRuntimeConfigForProfile, - isErr, - tryCreateAppLaunch, - tryDefineApp, - tryDefinePlugin, + App, + DerivedStateReplayBackend, + DerivedStateReplayDurability, + IngressKind, + Plugin, + createBalancedRuntime, } from "@sof/sdk"; -const plugin = tryDefinePlugin({ - name: "demo-plugin", - capabilities: [ExtensionCapability.ObserveObserverIngress], +const app = new App({ + runtime: createBalancedRuntime({ + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 4096, + maxSessions: 4, + }, + }, + }), + ingress: [ + { + kind: IngressKind.WebSocket, + url: "wss://example.invalid", + }, + ], + plugins: [new Plugin({ logPackets: true })], }); -if (!isErr(plugin)) { - const app = tryDefineApp({ - name: "demo-sof-app", - runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), - plugins: [plugin.value], - }); - - if (!isErr(app)) { - const launch = tryCreateAppLaunch(app.value, { - workerEntrypoint: "./dist/app.js", - }); - - if (!isErr(launch)) { - launch.value.runtimeEnvironment; - launch.value.plugins; - } - } -} +app; ``` -## Plugin API +## gRPC Provider Ingress + +gRPC provider ingress delivers provider-stream events, not raw packets. ```ts import { - ExtensionCapability, - isErr, + App, + GrpcIngressStream, + IngressKind, + Plugin, + ProviderCommitment, + RuntimeProviderEventKind, ok, runtimeExtensionAck, - tryDefinePlugin, } from "@sof/sdk"; -const plugin = tryDefinePlugin({ - name: "demo-plugin", - capabilities: [ExtensionCapability.ObserveObserverIngress], - onStart: () => ok(runtimeExtensionAck()), - onPacket: () => ok(runtimeExtensionAck()), - onStop: () => ok(runtimeExtensionAck()), +const app = new App({ + ingress: [ + { + kind: IngressKind.Grpc, + name: "yellowstone", + endpoint: "https://example.invalid", + stream: GrpcIngressStream.TransactionStatus, + commitment: ProviderCommitment.Processed, + }, + ], + plugins: [ + new Plugin({ + name: "provider-logger", + onProviderEvent: (event) => { + if (event.kind === RuntimeProviderEventKind.TransactionStatus) { + process.stdout.write(`${event.slot} ${event.signature}\n`); + } + return ok(runtimeExtensionAck()); + }, + }), + ], +}); + +await app.run(); +``` + +## Multi-Source Ingress + +When you configure more than one provider ingress source, `fanIn` is required. The SDK maps it to the Rust provider arbitration model. If you want promotion behavior, set source roles or priorities on each ingress. + +```ts +import { + App, + FanInStrategy, + IngressKind, + Plugin, + ProviderIngressRole, +} from "@sof/sdk"; + +const app = new App({ + ingress: [ + { + kind: IngressKind.Grpc, + name: "grpc-a", + endpoint: "https://one.example.invalid", + role: ProviderIngressRole.Primary, + }, + { + kind: IngressKind.Grpc, + name: "grpc-b", + endpoint: "https://two.example.invalid", + role: ProviderIngressRole.Fallback, + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeenThenPromote, + }, + plugins: [new Plugin({ name: "provider-logger" })], }); -if (!isErr(plugin)) { - plugin.value.manifest.extensionName; -} +app; ``` -## App Entrypoint +## Direct Shreds + +Direct shred ingress belongs on the app, not on a plugin. ```ts import { - ExtensionCapability, - isErr, + App, + IngressKind, + Plugin, + ShredTrustMode, +} from "@sof/sdk"; + +const app = new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + name: "trusted-shreds", + bindAddress: "0.0.0.0:20000", + trustMode: ShredTrustMode.TrustedRawShredProvider, + kernelBypass: { + interface: "eth0", + }, + }, + ], + plugins: [new Plugin({ name: "observer" })], +}); + +app; +``` + +## Plugins + +`Plugin` is the app extension surface. A plugin can implement lifecycle hooks and packet handlers: + +```ts +import { + App, + IngressKind, + Plugin, ok, - runSelectedPlugin, runtimeExtensionAck, - tryDefineApp, - tryDefinePlugin, } from "@sof/sdk"; -const plugin = tryDefinePlugin({ - name: "demo-plugin", - capabilities: [ExtensionCapability.ObserveObserverIngress], +const plugin = new Plugin({ + name: "packet-audit", onStart: () => ok(runtimeExtensionAck()), - onPacket: () => ok(runtimeExtensionAck()), + onPacket: (event) => { + process.stdout.write(`${event.bytes.length}\n`); + return ok(runtimeExtensionAck()); + }, onStop: () => ok(runtimeExtensionAck()), }); -if (!isErr(plugin)) { - const app = tryDefineApp({ - name: "demo-sof-app", - plugins: [plugin.value], - }); +const app = new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + url: "wss://example.invalid", + }, + ], + plugins: [plugin], +}); - if (!isErr(app)) { - await runSelectedPlugin(app.value); - } -} +app; ``` -## Runtime Config +`Extension` is also exported as an alias of `Plugin` if you prefer that name. + +## Derived State + +Use `runtime.derivedState` when the app needs replay/backfill/checkpoint behavior: ```ts import { - RuntimeDeliveryProfile, - isErr, - tryCreateRuntimeConfigForProfile, - trySerializeRuntimeConfigRecord, + App, + DerivedStateReplayBackend, + DerivedStateReplayDurability, + IngressKind, + Plugin, + createBalancedRuntime, } from "@sof/sdk"; -const config = tryCreateRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, -); - -if (!isErr(config)) { - const env = trySerializeRuntimeConfigRecord(config.value); +const app = new App({ + runtime: createBalancedRuntime({ + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + directory: ".sof-derived-state", + }, + }, + }), + ingress: [{ kind: IngressKind.WebSocket, url: "wss://example.invalid" }], + plugins: [new Plugin({ name: "stateful-app" })], +}); - env; -} +app; ``` ## Focused Imports ```ts -import { - createAppLaunch, - runSelectedPlugin, - tryDefineApp, - tryDefinePlugin, -} from "@sof/sdk/app"; -import { - ObserverRuntimeConfig, - observerRuntimeConfigForProfile, -} from "@sof/sdk/runtime/config"; -import { - ProviderStreamCapabilityPolicy, - ShredTrustMode, -} from "@sof/sdk/runtime/policy"; +import { App, IngressKind, Plugin } from "@sof/sdk/app"; +import { ObserverRuntimeConfig } from "@sof/sdk/runtime/config"; +import { ShredTrustMode } from "@sof/sdk/runtime/policy"; import { DerivedStateReplayBackend, DerivedStateReplayDurability, } from "@sof/sdk/runtime/derived-state"; import { RuntimeDeliveryProfile } from "@sof/sdk/runtime/delivery-profile"; -const config = observerRuntimeConfigForProfile( - RuntimeDeliveryProfile.DeliveryDisciplined, - { - shredTrustMode: ShredTrustMode.TrustedRawShredProvider, - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - derivedState: { - replay: { - backend: DerivedStateReplayBackend.Disk, - durability: DerivedStateReplayDurability.Fsync, - }, +const runtime = ObserverRuntimeConfig.forProfile(RuntimeDeliveryProfile.Balanced, { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, }, }, -); - -tryDefinePlugin; -tryDefineApp; -createAppLaunch; -runSelectedPlugin; -ObserverRuntimeConfig.latencyOptimized(); -config; +}); + +new App({ + runtime, + ingress: [{ kind: IngressKind.WebSocket, url: "wss://example.invalid" }], + plugins: [new Plugin({ name: "tx-logger" })], +}); ``` ## Examples Runnable examples live in `sdks/typescript/examples`: -- `sof-app-entrypoint.ts` -- `sof-app-launch-spec.ts` +- `app-config.ts` +- `app-entrypoint.ts` - `runtime-config-balanced.ts` - `runtime-config-parse.ts` - `runtime-extension-manifest.ts` -- `runtime-extension-worker.ts` Run them with: ```bash pnpm run check:examples ``` - -## Choosing An API - -- Use `tryDefinePlugin(...)`, `tryDefineApp(...)`, and `tryCreateAppLaunch(...)` for the normal app authoring flow. -- Use `runSelectedPlugin(...)` in your plugin entrypoint when the app should select the plugin to run from its environment. -- Use `parseRuntimeConfig(...)` or `ObserverRuntimeConfig.fromEnvironmentRecord(...)` when you need to validate env input. -- Use `createRuntimeConfigForProfile(...)` for the simplest runtime-profile workflow. -- Use the root `@sof/sdk` import for convenience. Use subpath imports when you want a smaller, more explicit import surface. diff --git a/sdks/typescript/examples/app-config.ts b/sdks/typescript/examples/app-config.ts new file mode 100644 index 00000000..7522989d --- /dev/null +++ b/sdks/typescript/examples/app-config.ts @@ -0,0 +1,43 @@ +import { + App, + FanInStrategy, + IngressKind, + Plugin, + ProviderIngressRole, + createBalancedRuntime, +} from "../dist/index.js"; + +const app = new App({ + runtime: createBalancedRuntime(), + ingress: [ + { + kind: IngressKind.Grpc, + name: "grpc-a", + endpoint: "https://one.example.invalid", + role: ProviderIngressRole.Primary, + }, + { + kind: IngressKind.Grpc, + name: "grpc-b", + endpoint: "https://two.example.invalid", + role: ProviderIngressRole.Fallback, + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeenThenPromote, + }, + plugins: [new Plugin({ name: "multi-ingress-demo" })], +}); + +process.stdout.write( + `${JSON.stringify( + { + appName: app.name, + ingress: app.ingress, + fanIn: app.fanIn, + runtimeEnvironment: app.runtime.toEnvironmentRecord(), + }, + undefined, + 2, + )}\n`, +); diff --git a/sdks/typescript/examples/app-entrypoint.ts b/sdks/typescript/examples/app-entrypoint.ts new file mode 100644 index 00000000..813b2ac8 --- /dev/null +++ b/sdks/typescript/examples/app-entrypoint.ts @@ -0,0 +1,38 @@ +import { App, IngressKind, Plugin, ok, runtimeExtensionAck } from "../dist/index.js"; + +function main(): number { + const plugin = new Plugin({ + name: "demo-plugin", + onStart: () => ok(runtimeExtensionAck()), + onPacket: () => ok(runtimeExtensionAck()), + onStop: () => ok(runtimeExtensionAck()), + }); + + const app = new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + name: "solana-websocket", + url: "wss://example.invalid", + }, + ], + plugins: [plugin], + }); + + process.stdout.write( + `${JSON.stringify( + { + appName: app.name, + pluginNames: app.plugins.map((value) => value.name), + ingress: app.ingress, + runtimeEnvironment: app.runtime.toEnvironmentRecord(), + }, + undefined, + 2, + )}\n`, + ); + + return 0; +} + +process.exitCode = main(); diff --git a/sdks/typescript/examples/runtime-extension-worker.ts b/sdks/typescript/examples/runtime-extension-worker.ts index f9732cd1..48763811 100644 --- a/sdks/typescript/examples/runtime-extension-worker.ts +++ b/sdks/typescript/examples/runtime-extension-worker.ts @@ -1,27 +1,21 @@ import { PassThrough } from "node:stream"; import { - ExtensionCapability, - RuntimeExtensionWorkerHostMessageTag, SdkLanguage, - extensionName, + createRuntimeExtensionWorkerManifest, isErr, ok, runtimeExtensionAck, - serializeRuntimeExtensionWorkerHostMessageWire, socketAddress, - tryDefineApp, - tryDefinePlugin, - tryRunPlugin, + tryDefineRuntimeExtension, } from "../dist/index.js"; +import { + RuntimeExtensionWorkerHostMessageTag, + runRuntimeExtensionWorkerStdio, + serializeRuntimeExtensionWorkerHostMessageWire, +} from "../dist/runtime/extension-stdio.js"; async function main(): Promise { - const extension = extensionName("demo-extension-worker"); - if (isErr(extension)) { - process.stderr.write(`${extension.error.message}\n`); - return 1; - } - const localAddress = socketAddress("127.0.0.1:21011"); if (isErr(localAddress)) { process.stderr.write(`${localAddress.error.message}\n`); @@ -29,30 +23,23 @@ async function main(): Promise { } let observedPacketLog = ""; - const plugin = tryDefinePlugin({ - name: extension.value, - capabilities: [ExtensionCapability.ObserveObserverIngress], - onStart: () => ok(runtimeExtensionAck()), - onPacket: (event) => { + const definition = tryDefineRuntimeExtension({ + manifest: createRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "demo-extension-worker", + }), + onReady: () => ok(runtimeExtensionAck()), + onPacketReceived: (event) => { observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; return ok(runtimeExtensionAck()); }, - onStop: () => ok(runtimeExtensionAck()), + onShutdown: () => ok(runtimeExtensionAck()), }); - if (isErr(plugin)) { - process.stderr.write(`${plugin.error.message}\n`); - return 1; - } - const app = tryDefineApp({ - name: "demo-sof-app", - plugins: [plugin.value], - }); - if (isErr(app)) { - process.stderr.write(`${app.error.message}\n`); + if (isErr(definition)) { + process.stderr.write(`${definition.error.message}\n`); return 1; } - const input = new PassThrough(); const output = new PassThrough(); const errorOutput = new PassThrough(); @@ -68,7 +55,7 @@ async function main(): Promise { protocolErrors += chunk; }); - const runner = tryRunPlugin(app.value, extension.value, { + const runner = runRuntimeExtensionWorkerStdio(definition.value, { input, output, error: errorOutput, @@ -79,7 +66,7 @@ async function main(): Promise { serializeRuntimeExtensionWorkerHostMessageWire({ tag: RuntimeExtensionWorkerHostMessageTag.Start, context: { - extensionName: extension.value, + extensionName: definition.value.manifest.extensionName, }, }), )}\n`, @@ -104,7 +91,7 @@ async function main(): Promise { serializeRuntimeExtensionWorkerHostMessageWire({ tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, context: { - extensionName: extension.value, + extensionName: definition.value.manifest.extensionName, }, }), )}\n`, diff --git a/sdks/typescript/examples/sof-app-entrypoint.ts b/sdks/typescript/examples/sof-app-entrypoint.ts deleted file mode 100644 index ac458fe4..00000000 --- a/sdks/typescript/examples/sof-app-entrypoint.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { PassThrough } from "node:stream"; - -import { - ExtensionCapability, - RuntimeExtensionWorkerHostMessageTag, - SdkLanguage, - extensionName, - isErr, - ok, - runtimeExtensionAck, - serializeRuntimeExtensionWorkerHostMessageWire, - sofRuntimeExtensionNameEnvVarName, - socketAddress, - tryDefineApp, - tryDefinePlugin, - tryRunSelectedPlugin, -} from "../dist/index.js"; - -async function main(): Promise { - const pluginName = extensionName("demo-plugin"); - if (isErr(pluginName)) { - process.stderr.write(`${pluginName.error.message}\n`); - return 1; - } - - const localAddress = socketAddress("127.0.0.1:21011"); - if (isErr(localAddress)) { - process.stderr.write(`${localAddress.error.message}\n`); - return 1; - } - - let observedPacketLog = ""; - const plugin = tryDefinePlugin({ - name: pluginName.value, - capabilities: [ExtensionCapability.ObserveObserverIngress], - onStart: () => ok(runtimeExtensionAck()), - onPacket: (event) => { - observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; - return ok(runtimeExtensionAck()); - }, - onStop: () => ok(runtimeExtensionAck()), - }); - if (isErr(plugin)) { - process.stderr.write(`${plugin.error.message}\n`); - return 1; - } - - const app = tryDefineApp({ - name: "demo-sof-app", - plugins: [plugin.value], - }); - if (isErr(app)) { - process.stderr.write(`${app.error.message}\n`); - return 1; - } - - const input = new PassThrough(); - const output = new PassThrough(); - const errorOutput = new PassThrough(); - - let protocolOutput = ""; - let protocolErrors = ""; - output.setEncoding("utf8"); - errorOutput.setEncoding("utf8"); - output.on("data", (chunk: string) => { - protocolOutput += chunk; - }); - errorOutput.on("data", (chunk: string) => { - protocolErrors += chunk; - }); - - const runner = tryRunSelectedPlugin( - app.value, - { - [sofRuntimeExtensionNameEnvVarName]: pluginName.value, - }, - { - input, - output, - error: errorOutput, - }, - ); - - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Start, - context: { - extensionName: pluginName.value, - }, - }), - )}\n`, - ); - input.write( - `${JSON.stringify({ - tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, - event: { - source: { - kind: 1, - transport: 1, - eventClass: 1, - localAddress: localAddress.value, - }, - bytes: [1, 2, 3, 4], - observedUnixMs: Date.now(), - }, - })}\n`, - ); - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, - context: { - extensionName: pluginName.value, - }, - }), - )}\n`, - ); - input.end(); - - const result = await runner; - if (isErr(result)) { - process.stderr.write(`${result.error.message}\n`); - return 1; - } - - process.stdout.write( - `${JSON.stringify( - { - sdkLanguage: SdkLanguage.TypeScript, - observedPacketLog, - protocolErrors: protocolErrors.trim(), - responses: protocolOutput - .trim() - .split("\n") - .filter((line) => line !== "") - .map((line) => JSON.parse(line) as unknown), - }, - undefined, - 2, - )}\n`, - ); - - return 0; -} - -process.exitCode = await main(); diff --git a/sdks/typescript/examples/sof-app-launch-spec.ts b/sdks/typescript/examples/sof-app-launch-spec.ts deleted file mode 100644 index 3b6deb1d..00000000 --- a/sdks/typescript/examples/sof-app-launch-spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - ExtensionCapability, - RuntimeDeliveryProfile, - tryCreateAppLaunch, - createRuntimeConfigForProfile, - tryDefineApp, - tryDefinePlugin, - isErr, -} from "../dist/index.js"; - -const plugin = tryDefinePlugin({ - name: "app-launch-extension", - capabilities: [ExtensionCapability.ObserveObserverIngress], -}); - -if (isErr(plugin)) { - process.stderr.write(`${plugin.error.message}\n`); - process.exitCode = 1; -} else { - const app = tryDefineApp({ - name: "demo-sof-app", - runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), - plugins: [plugin.value], - }); - if (isErr(app)) { - process.stderr.write(`${app.error.message}\n`); - process.exitCode = 1; - } else { - const launchSpec = tryCreateAppLaunch(app.value, { - workerEntrypoint: "./dist/worker.js", - }); - - if (isErr(launchSpec)) { - process.stderr.write(`${launchSpec.error.message}\n`); - process.exitCode = 1; - } else { - process.stdout.write(`${JSON.stringify(launchSpec.value, undefined, 2)}\n`); - } - } -} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 66b4f2ee..eba557c0 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,7 +1,7 @@ { "name": "@sof/sdk", "version": "0.1.0", - "description": "Unified SOF TypeScript SDK", + "description": "TypeScript App SDK", "license": "MIT OR Apache-2.0", "sideEffects": false, "packageManager": "pnpm@10.28.2", @@ -64,18 +64,19 @@ "scripts": { "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true }); rmSync('dist-test', { recursive: true, force: true }); rmSync('dist-examples', { recursive: true, force: true });\"", "build": "pnpm run clean && tsdown --config tsdown.config.ts", + "build:native": "node scripts/build-native-host.mjs", "build:test": "tsc -p tsconfig.test.json", "build:examples": "pnpm run build && tsc -p tsconfig.examples.json", - "format": "biome format --config-path biome.json --write src examples tsdown.config.ts", - "format:check": "biome format --config-path biome.json src examples tsdown.config.ts", - "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts", - "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts", - "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js && node dist-examples/sof-app-entrypoint.js && node dist-examples/sof-app-launch-spec.js", + "format": "biome format --config-path biome.json --write src examples scripts tsdown.config.ts", + "format:check": "biome format --config-path biome.json src examples scripts tsdown.config.ts", + "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts && oxlint --config .oxlintrc.json --deny-warnings --report-unused-disable-directives examples scripts", + "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts && oxlint --config .oxlintrc.json --fix --fix-suggestions examples scripts", + "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js && node dist-examples/app-entrypoint.js && node dist-examples/app-config.js", "check:package": "publint run --strict --pack pnpm", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm run build && pnpm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", - "prepack": "pnpm run build" + "prepack": "pnpm run build && pnpm run build:native" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/sdks/typescript/scripts/build-native-host.mjs b/sdks/typescript/scripts/build-native-host.mjs new file mode 100644 index 00000000..23267f8e --- /dev/null +++ b/sdks/typescript/scripts/build-native-host.mjs @@ -0,0 +1,52 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const scriptDirectory = import.meta.dirname; +const packageDirectory = join(scriptDirectory, ".."); +const workspaceDirectory = join(packageDirectory, "..", ".."); +const binaryName = process.platform === "win32" ? "sof_ts_runtime_host.exe" : "sof_ts_runtime_host"; +const targetTriple = `${process.platform}-${process.arch}`; +const sourcePath = join(workspaceDirectory, "target", "release", binaryName); +const outputDirectory = join(packageDirectory, "dist", "native", targetTriple); +const outputPath = join(outputDirectory, binaryName); +const features = ["provider-grpc", "gossip-bootstrap"]; + +if (process.platform === "linux") { + features.push("kernel-bypass"); +} + +const cargo = spawnSync( + "cargo", + [ + "build", + "--release", + "-p", + "sof", + "--features", + features.join(","), + "--bin", + "sof_ts_runtime_host", + ], + { + cwd: workspaceDirectory, + stdio: "inherit", + }, +); + +if (cargo.error !== undefined) { + throw new Error(`failed to run cargo: ${cargo.error.message}`); +} + +if (cargo.status !== 0) { + process.exitCode = cargo.status ?? 1; + throw new Error(`cargo build failed with status ${String(cargo.status)}`); +} + +if (!existsSync(sourcePath)) { + throw new Error(`native runtime host binary was not produced at ${sourcePath}`); +} + +mkdirSync(outputDirectory, { recursive: true }); +copyFileSync(sourcePath, outputPath); +chmodSync(outputPath, 0o755); diff --git a/sdks/typescript/src/app.test.ts b/sdks/typescript/src/app.test.ts index 41fcbff9..ac01c454 100644 --- a/sdks/typescript/src/app.test.ts +++ b/sdks/typescript/src/app.test.ts @@ -1,208 +1,486 @@ import assert from "node:assert/strict"; -import { PassThrough } from "node:stream"; import test from "node:test"; -import { isErr, isOk, ok } from "./result.js"; +import { isErr, isOk } from "./result.js"; import { + App, ExtensionCapability, - RuntimeDeliveryProfile, - RuntimeExtensionWorkerHostMessageTag, - SofApplication, - createAppLaunch, - createRuntimeConfigForProfile, - defineRuntimeExtension, - defineSofApplication, - runtimeExtensionAck, - sofApplicationNameEnvVarName, - serializeRuntimeExtensionWorkerHostMessageWire, - sofRuntimeExtensionNameEnvVarName, - tryDefineApp, - tryDefinePlugin, - tryCreateRuntimeExtensionWorkerManifest, - tryRunSelectedPlugin, + FanInStrategy, + GrpcIngressStream, + IngressKind, + Plugin, + ProviderCommitment, + ProviderIngressRole, + RuntimePacketSourceKind, + createBalancedRuntime, } from "./index.js"; -test("sof application produces node launch specs from ts-authored runtime and extensions", () => { - const plugin = tryDefinePlugin({ - name: "launch-spec-extension", - capabilities: [ExtensionCapability.ObserveObserverIngress], - onStart: () => ok(runtimeExtensionAck()), - onPacket: () => ok(runtimeExtensionAck()), - onStop: () => ok(runtimeExtensionAck()), +test("app derives a stable default name from the first plugin", () => { + const app = new App({ + runtime: createBalancedRuntime(), + ingress: [ + { + kind: IngressKind.WebSocket, + name: "solana-websocket", + url: "wss://example.invalid", + }, + ], + plugins: [new Plugin({ name: "tx-logger", logPackets: true })], }); - assert.equal(isOk(plugin), true); - if (!isOk(plugin)) { - return; - } - - const app = tryDefineApp({ - name: "demo-sof-app", - runtime: createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced), - plugins: [plugin.value], - }); - assert.equal(isOk(app), true); - if (!isOk(app)) { - return; - } + assert.equal(app.name, "tx-logger-app"); + assert.equal(app.runtime.runtimeDeliveryProfile, 2); + assert.deepEqual(app.ingress, [ + { + kind: IngressKind.WebSocket, + name: "solana-websocket", + url: "wss://example.invalid", + requests: [], + }, + ]); +}); - const launchSpec = createAppLaunch(app.value, { - workerEntrypoint: "./dist/worker.js", - workerCommand: "node", - workerArgs: ["--enable-source-maps"], +test("app rejects duplicate plugin names", () => { + const duplicatePlugin = new Plugin({ + name: "duplicate-extension", }); - assert.equal(launchSpec.appName, "demo-sof-app"); - assert.equal(launchSpec.runtimeEnvironment.SOF_RUNTIME_DELIVERY_PROFILE, "balanced"); - assert.equal(launchSpec.plugins.length, 1); - assert.equal(launchSpec.runtimeExtensions.length, 1); - assert.deepEqual(launchSpec.runtimeExtensions[0]?.args, [ - "--enable-source-maps", - "./dist/worker.js", - ]); - assert.equal( - launchSpec.runtimeExtensions[0]?.environment[sofApplicationNameEnvVarName], - "demo-sof-app", - ); - assert.equal( - launchSpec.runtimeExtensions[0]?.environment[sofRuntimeExtensionNameEnvVarName], - "launch-spec-extension", + assert.throws( + () => + new App({ + name: "duplicate-app", + plugins: [duplicatePlugin, duplicatePlugin], + }), + /registered more than once/, ); }); -test("sof application rejects duplicate runtime extension names", () => { - const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: "duplicate-extension", - capabilities: [ExtensionCapability.ObserveObserverIngress], +test("app resolves a plugin by name", () => { + const plugin = new Plugin({ + name: "selected-extension", }); - assert.equal(isOk(manifest), true); - if (!isOk(manifest)) { - return; - } - - const duplicateApp = SofApplication.tryCreate({ - name: "duplicate-app", - runtimeExtensions: [ - defineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), - }), - defineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), - }), - ], + const app = new App({ + plugins: [plugin], }); - assert.equal(isErr(duplicateApp), true); - if (isErr(duplicateApp)) { - assert.match(duplicateApp.error.message, /registered more than once/); + const resolved = app.getPlugin("selected-extension"); + assert.equal(isErr(resolved), false); + if (!isErr(resolved)) { + assert.equal(resolved.value.name, "selected-extension"); } }); -test("sof application runs one plugin selected from environment", async () => { - const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: "selected-extension", - capabilities: [ExtensionCapability.ObserveObserverIngress], +test("app reports missing plugin names with available plugins", () => { + const plugin = new Plugin({ + name: "available-extension", }); - assert.equal(isOk(manifest), true); - if (!isOk(manifest)) { - return; - } - - const app = defineSofApplication({ + const app = new App({ name: "selection-app", - runtimeExtensions: [ - defineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), - onPacketReceived: () => ok(runtimeExtensionAck()), - onShutdown: () => ok(runtimeExtensionAck()), - }), - ], + plugins: [plugin], }); - const input = new PassThrough(); - const output = new PassThrough(); - let responseText = ""; + const result = app.getPlugin("missing-extension"); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "pluginName"); + assert.deepEqual(result.error.availablePluginNames, ["available-extension"]); + } +}); - output.setEncoding("utf8"); - output.on("data", (chunk: string) => { - responseText += chunk; +test("plugin packet handlers default to observer ingress manifest access", () => { + const plugin = new Plugin({ + name: "packet-extension", + onPacket: () => { + throw new Error("not invoked by manifest construction"); + }, }); - const runner = tryRunSelectedPlugin( - app, + assert.deepEqual(plugin.manifest.manifest.capabilities, [ + ExtensionCapability.ObserveObserverIngress, + ]); + assert.deepEqual(plugin.manifest.manifest.subscriptions, [ { - [sofRuntimeExtensionNameEnvVarName]: "selected-extension", + sourceKind: RuntimePacketSourceKind.ObserverIngress, }, - { - input, - output, + ]); +}); + +test("plugin explicit manifest access is preserved", () => { + const plugin = new Plugin({ + name: "explicit-extension", + capabilities: [ExtensionCapability.ConnectWebSocket], + subscriptions: [], + onPacket: () => { + throw new Error("not invoked by manifest construction"); }, - ); + }); - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Start, - context: { - extensionName: manifest.value.extensionName, - }, + assert.deepEqual(plugin.manifest.manifest.capabilities, [ExtensionCapability.ConnectWebSocket]); + assert.deepEqual(plugin.manifest.manifest.subscriptions, []); +}); + +test("app requires fanIn when multiple ingress sources are configured", () => { + assert.throws( + () => + new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + url: "wss://one.example.invalid", + }, + { + kind: IngressKind.Grpc, + endpoint: "https://two.example.invalid", + }, + ], }), - )}\n`, + /fanIn is required/, ); - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, - context: { - extensionName: manifest.value.extensionName, +}); + +test("app accepts multiple ingress sources when fanIn is explicit", () => { + const app = new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + name: "ws", + url: "wss://one.example.invalid", + }, + { + kind: IngressKind.Grpc, + name: "grpc", + endpoint: "https://two.example.invalid", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, + }, + plugins: [new Plugin({})], + }); + + assert.deepEqual(app.fanIn, { + strategy: FanInStrategy.FirstSeen, + }); +}); + +test("app reports invalid runtime host override for non-websocket ingress", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/definitely/missing/sof_ts_runtime_host"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "SOF_SDK_RUNTIME_HOST_BINARY"); + assert.match(result.error.message, /failed to start runtime host/); + } + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app delegates non-websocket ingress to configured runtime host", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app delegates gRPC ingress to configured runtime host", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.Grpc, + endpoint: "https://example.invalid", + stream: GrpcIngressStream.TransactionStatus, + commitment: ProviderCommitment.Processed, + }, + ], + plugins: [ + new Plugin({ + name: "provider-extension", + onProviderEvent: () => { + throw new Error("not invoked by host delegation smoke"); + }, + }), + ], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app supports promote-capable native provider fan-in", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.Grpc, + name: "grpc-a", + endpoint: "https://one.example.invalid", + role: ProviderIngressRole.Primary, + }, + { + kind: IngressKind.Grpc, + name: "grpc-b", + endpoint: "https://two.example.invalid", + role: ProviderIngressRole.Fallback, + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeenThenPromote, + }, + plugins: [new Plugin({ name: "provider-extension" })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app rejects invalid provider ingress priority", () => { + assert.throws( + () => + new App({ + ingress: [ + { + kind: IngressKind.Grpc, + endpoint: "https://one.example.invalid", + priority: 70_000, + }, + ], + plugins: [new Plugin({ name: "provider-extension" })], }), - )}\n`, + /ingress.priority must be an integer between 0 and 65535/, ); - input.end(); +}); - const result = await runner; - assert.equal(isOk(result), true); - assert.match(responseText, /"tag":2/); - assert.match(responseText, /"tag":4/); +test("app reports mixed websocket and native ingress before starting a host", async () => { + const result = await new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + url: "wss://example.invalid", + }, + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, + }, + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "ingress"); + assert.match(result.error.message, /mixed websocket and native-host ingress/); + } }); -test("sof application reports missing runtime extension selection with available names", async () => { - const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: "available-extension", - capabilities: [ExtensionCapability.ObserveObserverIngress], - }); +test("app reports multi-websocket ingress fan-in as unsupported today", async () => { + const result = await new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + name: "ws-a", + url: "wss://one.example.invalid", + }, + { + kind: IngressKind.WebSocket, + name: "ws-b", + url: "wss://two.example.invalid", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, + }, + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); - assert.equal(isOk(manifest), true); - if (!isOk(manifest)) { + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "ingress"); + assert.match(result.error.message, /multi-websocket ingress fan-in/); + } +}); + +test("app accepts typed kernel-bypass config for direct shreds", async () => { + if (process.platform !== "linux") { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + kernelBypass: { + interface: "eth0", + }, + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "ingress.kernelBypass"); + assert.match(result.error.message, /only supported on Linux/); + } return; } - const app = defineSofApplication({ - name: "selection-app", - runtimeExtensions: [ - defineRuntimeExtension({ - manifest: manifest.value, - onReady: () => ok(runtimeExtensionAck()), + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + kernelBypass: { + interface: "eth0", + queueId: 0, + }, + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app composes direct shreds and gossip without fanIn", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + name: "direct-a", + bindAddress: "127.0.0.1:20000", + }, + { + kind: IngressKind.Gossip, + name: "gossip-a", + entrypoints: ["entrypoint.example.invalid:8001"], + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + +test("app rejects fanIn for direct shreds plus gossip composition", () => { + assert.throws( + () => + new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + { + kind: IngressKind.Gossip, + entrypoints: ["entrypoint.example.invalid:8001"], + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, + }, + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], }), - ], - }); + /fanIn is not used/, + ); +}); - const result = await tryRunSelectedPlugin(app, {}); +test("app reports multiple direct shred native ingress sources as unsupported today", async () => { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + name: "direct-a", + bindAddress: "127.0.0.1:20000", + }, + { + kind: IngressKind.DirectShreds, + name: "direct-b", + bindAddress: "127.0.0.1:20001", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, + }, + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); assert.equal(isErr(result), true); if (isErr(result)) { - assert.equal(result.error.field, sofRuntimeExtensionNameEnvVarName); - if ("availableExtensionNames" in result.error) { - assert.deepEqual(result.error.availableExtensionNames, ["available-extension"]); - } + assert.equal(result.error.field, "ingress"); + assert.match(result.error.message, /one direct shred ingress source/); } }); diff --git a/sdks/typescript/src/app.ts b/sdks/typescript/src/app.ts index 6cf04ad5..d423af24 100644 --- a/sdks/typescript/src/app.ts +++ b/sdks/typescript/src/app.ts @@ -1,135 +1,341 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { brand, type Brand } from "./brand.js"; -import { - envVarName, - environmentVariablesToRecord, - readEnvironmentVariable, - type EnvironmentInput, -} from "./environment.js"; import { err, isErr, ok, type Result } from "./result.js"; import { - type ExtensionCapability, + ExtensionCapability, type ExtensionContext, + type ExtensionName, type ExtensionResourceSpec, + extensionName, + ObserverRuntimeConfig, + observerRuntimeConfig, type ObserverRuntimeConfigInput, - type ObserverRuntimeEnvironmentOptions, - type ObserverRuntimeConfig, type PacketSubscription, - runRuntimeExtensionWorkerStdio, type RuntimeExtensionAck, + runtimeExtensionAck, type RuntimeExtensionDefinition, type RuntimeExtensionError, + type RuntimeExtensionWorkerManifest, + RuntimePacketEventClass, + RuntimePacketSourceKind, + RuntimePacketTransport, type RuntimePacketEvent, - type RuntimeExtensionWorkerStdioOptions, - extensionName, - tryCreateRuntimeExtensionWorkerManifest, + type RuntimeProviderEvent, + RuntimeWebSocketFrameType, + RuntimeDeliveryProfile, + type ShredTrustMode, + socketAddress, tryCreateRuntimeConfig, + tryCreateRuntimeExtensionWorkerManifest, tryDefineRuntimeExtension, - type ExtensionName, + webSocketUrl, } from "./runtime.js"; +import { runRuntimeExtensionWorkerStdio } from "./runtime/runtime-extension-stdio.js"; -export enum SofApplicationErrorKind { +export enum AppErrorKind { ValidationError = 1, - DuplicateRuntimeExtension = 2, - MissingRuntimeExtension = 3, - MissingRuntimeExtensionSelection = 4, + DuplicatePlugin = 2, + MissingPlugin = 3, } -export interface SofApplicationError { - readonly kind: SofApplicationErrorKind; +export interface AppError { + readonly kind: AppErrorKind; readonly field: string; readonly message: string; readonly received?: string; - readonly availableExtensionNames?: readonly string[]; + readonly availablePluginNames?: readonly string[]; } -export type SofApplicationName = Brand; +export type AppName = Brand; +export type PluginError = RuntimeExtensionError; +export type PluginName = ExtensionName; -export interface SofApplicationInit { - readonly name: string | SofApplicationName; - readonly runtime?: ObserverRuntimeConfigInput; - readonly runtimeExtensions?: readonly RuntimeExtensionDefinition[]; - readonly plugins?: readonly SofPluginInput[]; -} - -export type SofApplicationInput = SofApplication | SofApplicationInit; -export type SofPlugin = RuntimeExtensionDefinition; -export type SofPluginError = RuntimeExtensionError; -export type SofPluginInput = SofPlugin | SofPluginInit; - -export interface SofPluginInit { - readonly name: string | ExtensionName; - readonly capabilities?: readonly ExtensionCapability[]; - readonly resources?: readonly ExtensionResourceSpec[]; - readonly subscriptions?: readonly PacketSubscription[]; +export interface PluginHandler { readonly onStart?: ( context: ExtensionContext, - ) => - | Promise> - | Result; + ) => Promise> | Result; readonly onPacket?: ( event: RuntimePacketEvent, - ) => - | Promise> - | Result; + ) => Promise> | Result; + readonly onProviderEvent?: ( + event: RuntimeProviderEvent, + ) => Promise> | Result; readonly onStop?: ( context: ExtensionContext, - ) => - | Promise> - | Result; + ) => Promise> | Result; +} + +export interface PluginInit extends PluginHandler { + readonly name?: string | ExtensionName; + readonly capabilities?: readonly ExtensionCapability[]; + readonly resources?: readonly ExtensionResourceSpec[]; + readonly subscriptions?: readonly PacketSubscription[]; + readonly logPackets?: + | boolean + | { + readonly output?: NodeJS.WritableStream; + readonly formatter?: (event: RuntimePacketEvent) => string; + }; } -export interface SofNodeLaunchSpecInit { - readonly workerEntrypoint: string; - readonly workerCommand?: string; - readonly workerArgs?: readonly string[]; - readonly workerEnvironment?: Readonly>; - readonly runtimeEnvironment?: ObserverRuntimeEnvironmentOptions; +export enum IngressKind { + WebSocket = 1, + Grpc = 2, + Gossip = 3, + DirectShreds = 4, } -export interface SofNodeRuntimeExtensionLaunchSpec { - readonly extensionName: ExtensionName; - readonly transport: "stdio"; - readonly command: string; - readonly args: readonly string[]; - readonly environment: Readonly>; +export enum GrpcIngressStream { + Transactions = 1, + TransactionStatus = 2, + Accounts = 3, + BlockMeta = 4, + Slots = 5, +} + +export enum ProviderCommitment { + Processed = 1, + Confirmed = 2, + Finalized = 3, } -export type SofPluginLaunchSpec = SofNodeRuntimeExtensionLaunchSpec; +export enum ProviderIngressReadiness { + Required = 1, + Optional = 2, +} -export interface SofNodeLaunchSpec { - readonly appName: SofApplicationName; - readonly runtimeEnvironment: Readonly>; - readonly plugins: readonly SofPluginLaunchSpec[]; - readonly runtimeExtensions: readonly SofNodeRuntimeExtensionLaunchSpec[]; +export enum ProviderIngressRole { + Primary = 1, + Secondary = 2, + Fallback = 3, + ConfirmOnly = 4, } -export type SofApplicationWorkerRunError = SofApplicationError | RuntimeExtensionError; +export enum GossipRuntimeMode { + Full = 1, + BootstrapOnly = 2, + ControlPlaneOnly = 3, +} -export const sofApplicationNameEnvVarName = envVarName("SOF_SDK_APPLICATION_NAME"); -export const sofRuntimeExtensionNameEnvVarName = envVarName("SOF_SDK_RUNTIME_EXTENSION_NAME"); -export const sofTypeScriptSdkVersion = "0.1.0"; +export interface WebSocketIngressInit { + readonly kind: IngressKind.WebSocket; + readonly name?: string; + readonly url: string; + readonly requests?: readonly WebSocketRequest[]; +} -const defaultNodeCommand = "node"; -const sofApplicationValidatedInitTag = Symbol("SofApplicationValidatedInit"); +export interface GrpcIngressInit { + readonly kind: IngressKind.Grpc; + readonly name?: string; + readonly endpoint: string; + readonly tls?: boolean; + readonly stream?: GrpcIngressStream; + readonly xToken?: string; + readonly commitment?: ProviderCommitment; + readonly vote?: boolean; + readonly failed?: boolean; + readonly signature?: string; + readonly accountInclude?: readonly string[]; + readonly accountExclude?: readonly string[]; + readonly accountRequired?: readonly string[]; + readonly accounts?: readonly string[]; + readonly owners?: readonly string[]; + readonly requireTransactionSignature?: boolean; + readonly readiness?: ProviderIngressReadiness; + readonly role?: ProviderIngressRole; + readonly priority?: number; +} -interface SofApplicationValidatedInit { - readonly [sofApplicationValidatedInitTag]: true; - readonly name: SofApplicationName; - readonly runtime: ObserverRuntimeConfig; - readonly plugins: readonly SofPlugin[]; - readonly pluginsByName: ReadonlyMap; - readonly runtimeExtensions: readonly RuntimeExtensionDefinition[]; - readonly runtimeExtensionsByName: ReadonlyMap; +export interface GossipIngressInit { + readonly kind: IngressKind.Gossip; + readonly name?: string; + readonly bindAddress?: string; + readonly entrypoints?: readonly string[]; + readonly runtimeMode?: GossipRuntimeMode; + readonly entrypointPinned?: boolean; +} + +export interface KernelBypassInit { + readonly interface: string; + readonly queueId?: number; + readonly batchSize?: number; + readonly umemFrameCount?: number; + readonly ringDepth?: number; + readonly pollTimeoutMs?: number; +} + +export interface DirectShredsIngressInit { + readonly kind: IngressKind.DirectShreds; + readonly name?: string; + readonly bindAddress: string; + readonly trustMode?: ShredTrustMode; + readonly kernelBypass?: KernelBypassInit; +} + +export type IngressInit = + | WebSocketIngressInit + | GrpcIngressInit + | GossipIngressInit + | DirectShredsIngressInit; + +export enum FanInStrategy { + EmitAll = 1, + FirstSeen = 2, + FirstSeenThenPromote = 3, +} + +export interface FanInInit { + readonly strategy?: FanInStrategy; +} + +export interface WebSocketIngress { + readonly kind: IngressKind.WebSocket; + readonly name: string; + readonly url: string; + readonly requests: readonly string[]; +} + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | readonly JsonValue[] + | { + readonly [key: string]: JsonValue; + }; + +export interface WebSocketRequest { + readonly jsonrpc?: "2.0"; + readonly id?: string | number; + readonly method: string; + readonly params?: readonly JsonValue[]; +} + +export interface GrpcIngress { + readonly kind: IngressKind.Grpc; + readonly name: string; + readonly endpoint: string; + readonly tls: boolean; + readonly stream: GrpcIngressStream; + readonly xToken?: string; + readonly commitment?: ProviderCommitment; + readonly vote?: boolean; + readonly failed?: boolean; + readonly signature?: string; + readonly accountInclude: readonly string[]; + readonly accountExclude: readonly string[]; + readonly accountRequired: readonly string[]; + readonly accounts: readonly string[]; + readonly owners: readonly string[]; + readonly requireTransactionSignature: boolean; + readonly readiness: ProviderIngressReadiness; + readonly role: ProviderIngressRole; + readonly priority?: number; } -function sofApplicationError( - kind: SofApplicationErrorKind, +export interface GossipIngress { + readonly kind: IngressKind.Gossip; + readonly name: string; + readonly bindAddress?: string; + readonly entrypoints: readonly string[]; + readonly runtimeMode?: GossipRuntimeMode; + readonly entrypointPinned?: boolean; +} + +export interface DirectShredsIngress { + readonly kind: IngressKind.DirectShreds; + readonly name: string; + readonly bindAddress: string; + readonly trustMode?: ShredTrustMode; + readonly kernelBypass?: KernelBypassConfig; +} + +export interface KernelBypassConfig { + readonly interface: string; + readonly queueId: number; + readonly batchSize: number; + readonly umemFrameCount: number; + readonly ringDepth: number; + readonly pollTimeoutMs: number; +} + +export type Ingress = WebSocketIngress | GrpcIngress | GossipIngress | DirectShredsIngress; + +export interface FanIn { + readonly strategy: FanInStrategy; +} + +export interface AppInit { + readonly name?: string | AppName; + readonly runtime?: ObserverRuntimeConfigInput; + readonly ingress?: readonly IngressInit[]; + readonly fanIn?: FanInInit; + readonly plugins?: readonly Plugin[]; + readonly extensions?: readonly Plugin[]; +} + +export interface AppRunOptions { + readonly signal?: AbortSignal; +} + +export type AppRunError = AppError | RuntimeExtensionError; +export type ExtensionHandler = PluginHandler; +export type ExtensionError = PluginError; +export type ExtensionInit = PluginInit; +export const typeScriptSdkVersion = "0.1.0"; + +const defaultAppName = "app"; +const autoPluginNamePrefix = "plugin"; +const internalPluginWorkerEnvVarName = "SOF_SDK_INTERNAL_PLUGIN_WORKER"; +const runtimeHostBinaryEnvVarName = "SOF_SDK_RUNTIME_HOST_BINARY"; +const runtimeHostBinaryBaseName = "sof_ts_runtime_host"; +const grpcIngressStreams = [ + GrpcIngressStream.Transactions, + GrpcIngressStream.TransactionStatus, + GrpcIngressStream.Accounts, + GrpcIngressStream.BlockMeta, + GrpcIngressStream.Slots, +] as const satisfies readonly GrpcIngressStream[]; +const providerCommitments = [ + ProviderCommitment.Processed, + ProviderCommitment.Confirmed, + ProviderCommitment.Finalized, +] as const satisfies readonly ProviderCommitment[]; +const providerIngressReadiness = [ + ProviderIngressReadiness.Required, + ProviderIngressReadiness.Optional, +] as const satisfies readonly ProviderIngressReadiness[]; +const providerIngressRoles = [ + ProviderIngressRole.Primary, + ProviderIngressRole.Secondary, + ProviderIngressRole.Fallback, + ProviderIngressRole.ConfirmOnly, +] as const satisfies readonly ProviderIngressRole[]; +const gossipRuntimeModes = [ + GossipRuntimeMode.Full, + GossipRuntimeMode.BootstrapOnly, + GossipRuntimeMode.ControlPlaneOnly, +] as const satisfies readonly GossipRuntimeMode[]; +const defaultKernelBypassQueueId = 0; +const defaultKernelBypassBatchSize = 64; +const defaultKernelBypassUmemFrameCount = 4_096; +const defaultKernelBypassRingDepth = 2_048; +const defaultKernelBypassPollTimeoutMs = 100; + +let nextAutoPluginOrdinal = 1; + +function appError( + kind: AppErrorKind, field: string, message: string, received?: string, - availableExtensionNames?: readonly string[], -): SofApplicationError { - const error: SofApplicationError = { + availablePluginNames?: readonly string[], +): AppError { + const base: AppError = { kind, field, message, @@ -137,515 +343,1601 @@ function sofApplicationError( if (received !== undefined) { return { - ...error, + ...base, received, - ...(availableExtensionNames === undefined ? {} : { availableExtensionNames }), + ...(availablePluginNames === undefined ? {} : { availablePluginNames }), }; } - if (availableExtensionNames !== undefined) { + if (availablePluginNames !== undefined) { return { - ...error, - availableExtensionNames, + ...base, + availablePluginNames, }; } - return error; + return base; +} + +function throwAppError(error: AppError): never { + throw new RangeError(error.message); } -function parseNonEmptyAppValue( +function parseNonEmptyValue( value: string, field: string, wrap: (normalized: string) => T, -): Result { +): Result { const normalized = value.trim(); if (normalized === "") { - return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - field, - `${field} must not be empty`, - value, - ), - ); + return err(appError(AppErrorKind.ValidationError, field, `${field} must not be empty`, value)); } if (normalized.includes("\u0000")) { return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - field, - `${field} must not contain NUL bytes`, - value, - ), + appError(AppErrorKind.ValidationError, field, `${field} must not contain NUL bytes`, value), ); } return ok(wrap(normalized)); } -function asSofApplicationName(value: Value): SofApplicationName { - return brand(value); +function asAppName(value: Value): AppName { + return brand(value); } -function parseSofApplicationName(value: string): Result { - return parseNonEmptyAppValue(value, "name", asSofApplicationName); +function parseAppName(value: string): Result { + return parseNonEmptyValue(value, "name", asAppName); } -function mergeEnvironmentRecords( - base: Readonly> = {}, - overlay: Readonly> = {}, -): Readonly> { - const variables: Array<{ readonly name: string; readonly value: string }> = []; - - for (const source of [base, overlay]) { - for (const [key, value] of Object.entries(source)) { - if (value !== undefined) { - variables.push({ - name: key, - value, - }); - } - } +function nextAutoPluginName(): string { + const ordinal = nextAutoPluginOrdinal; + nextAutoPluginOrdinal += 1; + return `${autoPluginNamePrefix}-${ordinal}`; +} + +function defaultAppNameFromPlugins(plugins: readonly Plugin[]): string { + const firstPlugin = plugins[0]; + if (firstPlugin !== undefined) { + return `${String(firstPlugin.name)}-app`; } + return defaultAppName; +} - return environmentVariablesToRecord( - variables.map((variable) => ({ - name: envVarName(variable.name), - value: variable.value, - })), - ); +function mergePluginHandlers(base: PluginHandler, overlay: PluginHandler): PluginHandler { + return { + ...(base.onStart === undefined ? {} : { onStart: base.onStart }), + ...(overlay.onStart === undefined ? {} : { onStart: overlay.onStart }), + ...(base.onPacket === undefined ? {} : { onPacket: base.onPacket }), + ...(overlay.onPacket === undefined ? {} : { onPacket: overlay.onPacket }), + ...(base.onProviderEvent === undefined ? {} : { onProviderEvent: base.onProviderEvent }), + ...(overlay.onProviderEvent === undefined ? {} : { onProviderEvent: overlay.onProviderEvent }), + ...(base.onStop === undefined ? {} : { onStop: base.onStop }), + ...(overlay.onStop === undefined ? {} : { onStop: overlay.onStop }), + }; } -function throwSofApplicationError(error: SofApplicationError): never { - throw new RangeError(error.message); +function createPacketLoggerHandler( + output: NodeJS.WritableStream, + formatter?: (event: RuntimePacketEvent) => string, +): PluginHandler { + return { + onPacket: (event) => { + const line = + formatter?.(event) ?? + JSON.stringify( + { + observedUnixMs: event.observedUnixMs, + source: event.source, + bytes: Array.from(event.bytes), + }, + undefined, + 2, + ); + output.write(`${line}\n`); + return ok(runtimeExtensionAck()); + }, + }; } -function toRuntimeExtensionNameKey(value: ExtensionName): string { - return value; +function normalizePluginName( + value: string | ExtensionName | undefined, +): Result { + if (value === undefined) { + return extensionName(nextAutoPluginName()); + } + + return typeof value === "string" ? extensionName(value) : ok(value); } -export class SofApplication { - readonly name!: SofApplicationName; - readonly runtime!: ObserverRuntimeConfig; - readonly plugins!: readonly SofPlugin[]; - readonly pluginsByName!: ReadonlyMap; - readonly runtimeExtensions!: readonly RuntimeExtensionDefinition[]; - readonly runtimeExtensionsByName!: ReadonlyMap; - - constructor(init: SofApplicationInit | SofApplicationValidatedInit) { - if (sofApplicationValidatedInitTag in init) { - this.name = init.name; - this.runtime = init.runtime; - this.plugins = init.plugins; - this.pluginsByName = init.pluginsByName; - this.runtimeExtensions = init.runtimeExtensions; - this.runtimeExtensionsByName = init.runtimeExtensionsByName; - return; - } +function defaultPacketCapabilities(handlers: PluginHandler): readonly ExtensionCapability[] { + return handlers.onPacket === undefined ? [] : [ExtensionCapability.ObserveObserverIngress]; +} - const result = tryDefineSofApplication(init); - if (isErr(result)) { - throwSofApplicationError(result.error); - } +function defaultPacketSubscriptions(handlers: PluginHandler): readonly PacketSubscription[] { + return handlers.onPacket === undefined + ? [] + : [ + { + sourceKind: RuntimePacketSourceKind.ObserverIngress, + }, + ]; +} - this.name = result.value.name; - this.runtime = result.value.runtime; - this.plugins = result.value.plugins; - this.pluginsByName = result.value.pluginsByName; - this.runtimeExtensions = result.value.runtimeExtensions; - this.runtimeExtensionsByName = result.value.runtimeExtensionsByName; +function createPluginDefinition(init: PluginInit): Result { + const name = normalizePluginName(init.name); + if (isErr(name)) { + return name; } - toRuntimeEnvironmentRecord( - options: ObserverRuntimeEnvironmentOptions = {}, - ): Readonly> { - return this.runtime.toEnvironmentRecord(options); + const logger = + init.logPackets === true + ? { output: process.stdout } + : init.logPackets === undefined || init.logPackets === false + ? undefined + : { + output: init.logPackets.output ?? process.stdout, + ...(init.logPackets.formatter === undefined + ? {} + : { formatter: init.logPackets.formatter }), + }; + const handlers = + logger === undefined + ? init + : mergePluginHandlers(createPacketLoggerHandler(logger.output, logger.formatter), init); + + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: typeScriptSdkVersion, + extensionName: name.value, + capabilities: init.capabilities ?? defaultPacketCapabilities(handlers), + ...(init.resources === undefined ? {} : { resources: init.resources }), + subscriptions: init.subscriptions ?? defaultPacketSubscriptions(handlers), + }); + if (isErr(manifest)) { + return manifest; } - getRuntimeExtension( - name: string | ExtensionName, - ): Result { - const parsedName = typeof name === "string" ? extensionName(name) : ok(name); - if (isErr(parsedName)) { - return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - "runtimeExtensionName", - parsedName.error.message, - typeof name === "string" ? name : String(name), - ), - ); - } + return tryDefineRuntimeExtension({ + manifest: manifest.value, + ...(handlers.onStart === undefined ? {} : { onReady: handlers.onStart }), + ...(handlers.onPacket === undefined ? {} : { onPacketReceived: handlers.onPacket }), + ...(handlers.onProviderEvent === undefined + ? {} + : { onProviderEvent: handlers.onProviderEvent }), + ...(handlers.onStop === undefined ? {} : { onShutdown: handlers.onStop }), + }); +} - const found = this.runtimeExtensionsByName.get(toRuntimeExtensionNameKey(parsedName.value)); - if (found !== undefined) { - return ok(found); - } +function parseIngressName(value: string, field: string): Result { + return parseNonEmptyValue(value, field, (normalized) => normalized); +} +function validateWebSocketIngress( + ingress: WebSocketIngressInit, + index: number, +): Result { + const name = parseIngressName(ingress.name ?? `websocket-${index + 1}`, "ingress.name"); + if (isErr(name)) { + return name; + } + + const url = webSocketUrl(ingress.url); + if (isErr(url)) { return err( - sofApplicationError( - SofApplicationErrorKind.MissingRuntimeExtension, - "runtimeExtensionName", - `runtime extension ${String(parsedName.value)} is not registered in application ${String(this.name)}`, - String(parsedName.value), - this.runtimeExtensions.map((definition) => String(definition.manifest.extensionName)), - ), + appError(AppErrorKind.ValidationError, "ingress.url", url.error.message, ingress.url), ); } - getPlugin(name: string | ExtensionName): Result { - return this.getRuntimeExtension(name); - } + const requests: string[] = []; + for (const request of ingress.requests ?? []) { + const method = parseNonEmptyValue(request.method, "ingress.requests.method", (value) => value); + if (isErr(method)) { + return method; + } - toNodeLaunchSpec(init: SofNodeLaunchSpecInit): Result { - const workerEntrypoint = parseNonEmptyAppValue( - init.workerEntrypoint, - "workerEntrypoint", - (value) => value, + requests.push( + JSON.stringify({ + jsonrpc: request.jsonrpc ?? "2.0", + id: request.id ?? index + requests.length + 1, + method: method.value, + ...(request.params === undefined ? {} : { params: request.params }), + }), ); - if (isErr(workerEntrypoint)) { - return workerEntrypoint; - } + } + + return ok({ + kind: IngressKind.WebSocket, + name: name.value, + url: url.value, + requests, + }); +} + +function validateGrpcIngress( + ingress: GrpcIngressInit, + index: number, +): Result { + const name = parseIngressName(ingress.name ?? `grpc-${index + 1}`, "ingress.name"); + if (isErr(name)) { + return name; + } + + const endpoint = parseNonEmptyValue(ingress.endpoint, "ingress.endpoint", (value) => value); + if (isErr(endpoint)) { + return endpoint; + } - const command = parseNonEmptyAppValue( - init.workerCommand ?? defaultNodeCommand, - "workerCommand", - (value) => value, + const stream = ingress.stream ?? GrpcIngressStream.Transactions; + if (!grpcIngressStreams.includes(stream)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.stream", + "ingress.stream must be a supported gRPC stream", + String(stream), + ), ); - if (isErr(command)) { - return command; - } - - const runtimeEnvironment = this.runtime.toEnvironmentRecord(init.runtimeEnvironment); - const runtimeExtensions = this.runtimeExtensions.map((definition) => ({ - extensionName: definition.manifest.extensionName, - transport: "stdio" as const, - command: command.value, - args: [...(init.workerArgs ?? []), workerEntrypoint.value], - environment: mergeEnvironmentRecords(init.workerEnvironment, { - [sofApplicationNameEnvVarName]: this.name, - [sofRuntimeExtensionNameEnvVarName]: definition.manifest.extensionName, - }), - })); + } + if (ingress.commitment !== undefined && !providerCommitments.includes(ingress.commitment)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.commitment", + "ingress.commitment must be a supported provider commitment", + String(ingress.commitment), + ), + ); + } + if (ingress.readiness !== undefined && !providerIngressReadiness.includes(ingress.readiness)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.readiness", + "ingress.readiness must be a supported provider readiness policy", + String(ingress.readiness), + ), + ); + } + if (ingress.role !== undefined && !providerIngressRoles.includes(ingress.role)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.role", + "ingress.role must be a supported provider ingress role", + String(ingress.role), + ), + ); + } + if ( + ingress.priority !== undefined && + (!Number.isInteger(ingress.priority) || ingress.priority < 0 || ingress.priority > 65_535) + ) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.priority", + "ingress.priority must be an integer between 0 and 65535", + String(ingress.priority), + ), + ); + } - return ok({ - appName: this.name, - runtimeEnvironment, - plugins: runtimeExtensions, - runtimeExtensions, - }); + const xToken = + ingress.xToken === undefined + ? undefined + : parseNonEmptyValue(ingress.xToken, "ingress.xToken", (value) => value); + if (xToken !== undefined && isErr(xToken)) { + return xToken; } - static create(init: SofApplicationInit): SofApplication { - return defineSofApplication(init); + const signature = + ingress.signature === undefined + ? undefined + : parseNonEmptyValue(ingress.signature, "ingress.signature", (value) => value); + if (signature !== undefined && isErr(signature)) { + return signature; } - static tryCreate(init: SofApplicationInit): Result { - return tryDefineSofApplication(init); + const parseFilterList = ( + values: readonly string[] | undefined, + field: string, + ): Result => { + const normalized: string[] = []; + for (const value of values ?? []) { + const parsed = parseNonEmptyValue(value, field, (normalizedValue) => normalizedValue); + if (isErr(parsed)) { + return parsed; + } + normalized.push(parsed.value); + } + + return ok(normalized); + }; + const accountInclude = parseFilterList(ingress.accountInclude, "ingress.accountInclude"); + if (isErr(accountInclude)) { + return accountInclude; + } + const accountExclude = parseFilterList(ingress.accountExclude, "ingress.accountExclude"); + if (isErr(accountExclude)) { + return accountExclude; + } + const accountRequired = parseFilterList(ingress.accountRequired, "ingress.accountRequired"); + if (isErr(accountRequired)) { + return accountRequired; + } + const accounts = parseFilterList(ingress.accounts, "ingress.accounts"); + if (isErr(accounts)) { + return accounts; + } + const owners = parseFilterList(ingress.owners, "ingress.owners"); + if (isErr(owners)) { + return owners; } -} -function createValidatedSofApplication( - init: Omit, -): SofApplication { - return new SofApplication({ - [sofApplicationValidatedInitTag]: true, - ...init, + return ok({ + kind: IngressKind.Grpc, + name: name.value, + endpoint: endpoint.value, + tls: ingress.tls ?? true, + stream, + ...(xToken === undefined ? {} : { xToken: xToken.value }), + ...(ingress.commitment === undefined ? {} : { commitment: ingress.commitment }), + ...(ingress.vote === undefined ? {} : { vote: ingress.vote }), + ...(ingress.failed === undefined ? {} : { failed: ingress.failed }), + ...(signature === undefined ? {} : { signature: signature.value }), + accountInclude: accountInclude.value, + accountExclude: accountExclude.value, + accountRequired: accountRequired.value, + accounts: accounts.value, + owners: owners.value, + requireTransactionSignature: ingress.requireTransactionSignature ?? false, + readiness: ingress.readiness ?? ProviderIngressReadiness.Required, + role: ingress.role ?? ProviderIngressRole.Primary, + ...(ingress.priority === undefined ? {} : { priority: ingress.priority }), }); } -export function tryDefineSofApplication( - init: SofApplicationInput, -): Result { - if (init instanceof SofApplication) { - return ok(init); +function validateGossipIngress( + ingress: GossipIngressInit, + index: number, +): Result { + const name = parseIngressName(ingress.name ?? `gossip-${index + 1}`, "ingress.name"); + if (isErr(name)) { + return name; + } + + const bindAddress = + ingress.bindAddress === undefined ? undefined : socketAddress(ingress.bindAddress); + if (bindAddress !== undefined && isErr(bindAddress)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.bindAddress", + bindAddress.error.message, + ingress.bindAddress, + ), + ); + } + + if (ingress.runtimeMode !== undefined && !gossipRuntimeModes.includes(ingress.runtimeMode)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.runtimeMode", + "ingress.runtimeMode must be a supported gossip runtime mode", + String(ingress.runtimeMode), + ), + ); + } + + const entrypoints: string[] = []; + for (const value of ingress.entrypoints ?? []) { + const parsed = parseNonEmptyValue(value, "ingress.entrypoints", (normalized) => normalized); + if (isErr(parsed)) { + return parsed; + } + entrypoints.push(parsed.value); } - const name = typeof init.name === "string" ? parseSofApplicationName(init.name) : ok(init.name); + return ok({ + kind: IngressKind.Gossip, + name: name.value, + ...(bindAddress === undefined ? {} : { bindAddress: bindAddress.value }), + entrypoints, + ...(ingress.runtimeMode === undefined ? {} : { runtimeMode: ingress.runtimeMode }), + ...(ingress.entrypointPinned === undefined + ? {} + : { entrypointPinned: ingress.entrypointPinned }), + }); +} + +function validateDirectShredsIngress( + ingress: DirectShredsIngressInit, + index: number, +): Result { + const name = parseIngressName(ingress.name ?? `direct-shreds-${index + 1}`, "ingress.name"); if (isErr(name)) { return name; } - const runtime = tryCreateRuntimeConfig(init.runtime); - if (isErr(runtime)) { + const bindAddress = socketAddress(ingress.bindAddress); + if (isErr(bindAddress)) { return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - "runtime", - runtime.error.message, + appError( + AppErrorKind.ValidationError, + "ingress.bindAddress", + bindAddress.error.message, + ingress.bindAddress, ), ); } - const validatedRuntimeExtensions: RuntimeExtensionDefinition[] = []; - const runtimeExtensionsByName = new Map(); - const pluginDefinitions: RuntimeExtensionDefinition[] = []; - for (const plugin of init.plugins ?? []) { - const validatedPlugin = tryDefineSofPlugin(plugin); - if (isErr(validatedPlugin)) { - return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - "plugins", - validatedPlugin.error.message, - ), - ); + let kernelBypass: KernelBypassConfig | undefined; + if (ingress.kernelBypass !== undefined) { + const parsedKernelBypass = validateKernelBypassConfig(ingress.kernelBypass); + if (isErr(parsedKernelBypass)) { + return parsedKernelBypass; } + kernelBypass = parsedKernelBypass.value; + } + + return ok({ + kind: IngressKind.DirectShreds, + name: name.value, + bindAddress: bindAddress.value, + ...(ingress.trustMode === undefined ? {} : { trustMode: ingress.trustMode }), + ...(kernelBypass === undefined ? {} : { kernelBypass }), + }); +} - pluginDefinitions.push(validatedPlugin.value); +function validateKernelBypassConfig( + config: KernelBypassInit, +): Result { + const networkInterface = parseNonEmptyValue( + config.interface, + "ingress.kernelBypass.interface", + (value) => value, + ); + if (isErr(networkInterface)) { + return networkInterface; } - for (const runtimeExtension of [...(init.runtimeExtensions ?? []), ...pluginDefinitions]) { - const validatedRuntimeExtension = tryDefineRuntimeExtension(runtimeExtension); - if (isErr(validatedRuntimeExtension)) { + const validateNonNegativeInteger = (value: number, field: string): Result => { + if (!Number.isInteger(value) || value < 0) { return err( - sofApplicationError( - SofApplicationErrorKind.ValidationError, - "runtimeExtensions", - validatedRuntimeExtension.error.message, + appError( + AppErrorKind.ValidationError, + field, + `${field} must be a non-negative integer`, + String(value), ), ); } - const runtimeExtensionNameKey = toRuntimeExtensionNameKey( - validatedRuntimeExtension.value.manifest.extensionName, - ); - if (runtimeExtensionsByName.has(runtimeExtensionNameKey)) { + return ok(value); + }; + const validatePositiveIntegerField = (value: number, field: string): Result => { + if (!Number.isInteger(value) || value <= 0) { return err( - sofApplicationError( - SofApplicationErrorKind.DuplicateRuntimeExtension, - "runtimeExtensions", - `runtime extension ${runtimeExtensionNameKey} is registered more than once`, - runtimeExtensionNameKey, + appError( + AppErrorKind.ValidationError, + field, + `${field} must be a positive integer`, + String(value), ), ); } - validatedRuntimeExtensions.push(validatedRuntimeExtension.value); - runtimeExtensionsByName.set(runtimeExtensionNameKey, validatedRuntimeExtension.value); - } + return ok(value); + }; - return ok( - createValidatedSofApplication({ - name: name.value, - runtime: runtime.value, - plugins: validatedRuntimeExtensions, - pluginsByName: runtimeExtensionsByName, - runtimeExtensions: validatedRuntimeExtensions, - runtimeExtensionsByName, - }), + const queueId = validateNonNegativeInteger( + config.queueId ?? defaultKernelBypassQueueId, + "ingress.kernelBypass.queueId", ); -} - -export function tryDefineSofPlugin(init: SofPluginInput): Result { - if ("manifest" in init) { - return tryDefineRuntimeExtension(init); + if (isErr(queueId)) { + return queueId; } - - const manifest = tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: sofTypeScriptSdkVersion, - extensionName: init.name, - ...(init.capabilities === undefined ? {} : { capabilities: init.capabilities }), - ...(init.resources === undefined ? {} : { resources: init.resources }), - ...(init.subscriptions === undefined ? {} : { subscriptions: init.subscriptions }), - }); - if (isErr(manifest)) { - return manifest; + const batchSize = validatePositiveIntegerField( + config.batchSize ?? defaultKernelBypassBatchSize, + "ingress.kernelBypass.batchSize", + ); + if (isErr(batchSize)) { + return batchSize; + } + const umemFrameCount = validatePositiveIntegerField( + config.umemFrameCount ?? defaultKernelBypassUmemFrameCount, + "ingress.kernelBypass.umemFrameCount", + ); + if (isErr(umemFrameCount)) { + return umemFrameCount; + } + const ringDepth = validatePositiveIntegerField( + config.ringDepth ?? defaultKernelBypassRingDepth, + "ingress.kernelBypass.ringDepth", + ); + if (isErr(ringDepth)) { + return ringDepth; + } + const pollTimeoutMs = validatePositiveIntegerField( + config.pollTimeoutMs ?? defaultKernelBypassPollTimeoutMs, + "ingress.kernelBypass.pollTimeoutMs", + ); + if (isErr(pollTimeoutMs)) { + return pollTimeoutMs; } - return tryDefineRuntimeExtension({ - manifest: manifest.value, - ...(init.onStart === undefined ? {} : { onReady: init.onStart }), - ...(init.onPacket === undefined ? {} : { onPacketReceived: init.onPacket }), - ...(init.onStop === undefined ? {} : { onShutdown: init.onStop }), + return ok({ + interface: networkInterface.value, + queueId: queueId.value, + batchSize: batchSize.value, + umemFrameCount: umemFrameCount.value, + ringDepth: ringDepth.value, + pollTimeoutMs: pollTimeoutMs.value, }); } -export function defineSofPlugin(init: SofPluginInput): SofPlugin { - const result = tryDefineSofPlugin(init); - if (isErr(result)) { - throw new RangeError(result.error.message); - } - - return result.value; -} +function validateIngress( + ingress: readonly IngressInit[] = [], +): Result { + const normalized: Ingress[] = []; + const names = new Set(); + + for (const [index, value] of ingress.entries()) { + let parsed: Result; + + switch (value.kind) { + case IngressKind.WebSocket: + parsed = validateWebSocketIngress(value, index); + break; + case IngressKind.Grpc: + parsed = validateGrpcIngress(value, index); + break; + case IngressKind.Gossip: + parsed = validateGossipIngress(value, index); + break; + case IngressKind.DirectShreds: + parsed = validateDirectShredsIngress(value, index); + break; + } -export function defineSofApplication(init: SofApplicationInit): SofApplication { - const result = tryDefineSofApplication(init); - if (isErr(result)) { - throwSofApplicationError(result.error); - } + if (isErr(parsed)) { + return parsed; + } - return result.value; -} + if (names.has(parsed.value.name)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.name", + `ingress ${parsed.value.name} is registered more than once`, + parsed.value.name, + ), + ); + } -export function tryCreateSofNodeLaunchSpec( - app: SofApplicationInput, - init: SofNodeLaunchSpecInit, -): Result { - const application = tryDefineSofApplication(app); - if (isErr(application)) { - return application; + names.add(parsed.value.name); + normalized.push(parsed.value); } - return application.value.toNodeLaunchSpec(init); + return ok(normalized); } -export function createSofNodeLaunchSpec( - app: SofApplicationInput, - init: SofNodeLaunchSpecInit, -): SofNodeLaunchSpec { - const result = tryCreateSofNodeLaunchSpec(app, init); - if (isErr(result)) { - throwSofApplicationError(result.error); - } - - return result.value; -} +function validateFanIn( + fanIn: FanInInit | undefined, + ingress: readonly Ingress[], +): Result { + const noFanIn: FanIn | undefined = undefined; + const rawRuntimeComposition = ingress.length > 1 && isRawRuntimeComposition(ingress); -export function tryRunSofApplicationRuntimeExtensionWorker( - app: SofApplicationInput, - name: string | ExtensionName, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise> { - const application = tryDefineSofApplication(app); - if (isErr(application)) { - return Promise.resolve(application); + if (ingress.length <= 1) { + if (fanIn === undefined) { + return ok(noFanIn); + } + } else if (rawRuntimeComposition) { + if (fanIn === undefined) { + return ok(noFanIn); + } + return err( + appError( + AppErrorKind.ValidationError, + "fanIn", + "fanIn is not used for direct-shreds plus gossip runtime composition", + ), + ); + } else if (fanIn === undefined) { + return err( + appError( + AppErrorKind.ValidationError, + "fanIn", + "fanIn is required when more than one ingress source is configured", + ), + ); } - const runtimeExtension = application.value.getRuntimeExtension(name); - if (isErr(runtimeExtension)) { - return Promise.resolve(runtimeExtension); + if (fanIn === undefined) { + return ok(noFanIn); } - return runRuntimeExtensionWorkerStdio(runtimeExtension.value, options); + const strategy = fanIn.strategy ?? FanInStrategy.FirstSeen; + + return ok({ + strategy, + }); } -export async function runSofApplicationRuntimeExtensionWorker( - app: SofApplicationInput, - name: string | ExtensionName, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise { - const result = await tryRunSofApplicationRuntimeExtensionWorker(app, name, options); - if (isErr(result)) { - throw new RangeError(result.error.message); +function isRawRuntimeComposition(ingress: readonly Ingress[]): boolean { + let directShredsCount = 0; + let gossipCount = 0; + + for (const source of ingress) { + switch (source.kind) { + case IngressKind.DirectShreds: + directShredsCount += 1; + break; + case IngressKind.Gossip: + gossipCount += 1; + break; + case IngressKind.WebSocket: + case IngressKind.Grpc: + return false; + default: + return false; + } } - return result.value; + return directShredsCount <= 1 && gossipCount <= 1 && directShredsCount + gossipCount >= 2; } -export function tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment( - app: SofApplicationInput, - env: EnvironmentInput = process.env, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise> { - const selectedRuntimeExtensionName = readEnvironmentVariable( - env, - sofRuntimeExtensionNameEnvVarName, +function resolveRuntimeInput( + runtime: ObserverRuntimeConfigInput | undefined, + ingress: readonly Ingress[], +): Result { + const directShreds = ingress.filter( + (value): value is DirectShredsIngress => value.kind === IngressKind.DirectShreds, + ); + const inferredTrustModes = new Set( + directShreds + .map((value) => value.trustMode) + .filter((value): value is ShredTrustMode => value !== undefined), ); - if (selectedRuntimeExtensionName === undefined || selectedRuntimeExtensionName.trim() === "") { - const application = tryDefineSofApplication(app); - const availableExtensionNames = isErr(application) - ? undefined - : application.value.runtimeExtensions.map((definition) => - String(definition.manifest.extensionName), - ); - return Promise.resolve( - err( - sofApplicationError( - SofApplicationErrorKind.MissingRuntimeExtensionSelection, - String(sofRuntimeExtensionNameEnvVarName), - `${String(sofRuntimeExtensionNameEnvVarName)} must be set to select one runtime extension worker`, - selectedRuntimeExtensionName, - availableExtensionNames, - ), + if (inferredTrustModes.size > 1) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "direct shred ingress sources must agree on trustMode unless runtime.shredTrustMode is set explicitly", ), ); } - return tryRunSofApplicationRuntimeExtensionWorker(app, selectedRuntimeExtensionName, options); -} + const inferredTrustMode = inferredTrustModes.size === 1 ? [...inferredTrustModes][0] : undefined; + + let baseRuntime: ObserverRuntimeConfigInput = runtime ?? {}; + if (inferredTrustMode !== undefined) { + if (runtime === undefined) { + baseRuntime = { + shredTrustMode: inferredTrustMode, + }; + } else if ( + !(runtime instanceof ObserverRuntimeConfig) && + runtime.shredTrustMode === undefined + ) { + baseRuntime = { + ...runtime, + shredTrustMode: inferredTrustMode, + }; + } + } -export async function runSofApplicationRuntimeExtensionWorkerFromEnvironment( - app: SofApplicationInput, - env: EnvironmentInput = process.env, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise { - const result = await tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); - if (isErr(result)) { - throw new RangeError(result.error.message); + const config = tryCreateRuntimeConfig(baseRuntime); + if (isErr(config)) { + return err(appError(AppErrorKind.ValidationError, "runtime", config.error.message)); } - return result.value; + return ok(config.value); } -export type SofApp = SofApplication; -export type SofAppError = SofApplicationError; -export type SofAppInit = SofApplicationInit; -export type SofAppInput = SofApplicationInput; -export type SofAppLaunchSpec = SofNodeLaunchSpec; -export type SofAppLaunchSpecInit = SofNodeLaunchSpecInit; -export type SofPluginName = ExtensionName; - -export function tryDefineSofApp(init: SofAppInput): Result { - return tryDefineSofApplication(init); +function toPluginNameKey(value: ExtensionName): string { + return value; } -export function defineSofApp(init: SofAppInit): SofApp { - return defineSofApplication(init); +interface AppState { + readonly name: AppName; + readonly runtime: ObserverRuntimeConfig; + readonly ingress: readonly Ingress[]; + readonly fanIn: FanIn | undefined; + readonly plugins: readonly Plugin[]; + readonly pluginsByName: ReadonlyMap; } -export function tryCreateSofAppLaunch( - app: SofAppInput, - init: SofAppLaunchSpecInit, -): Result { - return tryCreateSofNodeLaunchSpec(app, init); +interface RuntimeHostPluginWorkerConfig { + readonly name: ExtensionName; + readonly manifest: RuntimeExtensionWorkerManifest; + readonly command: string; + readonly args: readonly string[]; + readonly environment: Readonly>; } -export function createSofAppLaunch(app: SofAppInput, init: SofAppLaunchSpecInit): SofAppLaunchSpec { - return createSofNodeLaunchSpec(app, init); +interface RuntimeHostConfig { + readonly sdkVersion: string; + readonly appName: AppName; + readonly runtimeEnvironment: Readonly>; + readonly ingress: readonly Ingress[]; + readonly fanIn: FanIn | undefined; + readonly pluginWorkers: readonly RuntimeHostPluginWorkerConfig[]; } -export function tryRunSofPlugin( - app: SofAppInput, - name: string | SofPluginName, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise> { - return tryRunSofApplicationRuntimeExtensionWorker(app, name, options); +interface ChildExit { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; } -export function runSofPlugin( - app: SofAppInput, - name: string | SofPluginName, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise { - return runSofApplicationRuntimeExtensionWorker(app, name, options); -} +function waitForChildExit(child: ChildProcess): Promise> { + return new Promise((resolve) => { + const onError = (error: Error) => { + child.off("exit", onExit); + resolve(err(error)); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + child.off("error", onError); + resolve(ok({ code, signal })); + }; + + child.once("error", onError); + child.once("exit", onExit); + }); +} + +function runtimeSourceForIngress(_ingress: WebSocketIngress, frameType: RuntimeWebSocketFrameType) { + return { + kind: RuntimePacketSourceKind.ObserverIngress, + transport: RuntimePacketTransport.WebSocket, + eventClass: RuntimePacketEventClass.Packet, + webSocketFrameType: frameType, + } as const; +} + +function toPacketBytes(data: MessageEvent["data"]): Uint8Array { + if (typeof data === "string") { + return new TextEncoder().encode(data); + } + + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + + if (data instanceof Blob) { + throw new TypeError("Blob websocket frames are not supported in the synchronous packet path"); + } + + return new TextEncoder().encode(String(data)); +} + +function toWebSocketFrameType(data: MessageEvent["data"]): RuntimeWebSocketFrameType { + return typeof data === "string" + ? RuntimeWebSocketFrameType.Text + : RuntimeWebSocketFrameType.Binary; +} + +function parseJsonRpcErrorMessage( + ingress: WebSocketIngress, + data: MessageEvent["data"], +): AppError | undefined { + if (typeof data !== "string") { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(data); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return undefined; + } + const id = + "id" in parsed && (typeof parsed.id === "string" || typeof parsed.id === "number") + ? parsed.id + : undefined; + const nestedError = + "error" in parsed && + typeof parsed.error === "object" && + parsed.error !== null && + !Array.isArray(parsed.error) + ? parsed.error + : undefined; + const message = + nestedError !== undefined && + "message" in nestedError && + typeof nestedError.message === "string" + ? nestedError.message + : undefined; + if (message === undefined) { + return undefined; + } + + return appError( + AppErrorKind.ValidationError, + "ingress.requests", + `websocket ingress ${ingress.name} rejected request ${String(id ?? "unknown")}: ${message}`, + data, + ); + } catch { + return undefined; + } +} + +function invokePluginReady(plugin: Plugin): Promise> { + const onReady = plugin.toDefinition().onReady; + if (onReady === undefined) { + return Promise.resolve(ok(runtimeExtensionAck())); + } + + return Promise.resolve( + onReady({ + extensionName: plugin.name, + }), + ); +} + +function invokePluginShutdown(plugin: Plugin): Promise> { + const onShutdown = plugin.toDefinition().onShutdown; + if (onShutdown === undefined) { + return Promise.resolve(ok(runtimeExtensionAck())); + } + + return Promise.resolve( + onShutdown({ + extensionName: plugin.name, + }), + ); +} + +function dispatchPacketToPlugin( + plugin: Plugin, + event: RuntimePacketEvent, +): Promise> { + const onPacket = plugin.toDefinition().onPacketReceived; + if (onPacket === undefined) { + return Promise.resolve(ok(runtimeExtensionAck())); + } + + return Promise.resolve(onPacket(event)); +} + +async function shutdownPlugins( + startedPlugins: readonly Plugin[], +): Promise> { + const shutdownResults = await Promise.all( + startedPlugins.map((plugin) => invokePluginShutdown(plugin)), + ); + for (const shutdown of shutdownResults) { + if (isErr(shutdown)) { + return shutdown; + } + } + + return ok(runtimeExtensionAck()); +} + +function stringEnvironmentRecord( + env: NodeJS.ProcessEnv = process.env, +): Readonly> { + const record: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + record[key] = value; + } + } + + return record; +} + +function currentNodeAppArgs(): Result { + const entrypoint = process.argv[1]; + if (entrypoint === undefined || entrypoint.trim() === "") { + return err( + appError( + AppErrorKind.ValidationError, + "process.argv[1]", + "app.run() needs a Node.js file entrypoint when delegating to the runtime host", + ), + ); + } + + return ok([...process.execArgv, ...process.argv.slice(1)]); +} + +function createRuntimeHostConfig(state: AppState): Result { + const appArgs = currentNodeAppArgs(); + if (isErr(appArgs)) { + return appArgs; + } + + return ok({ + sdkVersion: typeScriptSdkVersion, + appName: state.name, + runtimeEnvironment: state.runtime.toEnvironmentRecord({ includeDefaults: true }), + ingress: state.ingress, + fanIn: state.fanIn, + pluginWorkers: state.plugins.map((plugin) => ({ + name: plugin.name, + manifest: plugin.manifest, + command: process.execPath, + args: appArgs.value, + environment: { + ...stringEnvironmentRecord(), + [internalPluginWorkerEnvVarName]: plugin.name, + }, + })), + }); +} + +function validateNativeRuntimeSupport(state: AppState): Result { + const nativeIngress = state.ingress.filter((ingress) => ingress.kind !== IngressKind.WebSocket); + if (nativeIngress.length === 0) { + return ok(void 0); + } + + if (state.ingress.some((ingress) => ingress.kind === IngressKind.WebSocket)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "app.run() does not yet support mixed websocket and native-host ingress sources in one app", + ), + ); + } + + const rawIngress = nativeIngress.filter( + (ingress) => ingress.kind === IngressKind.DirectShreds || ingress.kind === IngressKind.Gossip, + ); + const directShredsIngress = rawIngress.filter( + (ingress): ingress is DirectShredsIngress => ingress.kind === IngressKind.DirectShreds, + ); + const gossipIngress = rawIngress.filter( + (ingress): ingress is GossipIngress => ingress.kind === IngressKind.Gossip, + ); + const providerIngress = nativeIngress.filter((ingress) => ingress.kind === IngressKind.Grpc); + if (providerIngress.length > 0 && rawIngress.length > 0) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "app.run() does not yet support mixed provider-stream and raw native ingress sources in one app", + ), + ); + } + + if (directShredsIngress.length > 1) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "app.run() currently supports one direct shred ingress source per app", + ), + ); + } + if (gossipIngress.length > 1) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "app.run() currently supports one gossip ingress source per app", + ), + ); + } + const kernelBypassIngress = directShredsIngress.find( + (ingress) => ingress.kernelBypass !== undefined, + ); + if (kernelBypassIngress !== undefined && process.platform !== "linux") { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.kernelBypass", + `kernel bypass for ingress ${kernelBypassIngress.name} is only supported on Linux`, + kernelBypassIngress.name, + ), + ); + } + + return ok(void 0); +} + +function runtimeHostExecutableName(): string { + return process.platform === "win32" + ? `${runtimeHostBinaryBaseName}.exe` + : runtimeHostBinaryBaseName; +} + +function packagedRuntimeHostPath(): string { + return join( + import.meta.dirname, + "native", + `${process.platform}-${process.arch}`, + runtimeHostExecutableName(), + ); +} + +function repoRuntimeHostPaths(): readonly string[] { + return [ + join(import.meta.dirname, "..", "..", "..", "target", "debug", runtimeHostExecutableName()), + join(import.meta.dirname, "..", "..", "..", "target", "release", runtimeHostExecutableName()), + ]; +} + +function makeRuntimeHostExecutable(candidate: string): Result { + try { + if (process.platform !== "win32") { + chmodSync(candidate, 0o755); + } + return ok(candidate); + } catch (error) { + return err( + appError( + AppErrorKind.ValidationError, + runtimeHostBinaryEnvVarName, + `runtime host binary exists but could not be made executable: ${ + error instanceof Error ? error.message : String(error) + }`, + candidate, + ), + ); + } +} + +function runtimeHostBinary(): Result { + const binary = process.env[runtimeHostBinaryEnvVarName]?.trim(); + if (binary !== undefined && binary !== "") { + return ok(binary); + } + + const candidates = [packagedRuntimeHostPath(), ...repoRuntimeHostPaths()]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return makeRuntimeHostExecutable(candidate); + } + } + + return err( + appError( + AppErrorKind.ValidationError, + runtimeHostBinaryEnvVarName, + `a compatible runtime host binary was not found; expected one of ${candidates.join(", ")}`, + ), + ); +} + +async function runRuntimeHost( + config: RuntimeHostConfig, + options: AppRunOptions, +): Promise> { + const hostBinary = runtimeHostBinary(); + if (isErr(hostBinary)) { + return hostBinary; + } + if (options.signal?.aborted === true) { + return ok(runtimeExtensionAck()); + } + + const tempDir = await mkdtemp(join(tmpdir(), "sof-sdk-runtime-")); + const configPath = join(tempDir, "runtime-host.json"); + await writeFile(configPath, `${JSON.stringify(config)}\n`, "utf8"); + + try { + const child = spawn(hostBinary.value, [configPath], { + env: process.env, + stdio: "inherit", + }); + let aborted = false; + let forceKillTimer: NodeJS.Timeout | undefined; + const abort = () => { + aborted = true; + child.kill("SIGTERM"); + forceKillTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, 2_000); + forceKillTimer.unref(); + }; + options.signal?.addEventListener("abort", abort, { once: true }); + + const exit = await waitForChildExit(child); + try { + if (isErr(exit)) { + return err( + appError( + AppErrorKind.ValidationError, + runtimeHostBinaryEnvVarName, + `failed to start runtime host: ${exit.error.message}`, + hostBinary.value, + ), + ); + } + + if (aborted || exit.value.code === 0) { + return ok(runtimeExtensionAck()); + } + + return err( + appError( + AppErrorKind.ValidationError, + runtimeHostBinaryEnvVarName, + `runtime host exited with ${ + exit.value.signal === null + ? `code ${String(exit.value.code)}` + : `signal ${exit.value.signal}` + }`, + hostBinary.value, + ), + ); + } finally { + options.signal?.removeEventListener("abort", abort); + if (forceKillTimer !== undefined) { + clearTimeout(forceKillTimer); + } + } + } finally { + await rm(tempDir, { force: true, recursive: true }); + } +} + +function createAppState(init: AppInit): Result { + const ingress = validateIngress(init.ingress); + if (isErr(ingress)) { + return ingress; + } + + const fanIn = validateFanIn(init.fanIn, ingress.value); + if (isErr(fanIn)) { + return fanIn; + } + + const plugins = [...(init.plugins ?? []), ...(init.extensions ?? [])]; + const pluginNames = new Set(); + const pluginsByName = new Map(); -export function tryRunSelectedSofPlugin( - app: SofAppInput, - env: EnvironmentInput = process.env, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise> { - return tryRunSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); + for (const plugin of plugins) { + const nameKey = toPluginNameKey(plugin.name); + if (pluginNames.has(nameKey)) { + return err( + appError( + AppErrorKind.DuplicatePlugin, + "plugins", + `plugin ${nameKey} is registered more than once`, + nameKey, + ), + ); + } + + pluginNames.add(nameKey); + pluginsByName.set(nameKey, plugin); + } + + const runtime = resolveRuntimeInput(init.runtime, ingress.value); + if (isErr(runtime)) { + return runtime; + } + + const resolvedName = + typeof init.name === "string" + ? parseAppName(init.name) + : init.name === undefined + ? parseAppName(defaultAppNameFromPlugins(plugins)) + : ok(init.name); + if (isErr(resolvedName)) { + return resolvedName; + } + + return ok({ + name: resolvedName.value, + runtime: runtime.value, + ingress: ingress.value, + fanIn: fanIn.value, + plugins, + pluginsByName, + }); +} + +export class Plugin { + private readonly definition: RuntimeExtensionDefinition; + + constructor(init: Plugin | PluginInit | RuntimeExtensionDefinition) { + if (init instanceof Plugin) { + this.definition = init.definition; + return; + } + + if (typeof init === "object" && init !== null && "manifest" in init) { + const validated = tryDefineRuntimeExtension(init); + if (isErr(validated)) { + throw new RangeError(validated.error.message); + } + this.definition = validated.value; + return; + } + + const definition = createPluginDefinition(init); + if (isErr(definition)) { + throw new RangeError(definition.error.message); + } + + this.definition = definition.value; + } + + static create( + init: Plugin | PluginInit | RuntimeExtensionDefinition, + ): Result { + if (init instanceof Plugin) { + return ok(init); + } + + if (typeof init === "object" && init !== null && "manifest" in init) { + const validated = tryDefineRuntimeExtension(init); + return isErr(validated) ? validated : ok(new Plugin(validated.value)); + } + + const definition = createPluginDefinition(init); + return isErr(definition) ? definition : ok(new Plugin(definition.value)); + } + + get name(): ExtensionName { + return this.definition.manifest.extensionName; + } + + get manifest(): RuntimeExtensionWorkerManifest { + return this.definition.manifest; + } + + toDefinition(): RuntimeExtensionDefinition { + return this.definition; + } +} + +export { Plugin as Extension }; + +export class App { + readonly #name: AppName; + readonly #runtime: ObserverRuntimeConfig; + readonly #ingress: readonly Ingress[]; + readonly #fanIn: FanIn | undefined; + readonly #plugins: readonly Plugin[]; + readonly #pluginsByName: ReadonlyMap; + + constructor(init: App | AppInit) { + if (init instanceof App) { + this.#name = init.#name; + this.#runtime = init.#runtime; + this.#ingress = init.#ingress; + this.#fanIn = init.#fanIn; + this.#plugins = init.#plugins; + this.#pluginsByName = init.#pluginsByName; + return; + } + + const state = createAppState(init); + if (isErr(state)) { + throwAppError(state.error); + } + + this.#name = state.value.name; + this.#runtime = state.value.runtime; + this.#ingress = state.value.ingress; + this.#fanIn = state.value.fanIn; + this.#plugins = state.value.plugins; + this.#pluginsByName = state.value.pluginsByName; + } + + static create(init: App | AppInit): Result { + if (init instanceof App) { + return ok(init); + } + + const state = createAppState(init); + if (isErr(state)) { + return state; + } + + return ok(new App(init)); + } + + get name(): AppName { + return this.#name; + } + + get runtime(): ObserverRuntimeConfig { + return this.#runtime; + } + + get ingress(): readonly Ingress[] { + return this.#ingress; + } + + get fanIn(): FanIn | undefined { + return this.#fanIn; + } + + get plugins(): readonly Plugin[] { + return this.#plugins; + } + + get extensions(): readonly Plugin[] { + return this.#plugins; + } + + getPlugin(name: string | ExtensionName): Result { + const parsedName = typeof name === "string" ? extensionName(name) : ok(name); + if (isErr(parsedName)) { + return err( + appError( + AppErrorKind.ValidationError, + "pluginName", + parsedName.error.message, + typeof name === "string" ? name : String(name), + ), + ); + } + + const plugin = this.#pluginsByName.get(toPluginNameKey(parsedName.value)); + if (plugin !== undefined) { + return ok(plugin); + } + + return err( + appError( + AppErrorKind.MissingPlugin, + "pluginName", + `plugin ${String(parsedName.value)} is not registered in app ${String(this.#name)}`, + String(parsedName.value), + this.#plugins.map((pluginValue) => String(pluginValue.name)), + ), + ); + } + + #toState(): AppState { + return { + name: this.#name, + runtime: this.#runtime, + ingress: this.#ingress, + fanIn: this.#fanIn, + plugins: this.#plugins, + pluginsByName: this.#pluginsByName, + }; + } + + #runInternalPluginWorker(pluginName: string): Promise> { + const plugin = this.getPlugin(pluginName); + if (isErr(plugin)) { + return Promise.resolve(plugin); + } + + return runRuntimeExtensionWorkerStdio(plugin.value.toDefinition()); + } + + run(options: AppRunOptions = {}): Promise> { + return this.runRuntime(options); + } + + async runRuntime(options: AppRunOptions = {}): Promise> { + if (this.#plugins.length === 0) { + return err( + appError(AppErrorKind.ValidationError, "plugins", "app must define at least one plugin"), + ); + } + + const internalWorkerPluginName = process.env[internalPluginWorkerEnvVarName]; + if (internalWorkerPluginName !== undefined) { + return this.#runInternalPluginWorker(internalWorkerPluginName); + } + + if (this.#ingress.some((ingress) => ingress.kind !== IngressKind.WebSocket)) { + const state = this.#toState(); + const support = validateNativeRuntimeSupport(state); + if (isErr(support)) { + return support; + } + + const runtimeHostConfig = createRuntimeHostConfig(state); + if (isErr(runtimeHostConfig)) { + return runtimeHostConfig; + } + + return runRuntimeHost(runtimeHostConfig.value, options); + } + + if (this.#ingress.length > 1) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress", + "app.run() does not yet support multi-websocket ingress fan-in", + ), + ); + } + + const readyResults = await Promise.all( + this.#plugins.map((plugin) => invokePluginReady(plugin)), + ); + for (const ready of readyResults) { + if (isErr(ready)) { + return ready; + } + } + const startedPlugins = [...this.#plugins]; + + if (this.#ingress.length === 0) { + if (options.signal !== undefined) { + if (options.signal.aborted) { + return ok(runtimeExtensionAck()); + } + + await new Promise((resolve) => { + options.signal?.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }); + } + + return shutdownPlugins(startedPlugins); + } + + const sockets: WebSocket[] = []; + let runtimeError: AppRunError | undefined; + let settleRuntime: (() => void) | undefined; + const runtimeSettled = new Promise((resolve) => { + settleRuntime = resolve; + }); + + const finishRuntime = () => { + settleRuntime?.(); + }; + + try { + await Promise.all( + this.#ingress.map( + (ingress) => + new Promise((resolve, reject) => { + if (ingress.kind !== IngressKind.WebSocket) { + reject( + new RangeError( + `unsupported ingress kind during websocket runtime execution: ${IngressKind[ingress.kind]}`, + ), + ); + return; + } + + const socket = new WebSocket(ingress.url); + sockets.push(socket); + + socket.binaryType = "arraybuffer"; + socket.addEventListener( + "open", + () => { + for (const request of ingress.requests) { + socket.send(request); + } + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + reject(new RangeError(`failed to open websocket ingress ${ingress.name}`)); + }, + { once: true }, + ); + socket.addEventListener("message", (message) => { + void (async () => { + try { + const jsonRpcError = parseJsonRpcErrorMessage(ingress, message.data); + if (jsonRpcError !== undefined) { + runtimeError = jsonRpcError; + socket.close(); + finishRuntime(); + return; + } + + const event: RuntimePacketEvent = { + source: runtimeSourceForIngress(ingress, toWebSocketFrameType(message.data)), + bytes: toPacketBytes(message.data), + observedUnixMs: Date.now(), + }; + + const handledResults = await Promise.all( + this.#plugins.map((plugin) => dispatchPacketToPlugin(plugin, event)), + ); + for (const handled of handledResults) { + if (isErr(handled)) { + runtimeError = handled.error; + socket.close(); + finishRuntime(); + } + } + } catch (error) { + runtimeError = appError( + AppErrorKind.ValidationError, + "ingress", + error instanceof Error ? error.message : String(error), + ); + socket.close(); + finishRuntime(); + } + })(); + }); + socket.addEventListener("close", () => { + if (options.signal === undefined || runtimeError !== undefined) { + finishRuntime(); + } + }); + }), + ), + ); + + if (options.signal?.aborted === true) { + finishRuntime(); + } else { + options.signal?.addEventListener( + "abort", + () => { + finishRuntime(); + }, + { once: true }, + ); + } + + await runtimeSettled; + } catch (error) { + runtimeError = appError( + AppErrorKind.ValidationError, + "ingress", + error instanceof Error ? error.message : String(error), + ); + } + + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.close(); + } + } + + const shutdown = await shutdownPlugins(startedPlugins); + if (isErr(shutdown)) { + return shutdown; + } + + return runtimeError === undefined ? ok(runtimeExtensionAck()) : err(runtimeError); + } } -export function runSelectedSofPlugin( - app: SofAppInput, - env: EnvironmentInput = process.env, - options: RuntimeExtensionWorkerStdioOptions = {}, -): Promise { - return runSofApplicationRuntimeExtensionWorkerFromEnvironment(app, env, options); +export function createBalancedRuntime( + init: Omit = {}, +): ObserverRuntimeConfig { + return observerRuntimeConfig({ + ...init, + runtimeDeliveryProfile: RuntimeDeliveryProfile.Balanced, + }); } -export const defineApp = defineSofApp; -export const definePlugin = defineSofPlugin; -export const tryDefineApp = tryDefineSofApp; -export const tryDefinePlugin = tryDefineSofPlugin; -export const createAppLaunch = createSofAppLaunch; -export const tryCreateAppLaunch = tryCreateSofAppLaunch; -export const runPlugin = runSofPlugin; -export const tryRunPlugin = tryRunSofPlugin; -export const runSelectedPlugin = runSelectedSofPlugin; -export const tryRunSelectedPlugin = tryRunSelectedSofPlugin; +export function createLatencyOptimizedRuntime( + init: Omit = {}, +): ObserverRuntimeConfig { + return observerRuntimeConfig({ + ...init, + runtimeDeliveryProfile: RuntimeDeliveryProfile.LatencyOptimized, + }); +} + +export function createDeliveryDisciplinedRuntime( + init: Omit = {}, +): ObserverRuntimeConfig { + return observerRuntimeConfig({ + ...init, + runtimeDeliveryProfile: RuntimeDeliveryProfile.DeliveryDisciplined, + }); +} diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index bd594330..6dcb5b7e 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -16,44 +16,8 @@ test("package exports resolve the documented public entry points", async () => { const extension = await importPackageEntry("@sof/sdk/runtime/extension"); const extensionStdio = await importPackageEntry("@sof/sdk/runtime/extension-stdio"); - assert.equal( - (root as { defineSofApplication: unknown }).defineSofApplication, - (app as { defineSofApplication: unknown }).defineSofApplication, - ); - assert.equal( - (root as { defineApp: unknown }).defineApp, - (app as { defineApp: unknown }).defineApp, - ); - assert.equal( - (root as { tryDefineApp: unknown }).tryDefineApp, - (app as { tryDefineApp: unknown }).tryDefineApp, - ); - assert.equal( - (root as { definePlugin: unknown }).definePlugin, - (app as { definePlugin: unknown }).definePlugin, - ); - assert.equal( - (root as { tryDefinePlugin: unknown }).tryDefinePlugin, - (app as { tryDefinePlugin: unknown }).tryDefinePlugin, - ); - assert.equal( - (root as { createAppLaunch: unknown }).createAppLaunch, - (app as { createAppLaunch: unknown }).createAppLaunch, - ); - assert.equal( - (root as { runSelectedPlugin: unknown }).runSelectedPlugin, - (app as { runSelectedPlugin: unknown }).runSelectedPlugin, - ); - assert.equal( - (root as { createSofNodeLaunchSpec: unknown }).createSofNodeLaunchSpec, - (app as { createSofNodeLaunchSpec: unknown }).createSofNodeLaunchSpec, - ); - assert.equal( - (root as { runSofApplicationRuntimeExtensionWorkerFromEnvironment: unknown }) - .runSofApplicationRuntimeExtensionWorkerFromEnvironment, - (app as { runSofApplicationRuntimeExtensionWorkerFromEnvironment: unknown }) - .runSofApplicationRuntimeExtensionWorkerFromEnvironment, - ); + assert.equal((root as { App: unknown }).App, (app as { App: unknown }).App); + assert.equal((root as { Plugin: unknown }).Plugin, (app as { Plugin: unknown }).Plugin); assert.equal( (root as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, @@ -97,7 +61,9 @@ test("package exports resolve the documented public entry points", async () => { .createRuntimeExtensionWorkerManifest, ); assert.equal( - (root as { runRuntimeExtensionWorkerStdio: unknown }).runRuntimeExtensionWorkerStdio, - (extensionStdio as { runRuntimeExtensionWorkerStdio: unknown }).runRuntimeExtensionWorkerStdio, + typeof (extensionStdio as { runRuntimeExtensionWorkerStdio: unknown }) + .runRuntimeExtensionWorkerStdio, + "function", ); + assert.equal("runRuntimeExtensionWorkerStdio" in (root as Record), false); }); diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index 074bb4fc..be82788c 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -2,5 +2,4 @@ export * from "./runtime/derived-state.js"; export * from "./runtime/runtime-config.js"; export * from "./runtime/runtime-delivery-profile.js"; export * from "./runtime/runtime-extension.js"; -export * from "./runtime/runtime-extension-stdio.js"; export * from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts index bd7746c6..6316c1ca 100644 --- a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts @@ -2,30 +2,33 @@ import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; import test from "node:test"; -import { isErr, isOk, ok } from "../result.js"; +import { ResultTag, isErr, ok } from "../result.js"; import { ExtensionCapability, RuntimeExtensionWorkerHostMessageTag, RuntimeExtensionWorkerResponseTag, + RuntimeProviderEventKind, extensionName, runtimeExtensionAck, + socketAddress, + tryCreateRuntimeExtensionWorkerManifest, + tryDefineRuntimeExtension, +} from "../runtime.js"; +import { runRuntimeExtensionWorkerStdio, serializeRuntimeExtensionWorkerHostMessageWire, serializeRuntimePacketEventWire, - socketAddress, tryParseRuntimeExtensionWorkerHostMessageWire, tryParseRuntimePacketEventWire, - tryCreateRuntimeExtensionWorkerManifest, - tryDefineRuntimeExtension, -} from "../runtime.js"; +} from "./runtime-extension-stdio.js"; test("runtime extension wire helpers round-trip packet delivery messages", () => { const parsedExtensionName = extensionName("wire-demo"); const localAddress = socketAddress("127.0.0.1:21011"); - assert.equal(isOk(parsedExtensionName), true); - assert.equal(isOk(localAddress), true); - if (!isOk(parsedExtensionName) || !isOk(localAddress)) { + assert.equal(parsedExtensionName.tag, ResultTag.Ok); + assert.equal(localAddress.tag, ResultTag.Ok); + if (parsedExtensionName.tag !== ResultTag.Ok || localAddress.tag !== ResultTag.Ok) { return; } @@ -45,8 +48,8 @@ test("runtime extension wire helpers round-trip packet delivery messages", () => }); const parsedWireMessage = tryParseRuntimeExtensionWorkerHostMessageWire(wireMessage); - assert.equal(isOk(parsedWireMessage), true); - if (!isOk(parsedWireMessage)) { + assert.equal(parsedWireMessage.tag, ResultTag.Ok); + if (parsedWireMessage.tag !== ResultTag.Ok) { return; } @@ -61,7 +64,37 @@ test("runtime extension wire helpers round-trip packet delivery messages", () => const parsedEvent = tryParseRuntimePacketEventWire( serializeRuntimePacketEventWire(parsedWireMessage.value.event), ); - assert.equal(isOk(parsedEvent), true); + assert.equal(parsedEvent.tag, ResultTag.Ok); +}); + +test("runtime extension wire helpers round-trip provider events", () => { + const wireMessage = serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, + event: { + kind: RuntimeProviderEventKind.TransactionStatus, + slot: 1, + commitmentStatus: 1, + signature: "sig", + isVote: false, + }, + }); + + const parsedWireMessage = tryParseRuntimeExtensionWorkerHostMessageWire(wireMessage); + assert.equal(parsedWireMessage.tag, ResultTag.Ok); + if (parsedWireMessage.tag !== ResultTag.Ok) { + return; + } + + assert.equal( + parsedWireMessage.value.tag, + RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, + ); + if (parsedWireMessage.value.tag !== RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent) { + return; + } + + assert.equal(parsedWireMessage.value.event.kind, RuntimeProviderEventKind.TransactionStatus); + assert.equal(parsedWireMessage.value.event.slot, 1); }); test("runtime extension stdio worker processes newline-delimited protocol messages", async () => { @@ -85,8 +118,8 @@ test("runtime extension stdio worker processes newline-delimited protocol messag extensionName: "stdio-demo", capabilities: [ExtensionCapability.ObserveObserverIngress], }); - assert.equal(isOk(manifest), true); - if (!isOk(manifest)) { + assert.equal(manifest.tag, ResultTag.Ok); + if (manifest.tag !== ResultTag.Ok) { return; } @@ -94,10 +127,11 @@ test("runtime extension stdio worker processes newline-delimited protocol messag manifest: manifest.value, onReady: () => ok(runtimeExtensionAck()), onPacketReceived: () => ok(runtimeExtensionAck()), + onProviderEvent: () => ok(runtimeExtensionAck()), onShutdown: () => ok(runtimeExtensionAck()), }); - assert.equal(isOk(definition), true); - if (!isOk(definition)) { + assert.equal(definition.tag, ResultTag.Ok); + if (definition.tag !== ResultTag.Ok) { return; } @@ -138,6 +172,18 @@ test("runtime extension stdio worker processes newline-delimited protocol messag }, })}\n`, ); + input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, + event: { + kind: RuntimeProviderEventKind.TransactionStatus, + slot: 100, + commitmentStatus: 1, + signature: "sig", + isVote: false, + }, + })}\n`, + ); input.write( `${JSON.stringify( serializeRuntimeExtensionWorkerHostMessageWire({ @@ -151,7 +197,7 @@ test("runtime extension stdio worker processes newline-delimited protocol messag input.end(); const runnerResult = await runner; - assert.equal(isOk(runnerResult), true); + assert.equal(runnerResult.tag, ResultTag.Ok); assert.equal(errorText, ""); const responses = outputText @@ -160,11 +206,12 @@ test("runtime extension stdio worker processes newline-delimited protocol messag .filter((line) => line !== "") .map((line) => JSON.parse(line) as { tag: number }); - assert.equal(responses.length, 4); + assert.equal(responses.length, 5); assert.equal(responses[0]?.tag, RuntimeExtensionWorkerResponseTag.Manifest); assert.equal(responses[1]?.tag, RuntimeExtensionWorkerResponseTag.Started); assert.equal(responses[2]?.tag, RuntimeExtensionWorkerResponseTag.EventHandled); - assert.equal(responses[3]?.tag, RuntimeExtensionWorkerResponseTag.ShutdownComplete); + assert.equal(responses[3]?.tag, RuntimeExtensionWorkerResponseTag.ProviderEventHandled); + assert.equal(responses[4]?.tag, RuntimeExtensionWorkerResponseTag.ShutdownComplete); }); test("runtime extension stdio worker rejects malformed protocol messages", async () => { @@ -183,8 +230,8 @@ test("runtime extension stdio worker rejects malformed protocol messages", async extensionName: "bad-wire-demo", capabilities: [ExtensionCapability.ObserveObserverIngress], }); - assert.equal(isOk(manifest), true); - if (!isOk(manifest)) { + assert.equal(manifest.tag, ResultTag.Ok); + if (manifest.tag !== ResultTag.Ok) { return; } @@ -194,8 +241,8 @@ test("runtime extension stdio worker rejects malformed protocol messages", async onPacketReceived: () => ok(runtimeExtensionAck()), onShutdown: () => ok(runtimeExtensionAck()), }); - assert.equal(isOk(definition), true); - if (!isOk(definition)) { + assert.equal(definition.tag, ResultTag.Ok); + if (definition.tag !== ResultTag.Ok) { return; } diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.ts index bfde2073..9394b47f 100644 --- a/sdks/typescript/src/runtime/runtime-extension-stdio.ts +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.ts @@ -20,6 +20,8 @@ import { type RuntimePacketSource, type RuntimePacketSourceKind, type RuntimePacketTransport, + type RuntimeProviderEvent, + type RuntimeProviderEventKind, type RuntimeWebSocketFrameType, extensionName, extensionResourceId, @@ -37,6 +39,9 @@ const runtimePacketEventClasses = [1, 2] as const satisfies readonly RuntimePack const runtimeWebSocketFrameTypes = [ 1, 2, 3, 4, ] as const satisfies readonly RuntimeWebSocketFrameType[]; +const runtimeProviderEventKinds = [ + 1, 2, 3, 4, 5, 6, 7, +] as const satisfies readonly RuntimeProviderEventKind[]; const maxPacketByteValue = 255; type JsonRecord = Record; @@ -59,6 +64,10 @@ export type RuntimeExtensionWorkerWireHostMessage = readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket; readonly event: RuntimePacketEventWire; } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent; + readonly event: RuntimeProviderEvent; + } | { readonly tag: RuntimeExtensionWorkerHostMessageTag.Shutdown; readonly context: ExtensionContext; @@ -165,6 +174,7 @@ function parseRuntimeExtensionWorkerHostMessageTag( RuntimeExtensionWorkerHostMessageTag.Start, RuntimeExtensionWorkerHostMessageTag.DeliverPacket, RuntimeExtensionWorkerHostMessageTag.Shutdown, + RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, ]); if (isErr(parsed)) { return parsed; @@ -182,6 +192,9 @@ function parseRuntimeExtensionWorkerHostMessageTag( if (parsed.value === 4) { return ok(RuntimeExtensionWorkerHostMessageTag.Shutdown); } + if (parsed.value === 5) { + return ok(RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent); + } return err( runtimeExtensionProtocolError( @@ -453,6 +466,18 @@ function parseRuntimePacketSource( }); } +function isRuntimeProviderEventKind(value: unknown): value is RuntimeProviderEventKind { + return ( + typeof value === "number" && + Number.isInteger(value) && + runtimeProviderEventKinds.some((kind) => kind === value) + ); +} + +function isRuntimeProviderEvent(value: unknown): value is RuntimeProviderEvent { + return isJsonRecord(value) && isRuntimeProviderEventKind(value.kind); +} + export function serializeRuntimePacketEventWire(event: RuntimePacketEvent): RuntimePacketEventWire { return { source: event.source, @@ -500,6 +525,27 @@ export function tryParseRuntimePacketEventWire( }); } +export function tryParseRuntimeProviderEventWire( + value: unknown, +): Result { + const eventRecord = parseJsonRecord(value, "event"); + if (isErr(eventRecord)) { + return eventRecord; + } + + if (!isRuntimeProviderEvent(eventRecord.value)) { + return err( + runtimeExtensionProtocolError( + "event.kind", + `event.kind must be one of ${runtimeProviderEventKinds.join(", ")}`, + JSON.stringify(eventRecord.value.kind), + ), + ); + } + + return ok(eventRecord.value); +} + export function serializeRuntimeExtensionWorkerHostMessageWire( message: RuntimeExtensionWorkerHostMessage, ): RuntimeExtensionWorkerWireHostMessage { @@ -516,6 +562,11 @@ export function serializeRuntimeExtensionWorkerHostMessageWire( tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, event: serializeRuntimePacketEventWire(message.event), }; + case RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent: + return { + tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, + event: message.event, + }; case RuntimeExtensionWorkerHostMessageTag.Shutdown: return { tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, @@ -566,6 +617,17 @@ export function tryParseRuntimeExtensionWorkerHostMessageWire( event: event.value, }); } + case RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent: { + const event = tryParseRuntimeProviderEventWire(messageRecord.value.event); + if (isErr(event)) { + return event; + } + + return ok({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent, + event: event.value, + }); + } case RuntimeExtensionWorkerHostMessageTag.Shutdown: { const context = parseContext(messageRecord.value.context); if (isErr(context)) { diff --git a/sdks/typescript/src/runtime/runtime-extension.ts b/sdks/typescript/src/runtime/runtime-extension.ts index 89aa5a8e..d8ab31b2 100644 --- a/sdks/typescript/src/runtime/runtime-extension.ts +++ b/sdks/typescript/src/runtime/runtime-extension.ts @@ -21,6 +21,14 @@ export interface RuntimeExtensionAck { readonly acknowledged: true; } +export type RuntimeJsonPrimitive = string | number | boolean | null; +export type RuntimeJsonValue = + | RuntimeJsonPrimitive + | readonly RuntimeJsonValue[] + | { + readonly [key: string]: RuntimeJsonValue; + }; + export type ExtensionName = Brand; export type ExtensionResourceId = Brand; export type SharedStreamTag = Brand; @@ -130,6 +138,128 @@ export interface RuntimePacketEvent { readonly observedUnixMs: number; } +export enum RuntimeProviderEventKind { + Transaction = 1, + TransactionLog = 2, + TransactionStatus = 3, + AccountUpdate = 4, + BlockMeta = 5, + SlotStatus = 6, + RecentBlockhash = 7, +} + +export enum RuntimeProviderCommitmentStatus { + Processed = 1, + Confirmed = 2, + Finalized = 3, +} + +export enum RuntimeProviderTransactionKind { + VoteOnly = 1, + Mixed = 2, + NonVote = 3, +} + +export enum RuntimeProviderSlotStatus { + Processed = 1, + Confirmed = 2, + Finalized = 3, + Orphaned = 4, +} + +export interface RuntimeProviderSource { + readonly kind: string; + readonly instance: string; + readonly priority: number; + readonly role: string; + readonly arbitration: string; +} + +interface RuntimeProviderEventBase { + readonly kind: RuntimeProviderEventKind; + readonly slot: number; + readonly providerSource?: RuntimeProviderSource; +} + +interface RuntimeProviderCommittedEventBase extends RuntimeProviderEventBase { + readonly commitmentStatus: RuntimeProviderCommitmentStatus; + readonly confirmedSlot?: number; + readonly finalizedSlot?: number; +} + +export interface RuntimeProviderTransactionEvent extends RuntimeProviderCommittedEventBase { + readonly kind: RuntimeProviderEventKind.Transaction; + readonly signature?: string; + readonly transactionKind: RuntimeProviderTransactionKind; + readonly transactionBase64?: string; +} + +export interface RuntimeProviderTransactionLogEvent extends RuntimeProviderCommittedEventBase { + readonly kind: RuntimeProviderEventKind.TransactionLog; + readonly signature: string; + readonly err?: RuntimeJsonValue; + readonly logs: readonly string[]; + readonly matchedFilter?: string; +} + +export interface RuntimeProviderTransactionStatusEvent extends RuntimeProviderCommittedEventBase { + readonly kind: RuntimeProviderEventKind.TransactionStatus; + readonly signature: string; + readonly isVote: boolean; + readonly index?: number; + readonly err?: string; +} + +export interface RuntimeProviderAccountUpdateEvent extends RuntimeProviderCommittedEventBase { + readonly kind: RuntimeProviderEventKind.AccountUpdate; + readonly pubkey: string; + readonly owner: string; + readonly lamports: number; + readonly executable: boolean; + readonly rentEpoch: number; + readonly dataBase64: string; + readonly writeVersion?: number; + readonly txnSignature?: string; + readonly isStartup: boolean; + readonly matchedFilter?: string; +} + +export interface RuntimeProviderBlockMetaEvent extends RuntimeProviderCommittedEventBase { + readonly kind: RuntimeProviderEventKind.BlockMeta; + readonly blockhash: string; + readonly parentSlot: number; + readonly parentBlockhash: string; + readonly blockTime?: number; + readonly blockHeight?: number; + readonly executedTransactionCount: number; + readonly entriesCount: number; +} + +export interface RuntimeProviderSlotStatusEvent extends RuntimeProviderEventBase { + readonly kind: RuntimeProviderEventKind.SlotStatus; + readonly parentSlot?: number; + readonly previousStatus?: RuntimeProviderSlotStatus; + readonly status: RuntimeProviderSlotStatus; + readonly tipSlot?: number; + readonly confirmedSlot?: number; + readonly finalizedSlot?: number; +} + +export interface RuntimeProviderRecentBlockhashEvent extends RuntimeProviderEventBase { + readonly kind: RuntimeProviderEventKind.RecentBlockhash; + readonly recentBlockhash: string; + readonly datasetTxCount: number; +} + +export type RuntimeProviderEvent = + | RuntimeProviderTransactionEvent + | RuntimeProviderTransactionLogEvent + | RuntimeProviderTransactionStatusEvent + | RuntimeProviderAccountUpdateEvent + | RuntimeProviderBlockMetaEvent + | RuntimeProviderSlotStatusEvent + | RuntimeProviderRecentBlockhashEvent; + export interface PacketSubscription { readonly sourceKind?: RuntimePacketSourceKind; readonly transport?: RuntimePacketTransport; @@ -192,6 +322,9 @@ export interface RuntimeExtensionDefinition { readonly onPacketReceived?: ( event: RuntimePacketEvent, ) => MaybePromise>; + readonly onProviderEvent?: ( + event: RuntimeProviderEvent, + ) => MaybePromise>; readonly onShutdown?: ( context: ExtensionContext, ) => MaybePromise>; @@ -202,6 +335,7 @@ export enum RuntimeExtensionWorkerHostMessageTag { Start = 2, DeliverPacket = 3, Shutdown = 4, + DeliverProviderEvent = 5, } export type RuntimeExtensionWorkerHostMessage = @@ -216,6 +350,10 @@ export type RuntimeExtensionWorkerHostMessage = readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket; readonly event: RuntimePacketEvent; } + | { + readonly tag: RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent; + readonly event: RuntimeProviderEvent; + } | { readonly tag: RuntimeExtensionWorkerHostMessageTag.Shutdown; readonly context: ExtensionContext; @@ -226,6 +364,7 @@ export enum RuntimeExtensionWorkerResponseTag { Started = 2, EventHandled = 3, ShutdownComplete = 4, + ProviderEventHandled = 5, } export type RuntimeExtensionWorkerResponse = @@ -241,6 +380,10 @@ export type RuntimeExtensionWorkerResponse = readonly tag: RuntimeExtensionWorkerResponseTag.EventHandled; readonly result: Result; } + | { + readonly tag: RuntimeExtensionWorkerResponseTag.ProviderEventHandled; + readonly result: Result; + } | { readonly tag: RuntimeExtensionWorkerResponseTag.ShutdownComplete; readonly result: Result; @@ -708,6 +851,18 @@ export class RuntimeExtensionWorkerRuntime { this.definition.onPacketReceived?.(message.event) ?? ok(runtimeExtensionAck()), ), }; + case RuntimeExtensionWorkerHostMessageTag.DeliverProviderEvent: + return { + tag: RuntimeExtensionWorkerResponseTag.ProviderEventHandled, + result: + this.definition.onProviderEvent === undefined + ? ok(runtimeExtensionAck()) + : await settleExtensionHook( + "onProviderEvent", + () => + this.definition.onProviderEvent?.(message.event) ?? ok(runtimeExtensionAck()), + ), + }; case RuntimeExtensionWorkerHostMessageTag.Shutdown: return { tag: RuntimeExtensionWorkerResponseTag.ShutdownComplete, From 7da84599eef4429ee50d8671325867694e83dec9 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 18:37:18 +0200 Subject: [PATCH 15/25] fix(ts-sdk): delegate ingress policy to native runtime --- .../src/bin/sof_ts_runtime_host.rs | 883 ++++++++++++------ crates/sof-observer/src/runtime.rs | 188 +++- sdks/typescript/README.md | 14 +- sdks/typescript/examples/app-entrypoint.ts | 2 +- sdks/typescript/src/app.test.ts | 163 ++-- sdks/typescript/src/app.ts | 371 +------- 6 files changed, 862 insertions(+), 759 deletions(-) diff --git a/crates/sof-observer/src/bin/sof_ts_runtime_host.rs b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs index e870c570..af1b9c93 100644 --- a/crates/sof-observer/src/bin/sof_ts_runtime_host.rs +++ b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs @@ -6,25 +6,26 @@ mod af_xdp; #[cfg(feature = "provider-grpc")] use std::str::FromStr; +use std::sync::Arc; #[cfg(all(target_os = "linux", feature = "kernel-bypass"))] -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(all(target_os = "linux", feature = "kernel-bypass"))] use std::time::Duration; -use std::{collections::HashMap, env, fs, net::SocketAddr, path::PathBuf, sync::Mutex}; +use std::{collections::HashMap, env, fs, net::SocketAddr, path::PathBuf}; use async_trait::async_trait; #[cfg(feature = "provider-grpc")] use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +use sof::provider_stream::websocket::{ + WebsocketTransactionCommitment, WebsocketTransactionConfig, spawn_websocket_source, +}; #[cfg(feature = "provider-grpc")] use sof::provider_stream::yellowstone::{ - YellowstoneGrpcCommitment, YellowstoneGrpcConfig, YellowstoneGrpcError, - YellowstoneGrpcSlotsConfig, YellowstoneGrpcStream, spawn_yellowstone_grpc_slot_source, - spawn_yellowstone_grpc_source, + YellowstoneGrpcCommitment, YellowstoneGrpcConfig, YellowstoneGrpcSlotsConfig, + YellowstoneGrpcStream, spawn_yellowstone_grpc_slot_source, spawn_yellowstone_grpc_source, }; #[cfg(feature = "provider-grpc")] use sof::provider_stream::{ @@ -66,6 +67,7 @@ use tokio::task::spawn_blocking; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, process::{Child, ChildStdin, ChildStdout, Command}, + sync::Mutex, }; /// Wire tag for websocket ingress handoff. @@ -155,7 +157,7 @@ struct RuntimeHostConfig { /// Ingress sources delegated to the native host. ingress: Vec, #[cfg(feature = "provider-grpc")] - /// Provider fan-in policy for multi-source gRPC ingress. + /// Provider fan-in policy for multi-source provider ingress. fan_in: Option, /// Plugin workers launched through the stdio worker bridge. plugin_workers: Vec, @@ -230,8 +232,12 @@ struct IngressConfig { priority: Option, /// Websocket URL for SDK-side validation errors. url: Option, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + /// Legacy custom JSON-RPC subscribe messages rejected by the native host. + requests: Option>, /// Gossip bootstrap entrypoints. entrypoints: Option>, + #[cfg(feature = "gossip-bootstrap")] /// Gossip runtime mode selector. runtime_mode: Option, /// Whether the active gossip entrypoint is pinned. @@ -356,18 +362,20 @@ struct PacketSubscriptionConfig { /// Runtime extension adapter that forwards packet events into one TS worker. struct TypeScriptRuntimeExtension { - /// Extension name registered with the runtime. - name: &'static str, - /// Worker launch configuration. - config: PluginWorkerConfig, - /// Live worker process handle. - process: Mutex>, + /// Shared worker bridge used for lifecycle and packet delivery. + bridge: Arc, } #[cfg(feature = "provider-grpc")] /// Observer plugin adapter that forwards provider events into one TS worker. struct TypeScriptObserverPlugin { - /// Plugin name registered with the observer plugin host. + /// Shared worker bridge used for lifecycle and provider-event delivery. + bridge: Arc, +} + +/// Shared worker bridge for one TypeScript plugin process. +struct TypeScriptWorkerBridge { + /// Extension/plugin name registered with the runtime. name: &'static str, /// Worker launch configuration. config: PluginWorkerConfig, @@ -413,48 +421,170 @@ async fn main() -> Result<(), HostError> { run_config(config).await } -/// Routes the parsed host config into the raw or provider runtime path. +/// Routes the parsed host config into one unified runtime host composition. async fn run_config(config: RuntimeHostConfig) -> Result<(), HostError> { validate_ingress(&config)?; + let setup = runtime_setup(&config); + let kernel_bypass = direct_shreds_ingress(&config) + .and_then(|ingress| ingress.kernel_bypass.as_ref()) + .cloned(); + let (extension_host, worker_bridges) = build_worker_bridges(&config.plugin_workers)?; + #[cfg(not(feature = "provider-grpc"))] + drop(worker_bridges); + + let runtime = ObserverRuntime::new() + .with_setup(setup) + .with_extension_host(extension_host); + + #[cfg(feature = "provider-grpc")] + let mut runtime = runtime; + + #[cfg(feature = "provider-grpc")] + let mut provider_source_handles = Vec::new(); + + #[cfg(feature = "provider-grpc")] if config .ingress .iter() - .any(|ingress| ingress.kind == INGRESS_KIND_GRPC) + .any(|ingress| ingress.kind == INGRESS_KIND_GRPC || ingress.kind == INGRESS_KIND_WEB_SOCKET) { - return run_provider_stream_config(config).await; + let (provider_stream_tx, provider_stream_rx) = + create_provider_stream_queue(PROVIDER_STREAM_QUEUE_CAPACITY); + let mut modes = Vec::new(); + for ingress in config + .ingress + .iter() + .filter(|ingress| ingress.kind == INGRESS_KIND_GRPC) + { + let source = spawn_yellowstone_ingress( + ingress, + config.fan_in.as_ref(), + provider_stream_tx.clone(), + ) + .await?; + modes.push(source.mode); + provider_source_handles.push(source); + } + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + for ingress in config + .ingress + .iter() + .filter(|ingress| ingress.kind == INGRESS_KIND_WEB_SOCKET) + { + let source = spawn_websocket_provider_ingress( + ingress, + config.fan_in.as_ref(), + provider_stream_tx.clone(), + ) + .await?; + modes.push(source.mode); + provider_source_handles.push(source); + } + #[cfg(not(feature = "provider-websocket"))] + if config + .ingress + .iter() + .any(|ingress| ingress.kind == INGRESS_KIND_WEB_SOCKET) + { + let ingress = config + .ingress + .iter() + .find(|ingress| ingress.kind == INGRESS_KIND_WEB_SOCKET) + .map(|ingress| ingress.name.as_str()) + .unwrap_or("unknown"); + return Err(HostError::InvalidConfig(format!( + "websocket ingress `{ingress}` requires a runtime host built with the provider-websocket feature" + ))); + } + drop(provider_stream_tx); + + runtime = runtime + .with_plugin_host(plugin_host_from_worker_bridges(&worker_bridges)) + .with_provider_stream_ingress( + provider_stream_mode(modes.as_slice()), + provider_stream_rx, + ); + if has_raw_ingress(&config) { + runtime = runtime.with_raw_ingress_alongside_provider_stream(); + } } - run_raw_ingress_config(config).await + #[cfg(not(feature = "provider-grpc"))] + if config + .ingress + .iter() + .any(|ingress| ingress.kind == INGRESS_KIND_GRPC || ingress.kind == INGRESS_KIND_WEB_SOCKET) + { + let ingress = config + .ingress + .iter() + .find(|ingress| { + ingress.kind == INGRESS_KIND_GRPC || ingress.kind == INGRESS_KIND_WEB_SOCKET + }) + .map(|ingress| (ingress.name.as_str(), ingress.kind)) + .unwrap_or(("unknown", INGRESS_KIND_GRPC)); + if ingress.1 == INGRESS_KIND_WEB_SOCKET { + return Err(HostError::InvalidConfig(format!( + "websocket ingress `{}` requires a runtime host built with the provider-grpc and provider-websocket features", + ingress.0 + ))); + } + return Err(HostError::InvalidConfig(format!( + "gRPC ingress `{}` requires a runtime host built with the provider-grpc feature", + ingress.0 + ))); + } + + let run_result = run_runtime_with_optional_kernel_bypass(runtime, kernel_bypass.as_ref()).await; + + #[cfg(feature = "provider-grpc")] + for handle in provider_source_handles { + handle.abort(); + } + + run_result } -/// Runs one raw-ingress observer runtime through the extension host bridge. -async fn run_raw_ingress_config(config: RuntimeHostConfig) -> Result<(), HostError> { - let setup = runtime_setup(&config)?; - let kernel_bypass = direct_shreds_ingress(&config) - .and_then(|ingress| ingress.kernel_bypass.as_ref()) - .cloned(); +/// Builds the extension host plus one shared worker bridge per TypeScript plugin. +fn build_worker_bridges( + workers: &[PluginWorkerConfig], +) -> Result<(RuntimeExtensionHost, Vec>), HostError> { let mut extension_host_builder = RuntimeExtensionHost::builder(); - for worker in config.plugin_workers { + let mut bridges = Vec::with_capacity(workers.len()); + + for worker in workers.iter().cloned() { let extension_name = leak_extension_name(worker.manifest.extension_name.clone())?; - extension_host_builder = extension_host_builder.add_extension(TypeScriptRuntimeExtension { + let bridge = Arc::new(TypeScriptWorkerBridge { name: extension_name, config: worker, process: Mutex::new(None), }); + extension_host_builder = extension_host_builder.add_extension(TypeScriptRuntimeExtension { + bridge: Arc::clone(&bridge), + }); + bridges.push(bridge); } - let runtime = ObserverRuntime::new() - .with_setup(setup) - .with_extension_host(extension_host_builder.build()); - run_raw_runtime_with_optional_kernel_bypass(runtime, kernel_bypass.as_ref()).await?; + Ok((extension_host_builder.build(), bridges)) +} - Ok(()) +#[cfg(feature = "provider-grpc")] +/// Builds a plugin host that reuses the shared TypeScript worker bridges. +fn plugin_host_from_worker_bridges(bridges: &[Arc]) -> PluginHost { + let mut plugin_host_builder = PluginHost::builder(); + for bridge in bridges { + plugin_host_builder = plugin_host_builder.add_plugin(TypeScriptObserverPlugin { + bridge: Arc::clone(bridge), + }); + } + + plugin_host_builder.build() } #[cfg(all(target_os = "linux", feature = "kernel-bypass"))] -/// Runs one raw observer runtime and optionally attaches AF_XDP ingest. -async fn run_raw_runtime_with_optional_kernel_bypass( +/// Runs one observer runtime and optionally attaches AF_XDP ingest until termination. +async fn run_runtime_with_optional_kernel_bypass( runtime: ObserverRuntime, kernel_bypass: Option<&KernelBypassConfig>, ) -> Result<(), HostError> { @@ -493,8 +623,8 @@ async fn run_raw_runtime_with_optional_kernel_bypass( } #[cfg(not(all(target_os = "linux", feature = "kernel-bypass")))] -/// Runs one raw observer runtime when kernel bypass is unavailable. -async fn run_raw_runtime_with_optional_kernel_bypass( +/// Runs one observer runtime when kernel bypass is unavailable. +async fn run_runtime_with_optional_kernel_bypass( runtime: ObserverRuntime, kernel_bypass: Option<&KernelBypassConfig>, ) -> Result<(), HostError> { @@ -509,71 +639,6 @@ async fn run_raw_runtime_with_optional_kernel_bypass( Ok(()) } -#[cfg(feature = "provider-grpc")] -/// Runs one provider-stream observer runtime through the plugin host bridge. -async fn run_provider_stream_config(config: RuntimeHostConfig) -> Result<(), HostError> { - let setup = runtime_setup(&config)?; - let (provider_stream_tx, provider_stream_rx) = - create_provider_stream_queue(PROVIDER_STREAM_QUEUE_CAPACITY); - let mut modes = Vec::new(); - let mut source_handles = Vec::new(); - - for ingress in &config.ingress { - if ingress.kind != INGRESS_KIND_GRPC { - return Err(HostError::InvalidConfig(format!( - "ingress `{}` cannot be mixed with gRPC provider-stream ingress in one TypeScript runtime host", - ingress.name - ))); - } - - let source = - spawn_yellowstone_ingress(ingress, config.fan_in.as_ref(), provider_stream_tx.clone()) - .await?; - modes.push(source.mode); - source_handles.push(source.handle); - } - drop(provider_stream_tx); - - let mut plugin_host_builder = PluginHost::builder(); - for worker in config.plugin_workers { - let plugin_name = leak_extension_name(worker.manifest.extension_name.clone())?; - plugin_host_builder = plugin_host_builder.add_plugin(TypeScriptObserverPlugin { - name: plugin_name, - config: worker, - process: Mutex::new(None), - }); - } - - let mode = provider_stream_mode(modes.as_slice()); - let runtime_result = ObserverRuntime::new() - .with_setup(setup) - .with_plugin_host(plugin_host_builder.build()) - .with_provider_stream_ingress(mode, provider_stream_rx) - .run_until_termination_signal() - .await; - - for handle in source_handles { - handle.abort(); - } - - runtime_result?; - Ok(()) -} - -#[cfg(not(feature = "provider-grpc"))] -/// Rejects provider-stream configs when the host was built without gRPC support. -async fn run_provider_stream_config(config: RuntimeHostConfig) -> Result<(), HostError> { - let ingress = config - .ingress - .iter() - .find(|ingress| ingress.kind == INGRESS_KIND_GRPC) - .map(|ingress| ingress.name.as_str()) - .unwrap_or("unknown"); - Err(HostError::InvalidConfig(format!( - "gRPC ingress `{ingress}` requires a runtime host built with the provider-grpc feature" - ))) -} - /// Validates ingress combinations accepted by the native host. fn validate_ingress(config: &RuntimeHostConfig) -> Result<(), HostError> { let mut direct_shreds_count = 0_usize; @@ -581,24 +646,18 @@ fn validate_ingress(config: &RuntimeHostConfig) -> Result<(), HostError> { let mut gossip_count = 0_usize; #[cfg(not(feature = "gossip-bootstrap"))] let gossip_count = 0_usize; - let mut provider_ingress_count = 0_usize; for ingress in &config.ingress { match ingress.kind { - INGRESS_KIND_WEB_SOCKET => { - return Err(HostError::InvalidConfig(format!( - "websocket ingress `{}` should run on the TypeScript websocket path (url={})", - ingress.name, - ingress.url.as_deref().unwrap_or("unknown"), - ))); - } - INGRESS_KIND_GRPC => { - provider_ingress_count = - provider_ingress_count.checked_add(1).ok_or_else(|| { - HostError::InvalidConfig( - "provider ingress count overflowed during validation".to_owned(), - ) - })?; - } + INGRESS_KIND_WEB_SOCKET => match ingress.url.as_deref().map(str::trim) { + Some(url) if !url.is_empty() => {} + _ => { + return Err(HostError::InvalidConfig(format!( + "websocket ingress `{}` is missing a valid url", + ingress.name + ))); + } + }, + INGRESS_KIND_GRPC => {} INGRESS_KIND_GOSSIP => { #[cfg(not(feature = "gossip-bootstrap"))] { @@ -620,6 +679,13 @@ fn validate_ingress(config: &RuntimeHostConfig) -> Result<(), HostError> { ingress.name ))); } + if let Some(runtime_mode) = ingress.runtime_mode + && gossip_runtime_mode_from_wire(runtime_mode).is_none() + { + return Err(HostError::InvalidConfig(format!( + "unsupported gossip runtime mode {runtime_mode}" + ))); + } } } INGRESS_KIND_DIRECT_SHREDS => { @@ -646,18 +712,6 @@ fn validate_ingress(config: &RuntimeHostConfig) -> Result<(), HostError> { } } - let raw_ingress_count = direct_shreds_count - .checked_add(gossip_count) - .ok_or_else(|| { - HostError::InvalidConfig("raw ingress count overflowed during validation".to_owned()) - })?; - if provider_ingress_count > 0 && raw_ingress_count > 0 { - return Err(HostError::InvalidConfig( - "TypeScript runtime host does not support mixing provider-stream ingress and raw packet ingress in one app run" - .to_owned(), - )); - } - if direct_shreds_count > 1 { return Err(HostError::InvalidConfig( "TypeScript runtime host currently supports one direct shred ingress source per app run" @@ -718,7 +772,7 @@ fn validate_kernel_bypass_config( } /// Builds the runtime setup derived from the TypeScript config. -fn runtime_setup(config: &RuntimeHostConfig) -> Result { +fn runtime_setup(config: &RuntimeHostConfig) -> RuntimeSetup { let mut setup = RuntimeSetup::new(); for (key, value) in &config.runtime_environment { setup = setup.with_env(key, value); @@ -734,23 +788,21 @@ fn runtime_setup(config: &RuntimeHostConfig) -> Result setup = setup.with_gossip_entrypoint_pinned(pinned); } #[cfg(feature = "gossip-bootstrap")] - if let Some(runtime_mode) = ingress.runtime_mode { - setup = setup.with_gossip_runtime_mode(gossip_runtime_mode_from_wire(runtime_mode)?); + if let Some(runtime_mode) = ingress.runtime_mode.and_then(gossip_runtime_mode_from_wire) { + setup = setup.with_gossip_runtime_mode(runtime_mode); } } - Ok(setup.with_env("SOF_TS_APP_NAME", &config.app_name)) + setup.with_env("SOF_TS_APP_NAME", &config.app_name) } #[cfg(feature = "gossip-bootstrap")] /// Maps the wire gossip runtime mode into the Rust runtime enum. -fn gossip_runtime_mode_from_wire(value: u8) -> Result { +const fn gossip_runtime_mode_from_wire(value: u8) -> Option { match value { - 1 => Ok(GossipRuntimeMode::Full), - 2 => Ok(GossipRuntimeMode::BootstrapOnly), - 3 => Ok(GossipRuntimeMode::ControlPlaneOnly), - other => Err(HostError::InvalidConfig(format!( - "unsupported gossip runtime mode {other}" - ))), + 1 => Some(GossipRuntimeMode::Full), + 2 => Some(GossipRuntimeMode::BootstrapOnly), + 3 => Some(GossipRuntimeMode::ControlPlaneOnly), + _ => None, } } @@ -770,6 +822,14 @@ fn gossip_ingress(config: &RuntimeHostConfig) -> Option<&IngressConfig> { .find(|ingress| ingress.kind == INGRESS_KIND_GOSSIP) } +#[cfg(feature = "provider-grpc")] +/// Returns whether the config declares any raw packet ingress. +fn has_raw_ingress(config: &RuntimeHostConfig) -> bool { + config.ingress.iter().any(|ingress| { + ingress.kind == INGRESS_KIND_DIRECT_SHREDS || ingress.kind == INGRESS_KIND_GOSSIP + }) +} + /// Returns the bind address that should drive raw runtime setup. fn effective_bind_address(config: &RuntimeHostConfig) -> Option<&str> { direct_shreds_ingress(config) @@ -782,8 +842,48 @@ fn effective_bind_address(config: &RuntimeHostConfig) -> Option<&str> { struct ProviderSourceHandle { /// Runtime mode inferred from the configured source. mode: ProviderStreamMode, - /// Join handle for the spawned Yellowstone source task. - handle: JoinHandle>, + /// Abort handle for the underlying provider source task. + abort_handle: tokio::task::AbortHandle, + /// Join guard that logs provider source task failures. + join_guard: JoinHandle<()>, +} + +#[cfg(feature = "provider-grpc")] +impl ProviderSourceHandle { + /// Stops the provider source task and its join guard. + fn abort(self) { + self.abort_handle.abort(); + self.join_guard.abort(); + } +} + +#[cfg(feature = "provider-grpc")] +/// Wraps one provider source task so failures are not silently detached. +fn spawn_provider_source_join_guard( + source_name: &str, + handle: JoinHandle>, +) -> (tokio::task::AbortHandle, JoinHandle<()>) +where + E: std::fmt::Display + Send + 'static, +{ + let abort_handle = handle.abort_handle(); + let source_name = source_name.to_owned(); + let join_guard = tokio::spawn(async move { + match handle.await { + Ok(Ok(())) => { + tracing::warn!(source = source_name, "provider source task ended"); + } + Ok(Err(error)) => { + tracing::warn!(source = source_name, error = %error, "provider source task failed"); + } + Err(error) => { + if !error.is_cancelled() { + tracing::warn!(source = source_name, error = %error, "provider source task join failed"); + } + } + } + }); + (abort_handle, join_guard) } #[cfg(feature = "provider-grpc")] @@ -816,7 +916,12 @@ async fn spawn_yellowstone_ingress( let handle = spawn_yellowstone_grpc_slot_source(config, sender) .await .map_err(|error| HostError::InvalidConfig(error.to_string()))?; - return Ok(ProviderSourceHandle { mode, handle }); + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + return Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }); } let mut config = YellowstoneGrpcConfig::new(endpoint) @@ -868,7 +973,100 @@ async fn spawn_yellowstone_ingress( let handle = spawn_yellowstone_grpc_source(config, sender) .await .map_err(|error| HostError::InvalidConfig(error.to_string()))?; - Ok(ProviderSourceHandle { mode, handle }) + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) +} + +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Spawns one websocket provider-stream source from the wire config. +async fn spawn_websocket_provider_ingress( + ingress: &IngressConfig, + fan_in: Option<&FanInConfig>, + sender: ProviderStreamSender, +) -> Result { + if !ingress.requests.as_deref().unwrap_or(&[]).is_empty() { + return Err(HostError::InvalidConfig(format!( + "websocket ingress `{}` must use the native SOF provider-stream websocket adapter; custom JSON-RPC requests are not accepted by the runtime host", + ingress.name + ))); + } + let endpoint = ingress.url.as_deref().ok_or_else(|| { + HostError::InvalidConfig(format!( + "websocket ingress `{}` is missing url", + ingress.name + )) + })?; + let mut config = + WebsocketTransactionConfig::new(endpoint).with_source_instance(ingress.name.clone()); + config = apply_websocket_source_policy(config, ingress)?; + config = apply_websocket_fan_in(config, fan_in)?; + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(websocket_commitment_from_wire(commitment)?); + } + if let Some(vote) = ingress.vote { + config = config.with_vote(vote); + } + if let Some(failed) = ingress.failed { + config = config.with_failed(failed); + } + if let Some(signature) = non_empty_optional(ingress.signature.as_deref()) { + config = config.with_signature(parse_signature(signature, "ingress.signature")?); + } + config = config + .with_account_include(parse_pubkeys( + ingress.account_include.as_deref().unwrap_or(&[]), + "ingress.accountInclude", + )?) + .with_account_exclude(parse_pubkeys( + ingress.account_exclude.as_deref().unwrap_or(&[]), + "ingress.accountExclude", + )?) + .with_account_required(parse_pubkeys( + ingress.account_required.as_deref().unwrap_or(&[]), + "ingress.accountRequired", + )?); + + let mode = config.runtime_mode(); + let handle = spawn_websocket_source(&config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) +} + +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Applies per-source readiness, role, and priority to one websocket config. +fn apply_websocket_source_policy( + mut config: WebsocketTransactionConfig, + ingress: &IngressConfig, +) -> Result { + if let Some(readiness) = ingress.readiness { + config = config.with_readiness(provider_readiness_from_wire(readiness)?); + } + if let Some(role) = ingress.role { + config = config.with_source_role(provider_role_from_wire(role)?); + } + if let Some(priority) = ingress.priority { + config = config.with_source_priority(priority); + } + Ok(config) +} + +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Applies fan-in arbitration to one websocket config. +fn apply_websocket_fan_in( + config: WebsocketTransactionConfig, + fan_in: Option<&FanInConfig>, +) -> Result { + Ok(config.with_source_arbitration(provider_source_arbitration(fan_in)?)) } #[cfg(feature = "provider-grpc")] @@ -1005,6 +1203,19 @@ fn yellowstone_commitment_from_wire(value: u8) -> Result Result { + match value { + 1 => Ok(WebsocketTransactionCommitment::Processed), + 2 => Ok(WebsocketTransactionCommitment::Confirmed), + 3 => Ok(WebsocketTransactionCommitment::Finalized), + other => Err(HostError::InvalidConfig(format!( + "unsupported websocket commitment {other}" + ))), + } +} + #[cfg(feature = "provider-grpc")] /// Trims and filters empty optional string values. fn non_empty_optional(value: Option<&str>) -> Option<&str> { @@ -1048,106 +1259,27 @@ fn leak_extension_name(name: String) -> Result<&'static str, HostError> { #[async_trait] impl RuntimeExtension for TypeScriptRuntimeExtension { fn name(&self) -> &'static str { - self.name + self.bridge.name } async fn setup( &self, _ctx: ExtensionContext, ) -> Result { - let process = start_worker_process(self.name, &self.config) + self.bridge + .ensure_started() .await .map_err(ExtensionSetupError::new)?; - - let manifest = extension_manifest_from_config(&self.config.manifest.manifest) - .map_err(ExtensionSetupError::new)?; - let mut guard = self - .process - .lock() - .map_err(|_error| ExtensionSetupError::new("worker process lock poisoned"))?; - *guard = Some(process); - Ok(manifest) + extension_manifest_from_config(&self.bridge.config.manifest.manifest) + .map_err(ExtensionSetupError::new) } async fn on_packet_received(&self, event: RuntimePacketEvent) { - let process = match self.process.lock() { - Ok(mut guard) => guard.take(), - Err(error) => { - tracing::warn!(extension = self.name, error = %error, "worker process lock poisoned"); - None - } - }; - let Some(mut process) = process else { - tracing::warn!( - extension = self.name, - "worker process is not available for packet" - ); - return; - }; - - if let Err(error) = send_worker_message( - &mut process, - json!({ - "tag": 3, - "event": runtime_packet_event_wire(&event), - }), - ) - .await - { - tracing::warn!(extension = self.name, error = %error, "failed to deliver packet to worker"); - } else { - match read_worker_response(&mut process, RESPONSE_TAG_EVENT_HANDLED).await { - Ok(response) => { - if let Err(error) = response_result_ok(self.name, &response) { - tracing::warn!(extension = self.name, error = %error, "worker rejected packet"); - } - } - Err(error) => { - tracing::warn!(extension = self.name, error = %error, "worker did not acknowledge packet"); - } - } - } - - if let Ok(mut guard) = self.process.lock() { - *guard = Some(process); - } + self.bridge.deliver_packet(event).await; } async fn shutdown(&self, _ctx: ExtensionContext) { - let process = match self.process.lock() { - Ok(mut guard) => guard.take(), - Err(error) => { - tracing::warn!(extension = self.name, error = %error, "worker process lock poisoned"); - None - } - }; - let Some(mut process) = process else { - return; - }; - - if let Err(error) = send_worker_message( - &mut process, - json!({ - "tag": 4, - "context": WorkerContext { - extension_name: self.name, - }, - }), - ) - .await - { - tracing::warn!(extension = self.name, error = %error, "failed to request worker shutdown"); - } else if let Err(error) = - read_worker_response(&mut process, RESPONSE_TAG_SHUTDOWN_COMPLETE) - .await - .and_then(|response| response_result_ok(self.name, &response)) - { - tracing::warn!(extension = self.name, error = %error, "worker shutdown was not acknowledged"); - } - - if let Err(error) = process.child.wait().await { - tracing::warn!(extension = self.name, error = %error, "failed to wait for worker process"); - } + self.bridge.shutdown().await; } } @@ -1155,7 +1287,7 @@ impl RuntimeExtension for TypeScriptRuntimeExtension { #[cfg(feature = "provider-grpc")] impl ObserverPlugin for TypeScriptObserverPlugin { fn name(&self) -> &'static str { - self.name + self.bridge.name } fn config(&self) -> PluginConfig { @@ -1172,69 +1304,111 @@ impl ObserverPlugin for TypeScriptObserverPlugin { } async fn setup(&self, _ctx: PluginContext) -> Result<(), PluginSetupError> { - let process = start_worker_process(self.name, &self.config) + self.bridge + .ensure_started() .await - .map_err(PluginSetupError::new)?; - let mut guard = self - .process - .lock() - .map_err(|_error| PluginSetupError::new("worker process lock poisoned"))?; - *guard = Some(process); - Ok(()) + .map_err(PluginSetupError::new) } async fn on_transaction(&self, event: &TransactionEvent) { - self.deliver_provider_event(provider_transaction_event_wire(event)) + self.bridge + .deliver_provider_event(provider_transaction_event_wire(event)) .await; } async fn on_transaction_log(&self, event: &TransactionLogEvent) { - self.deliver_provider_event(provider_transaction_log_event_wire(event)) + self.bridge + .deliver_provider_event(provider_transaction_log_event_wire(event)) .await; } async fn on_transaction_status(&self, event: &TransactionStatusEvent) { - self.deliver_provider_event(provider_transaction_status_event_wire(event)) + self.bridge + .deliver_provider_event(provider_transaction_status_event_wire(event)) .await; } async fn on_account_update(&self, event: &AccountUpdateEvent) { - self.deliver_provider_event(provider_account_update_event_wire(event)) + self.bridge + .deliver_provider_event(provider_account_update_event_wire(event)) .await; } async fn on_block_meta(&self, event: &BlockMetaEvent) { - self.deliver_provider_event(provider_block_meta_event_wire(event)) + self.bridge + .deliver_provider_event(provider_block_meta_event_wire(event)) .await; } async fn on_slot_status(&self, event: SlotStatusEvent) { - self.deliver_provider_event(provider_slot_status_event_wire(&event)) + self.bridge + .deliver_provider_event(provider_slot_status_event_wire(&event)) .await; } async fn on_recent_blockhash(&self, event: ObservedRecentBlockhashEvent) { - self.deliver_provider_event(provider_recent_blockhash_event_wire(&event)) + self.bridge + .deliver_provider_event(provider_recent_blockhash_event_wire(&event)) .await; } async fn shutdown(&self, _ctx: PluginContext) { - shutdown_worker_process(self.name, &self.process).await; + self.bridge.shutdown().await; } } -#[cfg(feature = "provider-grpc")] -impl TypeScriptObserverPlugin { +impl TypeScriptWorkerBridge { + /// Starts the shared worker process when it has not been started yet. + async fn ensure_started(&self) -> Result<(), String> { + let mut guard = self.process.lock().await; + if guard.is_some() { + return Ok(()); + } + + *guard = Some(start_worker_process(self.name, &self.config).await?); + Ok(()) + } + + /// Delivers one packet event into the bound TypeScript worker. + async fn deliver_packet(&self, event: RuntimePacketEvent) { + let mut guard = self.process.lock().await; + let Some(process) = guard.as_mut() else { + tracing::warn!( + extension = self.name, + "worker process is not available for packet" + ); + return; + }; + + if let Err(error) = send_worker_message( + process, + json!({ + "tag": 3, + "event": runtime_packet_event_wire(&event), + }), + ) + .await + { + tracing::warn!(extension = self.name, error = %error, "failed to deliver packet to worker"); + } else { + match read_worker_response(process, RESPONSE_TAG_EVENT_HANDLED).await { + Ok(response) => { + if let Err(error) = response_result_ok(self.name, &response) { + tracing::warn!(extension = self.name, error = %error, "worker rejected packet"); + } + } + Err(error) => { + tracing::warn!(extension = self.name, error = %error, "worker did not acknowledge packet"); + } + } + } + } + /// Delivers one provider event into the bound TypeScript worker. + #[cfg(feature = "provider-grpc")] async fn deliver_provider_event(&self, event: Value) { - let process = match self.process.lock() { - Ok(mut guard) => guard.take(), - Err(error) => { - tracing::warn!(plugin = self.name, error = %error, "worker process lock poisoned"); - None - } - }; - let Some(mut process) = process else { + let mut guard = self.process.lock().await; + let Some(process) = guard.as_mut() else { tracing::warn!( plugin = self.name, "worker process is not available for provider event" @@ -1243,7 +1417,7 @@ impl TypeScriptObserverPlugin { }; if let Err(error) = send_worker_message( - &mut process, + process, json!({ "tag": 5, "event": event, @@ -1253,7 +1427,7 @@ impl TypeScriptObserverPlugin { { tracing::warn!(plugin = self.name, error = %error, "failed to deliver provider event to worker"); } else { - match read_worker_response(&mut process, RESPONSE_TAG_PROVIDER_EVENT_HANDLED).await { + match read_worker_response(process, RESPONSE_TAG_PROVIDER_EVENT_HANDLED).await { Ok(response) => { if let Err(error) = response_result_ok(self.name, &response) { tracing::warn!(plugin = self.name, error = %error, "worker rejected provider event"); @@ -1264,10 +1438,38 @@ impl TypeScriptObserverPlugin { } } } + } + + /// Requests shutdown for the worker process when it is still running. + async fn shutdown(&self) { + let mut guard = self.process.lock().await; + let Some(process) = guard.as_mut() else { + return; + }; + + if let Err(error) = send_worker_message( + process, + json!({ + "tag": 4, + "context": WorkerContext { + extension_name: self.name, + }, + }), + ) + .await + { + tracing::warn!(extension = self.name, error = %error, "failed to request worker shutdown"); + } else if let Err(error) = read_worker_response(process, RESPONSE_TAG_SHUTDOWN_COMPLETE) + .await + .and_then(|response| response_result_ok(self.name, &response)) + { + tracing::warn!(extension = self.name, error = %error, "worker shutdown was not acknowledged"); + } - if let Ok(mut guard) = self.process.lock() { - *guard = Some(process); + if let Err(error) = process.child.wait().await { + tracing::warn!(extension = self.name, error = %error, "failed to wait for worker process"); } + *guard = None; } } @@ -1297,47 +1499,6 @@ async fn start_worker_process( Ok(process) } -#[cfg(feature = "provider-grpc")] -/// Requests shutdown for one worker process when it is still running. -async fn shutdown_worker_process( - extension_name: &'static str, - process: &Mutex>, -) { - let process = match process.lock() { - Ok(mut guard) => guard.take(), - Err(error) => { - tracing::warn!(extension = extension_name, error = %error, "worker process lock poisoned"); - None - } - }; - let Some(mut process) = process else { - return; - }; - - if let Err(error) = send_worker_message( - &mut process, - json!({ - "tag": 4, - "context": WorkerContext { - extension_name, - }, - }), - ) - .await - { - tracing::warn!(extension = extension_name, error = %error, "failed to request worker shutdown"); - } else if let Err(error) = read_worker_response(&mut process, RESPONSE_TAG_SHUTDOWN_COMPLETE) - .await - .and_then(|response| response_result_ok(extension_name, &response)) - { - tracing::warn!(extension = extension_name, error = %error, "worker shutdown was not acknowledged"); - } - - if let Err(error) = process.child.wait().await { - tracing::warn!(extension = extension_name, error = %error, "failed to wait for worker process"); - } -} - /// Spawns one stdio worker process from the provided launch config. async fn spawn_worker( extension_name: &str, @@ -1848,3 +2009,119 @@ const fn runtime_websocket_frame_type_to_wire(value: RuntimeWebSocketFrameType) RuntimeWebSocketFrameType::Pong => 4, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn websocket_ingress(name: &str) -> IngressConfig { + IngressConfig { + kind: INGRESS_KIND_WEB_SOCKET, + name: name.to_owned(), + bind_address: None, + #[cfg(feature = "provider-grpc")] + endpoint: None, + #[cfg(feature = "provider-grpc")] + stream: None, + #[cfg(feature = "provider-grpc")] + x_token: None, + #[cfg(feature = "provider-grpc")] + commitment: None, + #[cfg(feature = "provider-grpc")] + vote: None, + #[cfg(feature = "provider-grpc")] + failed: None, + #[cfg(feature = "provider-grpc")] + signature: None, + #[cfg(feature = "provider-grpc")] + account_include: None, + #[cfg(feature = "provider-grpc")] + account_exclude: None, + #[cfg(feature = "provider-grpc")] + account_required: None, + #[cfg(feature = "provider-grpc")] + accounts: None, + #[cfg(feature = "provider-grpc")] + owners: None, + #[cfg(feature = "provider-grpc")] + require_transaction_signature: None, + #[cfg(feature = "provider-grpc")] + readiness: None, + #[cfg(feature = "provider-grpc")] + role: None, + #[cfg(feature = "provider-grpc")] + priority: None, + url: Some("wss://example.invalid".to_owned()), + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + requests: Some(vec![]), + entrypoints: None, + #[cfg(feature = "gossip-bootstrap")] + runtime_mode: None, + entrypoint_pinned: None, + kernel_bypass: None, + } + } + + fn direct_shreds_ingress(name: &str) -> IngressConfig { + IngressConfig { + kind: INGRESS_KIND_DIRECT_SHREDS, + name: name.to_owned(), + bind_address: Some("127.0.0.1:20000".to_owned()), + #[cfg(feature = "provider-grpc")] + endpoint: None, + #[cfg(feature = "provider-grpc")] + stream: None, + #[cfg(feature = "provider-grpc")] + x_token: None, + #[cfg(feature = "provider-grpc")] + commitment: None, + #[cfg(feature = "provider-grpc")] + vote: None, + #[cfg(feature = "provider-grpc")] + failed: None, + #[cfg(feature = "provider-grpc")] + signature: None, + #[cfg(feature = "provider-grpc")] + account_include: None, + #[cfg(feature = "provider-grpc")] + account_exclude: None, + #[cfg(feature = "provider-grpc")] + account_required: None, + #[cfg(feature = "provider-grpc")] + accounts: None, + #[cfg(feature = "provider-grpc")] + owners: None, + #[cfg(feature = "provider-grpc")] + require_transaction_signature: None, + #[cfg(feature = "provider-grpc")] + readiness: None, + #[cfg(feature = "provider-grpc")] + role: None, + #[cfg(feature = "provider-grpc")] + priority: None, + url: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + requests: None, + entrypoints: None, + #[cfg(feature = "gossip-bootstrap")] + runtime_mode: None, + entrypoint_pinned: None, + kernel_bypass: None, + } + } + + #[test] + fn validate_ingress_accepts_mixed_websocket_and_raw_runtime_config() { + let config = RuntimeHostConfig { + app_name: "mixed-app".to_owned(), + runtime_environment: HashMap::new(), + ingress: vec![websocket_ingress("ws-a"), direct_shreds_ingress("direct-a")], + #[cfg(feature = "provider-grpc")] + fan_in: Some(FanInConfig { strategy: 2 }), + plugin_workers: vec![], + }; + + let result = validate_ingress(&config); + assert!(result.is_ok()); + } +} diff --git a/crates/sof-observer/src/runtime.rs b/crates/sof-observer/src/runtime.rs index 4bdf122a..5f6c4a0a 100644 --- a/crates/sof-observer/src/runtime.rs +++ b/crates/sof-observer/src/runtime.rs @@ -27,12 +27,12 @@ use crate::{ app::runtime as app_runtime, event::TxCommitmentStatus, framework, provider_stream, runtime_env, }; use agave_transaction_view::transaction_view::SanitizedTransactionView; -use app_runtime::RuntimeObservabilityService; #[cfg(feature = "gossip-bootstrap")] use app_runtime::{ ClusterTopologyTracker, ProviderStreamGossipControlPlane, start_provider_stream_gossip_control_plane, }; +use app_runtime::{RuntimeObservabilityHandle, RuntimeObservabilityService}; use provider_stream::{ ProviderSourceHealthEvent, ProviderSourceHealthStatus, ProviderSourceId, ProviderSourceIdentity, ProviderStreamMode, ProviderStreamReceiver, ProviderStreamUpdate, @@ -1678,6 +1678,8 @@ pub struct ObserverRuntime { packet_ingest_rx: Option, /// Optional externally supplied processed provider-stream receiver. provider_stream: Option<(ProviderStreamMode, ProviderStreamReceiver)>, + /// Whether processed provider-stream ingress should run alongside raw packet ingress. + run_raw_ingress_with_provider_stream: bool, } impl ObserverRuntime { @@ -1779,6 +1781,16 @@ impl ObserverRuntime { self } + /// Keeps raw packet ingress active when provider-stream ingress is also configured. + /// + /// Provider-stream ingress normally replaces raw packet ingress. Use this only when a runtime + /// composition intentionally needs both raw packets and processed provider updates. + #[must_use] + pub const fn with_raw_ingress_alongside_provider_stream(mut self) -> Self { + self.run_raw_ingress_with_provider_stream = true; + self + } + #[cfg(feature = "kernel-bypass")] /// Replaces the built-in UDP ingress with an externally supplied kernel-bypass ingress receiver. #[must_use] @@ -1805,6 +1817,19 @@ impl ObserverRuntime { runtime_env::clear_runtime_env_overrides(); self.setup.apply(); if let Some((mode, provider_stream_rx)) = self.provider_stream { + if self.run_raw_ingress_with_provider_stream { + return run_raw_and_provider_stream_runtime( + self.plugin_host, + self.extension_host, + self.derived_state_host, + shutdown_signal, + mode, + provider_stream_rx, + #[cfg(feature = "kernel-bypass")] + self.packet_ingest_rx, + ) + .await; + } return run_provider_stream_runtime( self.plugin_host, self.extension_host, @@ -1892,11 +1917,11 @@ async fn run_provider_stream_runtime( derived_state_host: DerivedStateHost, shutdown_signal: Option, mode: ProviderStreamMode, - mut provider_stream_rx: ProviderStreamReceiver, + provider_stream_rx: ProviderStreamReceiver, ) -> Result<(), RuntimeError> { let capability_check = enforce_provider_stream_capability_policy(mode, &plugin_host, &derived_state_host)?; - let mut gossip_control_plane = + let gossip_control_plane = bootstrap_provider_stream_gossip_control_plane(mode, &plugin_host, &derived_state_host) .await?; let observability = if let Some(bind_addr) = read_observability_bind_addr() { @@ -1946,6 +1971,158 @@ async fn run_provider_stream_runtime( derived_state_host.initialize(); tracing::info!(mode = mode.as_str(), "starting SOF provider-stream runtime"); + let result = run_provider_stream_loop( + plugin_host.clone(), + derived_state_host.clone(), + shutdown_signal, + mode, + provider_stream_rx, + observability_handle, + gossip_control_plane, + ) + .await; + + plugin_host.shutdown().await; + extension_host.shutdown().await; + if let Some(service) = observability { + service.shutdown().await; + } + result +} + +async fn run_raw_and_provider_stream_runtime( + plugin_host: PluginHost, + extension_host: RuntimeExtensionHost, + derived_state_host: DerivedStateHost, + shutdown_signal: Option, + mode: ProviderStreamMode, + provider_stream_rx: ProviderStreamReceiver, + #[cfg(feature = "kernel-bypass")] packet_ingest_rx: Option, +) -> Result<(), RuntimeError> { + enforce_provider_stream_capability_policy(mode, &plugin_host, &derived_state_host)?; + let gossip_control_plane = + bootstrap_provider_stream_gossip_control_plane(mode, &plugin_host, &derived_state_host) + .await?; + plugin_host + .startup() + .await + .map_err(|error| ProviderStreamRuntimeError::Startup { + message: error.to_string(), + })?; + derived_state_host.initialize(); + tracing::info!( + mode = mode.as_str(), + "starting SOF combined raw and provider-stream runtime" + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + if let Some(signal) = shutdown_signal { + let external_shutdown_tx = shutdown_tx.clone(); + tokio::spawn(async move { + signal.await; + let _ignored = external_shutdown_tx.send(true); + }); + } + let raw_shutdown = Some(shared_shutdown_signal(shutdown_rx.clone())); + let provider_shutdown = Some(shared_shutdown_signal(shutdown_rx)); + let provider_shutdown_tx = shutdown_tx.clone(); + let provider_task = tokio::spawn(run_provider_stream_loop( + plugin_host.clone(), + derived_state_host.clone(), + provider_shutdown, + mode, + provider_stream_rx, + None, + gossip_control_plane, + )); + let provider_shutdown_notifier = tokio::spawn(async move { + let result = provider_task.await; + let _ignored = provider_shutdown_tx.send(true); + result + }); + + let raw_result = { + #[cfg(feature = "kernel-bypass")] + { + if let Some(packet_ingest_rx) = packet_ingest_rx { + app_runtime::run_async_with_hosts_and_kernel_bypass_ingress( + plugin_host, + extension_host, + derived_state_host, + raw_shutdown, + packet_ingest_rx, + ) + .await + } else { + app_runtime::run_async_with_hosts( + plugin_host, + extension_host, + derived_state_host, + raw_shutdown, + ) + .await + } + } + #[cfg(not(feature = "kernel-bypass"))] + { + app_runtime::run_async_with_hosts( + plugin_host, + extension_host, + derived_state_host, + raw_shutdown, + ) + .await + } + } + .map_err(RuntimeError::from); + + let _ignored = shutdown_tx.send(true); + let provider_result = provider_shutdown_notifier.await.map_err(|error| { + RuntimeError::Runloop(format!( + "combined provider-stream runtime task join failed: {error}" + )) + })?; + let provider_result = provider_result.map_err(|error| { + RuntimeError::Runloop(format!( + "combined provider-stream runtime task join failed: {error}" + )) + })?; + + raw_result?; + provider_result?; + tracing::info!( + mode = mode.as_str(), + "SOF combined raw and provider-stream runtime stopped" + ); + Ok(()) +} + +fn shared_shutdown_signal(mut shutdown_rx: tokio::sync::watch::Receiver) -> ShutdownSignal { + Box::pin(async move { + loop { + if *shutdown_rx.borrow() { + return; + } + if shutdown_rx.changed().await.is_err() { + return; + } + } + }) +} + +async fn run_provider_stream_loop( + plugin_host: PluginHost, + derived_state_host: DerivedStateHost, + shutdown_signal: Option, + mode: ProviderStreamMode, + mut provider_stream_rx: ProviderStreamReceiver, + observability_handle: Option, + mut gossip_control_plane: ProviderRuntimeGossipControlPlane, +) -> Result<(), RuntimeError> { + tracing::info!( + mode = mode.as_str(), + "starting SOF provider-stream event loop" + ); let mut shutdown_signal = shutdown_signal; let mut replay_dedupe = ProviderReplayDedupe::new(PROVIDER_REPLAY_DEDUPE_CAPACITY); let mut provider_health = ProviderStreamHealth::default(); @@ -2016,11 +2193,6 @@ async fn run_provider_stream_runtime( } }; - plugin_host.shutdown().await; - extension_host.shutdown().await; - if let Some(service) = observability { - service.shutdown().await; - } gossip_control_plane.shutdown().await; if result.is_ok() { tracing::info!(mode = mode.as_str(), "SOF provider-stream runtime stopped"); diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index dcd59a38..48adf8ac 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -37,7 +37,9 @@ const app = new App({ plugins: [ new Plugin({ name: "tx-logger", - logPackets: true, + onProviderEvent: (event) => { + console.log(event); + }, }), ], }); @@ -48,14 +50,14 @@ await app.run(); `app.run()` is the normal execution path. It runs until the app is stopped. Current executable coverage: -- `app.run()` supports one WebSocket ingress source today. +- `app.run()` delegates every non-empty ingress config to the packaged native runtime host. +- WebSocket ingress uses SOF's native provider-stream websocket adapter and delivers events to `Plugin.onProviderEvent`. - `DirectShreds` runs through the packaged native runtime host for one raw packet source per app. - `Grpc` runs through the packaged native runtime host with Yellowstone provider-stream events delivered to `Plugin.onProviderEvent`. - `Gossip` runs through the packaged native runtime host with gossip-bootstrap support. - Direct shreds can enable kernel bypass on Linux through a typed `kernelBypass` object. - One `DirectShreds` ingress plus one `Gossip` ingress run together as one raw runtime composition without `fanIn`. -- Mixed WebSocket/native ingress and multi-WebSocket fan-in still return typed errors instead of silently falling through. -- Multi-gRPC fan-in uses the Rust arbitration model: `EmitAll`, `FirstSeen`, or `FirstSeenThenPromote`. +- Multi-provider fan-in uses the Rust arbitration model: `EmitAll`, `FirstSeen`, or `FirstSeenThenPromote`. - The package build includes the native host under `dist/native/-/`; `SOF_SDK_RUNTIME_HOST_BINARY` is only an override for development or custom deployments. - If the host is missing, `app.run()` returns a typed `Result` error instead of throwing. @@ -221,8 +223,8 @@ import { const plugin = new Plugin({ name: "packet-audit", onStart: () => ok(runtimeExtensionAck()), - onPacket: (event) => { - process.stdout.write(`${event.bytes.length}\n`); + onProviderEvent: (event) => { + process.stdout.write(`${event.kind}\n`); return ok(runtimeExtensionAck()); }, onStop: () => ok(runtimeExtensionAck()), diff --git a/sdks/typescript/examples/app-entrypoint.ts b/sdks/typescript/examples/app-entrypoint.ts index 813b2ac8..38f40624 100644 --- a/sdks/typescript/examples/app-entrypoint.ts +++ b/sdks/typescript/examples/app-entrypoint.ts @@ -4,7 +4,7 @@ function main(): number { const plugin = new Plugin({ name: "demo-plugin", onStart: () => ok(runtimeExtensionAck()), - onPacket: () => ok(runtimeExtensionAck()), + onProviderEvent: () => ok(runtimeExtensionAck()), onStop: () => ok(runtimeExtensionAck()), }); diff --git a/sdks/typescript/src/app.test.ts b/sdks/typescript/src/app.test.ts index ac01c454..04b39ed3 100644 --- a/sdks/typescript/src/app.test.ts +++ b/sdks/typescript/src/app.test.ts @@ -13,6 +13,8 @@ import { ProviderIngressRole, RuntimePacketSourceKind, createBalancedRuntime, + ok, + runtimeExtensionAck, } from "./index.js"; test("app derives a stable default name from the first plugin", () => { @@ -25,7 +27,7 @@ test("app derives a stable default name from the first plugin", () => { url: "wss://example.invalid", }, ], - plugins: [new Plugin({ name: "tx-logger", logPackets: true })], + plugins: [new Plugin({ name: "tx-logger", onProviderEvent: () => ok(runtimeExtensionAck()) })], }); assert.equal(app.name, "tx-logger-app"); @@ -35,7 +37,6 @@ test("app derives a stable default name from the first plugin", () => { kind: IngressKind.WebSocket, name: "solana-websocket", url: "wss://example.invalid", - requests: [], }, ]); }); @@ -303,81 +304,71 @@ test("app rejects invalid provider ingress priority", () => { ); }); -test("app reports mixed websocket and native ingress before starting a host", async () => { - const result = await new App({ - ingress: [ - { - kind: IngressKind.WebSocket, - url: "wss://example.invalid", - }, - { - kind: IngressKind.DirectShreds, - bindAddress: "127.0.0.1:20000", - }, - ], - fanIn: { - strategy: FanInStrategy.FirstSeen, - }, - plugins: [new Plugin({ name: "packet-extension", logPackets: false })], - }).run(); - - assert.equal(isErr(result), true); - if (isErr(result)) { - assert.equal(result.error.field, "ingress"); - assert.match(result.error.message, /mixed websocket and native-host ingress/); - } -}); - -test("app reports multi-websocket ingress fan-in as unsupported today", async () => { - const result = await new App({ - ingress: [ - { - kind: IngressKind.WebSocket, - name: "ws-a", - url: "wss://one.example.invalid", - }, - { - kind: IngressKind.WebSocket, - name: "ws-b", - url: "wss://two.example.invalid", +test("app delegates mixed websocket and native ingress to the runtime host", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.WebSocket, + url: "wss://example.invalid", + }, + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, }, - ], - fanIn: { - strategy: FanInStrategy.FirstSeen, - }, - plugins: [new Plugin({ name: "packet-extension", logPackets: false })], - }).run(); + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); - assert.equal(isErr(result), true); - if (isErr(result)) { - assert.equal(result.error.field, "ingress"); - assert.match(result.error.message, /multi-websocket ingress fan-in/); + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } } }); -test("app accepts typed kernel-bypass config for direct shreds", async () => { - if (process.platform !== "linux") { +test("app accepts multi-websocket ingress with explicit fanIn", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { const result = await new App({ ingress: [ { - kind: IngressKind.DirectShreds, - bindAddress: "127.0.0.1:20000", - kernelBypass: { - interface: "eth0", - }, + kind: IngressKind.WebSocket, + name: "ws-a", + url: "wss://one.example.invalid", + }, + { + kind: IngressKind.WebSocket, + name: "ws-b", + url: "wss://two.example.invalid", }, ], + fanIn: { + strategy: FanInStrategy.FirstSeenThenPromote, + }, plugins: [new Plugin({ name: "packet-extension", logPackets: false })], }).run(); - assert.equal(isErr(result), true); - if (isErr(result)) { - assert.equal(result.error.field, "ingress.kernelBypass"); - assert.match(result.error.message, /only supported on Linux/); + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; } - return; } +}); +test("app accepts typed kernel-bypass config for direct shreds", async () => { const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; try { @@ -458,29 +449,35 @@ test("app rejects fanIn for direct shreds plus gossip composition", () => { ); }); -test("app reports multiple direct shred native ingress sources as unsupported today", async () => { - const result = await new App({ - ingress: [ - { - kind: IngressKind.DirectShreds, - name: "direct-a", - bindAddress: "127.0.0.1:20000", - }, - { - kind: IngressKind.DirectShreds, - name: "direct-b", - bindAddress: "127.0.0.1:20001", +test("app delegates multiple direct shred sources to the runtime host", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + name: "direct-a", + bindAddress: "127.0.0.1:20000", + }, + { + kind: IngressKind.DirectShreds, + name: "direct-b", + bindAddress: "127.0.0.1:20001", + }, + ], + fanIn: { + strategy: FanInStrategy.FirstSeen, }, - ], - fanIn: { - strategy: FanInStrategy.FirstSeen, - }, - plugins: [new Plugin({ name: "packet-extension", logPackets: false })], - }).run(); + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); - assert.equal(isErr(result), true); - if (isErr(result)) { - assert.equal(result.error.field, "ingress"); - assert.match(result.error.message, /one direct shred ingress source/); + assert.equal(isOk(result), true); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } } }); diff --git a/sdks/typescript/src/app.ts b/sdks/typescript/src/app.ts index d423af24..a7758a05 100644 --- a/sdks/typescript/src/app.ts +++ b/sdks/typescript/src/app.ts @@ -21,12 +21,9 @@ import { type RuntimeExtensionDefinition, type RuntimeExtensionError, type RuntimeExtensionWorkerManifest, - RuntimePacketEventClass, RuntimePacketSourceKind, - RuntimePacketTransport, type RuntimePacketEvent, type RuntimeProviderEvent, - RuntimeWebSocketFrameType, RuntimeDeliveryProfile, type ShredTrustMode, socketAddress, @@ -126,7 +123,6 @@ export interface WebSocketIngressInit { readonly kind: IngressKind.WebSocket; readonly name?: string; readonly url: string; - readonly requests?: readonly WebSocketRequest[]; } export interface GrpcIngressInit { @@ -197,22 +193,6 @@ export interface WebSocketIngress { readonly kind: IngressKind.WebSocket; readonly name: string; readonly url: string; - readonly requests: readonly string[]; -} - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = - | JsonPrimitive - | readonly JsonValue[] - | { - readonly [key: string]: JsonValue; - }; - -export interface WebSocketRequest { - readonly jsonrpc?: "2.0"; - readonly id?: string | number; - readonly method: string; - readonly params?: readonly JsonValue[]; } export interface GrpcIngress { @@ -527,28 +507,10 @@ function validateWebSocketIngress( ); } - const requests: string[] = []; - for (const request of ingress.requests ?? []) { - const method = parseNonEmptyValue(request.method, "ingress.requests.method", (value) => value); - if (isErr(method)) { - return method; - } - - requests.push( - JSON.stringify({ - jsonrpc: request.jsonrpc ?? "2.0", - id: request.id ?? index + requests.length + 1, - method: method.value, - ...(request.params === undefined ? {} : { params: request.params }), - }), - ); - } - return ok({ kind: IngressKind.WebSocket, name: name.value, url: url.value, - requests, }); } @@ -1089,86 +1051,6 @@ function waitForChildExit(child: ChildProcess): Promise }); } -function runtimeSourceForIngress(_ingress: WebSocketIngress, frameType: RuntimeWebSocketFrameType) { - return { - kind: RuntimePacketSourceKind.ObserverIngress, - transport: RuntimePacketTransport.WebSocket, - eventClass: RuntimePacketEventClass.Packet, - webSocketFrameType: frameType, - } as const; -} - -function toPacketBytes(data: MessageEvent["data"]): Uint8Array { - if (typeof data === "string") { - return new TextEncoder().encode(data); - } - - if (data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - - if (ArrayBuffer.isView(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - - if (data instanceof Blob) { - throw new TypeError("Blob websocket frames are not supported in the synchronous packet path"); - } - - return new TextEncoder().encode(String(data)); -} - -function toWebSocketFrameType(data: MessageEvent["data"]): RuntimeWebSocketFrameType { - return typeof data === "string" - ? RuntimeWebSocketFrameType.Text - : RuntimeWebSocketFrameType.Binary; -} - -function parseJsonRpcErrorMessage( - ingress: WebSocketIngress, - data: MessageEvent["data"], -): AppError | undefined { - if (typeof data !== "string") { - return undefined; - } - - try { - const parsed: unknown = JSON.parse(data); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return undefined; - } - const id = - "id" in parsed && (typeof parsed.id === "string" || typeof parsed.id === "number") - ? parsed.id - : undefined; - const nestedError = - "error" in parsed && - typeof parsed.error === "object" && - parsed.error !== null && - !Array.isArray(parsed.error) - ? parsed.error - : undefined; - const message = - nestedError !== undefined && - "message" in nestedError && - typeof nestedError.message === "string" - ? nestedError.message - : undefined; - if (message === undefined) { - return undefined; - } - - return appError( - AppErrorKind.ValidationError, - "ingress.requests", - `websocket ingress ${ingress.name} rejected request ${String(id ?? "unknown")}: ${message}`, - data, - ); - } catch { - return undefined; - } -} - function invokePluginReady(plugin: Plugin): Promise> { const onReady = plugin.toDefinition().onReady; if (onReady === undefined) { @@ -1195,18 +1077,6 @@ function invokePluginShutdown(plugin: Plugin): Promise> { - const onPacket = plugin.toDefinition().onPacketReceived; - if (onPacket === undefined) { - return Promise.resolve(ok(runtimeExtensionAck())); - } - - return Promise.resolve(onPacket(event)); -} - async function shutdownPlugins( startedPlugins: readonly Plugin[], ): Promise> { @@ -1275,77 +1145,6 @@ function createRuntimeHostConfig(state: AppState): Result { - const nativeIngress = state.ingress.filter((ingress) => ingress.kind !== IngressKind.WebSocket); - if (nativeIngress.length === 0) { - return ok(void 0); - } - - if (state.ingress.some((ingress) => ingress.kind === IngressKind.WebSocket)) { - return err( - appError( - AppErrorKind.ValidationError, - "ingress", - "app.run() does not yet support mixed websocket and native-host ingress sources in one app", - ), - ); - } - - const rawIngress = nativeIngress.filter( - (ingress) => ingress.kind === IngressKind.DirectShreds || ingress.kind === IngressKind.Gossip, - ); - const directShredsIngress = rawIngress.filter( - (ingress): ingress is DirectShredsIngress => ingress.kind === IngressKind.DirectShreds, - ); - const gossipIngress = rawIngress.filter( - (ingress): ingress is GossipIngress => ingress.kind === IngressKind.Gossip, - ); - const providerIngress = nativeIngress.filter((ingress) => ingress.kind === IngressKind.Grpc); - if (providerIngress.length > 0 && rawIngress.length > 0) { - return err( - appError( - AppErrorKind.ValidationError, - "ingress", - "app.run() does not yet support mixed provider-stream and raw native ingress sources in one app", - ), - ); - } - - if (directShredsIngress.length > 1) { - return err( - appError( - AppErrorKind.ValidationError, - "ingress", - "app.run() currently supports one direct shred ingress source per app", - ), - ); - } - if (gossipIngress.length > 1) { - return err( - appError( - AppErrorKind.ValidationError, - "ingress", - "app.run() currently supports one gossip ingress source per app", - ), - ); - } - const kernelBypassIngress = directShredsIngress.find( - (ingress) => ingress.kernelBypass !== undefined, - ); - if (kernelBypassIngress !== undefined && process.platform !== "linux") { - return err( - appError( - AppErrorKind.ValidationError, - "ingress.kernelBypass", - `kernel bypass for ingress ${kernelBypassIngress.name} is only supported on Linux`, - kernelBypassIngress.name, - ), - ); - } - - return ok(void 0); -} - function runtimeHostExecutableName(): string { return process.platform === "win32" ? `${runtimeHostBinaryBaseName}.exe` @@ -1731,42 +1530,17 @@ export class App { return this.#runInternalPluginWorker(internalWorkerPluginName); } - if (this.#ingress.some((ingress) => ingress.kind !== IngressKind.WebSocket)) { - const state = this.#toState(); - const support = validateNativeRuntimeSupport(state); - if (isErr(support)) { - return support; - } - - const runtimeHostConfig = createRuntimeHostConfig(state); - if (isErr(runtimeHostConfig)) { - return runtimeHostConfig; - } - - return runRuntimeHost(runtimeHostConfig.value, options); - } - - if (this.#ingress.length > 1) { - return err( - appError( - AppErrorKind.ValidationError, - "ingress", - "app.run() does not yet support multi-websocket ingress fan-in", - ), + if (this.#ingress.length === 0) { + const readyResults = await Promise.all( + this.#plugins.map((plugin) => invokePluginReady(plugin)), ); - } - - const readyResults = await Promise.all( - this.#plugins.map((plugin) => invokePluginReady(plugin)), - ); - for (const ready of readyResults) { - if (isErr(ready)) { - return ready; + for (const ready of readyResults) { + if (isErr(ready)) { + return ready; + } } - } - const startedPlugins = [...this.#plugins]; + const startedPlugins = [...this.#plugins]; - if (this.#ingress.length === 0) { if (options.signal !== undefined) { if (options.signal.aborted) { return ok(runtimeExtensionAck()); @@ -1786,132 +1560,13 @@ export class App { return shutdownPlugins(startedPlugins); } - const sockets: WebSocket[] = []; - let runtimeError: AppRunError | undefined; - let settleRuntime: (() => void) | undefined; - const runtimeSettled = new Promise((resolve) => { - settleRuntime = resolve; - }); - - const finishRuntime = () => { - settleRuntime?.(); - }; - - try { - await Promise.all( - this.#ingress.map( - (ingress) => - new Promise((resolve, reject) => { - if (ingress.kind !== IngressKind.WebSocket) { - reject( - new RangeError( - `unsupported ingress kind during websocket runtime execution: ${IngressKind[ingress.kind]}`, - ), - ); - return; - } - - const socket = new WebSocket(ingress.url); - sockets.push(socket); - - socket.binaryType = "arraybuffer"; - socket.addEventListener( - "open", - () => { - for (const request of ingress.requests) { - socket.send(request); - } - resolve(); - }, - { once: true }, - ); - socket.addEventListener( - "error", - () => { - reject(new RangeError(`failed to open websocket ingress ${ingress.name}`)); - }, - { once: true }, - ); - socket.addEventListener("message", (message) => { - void (async () => { - try { - const jsonRpcError = parseJsonRpcErrorMessage(ingress, message.data); - if (jsonRpcError !== undefined) { - runtimeError = jsonRpcError; - socket.close(); - finishRuntime(); - return; - } - - const event: RuntimePacketEvent = { - source: runtimeSourceForIngress(ingress, toWebSocketFrameType(message.data)), - bytes: toPacketBytes(message.data), - observedUnixMs: Date.now(), - }; - - const handledResults = await Promise.all( - this.#plugins.map((plugin) => dispatchPacketToPlugin(plugin, event)), - ); - for (const handled of handledResults) { - if (isErr(handled)) { - runtimeError = handled.error; - socket.close(); - finishRuntime(); - } - } - } catch (error) { - runtimeError = appError( - AppErrorKind.ValidationError, - "ingress", - error instanceof Error ? error.message : String(error), - ); - socket.close(); - finishRuntime(); - } - })(); - }); - socket.addEventListener("close", () => { - if (options.signal === undefined || runtimeError !== undefined) { - finishRuntime(); - } - }); - }), - ), - ); - - if (options.signal?.aborted === true) { - finishRuntime(); - } else { - options.signal?.addEventListener( - "abort", - () => { - finishRuntime(); - }, - { once: true }, - ); - } - - await runtimeSettled; - } catch (error) { - runtimeError = appError( - AppErrorKind.ValidationError, - "ingress", - error instanceof Error ? error.message : String(error), - ); - } - - for (const socket of sockets) { - if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { - socket.close(); - } - } - - const shutdown = await shutdownPlugins(startedPlugins); - if (isErr(shutdown)) { - return shutdown; + const state = this.#toState(); + const runtimeHostConfig = createRuntimeHostConfig(state); + if (isErr(runtimeHostConfig)) { + return runtimeHostConfig; } - return runtimeError === undefined ? ok(runtimeExtensionAck()) : err(runtimeError); + return runRuntimeHost(runtimeHostConfig.value, options); } } From 0c73bdb98fe0f51f3a8f66c3ecc7a0835a852ada Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 19:38:14 +0200 Subject: [PATCH 16/25] fix(ts-sdk): narrow public api and harden runtime handoff --- .../examples/runtime-config-balanced.ts | 42 ++--- .../examples/runtime-extension-manifest.ts | 45 +++--- .../examples/runtime-extension-worker.ts | 127 --------------- sdks/typescript/package.json | 6 +- sdks/typescript/src/app.test.ts | 149 +++++++++++++++++- sdks/typescript/src/app.ts | 114 ++++++++------ sdks/typescript/src/package-exports.test.ts | 29 ++-- sdks/typescript/src/runtime.ts | 92 ++++++++++- sdks/typescript/src/runtime/app-internal.ts | 8 + sdks/typescript/src/runtime/extension.ts | 54 +++++++ .../src/runtime/runtime-config.test.ts | 80 +++++----- .../runtime/runtime-extension-stdio.test.ts | 2 +- .../src/runtime/runtime-extension.test.ts | 2 +- sdks/typescript/tsconfig.examples.json | 6 +- sdks/typescript/tsdown.config.ts | 3 +- 15 files changed, 454 insertions(+), 305 deletions(-) delete mode 100644 sdks/typescript/examples/runtime-extension-worker.ts create mode 100644 sdks/typescript/src/runtime/app-internal.ts create mode 100644 sdks/typescript/src/runtime/extension.ts diff --git a/sdks/typescript/examples/runtime-config-balanced.ts b/sdks/typescript/examples/runtime-config-balanced.ts index b8db9b73..5fce6791 100644 --- a/sdks/typescript/examples/runtime-config-balanced.ts +++ b/sdks/typescript/examples/runtime-config-balanced.ts @@ -4,37 +4,23 @@ import { ProviderStreamCapabilityPolicy, RuntimeDeliveryProfile, ShredTrustMode, - type Result, - isErr, - tryCreateRuntimeConfigForProfile, - trySerializeRuntimeConfigRecord, + createRuntimeConfigForProfile, + serializeRuntimeConfigRecord, } from "../dist/index.js"; -function expectOk( - result: Result, -): Value { - if (isErr(result)) { - throw new Error(result.error.message); - } - - return result.value; -} - -const config = expectOk( - tryCreateRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { - shredTrustMode: ShredTrustMode.TrustedRawShredProvider, - providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, - derivedState: { - replay: { - backend: DerivedStateReplayBackend.Disk, - durability: DerivedStateReplayDurability.Fsync, - maxEnvelopes: 1024, - maxSessions: 2, - }, +const config = createRuntimeConfigForProfile(RuntimeDeliveryProfile.Balanced, { + shredTrustMode: ShredTrustMode.TrustedRawShredProvider, + providerStreamCapabilityPolicy: ProviderStreamCapabilityPolicy.Strict, + derivedState: { + replay: { + backend: DerivedStateReplayBackend.Disk, + durability: DerivedStateReplayDurability.Fsync, + maxEnvelopes: 1024, + maxSessions: 2, }, - }), -); + }, +}); -const serialized = expectOk(trySerializeRuntimeConfigRecord(config)); +const serialized = serializeRuntimeConfigRecord(config); process.stdout.write(`${JSON.stringify(serialized, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-extension-manifest.ts b/sdks/typescript/examples/runtime-extension-manifest.ts index c038c687..55d98d47 100644 --- a/sdks/typescript/examples/runtime-extension-manifest.ts +++ b/sdks/typescript/examples/runtime-extension-manifest.ts @@ -1,6 +1,7 @@ import { ExtensionCapability, ExtensionStreamVisibilityTag, + Plugin, RuntimePacketSourceKind, RuntimePacketTransport, type Result, @@ -9,7 +10,6 @@ import { isErr, sharedExtensionStream, socketAddress, - tryCreateRuntimeExtensionWorkerManifest, udpListenerResource, } from "../dist/index.js"; @@ -30,27 +30,24 @@ const sharedVisibility = expectOk(sharedExtensionStream("demo-stream")); const udpResource = expectOk(udpListenerResource(resourceId, bindAddress, sharedVisibility)); -const manifest = expectOk( - tryCreateRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: extension, - capabilities: [ExtensionCapability.BindUdp, ExtensionCapability.ObserveSharedExtensionStream], - resources: [udpResource], - subscriptions: [ - { - sourceKind: RuntimePacketSourceKind.ExtensionResource, - transport: RuntimePacketTransport.Udp, - ownerExtension: extension, - resourceId, - }, - { - sourceKind: RuntimePacketSourceKind.ExtensionResource, - ...(sharedVisibility.tag === ExtensionStreamVisibilityTag.Shared - ? { sharedTag: sharedVisibility.sharedTag } - : {}), - }, - ], - }), -); +const plugin = new Plugin({ + name: extension, + capabilities: [ExtensionCapability.BindUdp, ExtensionCapability.ObserveSharedExtensionStream], + resources: [udpResource], + subscriptions: [ + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + transport: RuntimePacketTransport.Udp, + ownerExtension: extension, + resourceId, + }, + { + sourceKind: RuntimePacketSourceKind.ExtensionResource, + ...(sharedVisibility.tag === ExtensionStreamVisibilityTag.Shared + ? { sharedTag: sharedVisibility.sharedTag } + : {}), + }, + ], +}); -process.stdout.write(`${JSON.stringify(manifest, undefined, 2)}\n`); +process.stdout.write(`${JSON.stringify(plugin.manifest, undefined, 2)}\n`); diff --git a/sdks/typescript/examples/runtime-extension-worker.ts b/sdks/typescript/examples/runtime-extension-worker.ts deleted file mode 100644 index 48763811..00000000 --- a/sdks/typescript/examples/runtime-extension-worker.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { PassThrough } from "node:stream"; - -import { - SdkLanguage, - createRuntimeExtensionWorkerManifest, - isErr, - ok, - runtimeExtensionAck, - socketAddress, - tryDefineRuntimeExtension, -} from "../dist/index.js"; -import { - RuntimeExtensionWorkerHostMessageTag, - runRuntimeExtensionWorkerStdio, - serializeRuntimeExtensionWorkerHostMessageWire, -} from "../dist/runtime/extension-stdio.js"; - -async function main(): Promise { - const localAddress = socketAddress("127.0.0.1:21011"); - if (isErr(localAddress)) { - process.stderr.write(`${localAddress.error.message}\n`); - return 1; - } - - let observedPacketLog = ""; - const definition = tryDefineRuntimeExtension({ - manifest: createRuntimeExtensionWorkerManifest({ - sdkVersion: "0.1.0", - extensionName: "demo-extension-worker", - }), - onReady: () => ok(runtimeExtensionAck()), - onPacketReceived: (event) => { - observedPacketLog = `received ${event.bytes.length} bytes from ${String(event.source.localAddress)}`; - return ok(runtimeExtensionAck()); - }, - onShutdown: () => ok(runtimeExtensionAck()), - }); - - if (isErr(definition)) { - process.stderr.write(`${definition.error.message}\n`); - return 1; - } - const input = new PassThrough(); - const output = new PassThrough(); - const errorOutput = new PassThrough(); - - let protocolOutput = ""; - let protocolErrors = ""; - output.setEncoding("utf8"); - errorOutput.setEncoding("utf8"); - output.on("data", (chunk: string) => { - protocolOutput += chunk; - }); - errorOutput.on("data", (chunk: string) => { - protocolErrors += chunk; - }); - - const runner = runRuntimeExtensionWorkerStdio(definition.value, { - input, - output, - error: errorOutput, - }); - - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Start, - context: { - extensionName: definition.value.manifest.extensionName, - }, - }), - )}\n`, - ); - input.write( - `${JSON.stringify({ - tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, - event: { - source: { - kind: 1, - transport: 1, - eventClass: 1, - localAddress: localAddress.value, - }, - bytes: [1, 2, 3, 4], - observedUnixMs: Date.now(), - }, - })}\n`, - ); - input.write( - `${JSON.stringify( - serializeRuntimeExtensionWorkerHostMessageWire({ - tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, - context: { - extensionName: definition.value.manifest.extensionName, - }, - }), - )}\n`, - ); - input.end(); - - const result = await runner; - if (isErr(result)) { - process.stderr.write(`${result.error.message}\n`); - return 1; - } - - process.stdout.write( - `${JSON.stringify( - { - sdkLanguage: SdkLanguage.TypeScript, - observedPacketLog, - protocolErrors: protocolErrors.trim(), - responses: protocolOutput - .trim() - .split("\n") - .filter((line) => line !== "") - .map((line) => JSON.parse(line) as unknown), - }, - undefined, - 2, - )}\n`, - ); - - return 0; -} - -process.exitCode = await main(); diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index eba557c0..2aa3dd11 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -52,10 +52,6 @@ "./runtime/extension": { "types": "./dist/runtime/extension.d.ts", "import": "./dist/runtime/extension.js" - }, - "./runtime/extension-stdio": { - "types": "./dist/runtime/extension-stdio.d.ts", - "import": "./dist/runtime/extension-stdio.js" } }, "files": [ @@ -71,7 +67,7 @@ "format:check": "biome format --config-path biome.json src examples scripts tsdown.config.ts", "lint": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --deny-warnings --report-unused-disable-directives src tsdown.config.ts && oxlint --config .oxlintrc.json --deny-warnings --report-unused-disable-directives examples scripts", "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts && oxlint --config .oxlintrc.json --fix --fix-suggestions examples scripts", - "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/runtime-extension-manifest.js && node dist-examples/runtime-extension-worker.js && node dist-examples/app-entrypoint.js && node dist-examples/app-config.js", + "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/app-entrypoint.js && node dist-examples/app-config.js", "check:package": "publint run --strict --pack pnpm", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/sdks/typescript/src/app.test.ts b/sdks/typescript/src/app.test.ts index 04b39ed3..aea56c07 100644 --- a/sdks/typescript/src/app.test.ts +++ b/sdks/typescript/src/app.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { isErr, isOk } from "./result.js"; @@ -99,10 +102,8 @@ test("plugin packet handlers default to observer ingress manifest access", () => }, }); - assert.deepEqual(plugin.manifest.manifest.capabilities, [ - ExtensionCapability.ObserveObserverIngress, - ]); - assert.deepEqual(plugin.manifest.manifest.subscriptions, [ + assert.deepEqual(plugin.manifest.capabilities, [ExtensionCapability.ObserveObserverIngress]); + assert.deepEqual(plugin.manifest.subscriptions, [ { sourceKind: RuntimePacketSourceKind.ObserverIngress, }, @@ -119,8 +120,20 @@ test("plugin explicit manifest access is preserved", () => { }, }); - assert.deepEqual(plugin.manifest.manifest.capabilities, [ExtensionCapability.ConnectWebSocket]); - assert.deepEqual(plugin.manifest.manifest.subscriptions, []); + assert.deepEqual(plugin.manifest.capabilities, [ExtensionCapability.ConnectWebSocket]); + assert.deepEqual(plugin.manifest.subscriptions, []); +}); + +test("plugin create preserves the validated auto-generated name", () => { + const plugin = Plugin.create({ + onPacket: () => ok(runtimeExtensionAck()), + }); + + assert.equal(isErr(plugin), false); + if (!isErr(plugin)) { + assert.match(plugin.value.name, /^plugin-\d+$/); + assert.equal(plugin.value.name, "plugin-1"); + } }); test("app requires fanIn when multiple ingress sources are configured", () => { @@ -304,6 +317,130 @@ test("app rejects invalid provider ingress priority", () => { ); }); +test("app rejects unsupported ingress kinds from plain JavaScript input", () => { + const invalidInit: unknown = JSON.parse(`{ + "ingress": [ + { + "kind": 999, + "url": "wss://example.invalid" + } + ] + }`); + assert.notEqual(invalidInit, null); + assert.equal(typeof invalidInit, "object"); + if (invalidInit === null || typeof invalidInit !== "object") { + return; + } + + const result = App.create({ + ...invalidInit, + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }); + + assert.equal(isErr(result), true); + if (isErr(result)) { + assert.equal(result.error.field, "ingress.kind"); + } +}); + +test("app runtime host config does not serialize the full process environment", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + const previousSnapshot = process.env.SOF_SDK_CONFIG_SNAPSHOT; + const previousSecret = process.env.SOF_SDK_SHOULD_NOT_BE_SERIALIZED; + const tempDir = await mkdtemp(join(tmpdir(), "sof-sdk-host-test-")); + const hostPath = join(tempDir, "host.mjs"); + const snapshotPath = join(tempDir, "snapshot.json"); + await writeFile( + hostPath, + `#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +const configPath = process.argv[2]; +const snapshotPath = process.env.SOF_SDK_CONFIG_SNAPSHOT; +if (configPath === undefined || snapshotPath === undefined) process.exit(2); +const config = JSON.parse(readFileSync(configPath, "utf8")); +writeFileSync(snapshotPath, JSON.stringify(config.pluginWorkers[0].environment)); +`, + "utf8", + ); + await chmod(hostPath, 0o755); + process.env.SOF_SDK_RUNTIME_HOST_BINARY = hostPath; + process.env.SOF_SDK_CONFIG_SNAPSHOT = snapshotPath; + process.env.SOF_SDK_SHOULD_NOT_BE_SERIALIZED = "secret"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + const environment = JSON.parse(await readFile(snapshotPath, "utf8")) as Record; + assert.deepEqual(environment, { + SOF_SDK_INTERNAL_PLUGIN_WORKER: "packet-extension", + SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE: "1", + }); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + if (previousSnapshot === undefined) { + delete process.env.SOF_SDK_CONFIG_SNAPSHOT; + } else { + process.env.SOF_SDK_CONFIG_SNAPSHOT = previousSnapshot; + } + if (previousSecret === undefined) { + delete process.env.SOF_SDK_SHOULD_NOT_BE_SERIALIZED; + } else { + process.env.SOF_SDK_SHOULD_NOT_BE_SERIALIZED = previousSecret; + } + await rm(tempDir, { force: true, recursive: true }); + } +}); + +test("app ignores internal worker env without the internal worker mode flag", async () => { + const previousWorker = process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER; + const previousMode = process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE; + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER = "packet-extension"; + delete process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE; + process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + if (previousWorker === undefined) { + delete process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER; + } else { + process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER = previousWorker; + } + if (previousMode === undefined) { + delete process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE; + } else { + process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE = previousMode; + } + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + test("app delegates mixed websocket and native ingress to the runtime host", async () => { const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; diff --git a/sdks/typescript/src/app.ts b/sdks/typescript/src/app.ts index a7758a05..e60263fe 100644 --- a/sdks/typescript/src/app.ts +++ b/sdks/typescript/src/app.ts @@ -9,6 +9,7 @@ import { err, isErr, ok, type Result } from "./result.js"; import { ExtensionCapability, type ExtensionContext, + type ExtensionManifest, type ExtensionName, type ExtensionResourceSpec, extensionName, @@ -18,21 +19,23 @@ import { type PacketSubscription, type RuntimeExtensionAck, runtimeExtensionAck, - type RuntimeExtensionDefinition, type RuntimeExtensionError, - type RuntimeExtensionWorkerManifest, RuntimePacketSourceKind, type RuntimePacketEvent, type RuntimeProviderEvent, RuntimeDeliveryProfile, type ShredTrustMode, socketAddress, + webSocketUrl, +} from "./runtime.js"; +import { + type RuntimeExtensionDefinition, + type RuntimeExtensionWorkerManifest, + runRuntimeExtensionWorkerStdio, tryCreateRuntimeConfig, tryCreateRuntimeExtensionWorkerManifest, tryDefineRuntimeExtension, - webSocketUrl, -} from "./runtime.js"; -import { runRuntimeExtensionWorkerStdio } from "./runtime/runtime-extension-stdio.js"; +} from "./runtime/app-internal.js"; export enum AppErrorKind { ValidationError = 1, @@ -271,6 +274,8 @@ export const typeScriptSdkVersion = "0.1.0"; const defaultAppName = "app"; const autoPluginNamePrefix = "plugin"; const internalPluginWorkerEnvVarName = "SOF_SDK_INTERNAL_PLUGIN_WORKER"; +const internalPluginWorkerModeEnvVarName = "SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE"; +const internalPluginWorkerModeEnvValue = "1"; const runtimeHostBinaryEnvVarName = "SOF_SDK_RUNTIME_HOST_BINARY"; const runtimeHostBinaryBaseName = "sof_ts_runtime_host"; const grpcIngressStreams = [ @@ -307,6 +312,7 @@ const defaultKernelBypassRingDepth = 2_048; const defaultKernelBypassPollTimeoutMs = 100; let nextAutoPluginOrdinal = 1; +const pluginDefinitions = new WeakMap(); function appError( kind: AppErrorKind, @@ -487,6 +493,23 @@ function createPluginDefinition(init: PluginInit): Result { return parseNonEmptyValue(value, field, (normalized) => normalized); } @@ -860,6 +883,15 @@ function validateIngress( case IngressKind.DirectShreds: parsed = validateDirectShredsIngress(value, index); break; + default: + return err( + appError( + AppErrorKind.ValidationError, + "ingress.kind", + "ingress.kind must be WebSocket, Grpc, Gossip, or DirectShreds", + String((value as { readonly kind?: unknown }).kind), + ), + ); } if (isErr(parsed)) { @@ -1052,7 +1084,7 @@ function waitForChildExit(child: ChildProcess): Promise } function invokePluginReady(plugin: Plugin): Promise> { - const onReady = plugin.toDefinition().onReady; + const onReady = pluginDefinition(plugin).onReady; if (onReady === undefined) { return Promise.resolve(ok(runtimeExtensionAck())); } @@ -1065,7 +1097,7 @@ function invokePluginReady(plugin: Plugin): Promise> { - const onShutdown = plugin.toDefinition().onShutdown; + const onShutdown = pluginDefinition(plugin).onShutdown; if (onShutdown === undefined) { return Promise.resolve(ok(runtimeExtensionAck())); } @@ -1092,19 +1124,6 @@ async function shutdownPlugins( return ok(runtimeExtensionAck()); } -function stringEnvironmentRecord( - env: NodeJS.ProcessEnv = process.env, -): Readonly> { - const record: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) { - record[key] = value; - } - } - - return record; -} - function currentNodeAppArgs(): Result { const entrypoint = process.argv[1]; if (entrypoint === undefined || entrypoint.trim() === "") { @@ -1134,12 +1153,12 @@ function createRuntimeHostConfig(state: AppState): Result ({ name: plugin.name, - manifest: plugin.manifest, + manifest: pluginDefinition(plugin).manifest, command: process.execPath, args: appArgs.value, environment: { - ...stringEnvironmentRecord(), [internalPluginWorkerEnvVarName]: plugin.name, + [internalPluginWorkerModeEnvVarName]: internalPluginWorkerModeEnvValue, }, })), }); @@ -1340,20 +1359,19 @@ function createAppState(init: AppInit): Result { } export class Plugin { - private readonly definition: RuntimeExtensionDefinition; - - constructor(init: Plugin | PluginInit | RuntimeExtensionDefinition) { - if (init instanceof Plugin) { - this.definition = init.definition; + constructor(init: Plugin | PluginInit); + constructor(init: RuntimeExtensionDefinition, internalDefinition: true); + constructor(init: Plugin | PluginInit | RuntimeExtensionDefinition, _internalDefinition?: true) { + if (arguments[1] === true) { + if (!isRuntimeExtensionDefinition(init)) { + throw new RangeError("plugin definition is not initialized"); + } + setPluginDefinition(this, init); return; } - if (typeof init === "object" && init !== null && "manifest" in init) { - const validated = tryDefineRuntimeExtension(init); - if (isErr(validated)) { - throw new RangeError(validated.error.message); - } - this.definition = validated.value; + if (init instanceof Plugin) { + setPluginDefinition(this, pluginDefinition(init)); return; } @@ -1362,35 +1380,24 @@ export class Plugin { throw new RangeError(definition.error.message); } - this.definition = definition.value; + setPluginDefinition(this, definition.value); } - static create( - init: Plugin | PluginInit | RuntimeExtensionDefinition, - ): Result { + static create(init: Plugin | PluginInit): Result { if (init instanceof Plugin) { return ok(init); } - if (typeof init === "object" && init !== null && "manifest" in init) { - const validated = tryDefineRuntimeExtension(init); - return isErr(validated) ? validated : ok(new Plugin(validated.value)); - } - const definition = createPluginDefinition(init); - return isErr(definition) ? definition : ok(new Plugin(definition.value)); + return isErr(definition) ? definition : ok(new Plugin(definition.value, true)); } get name(): ExtensionName { - return this.definition.manifest.extensionName; - } - - get manifest(): RuntimeExtensionWorkerManifest { - return this.definition.manifest; + return pluginDefinition(this).manifest.extensionName; } - toDefinition(): RuntimeExtensionDefinition { - return this.definition; + get manifest(): ExtensionManifest { + return pluginDefinition(this).manifest.manifest; } } @@ -1511,7 +1518,7 @@ export class App { return Promise.resolve(plugin); } - return runRuntimeExtensionWorkerStdio(plugin.value.toDefinition()); + return runRuntimeExtensionWorkerStdio(pluginDefinition(plugin.value)); } run(options: AppRunOptions = {}): Promise> { @@ -1526,7 +1533,10 @@ export class App { } const internalWorkerPluginName = process.env[internalPluginWorkerEnvVarName]; - if (internalWorkerPluginName !== undefined) { + if ( + internalWorkerPluginName !== undefined && + process.env[internalPluginWorkerModeEnvVarName] === internalPluginWorkerModeEnvValue + ) { return this.#runInternalPluginWorker(internalWorkerPluginName); } diff --git a/sdks/typescript/src/package-exports.test.ts b/sdks/typescript/src/package-exports.test.ts index 6dcb5b7e..4ebb4763 100644 --- a/sdks/typescript/src/package-exports.test.ts +++ b/sdks/typescript/src/package-exports.test.ts @@ -14,7 +14,6 @@ test("package exports resolve the documented public entry points", async () => { const derivedState = await importPackageEntry("@sof/sdk/runtime/derived-state"); const deliveryProfile = await importPackageEntry("@sof/sdk/runtime/delivery-profile"); const extension = await importPackageEntry("@sof/sdk/runtime/extension"); - const extensionStdio = await importPackageEntry("@sof/sdk/runtime/extension-stdio"); assert.equal((root as { App: unknown }).App, (app as { App: unknown }).App); assert.equal((root as { Plugin: unknown }).Plugin, (app as { Plugin: unknown }).Plugin); @@ -30,14 +29,6 @@ test("package exports resolve the documented public entry points", async () => { (root as { observerRuntimeConfig: unknown }).observerRuntimeConfig, (config as { observerRuntimeConfig: unknown }).observerRuntimeConfig, ); - assert.equal( - (root as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, - (config as { tryObserverRuntimeConfig: unknown }).tryObserverRuntimeConfig, - ); - assert.equal( - (root as { tryCreateRuntimeConfig: unknown }).tryCreateRuntimeConfig, - (config as { tryCreateRuntimeConfig: unknown }).tryCreateRuntimeConfig, - ); assert.equal( (runtime as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, (config as { ObserverRuntimeConfig: unknown }).ObserverRuntimeConfig, @@ -55,15 +46,21 @@ test("package exports resolve the documented public entry points", async () => { (deliveryProfile as { RuntimeDeliveryProfile: unknown }).RuntimeDeliveryProfile, ); assert.equal( - (runtime as { createRuntimeExtensionWorkerManifest: unknown }) - .createRuntimeExtensionWorkerManifest, - (extension as { createRuntimeExtensionWorkerManifest: unknown }) - .createRuntimeExtensionWorkerManifest, + (runtime as { runtimeExtensionAck: unknown }).runtimeExtensionAck, + (extension as { runtimeExtensionAck: unknown }).runtimeExtensionAck, + ); + assert.equal("tryCreateRuntimeConfig" in (root as Record), false); + assert.equal("tryObserverRuntimeConfig" in (root as Record), false); + assert.equal("tryCreateRuntimeConfig" in (runtime as Record), false); + assert.equal("createRuntimeExtensionWorkerManifest" in (root as Record), false); + assert.equal( + "createRuntimeExtensionWorkerManifest" in (runtime as Record), + false, ); assert.equal( - typeof (extensionStdio as { runRuntimeExtensionWorkerStdio: unknown }) - .runRuntimeExtensionWorkerStdio, - "function", + "createRuntimeExtensionWorkerManifest" in (extension as Record), + false, ); assert.equal("runRuntimeExtensionWorkerStdio" in (root as Record), false); + await assert.rejects(() => importPackageEntry("@sof/sdk/runtime/extension-stdio")); }); diff --git a/sdks/typescript/src/runtime.ts b/sdks/typescript/src/runtime.ts index be82788c..d75c7d7e 100644 --- a/sdks/typescript/src/runtime.ts +++ b/sdks/typescript/src/runtime.ts @@ -1,5 +1,87 @@ -export * from "./runtime/derived-state.js"; -export * from "./runtime/runtime-config.js"; -export * from "./runtime/runtime-delivery-profile.js"; -export * from "./runtime/runtime-extension.js"; -export * from "./runtime/runtime-policy.js"; +export { + defaultDerivedStateCheckpointIntervalMs, + defaultDerivedStateRecoveryIntervalMs, + defaultDerivedStateReplayBackend, + defaultDerivedStateReplayDirectory, + defaultDerivedStateReplayDurability, + defaultDerivedStateReplayMaxEnvelopes, + defaultDerivedStateReplayMaxSessions, + type DerivedStateReplayConfigInput, + type DerivedStateReplayConfigInit, + type DerivedStateReplayDirectory, + DerivedStateReplayBackend, + derivedStateReplayBackendAllowedValues, + derivedStateReplayBackendEnvValues, + derivedStateReplayBackendEnvVarName, + DerivedStateReplayConfig, + DerivedStateReplayDurability, + derivedStateReplayDurabilityAllowedValues, + derivedStateReplayDurabilityEnvValues, + derivedStateReplayDurabilityEnvVarName, + derivedStateCheckpointIntervalEnvVarName, + derivedStateRecoveryIntervalEnvVarName, + derivedStateReplayDirEnvVarName, + derivedStateReplayMaxEnvelopesEnvVarName, + derivedStateReplayMaxSessionsEnvVarName, + DerivedStateRuntimeConfig, + type DerivedStateRuntimeConfigInput, + type DerivedStateRuntimeConfigInit, + type DerivedStateValidationError, + derivedStateReplayBackendToEnvValue, + derivedStateReplayConfig, + derivedStateReplayDirectory, + derivedStateReplayDurabilityToEnvValue, + derivedStateRuntimeConfig, + parseDerivedStateReplayBackend, + parseDerivedStateReplayDirectory, + parseDerivedStateReplayDurability, +} from "./runtime/derived-state.js"; +export { + type ObserverRuntimeConfigInput, + type ObserverRuntimeConfigInit, + type ObserverRuntimeEnvironmentOptions, + type ObserverRuntimeEnvironmentVariable, + type ObserverRuntimeProfileInit, + type ObserverRuntimeValidationError, + ObserverRuntimeConfig, + createRuntimeConfig, + createRuntimeConfigForProfile, + observerRuntimeConfig, + observerRuntimeConfigForProfile, + parseRuntimeConfig, + serializeRuntimeConfig, + serializeRuntimeConfigRecord, +} from "./runtime/runtime-config.js"; +export { + defaultRuntimeDeliveryProfile, + RuntimeDeliveryProfile, + runtimeDeliveryProfileAllowedValues, + runtimeDeliveryProfileEnvValues, + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileEnvDefaults, + runtimeDeliveryProfileToEnvValue, + parseRuntimeDeliveryProfile, +} from "./runtime/runtime-delivery-profile.js"; +export * from "./runtime/extension.js"; +export { + defaultProviderStreamAllowEof, + defaultProviderStreamCapabilityPolicy, + defaultShredTrustMode, + ProviderStreamCapabilityPolicy, + providerStreamAllowEofEnvVarName, + providerStreamCapabilityPolicyAllowedValues, + providerStreamCapabilityPolicyEnvValues, + providerStreamCapabilityPolicyEnvVarName, + ShredTrustMode, + parseProviderStreamCapabilityPolicy, + parseRuntimeBoolean, + parseShredTrustMode, + runtimeBooleanAllowedValues, + runtimeBooleanEnvValues, + providerStreamCapabilityPolicyToEnvValue, + runtimeBooleanToEnvValue, + shredTrustModeAllowedValues, + shredTrustModeEnvValues, + shredTrustModeEnvVarName, + shredTrustModeToEnvValue, +} from "./runtime/runtime-policy.js"; diff --git a/sdks/typescript/src/runtime/app-internal.ts b/sdks/typescript/src/runtime/app-internal.ts new file mode 100644 index 00000000..20855241 --- /dev/null +++ b/sdks/typescript/src/runtime/app-internal.ts @@ -0,0 +1,8 @@ +export { tryCreateRuntimeConfig } from "./runtime-config.js"; +export { + type RuntimeExtensionDefinition, + type RuntimeExtensionWorkerManifest, + tryCreateRuntimeExtensionWorkerManifest, + tryDefineRuntimeExtension, +} from "./runtime-extension.js"; +export { runRuntimeExtensionWorkerStdio } from "./runtime-extension-stdio.js"; diff --git a/sdks/typescript/src/runtime/extension.ts b/sdks/typescript/src/runtime/extension.ts new file mode 100644 index 00000000..c5c9b0b1 --- /dev/null +++ b/sdks/typescript/src/runtime/extension.ts @@ -0,0 +1,54 @@ +export { + type ExtensionContext, + type ExtensionManifest, + type ExtensionName, + type ExtensionResourceId, + type ExtensionResourceSpec, + ExtensionCapability, + ExtensionResourceKind, + ExtensionStreamVisibilityTag, + type ExtensionStreamVisibility, + type PacketSubscription, + type RuntimeExtensionAck, + type RuntimeExtensionError, + RuntimeExtensionErrorKind, + type RuntimeJsonPrimitive, + type RuntimeJsonValue, + type RuntimePacketEvent, + RuntimePacketEventClass, + type RuntimePacketSource, + RuntimePacketSourceKind, + RuntimePacketTransport, + RuntimeProviderCommitmentStatus, + type RuntimeProviderAccountUpdateEvent, + type RuntimeProviderBlockMetaEvent, + type RuntimeProviderEvent, + RuntimeProviderEventKind, + type RuntimeProviderRecentBlockhashEvent, + type RuntimeProviderSlotStatusEvent, + RuntimeProviderSlotStatus, + type RuntimeProviderSource, + type RuntimeProviderTransactionEvent, + type RuntimeProviderTransactionLogEvent, + type RuntimeProviderTransactionStatusEvent, + RuntimeProviderTransactionKind, + RuntimeWebSocketFrameType, + type SharedStreamTag, + type SocketAddress, + type TcpConnectorResourceSpec, + type TcpListenerResourceSpec, + type UdpListenerResourceSpec, + type WebSocketConnectorResourceSpec, + type WebSocketUrl, + createRuntimePacketEvent, + extensionName, + extensionResourceId, + packetSubscriptionMatches, + privateExtensionStream, + runtimeExtensionAck, + sharedExtensionStream, + socketAddress, + udpListenerResource, + webSocketConnectorResource, + webSocketUrl, +} from "./runtime-extension.js"; diff --git a/sdks/typescript/src/runtime/runtime-config.test.ts b/sdks/typescript/src/runtime/runtime-config.test.ts index 931798fb..15fed1ec 100644 --- a/sdks/typescript/src/runtime/runtime-config.test.ts +++ b/sdks/typescript/src/runtime/runtime-config.test.ts @@ -5,77 +5,83 @@ import { environmentVariable, environmentVariablesToRecord } from "../environmen import { ValidationErrorKind } from "../errors.js"; import { isErr, isOk, ResultTag } from "../result.js"; import { - createRuntimeConfig, - createRuntimeConfigForProfile, defaultDerivedStateReplayDirectory, - derivedStateCheckpointIntervalEnvVarName, DerivedStateReplayBackend, + DerivedStateReplayConfig, + DerivedStateReplayDurability, + DerivedStateRuntimeConfig, + derivedStateCheckpointIntervalEnvVarName, derivedStateReplayBackendAllowedValues, derivedStateReplayBackendEnvValues, derivedStateReplayBackendEnvVarName, derivedStateReplayBackendToEnvValue, - parseDerivedStateReplayDirectory, + derivedStateReplayConfig, derivedStateReplayDirectory, derivedStateReplayDirEnvVarName, - derivedStateRecoveryIntervalEnvVarName, - DerivedStateReplayConfig, - DerivedStateReplayDurability, derivedStateReplayDurabilityAllowedValues, derivedStateReplayDurabilityEnvValues, derivedStateReplayDurabilityEnvVarName, derivedStateReplayDurabilityToEnvValue, derivedStateReplayMaxEnvelopesEnvVarName, derivedStateReplayMaxSessionsEnvVarName, - DerivedStateRuntimeConfig, - derivedStateReplayConfig, + derivedStateRecoveryIntervalEnvVarName, derivedStateRuntimeConfig, nonNegativeIntegerToEnvValue, - observerRuntimeConfig, - observerRuntimeConfigForProfile, parseDerivedStateReplayBackend, + parseDerivedStateReplayDirectory, parseDerivedStateReplayDurability, parseNonNegativeInteger, - parseRuntimeConfig, - ObserverRuntimeConfig, - ProviderStreamCapabilityPolicy, - providerStreamAllowEofEnvVarName, - providerStreamCapabilityPolicyAllowedValues, - providerStreamCapabilityPolicyEnvValues, - providerStreamCapabilityPolicyEnvVarName, - providerStreamCapabilityPolicyToEnvValue, - parseProviderStreamCapabilityPolicy, - parseRuntimeBoolean, - parseShredTrustMode, - RuntimeDeliveryProfile, tryDerivedStateReplayBackendToEnvValue, tryDerivedStateReplayDurabilityToEnvValue, + tryNonNegativeIntegerToEnvValue, +} from "./derived-state.js"; +import { + ObserverRuntimeConfig, + createRuntimeConfig, + createRuntimeConfigForProfile, + observerRuntimeConfig, + observerRuntimeConfigForProfile, + parseRuntimeConfig, + serializeRuntimeConfig, + serializeRuntimeConfigRecord, tryCreateRuntimeConfig, tryCreateRuntimeConfigForProfile, - tryNonNegativeIntegerToEnvValue, tryObserverRuntimeConfig, tryObserverRuntimeConfigForProfile, - tryProviderStreamCapabilityPolicyToEnvValue, - tryRuntimeDeliveryProfileEnvDefaults, - tryRuntimeDeliveryProfileToEnvValue, - tryShredTrustModeToEnvValue, trySerializeRuntimeConfig, trySerializeRuntimeConfigRecord, +} from "./runtime-config.js"; +import { + RuntimeDeliveryProfile, + parseRuntimeDeliveryProfile, + runtimeDeliveryProfileAllowedValues, runtimeDeliveryProfileEnvDefaults, + runtimeDeliveryProfileEnvValues, + runtimeDeliveryProfileEnvVarName, + runtimeDeliveryProfileToEnvValue, + tryRuntimeDeliveryProfileEnvDefaults, + tryRuntimeDeliveryProfileToEnvValue, +} from "./runtime-delivery-profile.js"; +import { + ProviderStreamCapabilityPolicy, + ShredTrustMode, + parseProviderStreamCapabilityPolicy, + parseRuntimeBoolean, + parseShredTrustMode, + providerStreamAllowEofEnvVarName, + providerStreamCapabilityPolicyAllowedValues, + providerStreamCapabilityPolicyEnvValues, + providerStreamCapabilityPolicyEnvVarName, + providerStreamCapabilityPolicyToEnvValue, + tryProviderStreamCapabilityPolicyToEnvValue, + tryShredTrustModeToEnvValue, runtimeBooleanAllowedValues, runtimeBooleanEnvValues, - parseRuntimeDeliveryProfile, - serializeRuntimeConfig, - serializeRuntimeConfigRecord, shredTrustModeAllowedValues, shredTrustModeEnvValues, shredTrustModeEnvVarName, shredTrustModeToEnvValue, - ShredTrustMode, - runtimeDeliveryProfileAllowedValues, - runtimeDeliveryProfileEnvValues, - runtimeDeliveryProfileEnvVarName, - runtimeDeliveryProfileToEnvValue, -} from "../runtime.js"; +} from "./runtime-policy.js"; test("result and runtime policy discriminants stay stable", () => { assert.equal(ResultTag.Ok, 1); diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts index 6316c1ca..03bb47fa 100644 --- a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts @@ -13,7 +13,7 @@ import { socketAddress, tryCreateRuntimeExtensionWorkerManifest, tryDefineRuntimeExtension, -} from "../runtime.js"; +} from "./runtime-extension.js"; import { runRuntimeExtensionWorkerStdio, serializeRuntimeExtensionWorkerHostMessageWire, diff --git a/sdks/typescript/src/runtime/runtime-extension.test.ts b/sdks/typescript/src/runtime/runtime-extension.test.ts index 4a56f5e6..cf470cc0 100644 --- a/sdks/typescript/src/runtime/runtime-extension.test.ts +++ b/sdks/typescript/src/runtime/runtime-extension.test.ts @@ -25,7 +25,7 @@ import { tryCreateRuntimeExtensionWorkerManifest, tryCreateRuntimeExtensionWorkerRuntime, udpListenerResource, -} from "../runtime.js"; +} from "./runtime-extension.js"; test("runtime extension manifest creation validates stable typed metadata", () => { const parsedExtensionName = extensionName("demo-extension"); diff --git a/sdks/typescript/tsconfig.examples.json b/sdks/typescript/tsconfig.examples.json index bd63d748..cb74c95d 100644 --- a/sdks/typescript/tsconfig.examples.json +++ b/sdks/typescript/tsconfig.examples.json @@ -7,6 +7,10 @@ "sourceMap": false }, "include": [ - "examples/**/*.ts" + "examples/app-config.ts", + "examples/app-entrypoint.ts", + "examples/runtime-config-balanced.ts", + "examples/runtime-config-parse.ts", + "examples/runtime-extension-manifest.ts" ] } diff --git a/sdks/typescript/tsdown.config.ts b/sdks/typescript/tsdown.config.ts index cea74e96..47c79ef0 100644 --- a/sdks/typescript/tsdown.config.ts +++ b/sdks/typescript/tsdown.config.ts @@ -14,8 +14,7 @@ export default defineConfig({ "runtime/policy": "src/runtime/runtime-policy.ts", "runtime/derived-state": "src/runtime/derived-state.ts", "runtime/delivery-profile": "src/runtime/runtime-delivery-profile.ts", - "runtime/extension": "src/runtime/runtime-extension.ts", - "runtime/extension-stdio": "src/runtime/runtime-extension-stdio.ts", + "runtime/extension": "src/runtime/extension.ts", }, format: ["esm"], minify: true, From 1cc501a67133401d7901471486f16d092745445e Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 23:17:58 +0200 Subject: [PATCH 17/25] feat(ts-sdk): finalize runtime packaging and release flow --- .github/workflows/ci.yml | 7 +- .github/workflows/release-typescript-sdk.yml | 224 +++++++++ .../src/bin/sof_ts_runtime_host.rs | 402 ++++++++++++++-- .../pnpm-lock.yaml => pnpm-lock.yaml | 33 +- pnpm-workspace.yaml | 3 + sdks/typescript/README.md | 22 +- sdks/typescript/RELEASING.md | 56 +++ .../native/darwin-arm64/package.json | 31 ++ .../typescript/native/darwin-x64/package.json | 31 ++ .../native/linux-arm64/package.json | 31 ++ sdks/typescript/native/linux-x64/package.json | 31 ++ .../native/win32-arm64/package.json | 31 ++ sdks/typescript/native/win32-x64/package.json | 31 ++ sdks/typescript/package.json | 16 +- sdks/typescript/scripts/build-native-host.mjs | 59 ++- .../scripts/check-release-metadata.mjs | 101 ++++ sdks/typescript/src/app.test.ts | 114 ++++- sdks/typescript/src/app.ts | 442 +++++++++++++++++- .../runtime/runtime-extension-stdio.test.ts | 93 ++++ .../src/runtime/runtime-extension-stdio.ts | 87 +++- 20 files changed, 1763 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/release-typescript-sdk.yml rename sdks/typescript/pnpm-lock.yaml => pnpm-lock.yaml (97%) create mode 100644 pnpm-workspace.yaml create mode 100644 sdks/typescript/RELEASING.md create mode 100644 sdks/typescript/native/darwin-arm64/package.json create mode 100644 sdks/typescript/native/darwin-x64/package.json create mode 100644 sdks/typescript/native/linux-arm64/package.json create mode 100644 sdks/typescript/native/linux-x64/package.json create mode 100644 sdks/typescript/native/win32-arm64/package.json create mode 100644 sdks/typescript/native/win32-x64/package.json create mode 100644 sdks/typescript/scripts/check-release-metadata.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daec3585..e106f071 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,6 @@ jobs: typescript-sdk: runs-on: ubuntu-latest timeout-minutes: 20 - defaults: - run: - working-directory: sdks/typescript steps: - name: Checkout uses: actions/checkout@v4 @@ -45,13 +42,13 @@ jobs: with: node-version: "22" cache: pnpm - cache-dependency-path: sdks/typescript/pnpm-lock.yaml + cache-dependency-path: pnpm-lock.yaml - name: Install SDK dependencies run: pnpm install --frozen-lockfile - name: Run TypeScript SDK checks - run: pnpm run check + run: pnpm --dir sdks/typescript run check fuzz-smoke: runs-on: ubuntu-latest diff --git a/.github/workflows/release-typescript-sdk.yml b/.github/workflows/release-typescript-sdk.yml new file mode 100644 index 00000000..24d8f145 --- /dev/null +++ b/.github/workflows/release-typescript-sdk.yml @@ -0,0 +1,224 @@ +name: Release TypeScript SDK + +on: + push: + tags: + - "ts-sdk-v*.*.*" + workflow_dispatch: + inputs: + publish: + description: "Publish to npm instead of packing artifacts only" + required: false + default: false + type: boolean + +permissions: + contents: read + id-token: write + +concurrency: + group: release-typescript-sdk-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: "22" + SDK_DIRECTORY: sdks/typescript + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + sdk_version: ${{ steps.sdk-version.outputs.value }} + publish_release: ${{ steps.release-mode.outputs.value }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org + + - name: Decide release mode + id: release-mode + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + echo "value=true" >> "${GITHUB_OUTPUT}" + elif [[ "${{ inputs.publish }}" == "true" ]]; then + echo "value=true" >> "${GITHUB_OUTPUT}" + else + echo "value=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Read SDK version + id: sdk-version + shell: bash + run: | + set -euo pipefail + value="$(node -p "require('./${SDK_DIRECTORY}/package.json').version")" + echo "value=${value}" >> "${GITHUB_OUTPUT}" + + - name: Verify tagged commit is on main + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "FETCH_HEAD"; then + echo "Tag ${GITHUB_REF_NAME} points to commit ${GITHUB_SHA} which is not on origin/main" + exit 1 + fi + + - name: Verify tag matches SDK version + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -euo pipefail + expected_tag="ts-sdk-v${{ steps.sdk-version.outputs.value }}" + if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then + echo "Expected tag ${expected_tag}, got ${GITHUB_REF_NAME}" + exit 1 + fi + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Run TypeScript SDK checks + run: pnpm --dir "${SDK_DIRECTORY}" run check + + publish-native: + needs: verify + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - package_dir: sdks/typescript/native/linux-x64 + package_name: "@sof/sdk-native-linux-x64" + runner: ubuntu-24.04 + - package_dir: sdks/typescript/native/linux-arm64 + package_name: "@sof/sdk-native-linux-arm64" + runner: ubuntu-24.04-arm + - package_dir: sdks/typescript/native/darwin-x64 + package_name: "@sof/sdk-native-darwin-x64" + runner: macos-15-intel + - package_dir: sdks/typescript/native/darwin-arm64 + package_name: "@sof/sdk-native-darwin-arm64" + runner: macos-15 + - package_dir: sdks/typescript/native/win32-x64 + package_name: "@sof/sdk-native-win32-x64" + runner: windows-2025 + - package_dir: sdks/typescript/native/win32-arm64 + package_name: "@sof/sdk-native-win32-arm64" + runner: windows-11-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust CI toolchain and tools + uses: ./.github/actions/rust-ci-setup + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Publish native runtime package + if: needs.verify.outputs.publish_release == 'true' + working-directory: ${{ matrix.package_dir }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: pnpm publish --access public --no-git-checks + + - name: Pack native runtime package + if: needs.verify.outputs.publish_release != 'true' + working-directory: ${{ matrix.package_dir }} + shell: bash + run: pnpm pack --pack-destination ../../../dist/npm + + publish-sdk: + needs: + - verify + - publish-native + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Wait for native packages to index on npm + if: needs.verify.outputs.publish_release == 'true' + shell: bash + run: | + set -euo pipefail + version="${{ needs.verify.outputs.sdk_version }}" + packages="$(node -p "JSON.stringify(Object.keys(require('./${SDK_DIRECTORY}/package.json').optionalDependencies).sort())")" + + wait_until_indexed() { + local pkg="$1" + local attempt + for attempt in $(seq 1 30); do + if npm view "${pkg}@${version}" version >/dev/null 2>&1; then + echo ">>> ${pkg}@${version} is visible on npm" + return 0 + fi + echo ">>> Waiting for ${pkg}@${version} to appear on npm (${attempt}/30)..." + sleep 10 + done + echo ">>> ${pkg}@${version} did not appear on npm in time" + return 1 + } + + node -e "for (const pkg of ${packages}) console.log(pkg)" | while IFS= read -r package_name; do + wait_until_indexed "${package_name}" + done + + - name: Publish TypeScript SDK + if: needs.verify.outputs.publish_release == 'true' + working-directory: ${{ env.SDK_DIRECTORY }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: pnpm publish --access public --no-git-checks + + - name: Pack TypeScript SDK + if: needs.verify.outputs.publish_release != 'true' + working-directory: ${{ env.SDK_DIRECTORY }} + shell: bash + run: pnpm pack --pack-destination ../dist/npm diff --git a/crates/sof-observer/src/bin/sof_ts_runtime_host.rs b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs index af1b9c93..a46ac719 100644 --- a/crates/sof-observer/src/bin/sof_ts_runtime_host.rs +++ b/crates/sof-observer/src/bin/sof_ts_runtime_host.rs @@ -20,7 +20,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] use sof::provider_stream::websocket::{ - WebsocketTransactionCommitment, WebsocketTransactionConfig, spawn_websocket_source, + WebsocketLogsConfig, WebsocketLogsFilter, WebsocketPrimaryStream, + WebsocketTransactionCommitment, WebsocketTransactionConfig, spawn_websocket_logs_source, + spawn_websocket_source, }; #[cfg(feature = "provider-grpc")] use sof::provider_stream::yellowstone::{ @@ -93,6 +95,27 @@ const GRPC_STREAM_BLOCK_META: u8 = 4; #[cfg(feature = "provider-grpc")] /// Wire tag for Yellowstone slot stream selection. const GRPC_STREAM_SLOTS: u8 = 5; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket `transactionSubscribe`. +const WEBSOCKET_STREAM_TRANSACTIONS: u8 = 1; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket `logsSubscribe`. +const WEBSOCKET_STREAM_LOGS: u8 = 2; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket `accountSubscribe`. +const WEBSOCKET_STREAM_ACCOUNT: u8 = 3; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket `programSubscribe`. +const WEBSOCKET_STREAM_PROGRAM: u8 = 4; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket logs `all` filter. +const WEBSOCKET_LOGS_FILTER_ALL: u8 = 1; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket logs `allWithVotes` filter. +const WEBSOCKET_LOGS_FILTER_ALL_WITH_VOTES: u8 = 2; +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Wire tag for websocket logs `mentions` filter. +const WEBSOCKET_LOGS_FILTER_MENTIONS: u8 = 3; #[cfg(feature = "provider-grpc")] /// Provider-stream channel capacity used by the native host. const PROVIDER_STREAM_QUEUE_CAPACITY: usize = 4096; @@ -232,6 +255,17 @@ struct IngressConfig { priority: Option, /// Websocket URL for SDK-side validation errors. url: Option, + /// Account pubkey for `accountSubscribe`. + account: Option, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + /// Program pubkey for `programSubscribe`. + program_id: Option, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + /// Logs filter selector for `logsSubscribe`. + logs_filter: Option, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + /// Mention pubkey for websocket logs mention filters. + mentions: Option, #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] /// Legacy custom JSON-RPC subscribe messages rejected by the native host. requests: Option>, @@ -278,6 +312,8 @@ struct PluginWorkerConfig { args: Vec, /// Environment variables passed to the worker. environment: HashMap, + /// Whether the worker expects provider-event callbacks. + provider_events: bool, } /// Worker manifest envelope received from the TypeScript SDK. @@ -371,6 +407,8 @@ struct TypeScriptRuntimeExtension { struct TypeScriptObserverPlugin { /// Shared worker bridge used for lifecycle and provider-event delivery. bridge: Arc, + /// Hook contract derived from the active provider ingress modes. + config: PluginConfig, } /// Shared worker bridge for one TypeScript plugin process. @@ -500,7 +538,7 @@ async fn run_config(config: RuntimeHostConfig) -> Result<(), HostError> { drop(provider_stream_tx); runtime = runtime - .with_plugin_host(plugin_host_from_worker_bridges(&worker_bridges)) + .with_plugin_host(plugin_host_from_worker_bridges(&worker_bridges, &modes)) .with_provider_stream_ingress( provider_stream_mode(modes.as_slice()), provider_stream_rx, @@ -571,11 +609,19 @@ fn build_worker_bridges( #[cfg(feature = "provider-grpc")] /// Builds a plugin host that reuses the shared TypeScript worker bridges. -fn plugin_host_from_worker_bridges(bridges: &[Arc]) -> PluginHost { +fn plugin_host_from_worker_bridges( + bridges: &[Arc], + modes: &[ProviderStreamMode], +) -> PluginHost { let mut plugin_host_builder = PluginHost::builder(); for bridge in bridges { + if !bridge.config.provider_events { + continue; + } + plugin_host_builder = plugin_host_builder.add_plugin(TypeScriptObserverPlugin { bridge: Arc::clone(bridge), + config: provider_plugin_config(modes), }); } @@ -1000,46 +1046,126 @@ async fn spawn_websocket_provider_ingress( ingress.name )) })?; - let mut config = - WebsocketTransactionConfig::new(endpoint).with_source_instance(ingress.name.clone()); - config = apply_websocket_source_policy(config, ingress)?; - config = apply_websocket_fan_in(config, fan_in)?; - if let Some(commitment) = ingress.commitment { - config = config.with_commitment(websocket_commitment_from_wire(commitment)?); - } - if let Some(vote) = ingress.vote { - config = config.with_vote(vote); - } - if let Some(failed) = ingress.failed { - config = config.with_failed(failed); - } - if let Some(signature) = non_empty_optional(ingress.signature.as_deref()) { - config = config.with_signature(parse_signature(signature, "ingress.signature")?); - } - config = config - .with_account_include(parse_pubkeys( - ingress.account_include.as_deref().unwrap_or(&[]), - "ingress.accountInclude", - )?) - .with_account_exclude(parse_pubkeys( - ingress.account_exclude.as_deref().unwrap_or(&[]), - "ingress.accountExclude", - )?) - .with_account_required(parse_pubkeys( - ingress.account_required.as_deref().unwrap_or(&[]), - "ingress.accountRequired", - )?); + match ingress.stream.unwrap_or(WEBSOCKET_STREAM_TRANSACTIONS) { + WEBSOCKET_STREAM_TRANSACTIONS => { + let mut config = + WebsocketTransactionConfig::new(endpoint).with_source_instance(ingress.name.clone()); + config = apply_websocket_source_policy(config, ingress)?; + config = apply_websocket_fan_in(config, fan_in)?; + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(websocket_commitment_from_wire(commitment)?); + } + if let Some(vote) = ingress.vote { + config = config.with_vote(vote); + } + if let Some(failed) = ingress.failed { + config = config.with_failed(failed); + } + if let Some(signature) = non_empty_optional(ingress.signature.as_deref()) { + config = config.with_signature(parse_signature(signature, "ingress.signature")?); + } + config = config + .with_account_include(parse_pubkeys( + ingress.account_include.as_deref().unwrap_or(&[]), + "ingress.accountInclude", + )?) + .with_account_exclude(parse_pubkeys( + ingress.account_exclude.as_deref().unwrap_or(&[]), + "ingress.accountExclude", + )?) + .with_account_required(parse_pubkeys( + ingress.account_required.as_deref().unwrap_or(&[]), + "ingress.accountRequired", + )?); + + let mode = config.runtime_mode(); + let handle = spawn_websocket_source(&config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) + } + WEBSOCKET_STREAM_LOGS => { + let mut config = + WebsocketLogsConfig::new(endpoint).with_source_instance(ingress.name.clone()); + config = apply_websocket_logs_source_policy(config, ingress)?; + config = apply_websocket_logs_fan_in(config, fan_in)?; + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(websocket_commitment_from_wire(commitment)?); + } + config = config.with_filter(websocket_logs_filter_from_wire(ingress)?); + + let mode = config.runtime_mode(); + let handle = spawn_websocket_logs_source(&config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) + } + WEBSOCKET_STREAM_ACCOUNT => { + let account = parse_pubkey( + ingress.account.as_deref(), + "ingress.account", + "websocket account stream", + )?; + let mut config = WebsocketTransactionConfig::new(endpoint) + .with_source_instance(ingress.name.clone()) + .with_stream(WebsocketPrimaryStream::Account(account)); + config = apply_websocket_source_policy(config, ingress)?; + config = apply_websocket_fan_in(config, fan_in)?; + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(websocket_commitment_from_wire(commitment)?); + } - let mode = config.runtime_mode(); - let handle = spawn_websocket_source(&config, sender) - .await - .map_err(|error| HostError::InvalidConfig(error.to_string()))?; - let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); - Ok(ProviderSourceHandle { - mode, - abort_handle, - join_guard, - }) + let mode = config.runtime_mode(); + let handle = spawn_websocket_source(&config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) + } + WEBSOCKET_STREAM_PROGRAM => { + let program_id = parse_pubkey( + ingress.program_id.as_deref(), + "ingress.programId", + "websocket program stream", + )?; + let mut config = WebsocketTransactionConfig::new(endpoint) + .with_source_instance(ingress.name.clone()) + .with_stream(WebsocketPrimaryStream::Program(program_id)); + config = apply_websocket_source_policy(config, ingress)?; + config = apply_websocket_fan_in(config, fan_in)?; + if let Some(commitment) = ingress.commitment { + config = config.with_commitment(websocket_commitment_from_wire(commitment)?); + } + + let mode = config.runtime_mode(); + let handle = spawn_websocket_source(&config, sender) + .await + .map_err(|error| HostError::InvalidConfig(error.to_string()))?; + let (abort_handle, join_guard) = spawn_provider_source_join_guard(&ingress.name, handle); + Ok(ProviderSourceHandle { + mode, + abort_handle, + join_guard, + }) + } + other => Err(HostError::InvalidConfig(format!( + "unsupported websocket stream kind {other}" + ))), + } } #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] @@ -1069,6 +1195,33 @@ fn apply_websocket_fan_in( Ok(config.with_source_arbitration(provider_source_arbitration(fan_in)?)) } +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Applies per-source readiness, role, and priority to one websocket logs config. +fn apply_websocket_logs_source_policy( + mut config: WebsocketLogsConfig, + ingress: &IngressConfig, +) -> Result { + if let Some(readiness) = ingress.readiness { + config = config.with_readiness(provider_readiness_from_wire(readiness)?); + } + if let Some(role) = ingress.role { + config = config.with_source_role(provider_role_from_wire(role)?); + } + if let Some(priority) = ingress.priority { + config = config.with_source_priority(priority); + } + Ok(config) +} + +#[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] +/// Applies fan-in arbitration to one websocket logs config. +fn apply_websocket_logs_fan_in( + config: WebsocketLogsConfig, + fan_in: Option<&FanInConfig>, +) -> Result { + Ok(config.with_source_arbitration(provider_source_arbitration(fan_in)?)) +} + #[cfg(feature = "provider-grpc")] /// Applies per-source readiness, role, and priority to one Yellowstone config. fn apply_yellowstone_source_policy( @@ -1176,6 +1329,70 @@ fn provider_stream_mode(modes: &[ProviderStreamMode]) -> ProviderStreamMode { } } +#[cfg(feature = "provider-grpc")] +/// Derives one observer hook mask from the active provider ingress modes. +fn provider_plugin_config(modes: &[ProviderStreamMode]) -> PluginConfig { + let mut config = PluginConfig::new(); + for mode in modes { + match mode { + ProviderStreamMode::Generic => { + config = config + .with_transaction() + .with_transaction_status() + .with_account_update() + .with_block_meta() + .with_slot_status() + .with_recent_blockhash(); + config.transaction_log = true; + } + ProviderStreamMode::YellowstoneGrpc => { + config = config.with_transaction().with_recent_blockhash(); + } + ProviderStreamMode::YellowstoneGrpcTransactionStatus => { + config = config.with_transaction_status(); + } + ProviderStreamMode::YellowstoneGrpcAccounts => { + config = config.with_account_update(); + } + ProviderStreamMode::YellowstoneGrpcBlockMeta => { + config = config.with_block_meta(); + } + ProviderStreamMode::YellowstoneGrpcSlots => { + config = config.with_slot_status(); + } + ProviderStreamMode::LaserStream => { + config = config.with_transaction().with_recent_blockhash(); + } + ProviderStreamMode::LaserStreamTransactionStatus => { + config = config.with_transaction_status(); + } + ProviderStreamMode::LaserStreamAccounts => { + config = config.with_account_update(); + } + ProviderStreamMode::LaserStreamBlockMeta => { + config = config.with_block_meta(); + } + ProviderStreamMode::LaserStreamSlots => { + config = config.with_slot_status(); + } + #[cfg(feature = "provider-websocket")] + ProviderStreamMode::WebsocketTransaction => { + config = config.with_transaction().with_recent_blockhash(); + } + #[cfg(feature = "provider-websocket")] + ProviderStreamMode::WebsocketLogs => { + config.transaction_log = true; + } + #[cfg(feature = "provider-websocket")] + ProviderStreamMode::WebsocketAccount | ProviderStreamMode::WebsocketProgram => { + config = config.with_account_update(); + } + } + } + + config +} + #[cfg(feature = "provider-grpc")] /// Maps the wire Yellowstone stream selector into the Rust enum. fn yellowstone_stream_from_wire(value: u8) -> Result { @@ -1216,6 +1433,23 @@ fn websocket_commitment_from_wire(value: u8) -> Result Result { + match ingress.logs_filter.unwrap_or(WEBSOCKET_LOGS_FILTER_ALL) { + WEBSOCKET_LOGS_FILTER_ALL => Ok(WebsocketLogsFilter::All), + WEBSOCKET_LOGS_FILTER_ALL_WITH_VOTES => Ok(WebsocketLogsFilter::AllWithVotes), + WEBSOCKET_LOGS_FILTER_MENTIONS => Ok(WebsocketLogsFilter::Mentions(parse_pubkey( + ingress.mentions.as_deref(), + "ingress.mentions", + "websocket logs mention filter", + )?)), + other => Err(HostError::InvalidConfig(format!( + "unsupported websocket logs filter {other}" + ))), + } +} + #[cfg(feature = "provider-grpc")] /// Trims and filters empty optional string values. fn non_empty_optional(value: Option<&str>) -> Option<&str> { @@ -1230,6 +1464,17 @@ fn parse_signature(value: &str, field: &str) -> Result { }) } +#[cfg(feature = "provider-grpc")] +/// Parses one required pubkey from the wire config. +fn parse_pubkey(value: Option<&str>, field: &str, context: &str) -> Result { + let value = non_empty_optional(value).ok_or_else(|| { + HostError::InvalidConfig(format!("{field} is required for {context}")) + })?; + Pubkey::from_str(value).map_err(|error| { + HostError::InvalidConfig(format!("{field} is not a valid pubkey `{value}`: {error}")) + }) +} + #[cfg(feature = "provider-grpc")] /// Parses one list of pubkeys from the wire config. fn parse_pubkeys(values: &[String], field: &str) -> Result, HostError> { @@ -1292,14 +1537,26 @@ impl ObserverPlugin for TypeScriptObserverPlugin { fn config(&self) -> PluginConfig { PluginConfig { - transaction_log: true, - ..PluginConfig::new() - .with_transaction() - .with_transaction_status() - .with_account_update() - .with_block_meta() - .with_slot_status() - .with_recent_blockhash() + transaction_log: self.config.transaction_log, + raw_packet: self.config.raw_packet, + shred: self.config.shred, + dataset: self.config.dataset, + transaction: self.config.transaction, + transaction_status: self.config.transaction_status, + transaction_commitment: self.config.transaction_commitment, + transaction_dispatch_mode: self.config.transaction_dispatch_mode, + transaction_batch: self.config.transaction_batch, + transaction_batch_dispatch_mode: self.config.transaction_batch_dispatch_mode, + transaction_view_batch: self.config.transaction_view_batch, + transaction_view_batch_dispatch_mode: self.config.transaction_view_batch_dispatch_mode, + account_touch: self.config.account_touch, + account_update: self.config.account_update, + block_meta: self.config.block_meta, + slot_status: self.config.slot_status, + reorg: self.config.reorg, + recent_blockhash: self.config.recent_blockhash, + cluster_topology: self.config.cluster_topology, + leader_schedule: self.config.leader_schedule, } } @@ -2052,6 +2309,13 @@ mod tests { #[cfg(feature = "provider-grpc")] priority: None, url: Some("wss://example.invalid".to_owned()), + account: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + program_id: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + logs_filter: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + mentions: None, #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] requests: Some(vec![]), entrypoints: None, @@ -2100,6 +2364,13 @@ mod tests { #[cfg(feature = "provider-grpc")] priority: None, url: None, + account: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + program_id: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + logs_filter: None, + #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] + mentions: None, #[cfg(all(feature = "provider-grpc", feature = "provider-websocket"))] requests: None, entrypoints: None, @@ -2124,4 +2395,35 @@ mod tests { let result = validate_ingress(&config); assert!(result.is_ok()); } + + #[cfg(feature = "provider-grpc")] + #[test] + fn provider_plugin_config_matches_websocket_logs_mode() { + let config = provider_plugin_config(&[ProviderStreamMode::WebsocketLogs]); + + assert!(config.transaction_log); + assert!(!config.transaction); + assert!(!config.transaction_status); + assert!(!config.account_update); + assert!(!config.block_meta); + assert!(!config.slot_status); + assert!(!config.recent_blockhash); + } + + #[cfg(feature = "provider-grpc")] + #[test] + fn provider_plugin_config_unions_multiple_provider_modes() { + let config = provider_plugin_config(&[ + ProviderStreamMode::YellowstoneGrpcTransactionStatus, + ProviderStreamMode::YellowstoneGrpcAccounts, + ]); + + assert!(!config.transaction); + assert!(config.transaction_status); + assert!(config.account_update); + assert!(!config.block_meta); + assert!(!config.slot_status); + assert!(!config.recent_blockhash); + assert!(!config.transaction_log); + } } diff --git a/sdks/typescript/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 97% rename from sdks/typescript/pnpm-lock.yaml rename to pnpm-lock.yaml index f717109e..9a183b98 100644 --- a/sdks/typescript/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: importers: - .: + sdks/typescript: devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -29,6 +29,37 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + optionalDependencies: + '@sof/sdk-native-darwin-arm64': + specifier: workspace:* + version: link:native/darwin-arm64 + '@sof/sdk-native-darwin-x64': + specifier: workspace:* + version: link:native/darwin-x64 + '@sof/sdk-native-linux-arm64': + specifier: workspace:* + version: link:native/linux-arm64 + '@sof/sdk-native-linux-x64': + specifier: workspace:* + version: link:native/linux-x64 + '@sof/sdk-native-win32-arm64': + specifier: workspace:* + version: link:native/win32-arm64 + '@sof/sdk-native-win32-x64': + specifier: workspace:* + version: link:native/win32-x64 + + sdks/typescript/native/darwin-arm64: {} + + sdks/typescript/native/darwin-x64: {} + + sdks/typescript/native/linux-arm64: {} + + sdks/typescript/native/linux-x64: {} + + sdks/typescript/native/win32-arm64: {} + + sdks/typescript/native/win32-x64: {} packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..e7d8c356 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - sdks/typescript + - sdks/typescript/native/* diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index 48adf8ac..920786f7 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -6,9 +6,19 @@ TypeScript SDK for building apps with a typed `App`, `Plugin`, `runtime`, and `d - Use `pnpm` for this package. - `pnpm run build` produces minified ESM output plus `.d.ts` files. +- `pnpm run build:native` builds the current platform native runtime host package in the workspace. - `pnpm run format:check` verifies Biome formatting. - `pnpm run lint` runs the type-aware `oxlint` profile. - `pnpm run check` runs format, lint, typecheck, tests, examples, and package validation. +- SDK release tags and npm publish order are documented in `RELEASING.md`. + +## Install + +```sh +pnpm add @sof/sdk +``` + +For published releases, npm installs the matching optional native runtime package for the current platform automatically. App authors only import `@sof/sdk` and run Node. ## Mental Model @@ -24,7 +34,7 @@ TypeScript SDK for building apps with a typed `App`, `Plugin`, `runtime`, and `d ## Quick Start ```ts -import { App, IngressKind, Plugin } from "@sof/sdk"; +import { App, IngressKind, Plugin, ok, runtimeExtensionAck } from "@sof/sdk"; const app = new App({ ingress: [ @@ -38,7 +48,8 @@ const app = new App({ new Plugin({ name: "tx-logger", onProviderEvent: (event) => { - console.log(event); + console.error(event); + return ok(runtimeExtensionAck()); }, }), ], @@ -58,7 +69,8 @@ Current executable coverage: - Direct shreds can enable kernel bypass on Linux through a typed `kernelBypass` object. - One `DirectShreds` ingress plus one `Gossip` ingress run together as one raw runtime composition without `fanIn`. - Multi-provider fan-in uses the Rust arbitration model: `EmitAll`, `FirstSeen`, or `FirstSeenThenPromote`. -- The package build includes the native host under `dist/native/-/`; `SOF_SDK_RUNTIME_HOST_BINARY` is only an override for development or custom deployments. +- Published installs resolve the native host from the matching optional platform package such as `@sof/sdk-native-linux-x64`. +- `SOF_SDK_RUNTIME_HOST_BINARY` is only an override for development or custom deployments. - If the host is missing, `app.run()` returns a typed `Result` error instead of throwing. ## Runtime @@ -129,7 +141,7 @@ const app = new App({ name: "provider-logger", onProviderEvent: (event) => { if (event.kind === RuntimeProviderEventKind.TransactionStatus) { - process.stdout.write(`${event.slot} ${event.signature}\n`); + process.stderr.write(`${event.slot} ${event.signature}\n`); } return ok(runtimeExtensionAck()); }, @@ -224,7 +236,7 @@ const plugin = new Plugin({ name: "packet-audit", onStart: () => ok(runtimeExtensionAck()), onProviderEvent: (event) => { - process.stdout.write(`${event.kind}\n`); + process.stderr.write(`${event.kind}\n`); return ok(runtimeExtensionAck()); }, onStop: () => ok(runtimeExtensionAck()), diff --git a/sdks/typescript/RELEASING.md b/sdks/typescript/RELEASING.md new file mode 100644 index 00000000..700502ac --- /dev/null +++ b/sdks/typescript/RELEASING.md @@ -0,0 +1,56 @@ +# Releasing `@sof/sdk` + +The TypeScript SDK publishes as one main package plus six platform-native runtime packages. + +## Release Units + +- `@sof/sdk` +- `@sof/sdk-native-linux-x64` +- `@sof/sdk-native-linux-arm64` +- `@sof/sdk-native-darwin-x64` +- `@sof/sdk-native-darwin-arm64` +- `@sof/sdk-native-win32-x64` +- `@sof/sdk-native-win32-arm64` + +All seven packages must share the same version. + +## Tag Convention + +Use `ts-sdk-vX.Y.Z` tags for npm releases. + +Examples: + +```sh +git tag ts-sdk-v0.1.0 +git push origin ts-sdk-v0.1.0 +``` + +The release workflow rejects tags that do not match `sdks/typescript/package.json`. + +## Publish Order + +The release workflow publishes in this order: + +1. Verify the tagged commit is on `main`. +2. Run `pnpm --dir sdks/typescript run check`. +3. Publish each native runtime package on its matching platform runner. +4. Wait for all native runtime packages to appear on npm. +5. Publish `@sof/sdk`. + +This order matters because `@sof/sdk` depends on the native packages through optional dependencies. + +## Local Verification + +Run this before tagging: + +```sh +pnpm install --frozen-lockfile +pnpm --dir sdks/typescript run check +``` + +For a workflow-only dry run without publishing: + +1. Open the `Release TypeScript SDK` workflow in GitHub Actions. +2. Run it with `publish=false`. + +That path builds and packs the packages without uploading them to npm. diff --git a/sdks/typescript/native/darwin-arm64/package.json b/sdks/typescript/native/darwin-arm64/package.json new file mode 100644 index 00000000..29dfa3ea --- /dev/null +++ b/sdks/typescript/native/darwin-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-darwin-arm64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on macOS arm64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/darwin-arm64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/native/darwin-x64/package.json b/sdks/typescript/native/darwin-x64/package.json new file mode 100644 index 00000000..a1a311da --- /dev/null +++ b/sdks/typescript/native/darwin-x64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-darwin-x64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on macOS x64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/darwin-x64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/native/linux-arm64/package.json b/sdks/typescript/native/linux-arm64/package.json new file mode 100644 index 00000000..9da6a21b --- /dev/null +++ b/sdks/typescript/native/linux-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-linux-arm64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on Linux arm64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/linux-arm64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/native/linux-x64/package.json b/sdks/typescript/native/linux-x64/package.json new file mode 100644 index 00000000..81a241aa --- /dev/null +++ b/sdks/typescript/native/linux-x64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-linux-x64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on Linux x64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/linux-x64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/native/win32-arm64/package.json b/sdks/typescript/native/win32-arm64/package.json new file mode 100644 index 00000000..0f74ba08 --- /dev/null +++ b/sdks/typescript/native/win32-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-win32-arm64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on Windows arm64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/win32-arm64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/native/win32-x64/package.json b/sdks/typescript/native/win32-x64/package.json new file mode 100644 index 00000000..5458861a --- /dev/null +++ b/sdks/typescript/native/win32-x64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sof/sdk-native-win32-x64", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "description": "Native SOF runtime host for @sof/sdk on Windows x64", + "repository": { + "type": "git", + "url": "git+https://github.com/Lythaeon/sof.git", + "directory": "sdks/typescript/native/win32-x64" + }, + "bugs": { + "url": "https://github.com/Lythaeon/sof/issues" + }, + "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", + "publishConfig": { + "access": "public", + "provenance": true + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "vendor" + ], + "scripts": { + "prepack": "node ../../scripts/build-native-host.mjs" + } +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 2aa3dd11..4aee4076 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -15,7 +15,16 @@ }, "homepage": "https://github.com/Lythaeon/sof/tree/main/sdks/typescript", "publishConfig": { - "access": "public" + "access": "public", + "provenance": true + }, + "optionalDependencies": { + "@sof/sdk-native-darwin-arm64": "workspace:*", + "@sof/sdk-native-darwin-x64": "workspace:*", + "@sof/sdk-native-linux-arm64": "workspace:*", + "@sof/sdk-native-linux-x64": "workspace:*", + "@sof/sdk-native-win32-arm64": "workspace:*", + "@sof/sdk-native-win32-x64": "workspace:*" }, "type": "module", "main": "./dist/index.js", @@ -69,10 +78,11 @@ "lint:fix": "oxlint --config .oxlintrc.json --tsconfig tsconfig.lint.json --type-aware --fix --fix-suggestions src tsdown.config.ts && oxlint --config .oxlintrc.json --fix --fix-suggestions examples scripts", "check:examples": "pnpm run build:examples && node dist-examples/runtime-config-balanced.js && node dist-examples/runtime-config-parse.js && node dist-examples/app-entrypoint.js && node dist-examples/app-config.js", "check:package": "publint run --strict --pack pnpm", - "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package", + "check:release-metadata": "node scripts/check-release-metadata.mjs", + "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run check:examples && pnpm run check:package && pnpm run check:release-metadata", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm run build && pnpm run build:test && node --test dist-test/*.test.js dist-test/**/*.test.js", - "prepack": "pnpm run build && pnpm run build:native" + "prepack": "pnpm run build" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/sdks/typescript/scripts/build-native-host.mjs b/sdks/typescript/scripts/build-native-host.mjs index 23267f8e..c2ee4d06 100644 --- a/sdks/typescript/scripts/build-native-host.mjs +++ b/sdks/typescript/scripts/build-native-host.mjs @@ -1,21 +1,56 @@ -import { chmodSync, copyFileSync, existsSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; const scriptDirectory = import.meta.dirname; -const packageDirectory = join(scriptDirectory, ".."); -const workspaceDirectory = join(packageDirectory, "..", ".."); -const binaryName = process.platform === "win32" ? "sof_ts_runtime_host.exe" : "sof_ts_runtime_host"; -const targetTriple = `${process.platform}-${process.arch}`; -const sourcePath = join(workspaceDirectory, "target", "release", binaryName); -const outputDirectory = join(packageDirectory, "dist", "native", targetTriple); -const outputPath = join(outputDirectory, binaryName); -const features = ["provider-grpc", "gossip-bootstrap"]; +const sdkPackageDirectory = join(scriptDirectory, ".."); +const workspaceDirectory = join(sdkPackageDirectory, "..", ".."); + +function runtimeHostBinaryName() { + return process.platform === "win32" ? "sof_ts_runtime_host.exe" : "sof_ts_runtime_host"; +} + +function nativePackageDirectory() { + const cwdPackagePath = join(process.cwd(), "package.json"); + if (existsSync(cwdPackagePath)) { + const cwdPackage = JSON.parse(readFileSync(cwdPackagePath, "utf8")); + if (typeof cwdPackage.name === "string" && cwdPackage.name.startsWith("@sof/sdk-native-")) { + return process.cwd(); + } + } + + return join(sdkPackageDirectory, "native", `${process.platform}-${process.arch}`); +} + +function packageMetadata(packageDirectory) { + const packageJsonPath = join(packageDirectory, "package.json"); + if (!existsSync(packageJsonPath)) { + throw new Error( + `native package metadata was not found at ${packageJsonPath}; expected a package for ${process.platform}-${process.arch}`, + ); + } + + return JSON.parse(readFileSync(packageJsonPath, "utf8")); +} + +const packageDirectory = nativePackageDirectory(); +const packageJson = packageMetadata(packageDirectory); +const outputDirectory = join(packageDirectory, "vendor"); +const outputPath = join(outputDirectory, runtimeHostBinaryName()); +const sourcePath = join(workspaceDirectory, "target", "release", runtimeHostBinaryName()); +const features = ["provider-websocket", "provider-grpc", "gossip-bootstrap"]; if (process.platform === "linux") { features.push("kernel-bypass"); } +const packagePlatform = basename(packageDirectory); +if (packagePlatform !== `${process.platform}-${process.arch}`) { + throw new Error( + `native package ${packageJson.name ?? packagePlatform} does not match the current platform ${process.platform}-${process.arch}`, + ); +} + const cargo = spawnSync( "cargo", [ @@ -49,4 +84,6 @@ if (!existsSync(sourcePath)) { mkdirSync(outputDirectory, { recursive: true }); copyFileSync(sourcePath, outputPath); -chmodSync(outputPath, 0o755); +if (process.platform !== "win32") { + chmodSync(outputPath, 0o755); +} diff --git a/sdks/typescript/scripts/check-release-metadata.mjs b/sdks/typescript/scripts/check-release-metadata.mjs new file mode 100644 index 00000000..1ef3db5e --- /dev/null +++ b/sdks/typescript/scripts/check-release-metadata.mjs @@ -0,0 +1,101 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const scriptDirectory = import.meta.dirname; +const sdkDirectory = join(scriptDirectory, ".."); +const nativeDirectory = join(sdkDirectory, "native"); + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function fail(message) { + throw new Error(message); +} + +const sdkPackage = readJson(join(sdkDirectory, "package.json")); +const nativePackageDirectories = readdirSync(nativeDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted(); + +if (nativePackageDirectories.length === 0) { + fail("no native package directories were found under sdks/typescript/native"); +} + +const optionalDependencyNames = Object.keys(sdkPackage.optionalDependencies ?? {}).toSorted(); +const nativePackages = nativePackageDirectories.map((directoryName) => { + const packageJsonPath = join(nativeDirectory, directoryName, "package.json"); + const packageJson = readJson(packageJsonPath); + + if (packageJson.version !== sdkPackage.version) { + fail( + `native package ${packageJson.name} has version ${packageJson.version}, expected ${sdkPackage.version}`, + ); + } + + if (packageJson.publishConfig?.access !== "public") { + fail(`native package ${packageJson.name} must set publishConfig.access to public`); + } + + if (packageJson.publishConfig?.provenance !== true) { + fail(`native package ${packageJson.name} must set publishConfig.provenance to true`); + } + + if (!Array.isArray(packageJson.files) || !packageJson.files.includes("vendor")) { + fail(`native package ${packageJson.name} must publish the vendor directory`); + } + + if (packageJson.scripts?.prepack !== "node ../../scripts/build-native-host.mjs") { + fail(`native package ${packageJson.name} must build through scripts/build-native-host.mjs`); + } + + if (packageJson.repository?.type !== sdkPackage.repository?.type) { + fail(`native package ${packageJson.name} must mirror the sdk repository metadata`); + } + + if (packageJson.repository?.url !== sdkPackage.repository?.url) { + fail(`native package ${packageJson.name} must mirror the sdk repository URL`); + } + + if (packageJson.repository?.directory !== `sdks/typescript/native/${directoryName}`) { + fail( + `native package ${packageJson.name} must point repository.directory to sdks/typescript/native/${directoryName}`, + ); + } + + if (packageJson.bugs?.url !== sdkPackage.bugs?.url) { + fail(`native package ${packageJson.name} must mirror the sdk bugs URL`); + } + + if (packageJson.homepage !== sdkPackage.homepage) { + fail(`native package ${packageJson.name} must mirror the sdk homepage`); + } + + return packageJson; +}); + +const nativePackageNames = nativePackages.map((packageJson) => packageJson.name).toSorted(); + +if (JSON.stringify(optionalDependencyNames) !== JSON.stringify(nativePackageNames)) { + fail( + `sdk optionalDependencies ${JSON.stringify(optionalDependencyNames)} do not match native packages ${JSON.stringify(nativePackageNames)}`, + ); +} + +for (const packageJson of nativePackages) { + if (sdkPackage.optionalDependencies?.[packageJson.name] !== "workspace:*") { + fail(`sdk optional dependency ${packageJson.name} must stay on workspace:* in source control`); + } +} + +process.stdout.write( + `${JSON.stringify( + { + sdkVersion: sdkPackage.version, + nativePackages: nativePackages.map((packageJson) => packageJson.name), + }, + undefined, + 2, + )}\n`, +); diff --git a/sdks/typescript/src/app.test.ts b/sdks/typescript/src/app.test.ts index aea56c07..093e9675 100644 --- a/sdks/typescript/src/app.test.ts +++ b/sdks/typescript/src/app.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -40,6 +40,12 @@ test("app derives a stable default name from the first plugin", () => { kind: IngressKind.WebSocket, name: "solana-websocket", url: "wss://example.invalid", + stream: 1, + readiness: 1, + role: 1, + accountInclude: [], + accountExclude: [], + accountRequired: [], }, ]); }); @@ -208,6 +214,47 @@ test("app reports invalid runtime host override for non-websocket ingress", asyn } }); +test("app resolves the runtime host from the installed native package", async () => { + if (process.platform === "win32") { + return; + } + + const nativePackageDirectory = join( + process.cwd(), + "native", + `${process.platform}-${process.arch}`, + ); + const vendorDirectory = join(nativePackageDirectory, "vendor"); + const binaryPath = join(vendorDirectory, "sof_ts_runtime_host"); + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + + await mkdir(vendorDirectory, { recursive: true }); + await writeFile(binaryPath, "#!/bin/sh\nexit 0\n", "utf8"); + await chmod(binaryPath, 0o755); + + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.DirectShreds, + bindAddress: "127.0.0.1:20000", + }, + ], + plugins: [new Plugin({ name: "packet-extension", logPackets: false })], + }).run(); + + assert.equal(isOk(result), true); + } finally { + await rm(vendorDirectory, { force: true, recursive: true }); + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + } +}); + test("app delegates non-websocket ingress to configured runtime host", async () => { const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; process.env.SOF_SDK_RUNTIME_HOST_BINARY = "/bin/true"; @@ -403,6 +450,71 @@ writeFileSync(snapshotPath, JSON.stringify(config.pluginWorkers[0].environment)) } }); +test("app runtime host config marks provider-event workers explicitly", async () => { + const previousRuntimeHost = process.env.SOF_SDK_RUNTIME_HOST_BINARY; + const previousSnapshot = process.env.SOF_SDK_CONFIG_SNAPSHOT; + const tempDir = await mkdtemp(join(tmpdir(), "sof-sdk-host-test-")); + const hostPath = join(tempDir, "host.mjs"); + const snapshotPath = join(tempDir, "snapshot.json"); + await writeFile( + hostPath, + `#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +const configPath = process.argv[2]; +const snapshotPath = process.env.SOF_SDK_CONFIG_SNAPSHOT; +if (configPath === undefined || snapshotPath === undefined) process.exit(2); +const config = JSON.parse(readFileSync(configPath, "utf8")); +writeFileSync(snapshotPath, JSON.stringify(config.pluginWorkers.map((worker) => ({ + name: worker.name, + providerEvents: worker.providerEvents, +})))); +`, + "utf8", + ); + await chmod(hostPath, 0o755); + process.env.SOF_SDK_RUNTIME_HOST_BINARY = hostPath; + process.env.SOF_SDK_CONFIG_SNAPSHOT = snapshotPath; + try { + const result = await new App({ + ingress: [ + { + kind: IngressKind.Grpc, + endpoint: "https://example.invalid", + }, + ], + plugins: [ + new Plugin({ name: "packet-extension", logPackets: false }), + new Plugin({ + name: "provider-extension", + onProviderEvent: () => ok(runtimeExtensionAck()), + }), + ], + }).run(); + + assert.equal(isOk(result), true); + const workers = JSON.parse(await readFile(snapshotPath, "utf8")) as Array<{ + name: string; + providerEvents: boolean; + }>; + assert.deepEqual(workers, [ + { name: "packet-extension", providerEvents: false }, + { name: "provider-extension", providerEvents: true }, + ]); + } finally { + if (previousRuntimeHost === undefined) { + delete process.env.SOF_SDK_RUNTIME_HOST_BINARY; + } else { + process.env.SOF_SDK_RUNTIME_HOST_BINARY = previousRuntimeHost; + } + if (previousSnapshot === undefined) { + delete process.env.SOF_SDK_CONFIG_SNAPSHOT; + } else { + process.env.SOF_SDK_CONFIG_SNAPSHOT = previousSnapshot; + } + await rm(tempDir, { force: true, recursive: true }); + } +}); + test("app ignores internal worker env without the internal worker mode flag", async () => { const previousWorker = process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER; const previousMode = process.env.SOF_SDK_INTERNAL_PLUGIN_WORKER_MODE; diff --git a/sdks/typescript/src/app.ts b/sdks/typescript/src/app.ts index e60263fe..af65f653 100644 --- a/sdks/typescript/src/app.ts +++ b/sdks/typescript/src/app.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -98,6 +99,19 @@ export enum GrpcIngressStream { Slots = 5, } +export enum WebSocketIngressStream { + Transactions = 1, + Logs = 2, + Account = 3, + Program = 4, +} + +export enum WebSocketLogsFilterKind { + All = 1, + AllWithVotes = 2, + Mentions = 3, +} + export enum ProviderCommitment { Processed = 1, Confirmed = 2, @@ -126,6 +140,21 @@ export interface WebSocketIngressInit { readonly kind: IngressKind.WebSocket; readonly name?: string; readonly url: string; + readonly stream?: WebSocketIngressStream; + readonly commitment?: ProviderCommitment; + readonly vote?: boolean; + readonly failed?: boolean; + readonly signature?: string; + readonly accountInclude?: readonly string[]; + readonly accountExclude?: readonly string[]; + readonly accountRequired?: readonly string[]; + readonly account?: string; + readonly programId?: string; + readonly logsFilter?: WebSocketLogsFilterKind; + readonly mentions?: string; + readonly readiness?: ProviderIngressReadiness; + readonly role?: ProviderIngressRole; + readonly priority?: number; } export interface GrpcIngressInit { @@ -196,6 +225,21 @@ export interface WebSocketIngress { readonly kind: IngressKind.WebSocket; readonly name: string; readonly url: string; + readonly stream: WebSocketIngressStream; + readonly commitment?: ProviderCommitment; + readonly vote?: boolean; + readonly failed?: boolean; + readonly signature?: string; + readonly accountInclude?: readonly string[]; + readonly accountExclude?: readonly string[]; + readonly accountRequired?: readonly string[]; + readonly account?: string; + readonly programId?: string; + readonly logsFilter?: WebSocketLogsFilterKind; + readonly mentions?: string; + readonly readiness: ProviderIngressReadiness; + readonly role: ProviderIngressRole; + readonly priority?: number; } export interface GrpcIngress { @@ -271,6 +315,8 @@ export type ExtensionError = PluginError; export type ExtensionInit = PluginInit; export const typeScriptSdkVersion = "0.1.0"; +const require = createRequire(import.meta.url); + const defaultAppName = "app"; const autoPluginNamePrefix = "plugin"; const internalPluginWorkerEnvVarName = "SOF_SDK_INTERNAL_PLUGIN_WORKER"; @@ -285,6 +331,17 @@ const grpcIngressStreams = [ GrpcIngressStream.BlockMeta, GrpcIngressStream.Slots, ] as const satisfies readonly GrpcIngressStream[]; +const webSocketIngressStreams = [ + WebSocketIngressStream.Transactions, + WebSocketIngressStream.Logs, + WebSocketIngressStream.Account, + WebSocketIngressStream.Program, +] as const satisfies readonly WebSocketIngressStream[]; +const webSocketLogsFilters = [ + WebSocketLogsFilterKind.All, + WebSocketLogsFilterKind.AllWithVotes, + WebSocketLogsFilterKind.Mentions, +] as const satisfies readonly WebSocketLogsFilterKind[]; const providerCommitments = [ ProviderCommitment.Processed, ProviderCommitment.Confirmed, @@ -530,11 +587,333 @@ function validateWebSocketIngress( ); } - return ok({ + const stream = ingress.stream ?? WebSocketIngressStream.Transactions; + if (!webSocketIngressStreams.includes(stream)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.stream", + "ingress.stream must be a supported websocket stream", + String(stream), + ), + ); + } + if (ingress.commitment !== undefined && !providerCommitments.includes(ingress.commitment)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.commitment", + "ingress.commitment must be a supported provider commitment", + String(ingress.commitment), + ), + ); + } + if (ingress.readiness !== undefined && !providerIngressReadiness.includes(ingress.readiness)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.readiness", + "ingress.readiness must be a supported provider readiness policy", + String(ingress.readiness), + ), + ); + } + if (ingress.role !== undefined && !providerIngressRoles.includes(ingress.role)) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.role", + "ingress.role must be a supported provider ingress role", + String(ingress.role), + ), + ); + } + if ( + ingress.priority !== undefined && + (!Number.isInteger(ingress.priority) || ingress.priority < 0 || ingress.priority > 65_535) + ) { + return err( + appError( + AppErrorKind.ValidationError, + "ingress.priority", + "ingress.priority must be an integer between 0 and 65535", + String(ingress.priority), + ), + ); + } + + const parseOptionalField = ( + value: string | undefined, + field: string, + ): Result => { + if (value === undefined) { + return ok(value); + } + + const parsed = parseNonEmptyValue(value, field, (normalized) => normalized); + if (isErr(parsed)) { + return parsed; + } + + return ok(parsed.value); + }; + const signature = parseOptionalField(ingress.signature, "ingress.signature"); + if (isErr(signature)) { + return signature; + } + const account = parseOptionalField(ingress.account, "ingress.account"); + if (isErr(account)) { + return account; + } + const programId = parseOptionalField(ingress.programId, "ingress.programId"); + if (isErr(programId)) { + return programId; + } + const mentions = parseOptionalField(ingress.mentions, "ingress.mentions"); + if (isErr(mentions)) { + return mentions; + } + + const parseFilterList = ( + values: readonly string[] | undefined, + field: string, + ): Result => { + const normalized: string[] = []; + for (const value of values ?? []) { + const parsed = parseNonEmptyValue(value, field, (normalizedValue) => normalizedValue); + if (isErr(parsed)) { + return parsed; + } + normalized.push(parsed.value); + } + + return ok(normalized); + }; + const accountInclude = parseFilterList(ingress.accountInclude, "ingress.accountInclude"); + if (isErr(accountInclude)) { + return accountInclude; + } + const accountExclude = parseFilterList(ingress.accountExclude, "ingress.accountExclude"); + if (isErr(accountExclude)) { + return accountExclude; + } + const accountRequired = parseFilterList(ingress.accountRequired, "ingress.accountRequired"); + if (isErr(accountRequired)) { + return accountRequired; + } + + const base = { kind: IngressKind.WebSocket, name: name.value, url: url.value, - }); + stream, + ...(ingress.commitment === undefined ? {} : { commitment: ingress.commitment }), + readiness: ingress.readiness ?? ProviderIngressReadiness.Required, + role: ingress.role ?? ProviderIngressRole.Primary, + ...(ingress.priority === undefined ? {} : { priority: ingress.priority }), + } as const; + + const rejectUnsupportedField = (field: string, message: string, received?: string) => + err(appError(AppErrorKind.ValidationError, field, message, received)); + + switch (stream) { + case WebSocketIngressStream.Transactions: + if (account.value !== undefined) { + return rejectUnsupportedField( + "ingress.account", + "ingress.account is only supported for websocket account streams", + account.value, + ); + } + if (programId.value !== undefined) { + return rejectUnsupportedField( + "ingress.programId", + "ingress.programId is only supported for websocket program streams", + programId.value, + ); + } + if (mentions.value !== undefined) { + return rejectUnsupportedField( + "ingress.mentions", + "ingress.mentions is only supported for websocket logs mention filters", + mentions.value, + ); + } + if (ingress.logsFilter !== undefined) { + return rejectUnsupportedField( + "ingress.logsFilter", + "ingress.logsFilter is only supported for websocket logs streams", + String(ingress.logsFilter), + ); + } + + return ok({ + ...base, + ...(ingress.vote === undefined ? {} : { vote: ingress.vote }), + ...(ingress.failed === undefined ? {} : { failed: ingress.failed }), + ...(signature.value === undefined ? {} : { signature: signature.value }), + accountInclude: accountInclude.value, + accountExclude: accountExclude.value, + accountRequired: accountRequired.value, + }); + case WebSocketIngressStream.Logs: { + if (account.value !== undefined) { + return rejectUnsupportedField( + "ingress.account", + "ingress.account is only supported for websocket account streams", + account.value, + ); + } + if (programId.value !== undefined) { + return rejectUnsupportedField( + "ingress.programId", + "ingress.programId is only supported for websocket program streams", + programId.value, + ); + } + if (ingress.vote !== undefined) { + return rejectUnsupportedField( + "ingress.vote", + "ingress.vote is only supported for websocket transaction streams", + String(ingress.vote), + ); + } + if (ingress.failed !== undefined) { + return rejectUnsupportedField( + "ingress.failed", + "ingress.failed is only supported for websocket transaction streams", + String(ingress.failed), + ); + } + if (signature.value !== undefined) { + return rejectUnsupportedField( + "ingress.signature", + "ingress.signature is only supported for websocket transaction streams", + signature.value, + ); + } + if ( + accountInclude.value.length > 0 || + accountExclude.value.length > 0 || + accountRequired.value.length > 0 + ) { + return rejectUnsupportedField( + "ingress.accountInclude", + "transaction account filters are only supported for websocket transaction streams", + ); + } + + const logsFilter = ingress.logsFilter ?? WebSocketLogsFilterKind.All; + if (!webSocketLogsFilters.includes(logsFilter)) { + return rejectUnsupportedField( + "ingress.logsFilter", + "ingress.logsFilter must be a supported websocket logs filter", + String(logsFilter), + ); + } + if (logsFilter === WebSocketLogsFilterKind.Mentions && mentions.value === undefined) { + return rejectUnsupportedField( + "ingress.mentions", + "ingress.mentions is required when ingress.logsFilter is Mentions", + ); + } + if (logsFilter !== WebSocketLogsFilterKind.Mentions && mentions.value !== undefined) { + return rejectUnsupportedField( + "ingress.mentions", + "ingress.mentions is only supported when ingress.logsFilter is Mentions", + mentions.value, + ); + } + + return ok({ + ...base, + logsFilter, + ...(mentions.value === undefined ? {} : { mentions: mentions.value }), + }); + } + case WebSocketIngressStream.Account: + if (account.value === undefined) { + return rejectUnsupportedField( + "ingress.account", + "ingress.account is required for websocket account streams", + ); + } + if (programId.value !== undefined) { + return rejectUnsupportedField( + "ingress.programId", + "ingress.programId is only supported for websocket program streams", + programId.value, + ); + } + if (mentions.value !== undefined || ingress.logsFilter !== undefined) { + return rejectUnsupportedField( + "ingress.logsFilter", + "websocket logs filters are only supported for websocket logs streams", + ); + } + if ( + ingress.vote !== undefined || + ingress.failed !== undefined || + signature.value !== undefined || + accountInclude.value.length > 0 || + accountExclude.value.length > 0 || + accountRequired.value.length > 0 + ) { + return rejectUnsupportedField( + "ingress.vote", + "transaction-only websocket filters are not supported for websocket account streams", + ); + } + + return ok({ + ...base, + account: account.value, + }); + case WebSocketIngressStream.Program: + if (programId.value === undefined) { + return rejectUnsupportedField( + "ingress.programId", + "ingress.programId is required for websocket program streams", + ); + } + if (account.value !== undefined) { + return rejectUnsupportedField( + "ingress.account", + "ingress.account is only supported for websocket account streams", + account.value, + ); + } + if (mentions.value !== undefined || ingress.logsFilter !== undefined) { + return rejectUnsupportedField( + "ingress.logsFilter", + "websocket logs filters are only supported for websocket logs streams", + ); + } + if ( + ingress.vote !== undefined || + ingress.failed !== undefined || + signature.value !== undefined || + accountInclude.value.length > 0 || + accountExclude.value.length > 0 || + accountRequired.value.length > 0 + ) { + return rejectUnsupportedField( + "ingress.vote", + "transaction-only websocket filters are not supported for websocket program streams", + ); + } + + return ok({ + ...base, + programId: programId.value, + }); + default: + return rejectUnsupportedField( + "ingress.stream", + "ingress.stream must be a supported websocket stream", + String(stream), + ); + } } function validateGrpcIngress( @@ -1051,6 +1430,7 @@ interface RuntimeHostPluginWorkerConfig { readonly command: string; readonly args: readonly string[]; readonly environment: Readonly>; + readonly providerEvents: boolean; } interface RuntimeHostConfig { @@ -1160,6 +1540,7 @@ function createRuntimeHostConfig(state: AppState): Result { try { if (process.platform !== "win32") { @@ -1212,7 +1640,11 @@ function runtimeHostBinary(): Result { return ok(binary); } - const candidates = [packagedRuntimeHostPath(), ...repoRuntimeHostPaths()]; + const candidates = [ + installedRuntimeHostPath(), + packagedRuntimeHostPath(), + ...repoRuntimeHostPaths(), + ].filter((candidate): candidate is string => candidate !== undefined); for (const candidate of candidates) { if (existsSync(candidate)) { return makeRuntimeHostExecutable(candidate); @@ -1223,7 +1655,9 @@ function runtimeHostBinary(): Result { appError( AppErrorKind.ValidationError, runtimeHostBinaryEnvVarName, - `a compatible runtime host binary was not found; expected one of ${candidates.join(", ")}`, + runtimeHostPackageName() === undefined + ? `a compatible runtime host binary was not found for ${process.platform}-${process.arch}; set ${runtimeHostBinaryEnvVarName} or provide one of ${candidates.join(", ")}` + : `a compatible runtime host binary was not found; expected optional dependency ${runtimeHostPackageName()} or one of ${candidates.join(", ")}`, ), ); } diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts index 03bb47fa..a2842066 100644 --- a/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.test.ts @@ -259,3 +259,96 @@ test("runtime extension stdio worker rejects malformed protocol messages", async assert.equal(isErr(runnerResult), true); assert.match(errorText, /event\.source\.kind/); }); + +test("runtime extension stdio worker hard-blocks stdout writes inside callbacks", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); + let outputText = ""; + + output.setEncoding("utf8"); + output.on("data", (chunk: string) => { + outputText += chunk; + }); + + const manifest = tryCreateRuntimeExtensionWorkerManifest({ + sdkVersion: "0.1.0", + extensionName: "stdout-guard-demo", + capabilities: [ExtensionCapability.ObserveObserverIngress], + }); + assert.equal(manifest.tag, ResultTag.Ok); + if (manifest.tag !== ResultTag.Ok) { + return; + } + + const definition = tryDefineRuntimeExtension({ + manifest: manifest.value, + onPacketReceived: () => { + process.stdout.write("forbidden\n"); + return ok(runtimeExtensionAck()); + }, + onShutdown: () => ok(runtimeExtensionAck()), + }); + assert.equal(definition.tag, ResultTag.Ok); + if (definition.tag !== ResultTag.Ok) { + return; + } + + const runner = runRuntimeExtensionWorkerStdio(definition.value, { + input, + output, + error: errorOutput, + guardProcessStdout: true, + }); + + input.write( + `${JSON.stringify({ + tag: RuntimeExtensionWorkerHostMessageTag.DeliverPacket, + event: { + source: { + kind: 1, + transport: 1, + eventClass: 1, + }, + bytes: [1, 2, 3, 4], + observedUnixMs: 100, + }, + })}\n`, + ); + input.write( + `${JSON.stringify( + serializeRuntimeExtensionWorkerHostMessageWire({ + tag: RuntimeExtensionWorkerHostMessageTag.Shutdown, + context: { + extensionName: manifest.value.extensionName, + }, + }), + )}\n`, + ); + input.end(); + + const runnerResult = await runner; + assert.equal(runnerResult.tag, ResultTag.Ok); + + const responses = outputText + .trim() + .split("\n") + .filter((line) => line !== "") + .map( + (line) => + JSON.parse(line) as { + tag: number; + result?: { tag: number; error?: { message?: string; cause?: string } }; + }, + ); + + assert.equal(responses.length, 2); + assert.equal(responses[0]?.tag, RuntimeExtensionWorkerResponseTag.EventHandled); + assert.equal(responses[0]?.result?.tag, ResultTag.Err); + assert.match( + responses[0]?.result?.error?.cause ?? "", + /stdout is reserved for protocol messages/i, + ); + assert.equal(responses[1]?.tag, RuntimeExtensionWorkerResponseTag.ShutdownComplete); + assert.equal(responses[1]?.result?.tag, ResultTag.Ok); +}); diff --git a/sdks/typescript/src/runtime/runtime-extension-stdio.ts b/sdks/typescript/src/runtime/runtime-extension-stdio.ts index 9394b47f..76855173 100644 --- a/sdks/typescript/src/runtime/runtime-extension-stdio.ts +++ b/sdks/typescript/src/runtime/runtime-extension-stdio.ts @@ -79,6 +79,18 @@ export interface RuntimeExtensionWorkerStdioOptions { readonly input?: NodeJS.ReadableStream; readonly output?: NodeJS.WritableStream; readonly error?: NodeJS.WritableStream; + readonly guardProcessStdout?: boolean; +} + +interface WorkerStdoutGuard { + readonly writeProtocolText: (text: string) => Promise; + readonly restore: () => void; +} + +function stdoutReservedError(): Error { + return new Error( + "SOF SDK worker stdout is reserved for protocol messages; use stderr, console.error, or console.warn instead", + ); } function runtimeExtensionProtocolError( @@ -664,6 +676,73 @@ async function writeText(output: NodeJS.WritableStream, text: string): Promise writeText(output, text), + restore: () => {}, + }; + } + + const originalWrite = process.stdout.write.bind(process.stdout); + const originalConsoleLog = globalThis.console.log; + const originalConsoleInfo = globalThis.console.info; + const originalConsoleDebug = globalThis.console.debug; + const originalConsoleDir = globalThis.console.dir; + let protocolWriteDepth = 0; + const writeGuard: typeof process.stdout.write = ( + buffer: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void), + callback?: (err?: Error | null) => void, + ) => { + if (protocolWriteDepth > 0) { + if (typeof encodingOrCallback === "function") { + return originalWrite(buffer, encodingOrCallback); + } + + return originalWrite(buffer, encodingOrCallback, callback); + } + + throw stdoutReservedError(); + }; + const blockedConsoleWrite = (..._args: readonly unknown[]) => { + throw stdoutReservedError(); + }; + const blockedConsoleDir: typeof globalThis.console.dir = ( + _item?: unknown, + _options?: unknown, + ) => { + throw stdoutReservedError(); + }; + + process.stdout.write = writeGuard; + globalThis.console.log = blockedConsoleWrite; + globalThis.console.info = blockedConsoleWrite; + globalThis.console.debug = blockedConsoleWrite; + globalThis.console.dir = blockedConsoleDir; + + return { + writeProtocolText: async (text: string) => { + protocolWriteDepth += 1; + try { + await writeText(output, text); + } finally { + protocolWriteDepth -= 1; + } + }, + restore: () => { + process.stdout.write = originalWrite; + globalThis.console.log = originalConsoleLog; + globalThis.console.info = originalConsoleInfo; + globalThis.console.debug = originalConsoleDebug; + globalThis.console.dir = originalConsoleDir; + }, + }; +} + export async function runRuntimeExtensionWorkerStdio( definition: RuntimeExtensionDefinition, options: RuntimeExtensionWorkerStdioOptions = {}, @@ -676,6 +755,10 @@ export async function runRuntimeExtensionWorkerStdio( const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const errorOutput = options.error ?? process.stderr; + const stdoutGuard = createWorkerStdoutGuard( + output, + options.guardProcessStdout ?? output === process.stdout, + ); const lineReader = createInterface({ input, crlfDelay: Infinity, @@ -710,8 +793,7 @@ export async function runRuntimeExtensionWorkerStdio( } const response = await runtime.value.handleMessage(message.value); - await writeText( - output, + await stdoutGuard.writeProtocolText( `${JSON.stringify(serializeRuntimeExtensionWorkerResponseWire(response))}\n`, ); @@ -721,6 +803,7 @@ export async function runRuntimeExtensionWorkerStdio( } } finally { lineReader.close(); + stdoutGuard.restore(); } return err( From af5a567cf0891666adfe547a24d4949facb84c21 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 11 Apr 2026 23:54:09 +0200 Subject: [PATCH 18/25] docs(gitbook): add sdk docs and fix navbar --- docs/README.md | 8 ++- docs/gitbook/README.md | 15 ++++ docs/gitbook/SUMMARY.md | 3 + docs/gitbook/_layouts/website/page.html | 4 ++ docs/gitbook/getting-started/README.md | 3 + docs/gitbook/sdk/README.md | 37 ++++++++++ docs/gitbook/sdk/runtime-host.md | 58 +++++++++++++++ docs/gitbook/sdk/typescript.md | 94 +++++++++++++++++++++++++ docs/gitbook/styles/website.css | 27 +++++-- docs/gitbook/use-sof/README.md | 3 + 10 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 docs/gitbook/sdk/README.md create mode 100644 docs/gitbook/sdk/runtime-host.md create mode 100644 docs/gitbook/sdk/typescript.md diff --git a/docs/README.md b/docs/README.md index d9f97202..50f88521 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,8 @@ This folder is the documentation index for the SOF workspace. -Use it as the entry point for architecture references, operations guides, and crate-level setup docs. +Use it as the entry point for architecture references, operations guides, crate-level setup docs, +and SDK docs. The project is aimed at low-latency Solana infrastructure: ingest, replayable state, execution control planes, and deployment/tuning concerns that matter in real trading and market-data systems. @@ -26,6 +27,11 @@ Transaction SDK users: - `../crates/sof-tx/README.md` - `architecture/adr/0006-transaction-sdk-and-dual-submit-routing.md` +TypeScript SDK users: + +- `gitbook/sdk/README.md` +- `../sdks/typescript/README.md` + Contributors: - `../CONTRIBUTING.md` diff --git a/docs/gitbook/README.md b/docs/gitbook/README.md index a6210d1c..9b5de145 100644 --- a/docs/gitbook/README.md +++ b/docs/gitbook/README.md @@ -11,6 +11,10 @@ The public crates are: - `sof-tx`: transaction construction and submission - `sof-gossip-tuning`: typed tuning profiles for `sof` +The public application-facing SDK is: + +- `@sof/sdk`: TypeScript app SDK backed by the SOF native runtime host + There is also one internal backend crate: - `sof-solana-gossip`: vendored gossip bootstrap backend used by optional gossip mode @@ -42,6 +46,7 @@ If you are evaluating SOF as a product, read these first: - [Why SOF Exists](use-sof/why-sof-exists.md) - [SOF Compared To The Usual Alternatives](use-sof/sof-compared.md) +- [TypeScript SDK](sdk/typescript.md) - [Before You Start](getting-started/before-you-start.md) - [Common Questions](getting-started/common-questions.md) - [System Overview](architecture/system-overview.md) @@ -59,6 +64,16 @@ Choose this track if you want to: Start here: [Use SOF](use-sof/README.md) +### SDKs + +Choose this track if you want to: + +- build a SOF app from Node.js +- use the Rust runtime underneath without managing it directly +- understand the TypeScript package and native runtime host model + +Start here: [SDKs](sdk/README.md) + ### Maintain SOF Choose this track if you are working inside the repository and need: diff --git a/docs/gitbook/SUMMARY.md b/docs/gitbook/SUMMARY.md index 811426cf..44af4d76 100644 --- a/docs/gitbook/SUMMARY.md +++ b/docs/gitbook/SUMMARY.md @@ -24,6 +24,9 @@ - [Relay, Repair, and Traffic](operations/relay-repair-and-traffic.md) - [Tuning and Environment Controls](operations/tuning-and-env.md) - [Knob Registry](operations/knob-registry.md) +- [SDKs](sdk/README.md) + - [TypeScript SDK](sdk/typescript.md) + - [Runtime Host and Packaging](sdk/runtime-host.md) - [Maintain SOF](maintainers/README.md) - [Docs Site](maintainers/docs-site.md) - [Repository Layout](getting-started/workspace-layout.md) diff --git a/docs/gitbook/_layouts/website/page.html b/docs/gitbook/_layouts/website/page.html index 33afbce1..e138f2f2 100644 --- a/docs/gitbook/_layouts/website/page.html +++ b/docs/gitbook/_layouts/website/page.html @@ -135,6 +135,7 @@