projects/start-sdk/lib/StartSdk.ts
Rama master · réplica de lectura del repositorio original.
import * as fs from 'node:fs/promises'
import * as actions from '@start9labs/start-core/actions'
import { InputSpec } from '@start9labs/start-core/actions/input/builder/inputSpec'
import { List } from '@start9labs/start-core/actions/input/builder/list'
import { Value } from '@start9labs/start-core/actions/input/builder/value'
import { Variants } from '@start9labs/start-core/actions/input/builder/variants'
import {
customSmtp,
smtpInputSpec,
smtpProviderVariants,
systemSmtpSpec,
} from '@start9labs/start-core/actions/input/inputSpecConstants'
import {
Action,
ActionInfo,
Actions,
MaybeFn,
Run,
} from '@start9labs/start-core/actions/setupActions'
import { testTypeVersion } from '@start9labs/start-core/exver'
import {
setupInit,
setupOnInit,
setupOnUninit,
setupUninit,
} from '@start9labs/start-core/inits'
import { MultiHost, Scheme } from '@start9labs/start-core/interfaces/Host'
import { RangeInterfaceBuilder } from '@start9labs/start-core/interfaces/RangeInterfaceBuilder'
import { ServiceInterfaceBuilder } from '@start9labs/start-core/interfaces/ServiceInterfaceBuilder'
import { setupExportedUrls } from '@start9labs/start-core/interfaces/setupExportedUrls'
import { setupServiceInterfaces } from '@start9labs/start-core/interfaces/setupInterfaces'
import * as T from '@start9labs/start-core/types'
import { Effects, ServiceInterfaceType } from '@start9labs/start-core/types'
import { GetContainerIp } from '@start9labs/start-core/util/GetContainerIp'
import {
getBridgeAddress,
getHost,
getOwnHost,
} from '@start9labs/start-core/util/GetHostInfo'
import { GetStatus } from '@start9labs/start-core/util/GetStatus'
import * as patterns from '@start9labs/start-core/util/patterns'
import { Backups } from './backup/Backups'
import { Dependencies, Dependency } from './dependencies'
import { SetupBackupsParams, setupBackups } from './backup/setupBackups'
import {
SetupPrimaryUrlParams,
setupPrimaryUrl,
} from './primaryUrl/setupPrimaryUrl'
import { checkWebUrl, runHealthScript } from './health/checkFns'
import { checkPortListening } from './health/checkFns/checkPortListening'
import { getAddressGateways } from './util/getAddressGateways'
import { setupMain } from './mainFn'
import { Daemon, Daemons } from './mainFn/Daemons'
import { Mounts } from './mainFn/Mounts'
import { cooldownTrigger, onceTrigger, statusTrigger } from './trigger'
import { defaultTrigger } from './trigger/defaultTrigger'
import {
GetOutboundGateway,
GetSslCertificate,
GetSystemSmtp,
getRootCa,
getServiceManifest,
nullIfEmpty,
splitCommand,
} from './util'
import {
CommandOptions,
ExitError,
SubContainer,
SubContainerEager,
SubContainerLazy,
} from './util/SubContainer'
import { createVolumes } from './util/Volume'
import { getDataVersion, setDataVersion } from './version'
/** The minimum StartOS version required by this SDK release */
export const OSVersion = testTypeVersion('0.4.2')
// prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> =
T extends [] ? Else :
T extends [never, ...Array<any>] ? Then :
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
never
/**
* The top-level SDK facade for building StartOS service packages.
*
* Use `StartSdk.of()` to create an uninitialized instance, then call `.withManifest()`
* to bind it to a manifest, and finally `.build()` to obtain the full toolkit of helpers
* for actions, daemons, backups, interfaces, health checks, and more.
*
* @typeParam Manifest - The service manifest type; starts as `never` until `.withManifest()` is called.
*/
export class StartSdk<Manifest extends T.SDKManifest> {
private constructor(readonly manifest: Manifest) {}
/**
* Create an uninitialized StartSdk instance. Call `.withManifest()` next.
* @returns A new StartSdk with no manifest bound.
*/
static of() {
return new StartSdk<never>(null as never)
}
/**
* Bind a manifest to the SDK, producing a typed SDK instance.
* @param manifest - The service manifest definition
* @returns A new StartSdk instance parameterized by the given manifest type
*/
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest>(manifest)
}
private ifPluginEnabled<P extends T.PluginId, T>(
plugin: P,
value: T,
): Manifest extends { plugins: P[] } ? T : null {
if (this.manifest.plugins?.includes(plugin)) return value as any
return null as any
}
/**
* Finalize the SDK and return the full set of helpers for building a StartOS service.
*
* This method is only callable after `.withManifest()` has been called (enforced at the type level).
*
* @param isReady - Type-level gate; resolves to `true` only when a manifest is bound.
* @returns An object containing all SDK utilities: actions, daemons, backups, interfaces, health checks, volumes, triggers, and more.
*/
build(isReady: AnyNeverCond<[Manifest], 'Build not ready', true>) {
type NestedEffects =
| 'subcontainer'
| 'store'
| 'action'
| 'plugin'
| 'notification'
type InterfaceEffects =
| 'getServiceInterface'
| 'listServiceInterfaces'
| 'exportServiceInterface'
| 'exportRangeServiceInterface'
| 'clearServiceInterfaces'
| 'bind'
| 'bindRange'
| 'retireHost'
| 'retireBinding'
| 'getHostInfo'
type MainUsedEffects = 'setMainStatus'
type CallbackEffects =
| 'child'
| 'constRetry'
| 'isInContext'
| 'onLeaveContext'
| 'clearCallbacks'
type AlreadyExposed =
| 'getSslCertificate'
| 'getSystemSmtp'
| 'getOutboundGateway'
| 'getContainerIp'
| 'getStatus'
| 'getDataVersion'
| 'setDataVersion'
| 'getServiceManifest'
| 'prepareUpload'
| 'prepareDownload'
| 'resolveUpload'
| 'getUploadLimits'
// prettier-ignore
type StartSdkEffectWrapper = {
[K in keyof Omit<Effects, "eventId" | NestedEffects | InterfaceEffects | MainUsedEffects | CallbackEffects | AlreadyExposed>]: (effects: Effects, ...args: Parameters<Effects[K]>) => ReturnType<Effects[K]>
}
const startSdkEffectWrapper: StartSdkEffectWrapper = {
restart: (effects, ...args) => effects.restart(...args),
setDependencies: (effects, ...args) => effects.setDependencies(...args),
checkDependencies: (effects, ...args) =>
effects.checkDependencies(...args),
mount: (effects, ...args) => effects.mount(...args),
getInstalledPackages: (effects, ...args) =>
effects.getInstalledPackages(...args),
getServicePortForward: (effects, ...args) =>
effects.getServicePortForward(...args),
clearBindings: (effects, ...args) => effects.clearBindings(...args),
getOsIp: (effects, ...args) => effects.getOsIp(...args),
getSslKey: (effects, ...args) => effects.getSslKey(...args),
shutdown: (effects, ...args) => effects.shutdown(...args),
getDependencies: (effects, ...args) => effects.getDependencies(...args),
setHealth: (effects, ...args) => effects.setHealth(...args),
setBackupProgress: (effects, ...args) =>
effects.setBackupProgress(...args),
setInitProgress: (effects, ...args) => effects.setInitProgress(...args),
}
return {
/** The bound service manifest */
manifest: this.manifest,
Dependency,
Dependencies,
/** Volume path helpers derived from the manifest volume definitions */
volumes: createVolumes(this.manifest),
...startSdkEffectWrapper,
/** Prepares an upload destination for a File action input. */
prepareUpload: (
effects: Effects,
options: Omit<Parameters<Effects['prepareUpload']>[0], 'volume'> & {
volume: Manifest['volumes'][number]
},
) => effects.prepareUpload(options),
/** Prepares a volume file for a File action result. */
prepareDownload: (
effects: Effects,
options: Omit<Parameters<Effects['prepareDownload']>[0], 'volume'> & {
volume: Manifest['volumes'][number]
},
) => effects.prepareDownload(options),
/** Persist the current data version to the StartOS effect system */
setDataVersion,
/** Retrieve the current data version from the StartOS effect system */
getDataVersion,
action: {
/** Execute an action by its ID, optionally providing input */
run: actions.runAction,
/** Create a task notification for a specific package's action */
createTask: <T extends ActionInfo<T.ActionId, any>>(
effects: T.Effects,
packageId: T.PackageId,
action: T,
severity: T.TaskSeverity,
options?: actions.TaskOptions<T>,
) =>
actions.createTask({
effects,
packageId,
action,
severity,
options: options,
}),
/** Create a task notification for this service's own action (uses manifest.id automatically) */
createOwnTask: <T extends ActionInfo<T.ActionId, any>>(
effects: T.Effects,
action: T,
severity: T.TaskSeverity,
options?: actions.TaskOptions<T>,
) =>
actions.createTask({
effects,
packageId: this.manifest.id,
action,
severity,
options: options,
}),
/**
* Clear one or more task notifications by their replay IDs
* @param effects - The effects context
* @param replayIds - One or more replay IDs of the tasks to clear
*/
clearTask: (effects: T.Effects, ...replayIds: string[]) =>
effects.action.clearTasks({ only: replayIds }),
},
notification: {
/**
* @description Create a notification attributed to this service. The
* notification appears in the StartOS notifications panel just like
* the ones StartOS itself generates (e.g. on backup completion).
*
* Omit `data` for a plain notification — the panel row shows
* `title` and `message` only.
*
* Pass `data` as markdown text to attach a long-form body. The
* panel row still shows `title` and `message`; clicking
* "View Details" opens `data` rendered as markdown in a large
* modal. Use this for release notes, post-update changelogs,
* structured error reports — long-form content, not a short
* status string.
* @example
* ```
* await sdk.notification.create(effects, {
* level: 'info',
* title: 'Sync Complete',
* message: 'Initial block download finished.',
* })
* ```
* @example
* ```
* await sdk.notification.create(effects, {
* level: 'success',
* title: 'Update Complete',
* message: 'Hello World was updated to 2.0.0. Tap for release notes.',
* data: [
* '## What\'s new in 2.0.0',
* '',
* '- Faster sync on slow networks',
* '- New `--verbose` flag for the daemon',
* '- Fixed a crash on startup when the data volume was empty',
* '',
* 'See the full changelog at https://example.com/changelog.',
* ].join('\n'),
* })
* ```
*/
create: (
effects: T.Effects,
options: T.CreateNotificationParamsInput,
) => effects.notification.create(options),
},
host: {
/**
* Retrieve one of this package's own hosts by id, with reactive read
* strategies (`const`/`once`/`watch`/`onChange`/`waitFor`). Reach an
* exported service interface by walking the host:
* `host.bindings[internalPort].interfaces[id]` (single-port) or
* `host.bindingRanges[internalStartPort].interface` (port range).
*
* Pass an optional `map` (and `eq`) to react to only a slice of the
* host — with `const()`, the calling context re-runs when the mapped
* value changes rather than on any change to the whole host.
*
* @param effects - The effects context
* @param hostId - The host id passed to `sdk.MultiHost.of`
* @param map - optional selector narrowing reactivity to a child attr
* @param eq - optional equality for the mapped value (default deep-equal)
*/
getOwn: getOwnHost,
/**
* Retrieve a host from any package by id (defaults to this package when
* `packageId` is omitted), with the same reactive read strategies and
* the same optional `map`/`eq` selective reactivity as `getOwn`.
*
* @param effects - The effects context
* @param opts - `{ hostId, packageId? }`
* @param map - optional selector narrowing reactivity to a child attr
* @param eq - optional equality for the mapped value (default deep-equal)
*/
get: getHost,
/**
* Resolve the gateways publishing the addresses a service advertises,
* beside its outbound gateway. Reads once per call, for use in a
* health check.
*
* @param effects - The effects context
* @param hostId - The host whose bindings publish the addresses
* @param addresses - IP literals or domains read from the service's rendered configuration
*/
getAddressGateways,
/**
* Resolve the bridge address (`10.0.3.1:<port>`) a dependency's binding
* is reachable at from this container, with the same reactive read
* strategies as `get`/`getOwn`.
*
* Prefer this over reading `net.assignedPort` / `net.assignedSslPort`:
* which of those are populated is a property of how the *dependency*
* bound the port, so reading either directly resolves `null` the day
* the dependency changes how it serves TLS. It
* also resolves bindings with no exported interface, such as tor's
* SOCKS proxy.
*
* @param effects - The effects context
* @param opts - `{ hostId, packageId?, internalPort, ssl?, fallbackPort? }`.
* Pass `ssl` only for a binding publishing both a plaintext and a TLS
* address; `fallbackPort` only for a flag that must be passed even while
* the dependency is absent (tor's SOCKS 9050).
*/
getBridgeAddress,
},
/**
* Get the container IP address with reactive subscription support.
*
* Returns an object with multiple read strategies: `const()` for a value
* that retries on change, `once()` for a single read, `watch()` for an async
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
*
* @param effects - The effects context
* @param options - Optional filtering options (e.g. `containerId`)
*/
getContainerIp: <Opts extends { packageId?: T.PackageId } = {}>(
effects: T.Effects,
options: Opts = {} as Opts,
) => new GetContainerIp(effects, options),
/**
* Get the service's current status with reactive subscription support.
*
* Returns an object with multiple read strategies: `const()` for a value
* that retries on change, `once()` for a single read, `watch()` for an async
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
*
* @param effects - The effects context
* @param options - Optional filtering options (e.g. `packageId`)
*/
getStatus: (
effects: T.Effects,
options: Omit<Parameters<T.Effects['getStatus']>[0], 'callback'> = {},
) => new GetStatus(effects, options),
MultiHost: {
/**
* Create a new MultiHost instance for binding ports and exporting interfaces.
* @param effects - The effects context
* @param id - A unique identifier for this multi-host group
*/
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
},
/**
* Return `null` if the given string is empty, otherwise return the string unchanged.
* Useful for converting empty user input into explicit null values.
*/
nullIfEmpty,
/**
* Indicate that a daemon should use the container image's configured entrypoint.
* @param overrideCmd - Optional command arguments to append after the entrypoint
*/
useEntrypoint: (overrideCmd?: string[]) =>
new T.UseEntrypoint(overrideCmd),
/**
* @description Use this class to create an Action. By convention, each Action should receive its own file.
*
*/
Action: {
/**
* @description Use this function to create an action that accepts form input
* @param id - a unique ID for this action
* @param metadata - information describing the action and its availability
* @param inputSpec - define the form input using the InputSpec and Value classes
* @param prefillFn - optionally fetch data from the file system to pre-fill the input form. Must returns a deep partial of the input spec
* @param executionFn - execute the action. Optionally return data for the user to view. Must be in the structure of an ActionResult, version "1"
* @example
* In this example, we create an action for a user to provide their name.
* We prefill the input form with their existing name from the service's yaml file.
* The new name is saved to the yaml file, and we return nothing to the user, which
* means they will receive a generic success message.
*
* ```
import { sdk } from '../sdk'
import { yamlFile } from '../file-models/config.yml'
const { InputSpec, Value } = sdk
export const inputSpec = InputSpec.of({
name: Value.text({
name: 'Name',
description:
'When you launch the Hello World UI, it will display "Hello [Name]"',
required: true,
default: 'World',
}),
})
export const setName = sdk.Action.withInput(
// id
'set-name',
// metadata
async ({ effects }) => ({
name: 'Set Name',
description: 'Set your name so Hello World can say hello to you',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// form input specification
inputSpec,
// optionally pre-fill the input form
async ({ effects }) => {
const name = await yamlFile.read.const(effects)?.name
return { name }
},
// the execution function
async ({ effects, input }) => yamlFile.merge(input)
)
* ```
*/
withInput: Action.withInput,
/**
* @description Use this function to create an action that does not accept form input
* @param id - a unique ID for this action
* @param metadata - information describing the action and its availability
* @param executionFn - execute the action. Optionally return data for the user to view. Must be in the structure of an ActionResult, version "1"
* @example
* In this example, we create an action that returns a secret phrase for the user to see.
*
* ```
import { store } from '../file-models/store.json'
import { sdk } from '../sdk'
export const showSecretPhrase = sdk.Action.withoutInput(
// id
'show-secret-phrase',
// metadata
async ({ effects }) => ({
name: 'Show Secret Phrase',
description: 'Reveal the secret phrase for Hello World',
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// the execution function
async ({ effects }) => ({
version: '1',
title: 'Secret Phrase',
message:
'Below is your secret phrase. Use it to gain access to extraordinary places',
result: {
type: 'single',
value: (await store.read.once())?.secretPhrase,
copyable: true,
qr: true,
masked: true,
launchable: false,
},
}),
)
* ```
*/
withoutInput: <Id extends T.ActionId>(
id: Id,
metadata: MaybeFn<Omit<T.ActionMetadataInput, 'hasInput'>>,
run: Run<{}>,
) => Action.withoutInput(id, metadata, run),
},
inputSpecConstants: {
smtpInputSpec,
systemSmtpSpec,
customSmtp,
smtpProviderVariants,
},
/**
* @description Use this function to create a service interface.
* @param effects
* @param options
* @example
* In this example, we create a standard web UI
*
* ```
const ui = sdk.createInterface(effects, {
name: 'Web UI',
id: 'ui',
description: 'The primary web app for this service.',
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
* ```
*/
createInterface: (
effects: Effects,
options: {
/** The human readable name of this service interface. */
name: string
/** A unique ID for this service interface. */
id: string
/** The human readable description. */
description: string
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see an option to open the UI in a new tab */
type: ServiceInterfaceType
/** (optional) prepends the provided username to all URLs. */
username: null | string
/** (optional) appends the provided path to all URLs. */
path: string
/** (optional) appends the provided query params to all URLs. */
query: Record<string, string>
/** (optional) overrides the protocol prefix provided by the bind function.
*
* @example `{ ssl: 'ftps', noSsl: 'ftp' }`
*/
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
/** mask the url (recommended if it contains credentials such as an API key or password) */
masked: boolean
/** An absolute interface URL that StartOS should prefer for Open UI.
*
* @see {@link https://docs.start9.com/packaging/interfaces.html#nominating-an-address-to-open Nominating an Address to Open}
*/
preferredLauncherAddress?: string | null
},
) => new ServiceInterfaceBuilder({ ...options, effects }),
/**
* A function for creating the single, restricted service interface a port
* range exports. Pass the result to `RangeOrigin.export` (the handle
* returned by `MultiHost.bindPortRange`). A range interface is always
* `api`-typed and has no masked/username/path/query/SSL — its address is
* the host plus the range's external port span.
*
* @example
* ```
const range = await sdk.MultiHost.of(effects, 'zmq').bindPortRange({
internalStartPort: 28332,
externalStartPort: 28332,
numberOfPorts: 2,
})
await range.export(
sdk.createRangeInterface(effects, {
id: 'zmq',
name: 'ZMQ',
description: 'Bitcoin ZMQ notification endpoints',
scheme: 'tcp',
}),
)
* ```
*/
createRangeInterface: (
effects: Effects,
options: {
/** A unique ID for this range service interface. */
id: string
/** The human readable name of this range service interface. */
name: string
/** The human readable description. */
description: string
/** (optional) transport scheme prefix, e.g. `'tcp'` for bitcoin ZMQ
* endpoints. Omit for raw UDP/TCP ranges (coturn, RTP, FTP). */
scheme?: string | null
},
) => new RangeInterfaceBuilder({ ...options, effects }),
/**
* Get the system SMTP configuration with reactive subscription support.
* @param effects - The effects context
*/
getSystemSmtp: <E extends Effects>(effects: E) =>
new GetSystemSmtp(effects),
/**
* Get the outbound network gateway address with reactive subscription support.
* @param effects - The effects context
*/
getOutboundGateway: <E extends Effects>(effects: E) =>
new GetOutboundGateway(effects),
/**
* Get an SSL certificate for the given hostnames with reactive subscription support.
* @param effects - The effects context
* @param hostnames - The hostnames to obtain a certificate for
* @param algorithm - Optional algorithm preference (e.g. Ed25519)
*/
getSslCertificate: <E extends Effects>(
effects: E,
hostnames: string[],
algorithm?: T.Algorithm,
) => new GetSslCertificate(effects, { hostnames, algorithm }),
/**
* Get this server's root CA certificate (PEM). Install it in your
* container to reach other services on this server over HTTPS.
*/
getRootCa,
/** Retrieve the manifest of any installed service package by its ID */
getServiceManifest,
/**
* Built-in health check functions for use in `ready.fn`.
*
* These cover the most common readiness patterns. For custom logic,
* write your own async function returning a {@link HealthCheckResult}.
*/
healthCheck: {
/** Check whether a TCP/UDP port is bound and listening (reads /proc/net) */
checkPortListening,
/** Check whether a URL is reachable via HTTP fetch */
checkWebUrl,
/** Run a command in a subcontainer and succeed on exit code 0 */
runHealthScript,
},
/** Common utility patterns (e.g. hostname regex, port validators) */
patterns,
/**
* @description Use this function to list every Action offered by the service. Actions will be displayed in the provided order.
*
* By convention, each Action should receive its own file in the "actions" directory.
* @example
*
* ```
import { sdk } from '../sdk'
import { config } from './config'
import { nameToLogs } from './nameToLogs'
export const actions = sdk.Actions.of().addAction(config).addAction(nameToLogs)
* ```
*/
Actions,
/**
* @description Use this function to determine which volumes are backed up when a user creates a backup, including advanced options.
* @example
* In this example, we back up the entire "main" volume and nothing else.
*
* ```
import { sdk } from './sdk'
export const { createBackup, restoreBackup } = sdk.setupBackups(
async ({ effects }) => sdk.Backups.volumes('main'),
)
* ```
* @example
* In this example, we back up the "main" volume, but exclude hypothetical directory "excludedDir".
*
* ```
import { sdk } from './sdk'
export const { createBackup, restoreBackup } = sdk.setupBackups(async () =>
sdk.Backups.volumes('main').setOptions({
exclude: ['excludedDir'],
}),
)
* ```
*/
setupBackups: (options: SetupBackupsParams<Manifest>) =>
setupBackups<Manifest>(options),
/**
* @description Let the user choose which of an interface's URLs the service advertises as its own — the one it puts in the links, invites and callbacks it generates. The package stores the choice: `get` is its reader, in the shape `FileHelper.read()` returns, and `set` writes it.
*
* Returns `action` (add it to `sdk.Actions.of()`); `bestUsable(effects)`, a reader for the URL the service should use; and `setupTask(severity, options)`, an init script (add it to `sdk.setupInit()`) that keeps a task raised while the stored URL is unset or no longer one of the interface's addresses.
* @example
* ```
import { sdk } from './sdk'
import { i18n } from './i18n'
import { storeJson } from './fileModels/store.json'
export const primaryUrl = sdk.setupPrimaryUrl({
id: 'set-primary-url',
hostId: 'ui-multi',
interfaceId: 'ui',
metadata: {
name: i18n('Set Primary URL'),
description: i18n('Choose the URL Immich puts in the share links it generates'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
},
field: { name: i18n('URL'), description: null },
get: storeJson.read(s => s.primaryUrl),
set: (effects, url) => storeJson.merge(effects, { primaryUrl: url }),
})
// init/index.ts, after `actions`
primaryUrl.setupTask('important', {
reason: i18n('Choose the URL Immich puts in its share links'),
})
// main.ts, and interfaces.ts for `preferredLauncherAddress`
const url = await primaryUrl.bestUsable(effects).const()
* ```
*/
setupPrimaryUrl: <Id extends T.ActionId>(
params: SetupPrimaryUrlParams<Id>,
) => setupPrimaryUrl<Id>(this.manifest.id, params),
/**
* @description Use this function to create an InitScript that runs every time the service initializes (install, update, restore, rebuild, and server bootup)
*/
setupOnInit,
/**
* @description Use this function to create an UninitScript that runs every time the service uninitializes (update, uninstall, and server shutdown)
*/
setupOnUninit,
/**
* @description Use this function to setup what happens when the service initializes.
*
* This happens when the server boots, or a service is installed, updated, or restored
*
* Not every init script does something on every initialization. For example, versions only does something on install or update
*
* These scripts are run in the order they are supplied
* @example
*
* ```
export const init = sdk.setupInit(
restoreInit,
versions,
setInterfaces,
actions,
dependencies,
postInstall,
)
* ```
*/
setupInit: setupInit,
/**
* @description Use this function to setup what happens when the service uninitializes.
*
* This happens when the server shuts down, or a service is uninstalled or updated
*
* Not every uninit script does something on every uninitialization. For example, versions only does something on uninstall or update
*
* These scripts are run in the order they are supplied
* @example
*
* ```
export const uninit = sdk.setupUninit(
versions,
)
* ```
*/
setupUninit: setupUninit,
/**
* @description Use this function to determine how this service will be hosted and served. The function executes on service install, service update, and inputSpec save.
* @param inputSpec - The inputSpec spec of this service as exported from /inputSpec/spec.
* @param fn - an async function that returns an array of interface receipts. The function always has access to `effects`; it has access to `input` only after inputSpec save, otherwise `input` will be null.
* @example
* In this example, we create two UIs from one multi-host, and one API from another multi-host.
*
* ```
export const setInterfaces = sdk.setupInterfaces(
async ({ effects }) => {
// ** UI multi-host **
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(80, {
protocol: 'http',
})
// Primary UI
const primaryUi = sdk.createInterface(effects, {
name: 'Primary UI',
id: 'primary-ui',
description: 'The primary web app for this service.',
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
// Admin UI
const adminUi = sdk.createInterface(effects, {
name: 'Admin UI',
id: 'admin-ui',
description: 'The admin web app for this service.',
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '/admin',
query: {},
})
// UI receipt
const uiReceipt = await uiMultiOrigin.export([primaryUi, adminUi])
// ** API multi-host **
const apiMulti = sdk.MultiHost.of(effects, 'api-multi')
const apiMultiOrigin = await apiMulti.bindPort(5959, {
protocol: 'http',
})
// API
const api = sdk.createInterface(effects, {
name: 'Admin API',
id: 'api',
description: 'The advanced API for this service.',
type: 'api',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
// API receipt
const apiReceipt = await apiMultiOrigin.export([api])
// ** Return receipts **
return [uiReceipt, apiReceipt]
},
)
* ```
*/
setupInterfaces: setupServiceInterfaces,
/**
* Define the main entrypoint for the service. `main` is always `setupMain`.
* The provided function returns the daemon topology: either a static
* `sdk.Daemons.of(effects).addDaemon(...)` chain, or — for a daemon set that
* changes at runtime — the reconciler from
* `sdk.Daemons.dynamic(effects, fn)`. Both are `T.DaemonBuildable`.
* @param fn - Async function that receives `effects` and returns a `Daemons` chain or a `DaemonsReconciler`
*/
setupMain: (
fn: (o: { effects: Effects }) => Promise<T.DaemonBuildable>,
) => setupMain<Manifest>(fn),
/**
* Built-in trigger strategies that control how often a health check polls.
*
* Pass any of these to the `trigger` field on a {@link Ready} config.
* If omitted, health checks use `defaultTrigger`.
*/
trigger: {
/**
* Every 1 s while `starting` or `failure`; every 30 s while `success`,
* `loading`, `disabled`, or `starting` with `waiting: true`, except that
* `loading` or `waiting: true` before the first `success` backs off from
* 1 s, doubling to 30 s.
*/
defaultTrigger,
/** Fixed interval between checks. Use for a simple, constant polling rate. */
cooldownTrigger,
/**
* One check at start. Pair with `gracePeriod: 0`, or the single
* result is reported as `starting`.
*/
onceTrigger,
/**
* Per-status polling intervals. Map each health status (`success`,
* `loading`, `starting`, `failure`, etc.) to a cooldown in
* milliseconds. Unspecified statuses fall back to 30 s.
*/
statusTrigger,
},
Mounts: {
/**
* Create an empty Mounts builder for declaring volume, asset, dependency, and backup mounts.
* @returns A new Mounts instance with no mounts configured
*/
of: Mounts.of<Manifest>,
},
Backups: {
/**
* Create a Backups configuration that backs up entire volumes by name.
* @param volumeNames - Volume IDs from the manifest to include in backups
*/
ofVolumes: Backups.ofVolumes<Manifest>,
/**
* Create a Backups configuration from explicit sync path pairs.
* @param syncs - Array of `{ dataPath, backupPath }` objects
*/
ofSyncs: Backups.ofSyncs<Manifest>,
/**
* Create a Backups configuration with custom rsync options (e.g. exclude patterns).
* @param options - Partial sync options to override defaults
*/
withOptions: Backups.withOptions<Manifest>,
/**
* Create a Backups configuration that uses pg_dump/pg_restore instead of
* rsyncing the raw PostgreSQL data directory. Chain `.addVolume()` to include
* additional volumes in the backup.
*/
withPgDump: Backups.withPgDump<Manifest>,
/** Back up and restore a MySQL database through a logical dump. */
withMysqlDump: Backups.withMysqlDump<Manifest>,
/** Back up and restore a MariaDB database through a logical dump. */
withMariadbDump: Backups.withMariadbDump<Manifest>,
},
InputSpec: {
/**
* @description Use this function to define the inputSpec specification that will ultimately present to the user as validated form inputs.
*
* Most form controls are supported, including text, textarea, number, toggle, select, multiselect, list, color, datetime, object (sub form), and union (conditional sub form).
* @example
* In this example, we define a inputSpec form with two value: name and makePublic.
*
* ```
import { sdk } from '../sdk'
const { InputSpec, Value } = sdk
export const inputSpecSpec = InputSpec.of({
name: Value.text({
name: 'Name',
description:
'When you launch the Hello World UI, it will display "Hello [Name]"',
required: true,
default: 'World'
}),
makePublic: Value.toggle({
name: 'Make Public',
description: 'Whether or not to expose the service to the network',
default: false,
}),
})
* ```
*/
of: <Spec extends Record<string, Value<any>>>(spec: Spec) =>
InputSpec.of<Spec>(spec),
},
Daemon: {
/**
* Create a single Daemon that wraps a long-running process with automatic restart logic.
* Returns a curried function: call with `(effects, subcontainer, exec)`.
*/
get of() {
return Daemon.of<Manifest>()
},
},
Daemons: {
/**
* Create a new Daemons builder for defining the service's daemon topology.
* Chain `.addDaemon()` calls to register each long-running process.
* @param effects - The effects context
*/
of(effects: Effects) {
return Daemons.of<Manifest>({ effects })
},
/**
* Build a reconciler whose daemon set is a function of on-disk state:
* it diffs `fn`'s freshly-built `Daemons` chain against the running set
* on every `effects.constRetry` trigger, touching only what changed.
*
* Return it from `setupMain` — it is a `T.DaemonBuildable`, just like a
* static `sdk.Daemons.of(...)` chain. See {@link Daemons.dynamic} for
* diff semantics and the rule that `fn`'s subcontainers must be lazy
* (`sdk.SubContainer.of(...)`).
*
* @param effects The effects context, from `setupMain`
* @param fn Async builder invoked on startup and on every constRetry
*/
dynamic(
effects: Effects,
fn: (o: {
effects: Effects
}) => Promise<Daemons<Manifest, any>> | Daemons<Manifest, any>,
) {
return Daemons.dynamic<Manifest>(effects, fn)
},
},
SubContainer: {
/**
* @description Create a new SubContainer
* @param effects
* @param image - what container image to use
* @param mounts - what to mount to the subcontainer
* @param name - a name to use to refer to the subcontainer for debugging purposes
*/
of(
effects: Effects,
image: {
imageId: T.ImageId & keyof Manifest['images']
sharedRun?: boolean
},
mounts: Mounts<Manifest> | null,
name: string,
): SubContainerLazy<Manifest, Effects> {
return SubContainer.of<Manifest, Effects>(
effects,
image,
mounts,
name,
)
},
/**
* Create an eager SubContainer, materializing its backing
* filesystem immediately. Use when you need synchronous access to
* `rootfs` / `guid` / `subpath()` before running any methods, or
* want `createFs` failures to surface here instead of at first
* method call.
*
* **Do not pass an eager SubContainer to `Daemons.dynamic`'s `fn`** —
* see {@link SubContainer.eager}.
*/
eager(
effects: Effects,
image: {
imageId: T.ImageId & keyof Manifest['images']
sharedRun?: boolean
},
mounts: Mounts<Manifest> | null,
name: string,
): Promise<SubContainerEager<Manifest, Effects>> {
return SubContainer.eager<Manifest, Effects>(
effects,
image,
mounts,
name,
)
},
/**
* Run a function with an ephemeral, eager SubContainer. The
* container is created before `fn` runs and destroyed in a
* `finally` block after.
*/
withTemp<T>(
effects: Effects,
image: {
imageId: T.ImageId & keyof Manifest['images']
sharedRun?: boolean
},
mounts: Mounts<Manifest> | null,
name: string,
fn: (
subContainer: SubContainerEager<Manifest, Effects>,
) => Promise<T>,
): Promise<T> {
return SubContainer.withTemp<Manifest, T, Effects>(
effects,
image,
mounts,
name,
fn,
)
},
},
List,
Value,
Variants,
plugin: {
url: this.ifPluginEnabled('url-v0' as const, {
register: (
effects: T.Effects,
options: {
tableAction: ActionInfo<
T.ActionId,
{
urlPluginMetadata: {
packageId: T.PackageId
interfaceId: T.ServiceInterfaceId
hostId: T.HostId
internalPort: number
}
}
>
},
) =>
effects.plugin.url.register({
tableAction: options.tableAction.id,
}),
exportUrl: (
effects: T.Effects,
options: {
hostnameInfo: T.PluginHostnameInfo
removeAction: ActionInfo<
T.ActionId,
{
urlPluginMetadata: T.PluginHostnameInfo & {
interfaceId: T.ServiceInterfaceId
}
}
> | null
overflowActions: ActionInfo<
T.ActionId,
{
urlPluginMetadata: T.PluginHostnameInfo & {
interfaceId: T.ServiceInterfaceId
}
}
>[]
},
) =>
effects.plugin.url.exportUrl({
hostnameInfo: options.hostnameInfo,
removeAction: options.removeAction?.id ?? null,
overflowActions: options.overflowActions.map(a => a.id),
}),
setupExportedUrls, // similar to setupInterfaces
}),
},
}
}
}
/**
* Run a one-shot command inside a temporary subcontainer.
*
* Creates a subcontainer, executes the command, and destroys the subcontainer when finished.
* Throws an {@link ExitError} if the command exits with a non-zero code or signal.
*
* @param effects - The effects context
* @param image - The container image to use
* @param command - The command to execute (string array or UseEntrypoint)
* @param options - Mount and command options
* @param name - Optional human-readable name for debugging
* @returns The stdout and stderr output of the command
*/
export async function runCommand<Manifest extends T.SDKManifest>(
effects: Effects,
image: { imageId: keyof Manifest['images'] & T.ImageId; sharedRun?: boolean },
command: T.CommandType,
options: CommandOptions & {
mounts: Mounts<Manifest> | null
},
name?: string,
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
let commands: string[]
if (T.isUseEntrypoint(command)) {
const imageMeta: T.ImageMetadata = await fs
.readFile(`/media/startos/images/${image.imageId}.json`, {
encoding: 'utf8',
})
.catch(() => '{}')
.then(JSON.parse)
commands = imageMeta.entrypoint ?? []
commands = commands.concat(...(command.overridCmd ?? imageMeta.cmd ?? []))
} else commands = splitCommand(command)
return SubContainer.withTemp<
Manifest,
{ stdout: string | Buffer; stderr: string | Buffer }
>(
effects,
image,
options.mounts,
name ||
commands
.map(c => {
if (c.includes(' ')) {
return `"${c.replace(/"/g, `\"`)}"`
} else {
return c
}
})
.join(' '),
async subcontainer => {
const res = await subcontainer.exec(commands)
if (res.exitCode || res.exitSignal) {
throw new ExitError(commands[0], res)
} else {
return res
}
},
)
}