20 KiB
SDK Architecture
The Start SDK is split into two npm packages that form a layered architecture: base provides the foundational types, ABI contract, and effects interface; package builds on base to provide the developer-facing SDK facade.
┌─────────────────────────────────────────────────────────────┐
│ package/ (@start9labs/start-sdk) │
│ Developer-facing facade, daemon management, health checks, │
│ backup system, file helpers, triggers, subcontainers │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ base/ (@start9labs/start-sdk-base) │ │
│ │ ABI, Effects, OS bindings, actions/input builders, │ │
│ │ ExVer parser, interfaces, dependencies, S9pk, utils │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ ▲
│ Effects calls (RPC) │ Callbacks
▼ │
┌─────────────────────────────────────────────────────────────┐
│ StartOS Runtime (Rust supervisor) │
│ Executes effects, manages containers, networking, storage │
└─────────────────────────────────────────────────────────────┘
The SDK follows Semantic Versioning within the 0.4.0-beta.* pre-release series. The SDK version tracks independently from the StartOS release versions.
Base Package (base/)
The base package is a self-contained library of types, interfaces, and low-level builders. It has no dependency on the package layer and can be used independently when only type definitions or validation are needed.
OS Bindings (base/lib/osBindings/)
~325 auto-generated TypeScript files defining every type exchanged between the SDK and the StartOS runtime. These cover the full surface area of the system: manifests, actions, health checks, service interfaces, bind parameters, dependency requirements, alerts, SSL, domains, SMTP, networking, images, and more.
All bindings are re-exported through base/lib/osBindings/index.ts.
Key types include:
Manifest— The full service package manifest as understood by the OSActionMetadata— Describes an action's name, description, visibility, and availabilityBindParams— Port binding configuration (protocol, hostId, internal port)ServiceInterface— A network endpoint exported to usersDependencyRequirement— Version range and health check requirements for a dependencySetHealth— Health check result reportingHostnameInfo/Host— Hostname and host metadata
ABI and Core Types (base/lib/types.ts)
Defines the Application Binary Interface — the contract every service package must fulfill:
namespace ExpectedExports {
main // Start the service daemon(s)
init // Initialize on install/update/restore
uninit // Clean up on uninstall/update/shutdown
manifest // Service metadata
actions // User-invocable operations
createBackup // Export service data
}
Also defines foundational types used throughout the SDK:
Daemon/DaemonReturned— Running process handles withwait()andterm()CommandType— Shell string, argv array, orUseEntrypointServiceInterfaceType—'ui' | 'api' | 'p2p'SmtpValue— SMTP server configurationKnownError— Structured user-facing errorsDependsOn— Package-to-health-check dependency mappingPathMaker,MaybePromise,DeepPartial,DeepReadonly— Utility types
Effects Interface (base/lib/Effects.ts)
The bridge between TypeScript service code and the StartOS runtime. Every runtime capability is accessed through an Effects object passed to lifecycle hooks.
Effects are organized by subsystem:
| Subsystem | Methods | Purpose |
|---|---|---|
| Action | export, clear, getInput, run, createTask, clearTasks |
Register and invoke user actions |
| Control | restart, shutdown, getStatus, setMainStatus |
Service lifecycle control |
| Dependency | setDependencies, getDependencies, checkDependencies, mount, getInstalledPackages, getServiceManifest |
Inter-service dependency management |
| Health | setHealth |
Report health check results |
| Subcontainer | createFs, destroyFs |
Container filesystem management |
| Networking | bind, getServicePortForward, clearBindings, getHostInfo, getContainerIp, getOsIp, getOutboundGateway |
Port binding and network info |
| Interfaces | exportServiceInterface, getServiceInterface, listServiceInterfaces, clearServiceInterfaces |
Service endpoint management |
| Plugin | plugin.url.register, plugin.url.exportUrl, plugin.url.clearUrls |
Plugin system hooks |
| SSL | getSslCertificate, getSslKey |
TLS certificate management |
| System | getSystemSmtp, setDataVersion, getDataVersion |
System-wide configuration |
Effects also support reactive callbacks: many methods accept an optional callback parameter that the runtime invokes when the underlying value changes, enabling the reactive subscription patterns (const(), watch(), etc.).
Action and Input System (base/lib/actions/)
Actions (setupActions.ts)
The Action class defines user-invocable operations. Two factory methods:
Action.withInput(id, metadata, inputSpec, prefill, execute)— Action with a validated formAction.withoutInput(id, metadata, execute)— Action without user input
Actions is a typed map accumulated via .addAction() chaining.
Input Specification (actions/input/)
A builder-pattern system for declaring validated form inputs:
inputSpec/
├── builder/
│ ├── inputSpec.ts — InputSpec.of() entry point
│ ├── value.ts — Value class (individual form fields)
│ ├── list.ts — List builder (arrays of values)
│ └── variants.ts — Variants/Union builder (conditional fields)
├── inputSpecTypes.ts — Type definitions for all field types
└── inputSpecConstants.ts — Pre-built specs (SMTP, etc.)
Supported field types via Value:
text,textarea,number— Text and numeric inputtoggle— Boolean switchselect,multiselect— Single/multi-choice dropdownlist— Repeatable array of sub-valuescolor,datetime— Specialized pickersobject— Nested sub-formunion/dynamicUnion— Conditional fields based on a discriminator
Dependencies (base/lib/dependencies/)
setupDependencies.ts— Declare what the service depends on (package IDs, version ranges, health checks)dependencies.ts— Runtime dependency checking viacheckDependencies()
Interfaces (base/lib/interfaces/)
Network interface declaration and port binding:
setupInterfaces.ts— Top-levelsetupServiceInterfaces()functionHost.ts—MultiHostclass for binding ports and exporting interfaces. A single MultiHost can bind a port and export multiple interfaces (e.g. a primary UI and admin UI on the same port with different paths)ServiceInterfaceBuilder.ts— Builder for constructingServiceInterfaceobjects with name, type, description, scheme overrides, username, path, and query paramssetupExportedUrls.ts— URL plugin support for exporting URLs to other services
Initialization (base/lib/inits/)
setupInit.ts— Compose init scripts that run on install, update, restore, or bootsetupUninit.ts— Compose uninit scripts that run on uninstall, update, or shutdownsetupOnInit/setupOnUninit— Register callbacks for specific init/uninit events
Init scripts receive a kind parameter ('install' | 'update' | 'restore' | null) so they can branch logic based on the initialization context.
Extended Versioning (base/lib/exver/)
A PEG parser-based versioning system that extends semver:
Version— Standard semantic version (1.2.3-beta.1)ExtendedVersion(ExVer) — Adds an optional flavor prefix and a downstream version:#flavor:upstream:downstreamVersionRange— Boolean expressions over version comparisons (>=1.0.0 && <2.0.0 || =3.0.0)
The parser is generated from exver.pegjs via Peggy and emitted as exver.ts.
ExVer separates upstream project versions from StartOS wrapper versions, allowing the package maintainer's versioning to evolve independently from the upstream software.
S9pk Format (base/lib/s9pk/)
Parser and verifier for .s9pk service package archives:
S9pkclass — Deserialize and inspect package contents- Merkle archive support for cryptographic verification of package integrity
- Methods:
deserialize(),icon(),license(), etc.
Utilities (base/lib/util/)
~28 utility modules including:
Reactive subscription wrappers — Each wraps an Effects callback-based method into a consistent reactive API:
Watchable— Base class providingconst(),once(),watch(),onChange(),waitFor()GetContainerIp,GetStatus,GetSystemSmtp,GetOutboundGateway,GetSslCertificate,GetHostInfo,GetServiceManifest— Typed wrappers for specific Effects methods
General utilities:
deepEqual/deepMerge— Deep object comparison and mergingpatterns— Hostname regex, port validatorssplitCommand— Parse shell command strings into argv arraysDrop— RAII-style cleanup utilitygraph— Dependency graph utilities
Package Layer (package/)
The package layer provides the developer-facing API. It re-exports everything from base and adds higher-level abstractions.
StartSdk Facade (package/lib/StartSdk.ts)
The primary entry point for service developers. Constructed via a builder chain:
const sdk = StartSdk.of()
.withManifest(manifest)
.build(true)
The .build() method returns an object containing the entire SDK surface area, organized by concern:
| Category | Members | Purpose |
|---|---|---|
| Manifest | manifest, volumes |
Access manifest data and volume paths |
| Actions | Action.withInput, Action.withoutInput, Actions, action.run, action.createTask, action.createOwnTask, action.clearTask |
Define and manage user actions |
| Daemons | Daemons.of, Daemon.of, setupMain |
Configure service processes |
| Health | healthCheck.checkPortListening, .checkWebUrl, .runHealthScript |
Built-in health checks |
| Interfaces | createInterface, MultiHost.of, setupInterfaces, serviceInterface.* |
Network endpoint management |
| Backups | setupBackups, Backups.ofVolumes, Backups.ofSyncs, Backups.withOptions |
Backup configuration |
| Dependencies | setupDependencies, checkDependencies |
Dependency declaration and verification |
| Init/Uninit | setupInit, setupUninit, setupOnInit, setupOnUninit |
Lifecycle hooks |
| Containers | SubContainer.of, SubContainer.withTemp, Mounts.of |
Container execution with mounts |
| Forms | InputSpec.of, Value, Variants, List |
Form input builders |
| Triggers | trigger.defaultTrigger, .cooldownTrigger, .changeOnFirstSuccess, .successFailure |
Health check polling strategies |
| Reactive | getContainerIp, getStatus, getSystemSmtp, getOutboundGateway, getSslCertificate, getServiceManifest |
Subscription-based data access |
| Plugins | plugin.url.register, plugin.url.exportUrl |
Plugin system (gated by manifest plugins field) |
| Effects | restart, shutdown, setHealth, mount, clearBindings, ... |
Direct effect wrappers |
| Utilities | nullIfEmpty, useEntrypoint, patterns, setDataVersion, getDataVersion |
Misc helpers |
Daemon Management (package/lib/mainFn/)
The daemon subsystem manages long-running processes:
mainFn/
├── Daemons.ts — Multi-daemon topology builder
├── Daemon.ts — Single daemon wrapper
├── HealthDaemon.ts — Health check executor
├── CommandController.ts — Command execution controller
├── Mounts.ts — Volume/asset/dependency mount builder
├── Oneshot.ts — One-time startup commands
└── index.ts — setupMain() entry point
Daemons is a builder that accumulates process definitions:
sdk.Daemons.of(effects)
.addDaemon('db', { /* command, ready probe, mounts */ })
.addDaemon('app', { requires: ['db'], /* ... */ })
.addHealthCheck('primary', { /* ... */ })
Features:
- Startup ordering via
requires(dependency graph between daemons) - Ready probes (wait for a daemon to be ready before starting dependents)
- Graceful shutdown with configurable signals and timeouts
- One-shot commands that run before daemons start
Mounts declares what to attach to a container:
sdk.Mounts.of()
.mountVolume('main', '/data')
.mountAssets('scripts', '/scripts')
.mountDependency('bitcoind', 'main', '/bitcoin-data', { readonly: true })
.mountBackup('/backup')
Health Checks (package/lib/health/)
health/
├── HealthCheck.ts — Periodic probe with startup grace period
└── checkFns/
├── checkPortListening.ts — TCP port connectivity check
├── checkWebUrl.ts — HTTP(S) status code check
└── runHealthScript.ts — Script exit code check
Health checks are paired with triggers that control polling behavior:
defaultTrigger— Fixed interval (e.g. every 30s)cooldownTrigger— Wait longer after failureschangeOnFirstSuccess— Rapid polling until first success, then slow downsuccessFailure— Different intervals for healthy vs unhealthy states
Backup System (package/lib/backup/)
backup/
├── setupBackups.ts — Top-level setup function
└── Backups.ts — Volume selection and rsync options
Three builder patterns:
Backups.ofVolumes('main', 'data')— Back up entire volumesBackups.ofSyncs([{ dataPath, backupPath }])— Custom sync pairsBackups.withOptions({ exclude: ['cache/'] })— Rsync options
File Helpers (package/lib/util/fileHelper.ts)
Type-safe configuration file management:
const configFile = FileHelper.yaml(effects, sdk.volumes.main.path('config.yml'), {
port: 8080,
debug: false,
})
// Reactive reading
const config = await configFile.read.const(effects)
// Partial merge
await configFile.merge({ debug: true })
// Full write
await configFile.write({ port: 9090, debug: true })
Supported formats: JSON, YAML, TOML, INI, ENV, and custom parsers.
Subcontainers (package/lib/util/SubContainer.ts)
Execute commands in isolated container environments:
// Long-lived subcontainer
const container = await sdk.SubContainer.of(effects, { imageId: 'main' }, mounts, 'app')
// One-shot execution
await sdk.SubContainer.withTemp(effects, { imageId: 'main' }, mounts, 'migrate', async (c) => {
await c.exec(['run-migrations'])
})
Manifest Building (package/lib/manifest/)
const manifest = setupManifest({
id: 'my-service',
title: 'My Service',
license: 'MIT',
description: { short: '...', long: '...' },
images: { main: { source: { dockerTag: 'myimage:1.0' } } },
volumes: { main: {} },
dependencies: {},
// ...
})
export default buildManifest(manifest)
buildManifest() finalizes the manifest with the current SDK version, OS version compatibility, and migration version ranges.
Versioning (package/lib/version/)
Helpers for data version management during migrations:
await sdk.setDataVersion(effects, '1.2.0:0')
const version = await sdk.getDataVersion(effects)
Used in init scripts to track which migration version the service's data has been brought to.
Internationalization (package/lib/i18n/)
const t = setupI18n({ en_US: enStrings, es_ES: esStrings })
const greeting = t('hello', { name: 'World' }) // "Hello, World!" or "Hola, World!"
Supports locale fallback and Intl-based formatting.
Triggers (package/lib/trigger/)
Polling strategy functions that determine when health checks run:
sdk.trigger.defaultTrigger({ timeout: 30_000 })
sdk.trigger.cooldownTrigger({ timeout: 30_000, cooldown: 60_000 })
sdk.trigger.changeOnFirstSuccess({ first: 5_000, then: 30_000 })
sdk.trigger.successFailure({ success: 60_000, failure: 10_000 })
Build Pipeline
See CONTRIBUTING.md for detailed build instructions, make targets, and development workflow.
At a high level: Peggy generates the ExVer parser, TypeScript compiles both packages in strict mode (base to baseDist/, package to dist/), hand-written .js/.d.ts pairs are copied into the output, and node_modules are bundled for self-contained distribution.
Data Flow
A typical service package lifecycle:
1. INSTALL / UPDATE / RESTORE
├── init({ effects, kind })
│ ├── Version migrations (if update)
│ ├── setupDependencies()
│ ├── setupInterfaces() → bind ports, export interfaces
│ └── Actions registration → export actions to OS
│
2. MAIN
│ setupMain() → Daemons.of(effects)
│ ├── Oneshots run first
│ ├── Daemons start in dependency order
│ ├── Health checks begin polling
│ └── Service runs until shutdown/restart
│
3. SHUTDOWN / UNINSTALL / UPDATE
│ uninit({ effects, target })
│ └── Version down-migrations (if needed)
│
4. BACKUP (user-triggered)
createBackup({ effects })
└── rsync volumes to backup location
Key Design Patterns
Builder Pattern
Most SDK APIs use immutable builder chains: Daemons.of().addDaemon().addHealthCheck(), Mounts.of().mountVolume().mountAssets(), Actions.of().addAction(). This provides type accumulation — each chained call narrows the type to reflect what has been configured.
Effects as Capability System
All runtime interactions go through the Effects object rather than direct system calls. This makes the runtime boundary explicit, enables the OS to mediate all side effects, and makes service code testable by providing mock effects.
Reactive Subscriptions
The Watchable base class provides a consistent API for values that can change over time:
const(effects)— Read once; if the value changes, triggers a retry of the enclosing contextonce()— Read once without reactivitywatch()— Async generator yielding on each changeonChange(callback)— Invoke callback on each changewaitFor(predicate)— Block until a condition is met
Type-safe Manifest Threading
The manifest type flows through the entire SDK via generics. When you call StartSdk.of().withManifest(manifest), the manifest's volume names, image IDs, dependency IDs, and plugin list become available as type constraints throughout all subsequent API calls. For example, Mounts.of().mountVolume() only accepts volume names declared in the manifest.