host: the pieces every package uses live in shared, so a package never depends on an app

Clock, Logger, silentLogger, errorMessage, ControlPlaneError, notFound and the stderr logger move from
apps/scheduler to packages/shared; packages/channels and apps/api import them from there, and channels keeps the
scheduler for its tests only. One abortableSleep replaces four copies (scheduler, CLI, SDK, WhatsApp); the one
shared had slept the whole time on a signal that had already aborted. The API reports its package's version, as the
CLI does, instead of a hard-coded 0.1.0.
This commit is contained in:
feder-cr committed 2026-10-11 17:44:28 +02:00
1 parent c8cb603941
commit b0514eac62
25 files changed
+121 -181

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
* migrated, and checked against a master key that was lost.
*/
import { Database, databaseTarget, describeDatabaseTarget } from "@invisible-dots/database";
import { errorMessage, type Logger } from "@invisible-dots/scheduler";
import { errorMessage, type Logger } from "@invisible-dots/shared";
export interface OpenDatabaseOptions {
env: Record<string, string | undefined>;
+5 -18
View File
@@ -6,7 +6,7 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { TELEGRAM_TOKEN_SECRET, type ChannelHub } from "@invisible-dots/channels";
import { StreamOverflowError } from "@invisible-dots/events";
import { ControlPlaneError, errorMessage, silentLogger, type Logger, type Scheduler } from "@invisible-dots/scheduler";
import { type Scheduler } from "@invisible-dots/scheduler";
import { STREAM_ERROR_EVENT } from "@invisible-dots/sdk";
import type {
ApprovalsAnswer,
@@ -24,27 +24,14 @@ import type {
TasksAnswer,
UsageAnswer,
} from "@invisible-dots/sdk/types";
import {
APPROVAL_LIST_LIMIT,
CONVERSATION_LIST_LIMIT,
APPROVAL_STATUSES,
CHANNEL_KINDS,
LIST_ORDERS,
MAX_EVENT_PAGE,
TASK_LIST_LIMIT,
type ApprovalStatus,
type ChannelKind,
type DoctorCheck,
type ListOrder,
type SkillListAnswer,
type McpSecretsAnswer,
type ToolListAnswer,
} from "@invisible-dots/shared";
import { APPROVAL_LIST_LIMIT, APPROVAL_STATUSES, type ApprovalStatus, CHANNEL_KINDS, type ChannelKind, ControlPlaneError, CONVERSATION_LIST_LIMIT, type DoctorCheck, errorMessage, LIST_ORDERS, type ListOrder, type Logger, MAX_EVENT_PAGE, type McpSecretsAnswer, silentLogger, type SkillListAnswer, TASK_LIST_LIMIT, type ToolListAnswer } from "@invisible-dots/shared";
import { doctorAnswer } from "@invisible-dots/vm-manager";
import { serveFile } from "./file-types.js";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import apiPackage from "../package.json" with { type: "json" };
export const API_VERSION = "0.1.0";
/** The version `GET /api/health` reports: this package's, as the CLI's `--version` reports its own. */
export const API_VERSION: string = apiPackage.version;
/** A comment line on idle SSE connections, so proxies and clients see the connection is alive. */
export const SSE_HEARTBEAT_MS = 15_000;
+2 -2
View File
@@ -16,8 +16,8 @@ import {
} from "@invisible-dots/channels";
import type { Database } from "@invisible-dots/database";
import type { HostFacts } from "@invisible-dots/sdk/types";
import { errorMessage, prefixedStderrLogger, Scheduler, type ComputerDriver, type Logger, type SchedulerOptions } from "@invisible-dots/scheduler";
import { ensureDir, ENV, hostPaths, type HostPaths } from "@invisible-dots/shared";
import { Scheduler, type ComputerDriver, type SchedulerOptions } from "@invisible-dots/scheduler";
import { ensureDir, ENV, errorMessage, hostPaths, type HostPaths, type Logger, prefixedStderrLogger } from "@invisible-dots/shared";
import { hostDoctorDeps, openRouterCheck, runDoctor, type DoctorDeps } from "@invisible-dots/vm-manager";
import { loadOrCreateApiToken, loadOrGenerateMasterKey, parseListen, saveMasterKey, type ListenAddress } from "./config.js";
import { assertNothingEncrypted, openControlPlaneDatabase } from "./database.js";
+2 -12
View File
@@ -11,18 +11,8 @@
*/
import { access, readdir } from "node:fs/promises";
import { join } from "node:path";
import type {
ComputerDriver,
ComputerSpecInput,
ComputerState,
CreatedComputer,
GuestApi,
GuestEndpoint,
Logger,
StartedComputer,
WaitForHealthOptions,
} from "@invisible-dots/scheduler";
import { imageLabel, imageVersionOf, type HealthAnswer, type HostPaths, type ImageKind } from "@invisible-dots/shared";
import { type ComputerDriver, type ComputerSpecInput, type ComputerState, type CreatedComputer, type GuestApi, type GuestEndpoint, type StartedComputer, type WaitForHealthOptions } from "@invisible-dots/scheduler";
import { type HealthAnswer, type HostPaths, type ImageKind, imageLabel, imageVersionOf, type Logger } from "@invisible-dots/shared";
import { VmManager, type StartVmResult, type VmSpec } from "@invisible-dots/vm-manager";
/** Compare versions such as "2026.10.1" and "2026.9.3" numerically, part by part. */
+2 -2
View File
@@ -10,9 +10,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestDatabase, type TestDatabase } from "@invisible-dots/database/testing";
import { prefixedStderrLogger, Scheduler } from "@invisible-dots/scheduler";
import { Scheduler } from "@invisible-dots/scheduler";
import { FAKE_MAX_IDENTITIES, FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
import { computerResources, hostPaths, parseDotConfig, type HostPaths } from "@invisible-dots/shared";
import { computerResources, hostPaths, type HostPaths, parseDotConfig, prefixedStderrLogger } from "@invisible-dots/shared";
import { silentLogger, VmManager } from "@invisible-dots/vm-manager";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FakeQemuHost, FakeRunner, type FakeVm } from "../../vm-manager/test/fakes.js";
+2 -14
View File
@@ -7,7 +7,7 @@
*/
import { stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { allowlistedEnvironment, BASE_CHILD_ENV_VARS, ENV } from "@invisible-dots/shared";
import { abortableSleep, allowlistedEnvironment, BASE_CHILD_ENV_VARS, ENV } from "@invisible-dots/shared";
import { startProcess, WEB_BUILD_COMMAND, type StartedProcess, type StartProcessOptions, type WebBuild } from "@invisible-dots/vm-manager";
/**
@@ -97,18 +97,6 @@ function answers(url: string): Promise<boolean> {
);
}
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
}
signal?.addEventListener("abort", done, { once: true });
});
}
/**
* Start the web server and resolve once it answers a request. Throws
* WebStartError, with the child already gone, when the build is incomplete,
@@ -167,7 +155,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
started = true;
return { url, stop };
}
await sleep(READY_POLL_MS, options.signal);
await abortableSleep(READY_POLL_MS, options.signal);
}
} catch (error) {
await stop();
+1 -1
View File
@@ -6,7 +6,7 @@
* inbound delivery's job (inbound.ts).
*/
import type { Database } from "@invisible-dots/database";
import { errorMessage, type Clock, type Logger } from "./support.js";
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
// A claim skips a row another transaction holds (SKIP LOCKED); such holds last as long as one guest event's
// transaction. A pass that claimed nothing while a task could be claimed comes back this soon, this many times;
+1 -1
View File
@@ -16,7 +16,7 @@ import { TASK_ENDED_BEFORE_DELIVERY, type Database, type InboundRecord } from "@
import type { EventLog } from "@invisible-dots/events";
import { guestErrorCode, guestErrorStatus } from "./driver.js";
import type { Lifecycle } from "./lifecycle.js";
import { errorMessage, type Clock, type Logger } from "./support.js";
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
export interface InboundDeliveryOptions {
/** Failed sends with a known outcome before a task.created gives its task up as FAILED. */
+3 -2
View File
@@ -31,7 +31,8 @@ import {
} from "@invisible-dots/shared";
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type ComputerSpecInput, type ComputerState, type GuestApi } from "./driver.js";
import { applyGuestEvent, dotStatusForAgent, type AppliedGuestEvent } from "./guest-events.js";
import { BackgroundWork, ControlPlaneError, errorMessage, KeyedMutex, sleep, type Clock, type Logger } from "./support.js";
import { abortableSleep, ControlPlaneError, errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
import { BackgroundWork, KeyedMutex } from "./support.js";
export interface LifecycleOptions {
/** How long a started VM may take to pass the READY procedure. Default 10 minutes (a first boot runs cloud-init). */
@@ -716,7 +717,7 @@ export class Lifecycle {
this.#background.run("recording a VM that stopped by itself", dotId, () => this.#exited(dotId));
return;
}
await sleep(delay, signal);
await abortableSleep(delay, signal);
delay = Math.min(delay * 2, this.#opts.pumpMaxRetryMs);
}
}
+8 -10
View File
@@ -24,29 +24,36 @@ import {
checkMcpSecret,
checkOpenRouterKey,
COMPUTER_STOPPED,
ControlPlaneError,
computerIsUp,
computerResources,
CONVERSATION_LIST_LIMIT,
DotConfigError,
errorMessage,
isIdentityAnswer,
isStoredEventType,
MAX_HOST_FILE_BYTES,
newId,
notFound,
parseDotConfig,
parseMessageOrigin,
mcpServerNames,
parseSize,
permissionNames,
silentLogger,
systemClock,
TASK_CANCELLED_SYSTEM_EVENT,
TERMINAL_TASK_STATES,
vmName,
type ApprovalStatus,
type BrowserIdentity,
type Clock,
type CreateBrowserIdentityRequest,
type DotConfig,
type FilesListAnswer,
type InboundEvent,
type ListOrder,
type Logger,
type McpSecretsAnswer,
type MessageOrigin,
type StoredEvent,
@@ -58,16 +65,7 @@ import { Dispatcher } from "./dispatcher.js";
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type GuestApi } from "./driver.js";
import { InboundDelivery, type InboundDeliveryOptions } from "./inbound.js";
import { Lifecycle, MISSING_KEY_MESSAGE, type LifecycleOptions } from "./lifecycle.js";
import {
BackgroundWork,
ControlPlaneError,
errorMessage,
notFound,
silentLogger,
systemClock,
type Clock,
type Logger,
} from "./support.js";
import { BackgroundWork } from "./support.js";
export interface SchedulerOptions {
db: Database;
+2 -62
View File
@@ -1,33 +1,5 @@
/** Small pieces the scheduler is built from: clock, logger, a per-key lock and the error types the API maps to HTTP. */
export interface Clock {
now(): Date;
}
export const systemClock: Clock = { now: () => new Date() };
/** The logger shape vm-manager uses too, so one logger serves the whole process. */
export interface Logger {
debug(message: string, fields?: Record<string, unknown>): void;
info(message: string, fields?: Record<string, unknown>): void;
warn(message: string, fields?: Record<string, unknown>): void;
error(message: string, fields?: Record<string, unknown>): void;
}
export function prefixedStderrLogger(prefix: string, debug = false): Logger {
const write = (level: string, message: string, fields?: Record<string, unknown>) => {
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
process.stderr.write(`${new Date().toISOString()} [${prefix}] ${level} ${message}${suffix}\n`);
};
return {
debug: debug ? (m, f) => write("debug", m, f) : () => {},
info: (m, f) => write("info", m, f),
warn: (m, f) => write("warn", m, f),
error: (m, f) => write("error", m, f),
};
}
export const silentLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
/** The scheduler's own building blocks: a per-key lock, and the work it starts without waiting for it. */
import { errorMessage, type Logger } from "@invisible-dots/shared";
/**
* Serializes work per key: lifecycle operations on one Dot (create, start,
@@ -53,38 +25,6 @@ export class KeyedMutex {
}
}
/** An error with the HTTP status and `error` code the API answers with (section 9.6). */
export class ControlPlaneError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "ControlPlaneError";
}
}
export const notFound = (what: string, id: string) => new ControlPlaneError(404, "not_found", `${what} "${id}" not found`);
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const done = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
};
const timer = setTimeout(done, ms);
signal?.addEventListener("abort", done, { once: true });
});
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Work a caller starts without waiting for it (a provisioning, a stop, a push to a guest): its failure is logged,
* since nobody awaits it, and `settle` waits until all of it has finished (tests and shutdown). After `close` nothing
+2 -1
View File
@@ -8,7 +8,8 @@ import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import type { InboundEvent, OutboundEvent, SecretsRequest } from "@invisible-dots/shared";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { Scheduler, type Logger, type SchedulerOptions } from "../src/index.js";
import { Scheduler, type SchedulerOptions } from "../src/index.js";
import { type Logger } from "@invisible-dots/shared";
import type { GuestApi, GuestEndpoint } from "../src/index.js";
import { FakeDriver, FakeGuest, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
+3 -1
View File
@@ -9149,9 +9149,11 @@
"dependencies": {
"@invisible-dots/database": "0.0.0",
"@invisible-dots/events": "0.0.0",
"@invisible-dots/scheduler": "0.0.0",
"@invisible-dots/shared": "0.0.0",
"grammy": "1.46.0"
},
"devDependencies": {
"@invisible-dots/scheduler": "0.0.0"
}
},
"packages/database": {
+3 -1
View File
@@ -15,8 +15,10 @@
"dependencies": {
"@invisible-dots/database": "0.0.0",
"@invisible-dots/events": "0.0.0",
"@invisible-dots/scheduler": "0.0.0",
"@invisible-dots/shared": "0.0.0",
"grammy": "1.46.0"
},
"devDependencies": {
"@invisible-dots/scheduler": "0.0.0"
}
}
+1 -15
View File
@@ -10,21 +10,7 @@
import type { ChannelBindingRecord, ChannelPeerRow, Database } from "@invisible-dots/database";
import { isUniqueViolation } from "@invisible-dots/database";
import type { EventLog } from "@invisible-dots/events";
import {
CHANNEL_KINDS,
type ApprovalRecord,
newId,
type ChannelChange,
type ChannelKind,
type ChannelLinkFrame,
type ChannelPairingAnswer,
type ChannelRecord,
type ChannelSettings,
type MessageAnswer,
type MessageOrigin,
type StoredEvent,
} from "@invisible-dots/shared";
import { ControlPlaneError, errorMessage, notFound, silentLogger, systemClock, type Clock, type Logger } from "@invisible-dots/scheduler";
import { type ApprovalRecord, CHANNEL_KINDS, type ChannelChange, type ChannelKind, type ChannelLinkFrame, type ChannelPairingAnswer, type ChannelRecord, type ChannelSettings, type Clock, ControlPlaneError, errorMessage, type Logger, type MessageAnswer, type MessageOrigin, newId, notFound, silentLogger, type StoredEvent, systemClock } from "@invisible-dots/shared";
import { DEFAULT_BACKOFF, type BackoffOptions } from "./backoff.js";
import { parseApprovalReply } from "./approval-text.js";
import { ChannelCredentialsError, type ApprovalAction, type ChannelSink, type ChannelStatusReport, type ChannelType, type InboundChat, type PairingAttempt } from "./channel.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Clock } from "@invisible-dots/scheduler";
import { type Clock } from "@invisible-dots/shared";
/** A token bucket per key: `capacity` messages at once, refilled at `refillPerSecond`. */
export class RateLimiter {
+5 -6
View File
@@ -8,8 +8,7 @@
*/
import type { ChannelBindingRecord, Database } from "@invisible-dots/database";
import { StreamOverflowError, type EventLog } from "@invisible-dots/events";
import { parseMessageOrigin, type ChannelKind, type StoredEvent } from "@invisible-dots/shared";
import { errorMessage, sleep, type Logger } from "@invisible-dots/scheduler";
import { abortableSleep, type ChannelKind, errorMessage, type Logger, parseMessageOrigin, type StoredEvent } from "@invisible-dots/shared";
import { Backoff, type BackoffOptions } from "./backoff.js";
import { BindingSecrets } from "./binding-secrets.js";
import { approvalOutcomeText, approvalPromptText } from "./approval-text.js";
@@ -173,7 +172,7 @@ export class BindingRunner {
}
this.#o.logger.warn("a channel stopped, starting it again", { binding: this.bindingId, kind: this.kind, reason: failure });
this.report({ status: "error", detail: failure });
await sleep(Math.max(backoff.next(), asked), this.#signal);
await abortableSleep(Math.max(backoff.next(), asked), this.#signal);
}
}
@@ -192,7 +191,7 @@ export class BindingRunner {
if (this.#signal.aborted) return;
this.#o.logger.warn("a channel's event stream failed", { binding: this.bindingId, error: errorMessage(error) });
// Falling behind is not a fault of the database: resume from the cursor at once.
if (!(error instanceof StreamOverflowError)) await sleep(backoff.next(), this.#signal);
if (!(error instanceof StreamOverflowError)) await abortableSleep(backoff.next(), this.#signal);
}
}
}
@@ -349,7 +348,7 @@ export class BindingRunner {
const backoff = new Backoff(this.#o.backoff);
while (!this.#signal.aborted) {
if (this.channel) return this.channel;
await sleep(backoff.next(), this.#signal);
await abortableSleep(backoff.next(), this.#signal);
}
return null;
}
@@ -380,7 +379,7 @@ export class BindingRunner {
}
const wait = Math.max(backoff.next(), error instanceof ChannelSendError ? (error.options.retryAfterMs ?? 0) : 0);
this.#o.logger.warn("a channel could not send, trying again", { binding: this.bindingId, kind: this.kind, reason, waitMs: wait });
await sleep(wait, this.#signal);
await abortableSleep(wait, this.#signal);
}
}
return null;
@@ -19,7 +19,7 @@
* hub could not record is lost: the connection is dropped, the channel shows `error`, and the person writes again.
*/
import type { ChannelBindingRecord } from "@invisible-dots/database";
import { errorMessage } from "@invisible-dots/scheduler";
import { abortableSleep, errorMessage } from "@invisible-dots/shared";
import { approvalReplyHint } from "../approval-text.js";
import {
ChannelNeedsRelinkError,
@@ -51,7 +51,6 @@ const MAX_RESTARTS = 3;
const UNREAD_MAX = 500;
const defaultPause = () => 400 + Math.random() * 1100;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
interface Identity {
peerId: string;
@@ -153,7 +152,7 @@ class WhatsAppChannel implements Channel {
await connection.markRead(unread).catch(() => {});
}
await connection.typing(chatId).catch(() => {});
await sleep(this.pauseMs());
await abortableSleep(this.pauseMs());
return connection.sendText(chatId, text);
}
+1 -1
View File
@@ -6,7 +6,7 @@
*/
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import type { Logger } from "@invisible-dots/scheduler";
import { type Logger } from "@invisible-dots/shared";
import { waitFor } from "@invisible-dots/scheduler/testing";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { DEFAULT_CHANNEL_SETTINGS, TelegramChannelType } from "../src/index.js";
+2 -14
View File
@@ -3,6 +3,7 @@
* section 9.6), built on `fetch` so it runs in Node and in browsers.
*/
import {
abortableSleep,
SseParser,
type ApprovalStatus,
type BrowserIdentity,
@@ -501,7 +502,7 @@ export class InvisibleDotsClient {
}
attempt++;
options.onReconnect?.({ after, attempt, error: failure });
await delay(Math.min(500 * 2 ** (attempt - 1), 15_000), options.signal);
await abortableSleep(Math.min(500 * 2 ** (attempt - 1), 15_000), options.signal);
}
}
@@ -535,16 +536,3 @@ async function toApiError(response: Response, route: string): Promise<ApiError>
}
return new ApiError(response.status, `http_${response.status}`, `${route}: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
}
signal?.addEventListener("abort", done, { once: true });
});
}
+1
View File
@@ -10,5 +10,6 @@ export * from "./ids.js";
export * from "./protocol.js";
export * from "./sse.js";
export * from "./states.js";
export * from "./support.js";
export * from "./labels.js";
export * from "./tools.js";
+1 -13
View File
@@ -5,6 +5,7 @@
* every caller and every test that stands in for the VM layer.
*/
import type { HealthAnswer } from "./protocol.js";
import { abortableSleep } from "./support.js";
/** "agentd": dot-agentd answers. "agent": the agent behind it also reports status "ok". */
export type HealthUntil = "agentd" | "agent";
@@ -87,16 +88,3 @@ export async function pollGuestHealth(source: HealthSource, options: PollGuestHe
await abortableSleep(interval, options.signal);
}
}
/** A sleep that ends early, without an error, when `signal` aborts. */
export function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
}
signal?.addEventListener("abort", done, { once: true });
});
}
+1
View File
@@ -3,6 +3,7 @@ export * from "./browser.js";
export * from "./files.js";
export * from "./paths.js";
export * from "./sockets.js";
export * from "./stderr-logger.js";
export * from "./process.js";
export * from "./run-process.js";
export * from "./replace-file.js";
+15
View File
@@ -0,0 +1,15 @@
import type { Logger } from "./support.js";
/** Lines on stderr, each with the time and `[prefix]`; debug lines only when `debug` is set. */
export function prefixedStderrLogger(prefix: string, debug = false): Logger {
const write = (level: string, message: string, fields?: Record<string, unknown>) => {
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
process.stderr.write(`${new Date().toISOString()} [${prefix}] ${level} ${message}${suffix}\n`);
};
return {
debug: debug ? (m, f) => write("debug", m, f) : () => {},
info: (m, f) => write("info", m, f),
warn: (m, f) => write("warn", m, f),
error: (m, f) => write("error", m, f),
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Small pieces every part of the host is built from: the clock, the logger's shape, an error's message, a sleep that
* an abort ends, and the error the API answers with. They live here, below every app and package, so that a package
* (channels, the database layer) never depends on an app for them. All of it runs in a browser too.
*/
export interface Clock {
now(): Date;
}
export const systemClock: Clock = { now: () => new Date() };
/** The logger of every part of the control plane process, so one logger serves it all. */
export interface Logger {
debug(message: string, fields?: Record<string, unknown>): void;
info(message: string, fields?: Record<string, unknown>): void;
warn(message: string, fields?: Record<string, unknown>): void;
error(message: string, fields?: Record<string, unknown>): void;
}
export const silentLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** A sleep that ends early, without an error, when `signal` aborts, also one that has already aborted. */
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
}
signal?.addEventListener("abort", done, { once: true });
});
}
/** An error with the HTTP status and `error` code the API answers with (section 9.6). */
export class ControlPlaneError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "ControlPlaneError";
}
}
export const notFound = (what: string, id: string) => new ControlPlaneError(404, "not_found", `${what} "${id}" not found`);