/** * Bun.serve() wrapper with WebSocket and SSE support, optional external UI * mounting, and clean shutdown. * * @module server */ import type { RegistryAgnosticEngine } from '../core/engine.ts'; import type { RetryPolicy } from '../core/types.ts'; import type { PrometheusExporter } from '../observability/metrics.ts'; import type { RoutingPolicy } from '../worker/registry.ts'; import { WorkerRegistry } from '../worker/registry.ts'; import type { AuthConfig, RateLimitConfig } from './authentication.ts'; import { type DashboardAssets } from './dashboard-assets.ts'; import type { DiscoveryInfo } from './discovery-info.ts'; import type { CorsOptions } from './runtime/cors.ts'; import type { TaskResultView } from './runtime/task-result-view.ts'; import type { SchedulingPolicy } from './task-queue-types.ts'; import { TaskQueue } from './task-queue.ts'; import type { WorkerAdmissionPolicy } from './worker-admission-policy.ts'; export { wireEventBroadcasting, type EventBroadcastingHandle, } from './runtime/event-broadcasting.ts'; export type { DashboardAssets } from './dashboard-assets.ts'; export type { CorsOptions } from './runtime/cors.ts'; export type { WorkerAdmissionDecision, WorkerAdmissionPolicy, WorkerAdmissionRequest, } from './worker-admission-policy.ts'; export { AUTHORIZATION_SCOPES, isAuthorizationScope, type AuthorizationScope, } from './authorization-scope.ts'; export type { GetPrincipalOutput } from './operations/get-principal.ts'; export { createRateLimiter, createRotatingApiKeyStore, defaultAuthAuditSink, isSensitiveHeader, redactCredential, redactHeaders, validateRateLimitConfig, type ApiKeyRegistration, type AuthAuditEvent, type AuthAuditSink, type AuthConfig, type RateLimitConfig, type RateLimitDecision, type RateLimiter, type RotatingApiKeyStore, } from './authentication.ts'; export type { RetryPolicy } from '../core/types.ts'; export type { PrometheusExporter } from '../observability/metrics.ts'; export { WorkerRegistry } from '../worker/registry.ts'; export type { RoutingPolicy } from '../worker/registry.ts'; export type { DiscoveryInfo } from './discovery-info.ts'; export type { TaskResultView } from './runtime/task-result-view.ts'; export type { SchedulingPolicy } from './task-queue-types.ts'; export { TaskQueue } from './task-queue.ts'; /** * Static route patterns at which an externally supplied dashboard shell is * served. Weft no longer bundles a dashboard, but `serve({ dashboard })` * keeps a same-origin mounting point for packages that provide one. * * They are intentionally specific (no blanket `/*`) so they cannot shadow the * API served under the `/api` prefix or the root-stable discovery endpoints — * those fall through to the `fetch` handler. * The supported page routes are `/`, `/workflows`, `/workflows/*`, `/reviews`, * `/workers`, `/schedules`, `/storage`, and `/system`. * * @example * ```ts * import { DASHBOARD_PAGE_ROUTES } from '@lostgradient/weft/server'; * * // A mounted dashboard shell owns these specific page routes. * console.log(DASHBOARD_PAGE_ROUTES[0]); // '/' * console.log(DASHBOARD_PAGE_ROUTES.includes('/workflows')); // true * ``` */ export declare const DASHBOARD_PAGE_ROUTES: readonly ["/", "/workflows", "/workflows/*", "/reviews", "/workers", "/schedules", "/storage", "/system"]; /** * Route pattern owned by an externally supplied dashboard shell. * * Weft mounts a caller-provided `serve({ dashboard })` target only at these * page routes, leaving API and discovery routes to the server fetch handler. * * @example * ```ts * import type { DashboardPageRoute } from '@lostgradient/weft/server'; * * const workflowRoute: DashboardPageRoute = '/workflows/*'; * void workflowRoute; * ``` */ export type DashboardPageRoute = (typeof DASHBOARD_PAGE_ROUTES)[number]; /** * Bun route target accepted by `serve({ dashboard })`. * * Pass a static `Response` or a Bun route handler supplied by an external * dashboard package. Weft serves that target only at {@link DASHBOARD_PAGE_ROUTES}. * * @example * ```ts * import type { DashboardRouteTarget } from '@lostgradient/weft/server'; * * const dashboard: DashboardRouteTarget = new Response('
', { * headers: { 'Content-Type': 'text/html; charset=utf-8' }, * }); * void dashboard; * ``` */ export type DashboardRouteTarget = Bun.Serve.Routes[string]; /** * Startup policy for `serve()` when no `auth` configuration is supplied. * * @example * ```ts * import type { UnauthenticatedAccessPolicy } from '@lostgradient/weft/server'; * * const unauthenticatedAccess: UnauthenticatedAccessPolicy = 'reject'; * void unauthenticatedAccess; * ``` */ export type UnauthenticatedAccessPolicy = 'warn' | 'allow' | 'reject'; /** * Configuration object for the `serve()` function. * * At minimum supply an `engine` and optionally a `port`. Authentication, * routing policy, metrics, and worker-dispatch settings are all optional — the * server runs with sensible defaults when omitted. * * @example * ```ts * import { serve, type ServeOptions } from '@lostgradient/weft/server'; * import { Engine, MemoryStorage } from '@lostgradient/weft'; * * await using storage = new MemoryStorage(); * await using engine = new Engine({ storage }); * * const options: ServeOptions = { * engine, * port: 3000, * auth: { apiKeys: ['secret'] }, * }; * await using server = serve(options); * console.log(server.url); // http://localhost:3000 * ``` */ export interface ServeOptions { /** * The engine `serve()` hosts. Typed as {@link RegistryAgnosticEngine} * (see its JSDoc) rather than the plain default `Engine`, so both * documented construction patterns type-check: `new Engine({ storage })` * (registry inferred as the default, empty registry) and * `Engine.create({ workflows })` (registry narrowed to the concrete * workflow map). `serve()` never calls registry-typed methods * (`register`, `start`, etc.) on this value — only registry-erased ones * (`storage`, event listening, `dispatchEvent`). See #708. */ engine: RegistryAgnosticEngine; port?: number; hostname?: string; /** Enable Bun's development mode (HMR, source maps, detailed errors). */ development?: boolean; /** Optional external dashboard shell served at {@link DASHBOARD_PAGE_ROUTES}. */ dashboard?: DashboardRouteTarget; /** * Static files served below an explicit prefix for the supplied dashboard. * The directory and prefix are validated synchronously before the port binds. */ dashboardAssets?: DashboardAssets; /** Authentication configuration. When provided, all non-public endpoints require valid credentials. */ auth?: AuthConfig; /** * In-process request rate limiting. When provided, the server throttles * requests per key — the authenticated principal's subject when available, * otherwise the client address — returning HTTP `429` with `Retry-After` * once a key exceeds its window budget. Public-path requests (health, * metrics, discovery) and CORS preflight are exempt. * * **This is a single-process load-shedding guardrail, not a distributed * quota.** Behind multiple instances each process keeps its own counters; * deployments needing a global budget should still front Weft with a * shared reverse-proxy limiter. Omitting `rateLimit` disables limiting (the * historical behavior). See {@link RateLimitConfig}. */ rateLimit?: RateLimitConfig; /** * Cross-Origin Resource Sharing policy for browser clients (external * dashboards and the Service Worker / IndexedDB browser runtime) that call * the server from a different origin. **Omitting `cors` is the safe default: * the server emits no `Access-Control-*` headers and only same-origin browser requests * succeed — it never defaults to `Access-Control-Allow-Origin: *`.** When set, * `serve()` answers CORS preflight (`OPTIONS`) requests and decorates * responses for allowed origins, and rejects cross-origin WebSocket upgrades * from disallowed origins. Validated synchronously: a wildcard origin with * `credentials: true`, or with an `Authorization` allowed-header, throws * before the port binds. See {@link CorsOptions}. */ cors?: CorsOptions; /** * Startup policy when `auth` is omitted. Defaults to `'warn'`, which starts * the server and logs a loud warning. Set `'reject'` for production * deployments so an omitted auth configuration fails closed before binding. * Set `'allow'` only for explicitly trusted local process boundaries. */ unauthenticatedAccess?: UnauthenticatedAccessPolicy; /** * Maximum request body size in bytes for REST operation routes and JSON-RPC * HTTP. Defaults to 1 MB. Oversized requests are rejected before the full body * is buffered. */ maxRequestBodyBytes?: number; /** * Maximum concurrent `/v1/workflows/:id/stream`, * `/v1/workflows/:id/watch`, and `/v1/workflows/:id/events/sse` * connections for a single workflow. Defaults to 100. Excess WebSockets are * closed with policy-violation code `1008` after the upgrade opens; excess * workflow SSE requests return `429`. */ maxStreamConnectionsPerWorkflow?: number; /** How often (in ms) the server scans the task ledger for expired visibility deadlines. Defaults to 5 000. */ visibilityPollIntervalMs?: number; /** * Grace period (in ms) between a worker WebSocket close and the requeue of * its in-flight tasks. A re-`register` from the same `workerId` within this * window cancels the pending requeue so the reconnect keeps the work it * already started. Defaults to `2_000`. Set to `0` to disable the grace * period entirely — close handler runs requeue inline as in earlier versions * of the server. Use `100` only for low-latency test or embedded scenarios; * use `5_000` for cloud or load-balancer deployments where replacement * workers commonly need several seconds to reconnect. Values are clamped to * `[0, 5_000]`. */ workerReconnectGracePeriodMs?: number; /** * How long (in ms) an adopted terminal task-ledger record is retained * before the periodic reconciliation scan reaps it. Defaults to `undefined` * — retention is opt-in, and unset means terminal records are kept forever * regardless of adoption, matching the durable ledger's default posture of * never discarding a task's authoritative record without an explicit * policy. Only adopted records are ever eligible; a terminal record whose * result no caller has adopted via {@link WeftServer.adoptTaskResult} is * retained indefinitely no matter how old it is. Unlike workflow history's * count-based `history.retentionWindow`, this is time-based — a terminal * task record has no natural sequence axis to bound by count. */ taskRetentionWindowMs?: number; /** * Maximum time (in ms) `server.stop()` waits for connected remote workers to * drain in-flight task results after receiving a shutdown frame before the * Bun server is stopped. Defaults to `30_000`. */ workerShutdownTimeoutMs?: number; /** * Routing policy used by the {@link WorkerRegistry} when dispatching tasks. * Defaults to `'least-loaded'`. Set to `'round-robin'` for deterministic * rotation across workers. * * **Note on `'fair-share'`:** fair-share requires a `fairShareKey` to be * passed at dispatch time via {@link TaskDispatch.fairShareKey}. `serve()` * does not derive that key automatically — call * sites must thread it through each `dispatchTask()` call themselves. When * the key is omitted on a dispatch, the registry degrades gracefully to * least-loaded for that single call. */ routingPolicy?: RoutingPolicy; /** * Gate on which workers may become routing-eligible, evaluated after the * manifest is validated and checked for deployment consistency and before * registry insertion. Defaults to `undefined`, which accepts every worker * that already passed authentication — the behavior before this option * existed. */ workerAdmissionPolicy?: WorkerAdmissionPolicy; /** * Scheduling policy used by the {@link TaskQueue} when ordering pending tasks * within a queue. Defaults to `'priority'`. */ schedulingPolicy?: SchedulingPolicy; /** * Optional {@link PrometheusExporter} that produces the body of `/v1/metrics`. * Recommended for projects that source metrics from the OpenTelemetry SDK — * e.g. wrap `@opentelemetry/exporter-prometheus` to satisfy the interface. */ prometheusExporter?: PrometheusExporter; /** * Optional metadata applied uniformly to all three discovery documents * (`/openapi.json`, `/openrpc.json`, `/asyncapi.json`). When set, the * description, contact, license, and externalDocs fields appear in every * discovery surface from one source — ensuring zero drift across the * three documents. */ discoveryInfo?: DiscoveryInfo; /** * Explicit public origin used by discovery routes that emit absolute URLs, * including `/.well-known/api-catalog` and `/.well-known/mcp.json` (e.g. * `https://api.example.com`). Recommended in production. Either this or * `trustedHosts` MUST be set or those discovery routes return 503. */ publicOrigin?: string; /** * Allowlist of `Host` values trusted to source absolute URLs in * discovery routes that emit absolute URLs, including * `/.well-known/api-catalog` and `/.well-known/mcp.json`. Required (with * `publicOrigin` as the alternative) in production — Bun.serve() resolves * `request.url` from the incoming Host header so attackers can otherwise * poison the discovery URLs. */ trustedHosts?: ReadonlyArray; } /** * Descriptor for a task dispatched to a remote worker via * {@link WeftServer.dispatchTask}. * * `operationId`, `activityName`, and `workflowType` are required; all other * fields refine routing, retry behaviour, and priority. Set `sticky: true` * together with `workflowId` to route the task to the worker that last * handled tasks for that workflow. * * @example * ```ts * import { type TaskDispatch } from '@lostgradient/weft/server'; * * const task: TaskDispatch = { * operationId: crypto.randomUUID(), * activityName: 'sendEmail', * workflowType: 'notifications', * input: { to: 'user@example.com', subject: 'Hello' }, * queue: 'email', * retryPolicy: { maxAttempts: 3, initialBackoff: '1s', backoffMultiplier: 2, maxBackoff: '30s' }, * }; * void task; * ``` */ export interface TaskDispatch { operationId: string; activityName: string; input: unknown; /** Queue to dispatch the task to. Defaults to `'default'`. */ queue?: string; /** * The dispatching workflow's registered type. Required — the durable task * ledger's envelope (`RemoteTaskBase.workflowType`) is required, and * `buildWorkerExecutionIdentity` needs it to look up the claiming worker's * manifest entry (`manifest.workflows[workflowType].activities[activityName]`). * `dispatchTaskImpl` rejects a call missing this field with an actionable * error rather than defaulting it — there is no safe placeholder value. */ workflowType: string; /** Workflow ID. Required for sticky routing to track worker affinity. */ workflowId?: string | undefined; /** Durable token for the workflow run that launched this task, when known. */ workflowExecutionToken?: string | undefined; /** * The dispatching workflow run's persisted `WorkflowState.revision` * (WFT-20), when known. When BOTH `workflowId` and `workflowRevision` are * supplied, `dispatchTask` rejects the dispatch before reserving any * worker capacity if the persisted run's revision disagrees — the run was * displaced (e.g. by a `start-new` restart) since this revision was * captured. The worker echoes this value back on `taskResult`; a * completion whose echoed revision disagrees with the in-flight dispatch's * is rejected as stale. */ workflowRevision?: string | undefined; /** When true, prefer the worker that last handled a task for this workflow. Requires `workflowId`. */ sticky?: boolean; /** Visibility timeout in milliseconds. Defaults to `DEFAULT_VISIBILITY_TIMEOUT` (30 000). */ visibilityTimeout?: number; /** Retry policy governing maxAttempts and backoff between reassignment attempts. */ retryPolicy?: RetryPolicy; /** Propagated interceptor headers (e.g. W3C trace context, auth tokens). */ headers?: Record; /** Task priority. Higher values are dequeued first. Agent tasks default to 10. */ priority?: number; /** * Partition key for `'fair-share'` routing — typically a customer * id. Ignored by other policies. When omitted under `'fair-share'`, the * registry degrades gracefully to `'least-loaded'` for that dispatch. */ fairShareKey?: string; } /** * Handle returned by `serve()` that exposes the running server's address, * worker registry, task dispatch, and shutdown controls. * * Implements `AsyncDisposable` — `serve()` itself is synchronous, but the * returned handle is awaitable for cleanup. Use `await using server = serve(...)` * in TypeScript 5.2+ to have the server stop automatically when the enclosing * block exits. * * Both `registry` ({@link WorkerRegistry}) and `taskQueue` ({@link TaskQueue}) * are re-exported from `'@lostgradient/weft/server'`, so you can name these * types without a second import. Still prefer `WeftServer` methods * (`dispatchTask`, `shutdownWorker`, etc.) over reaching into `taskQueue` * directly — it is exposed for inspection, not as a stable mutation surface. * * @example * ```ts * import { serve, type WeftServer } from '@lostgradient/weft/server'; * import { Engine, MemoryStorage } from '@lostgradient/weft'; * * await using storage = new MemoryStorage(); * await using engine = new Engine({ storage }); * await using server: WeftServer = serve({ engine, port: 4000 }); * * console.log(server.url); // http://localhost:4000 * console.log(server.registry); // WorkerRegistry instance * await server.stop(); * ``` */ export interface WeftServer extends AsyncDisposable { readonly port: number; readonly hostname: string; readonly url: string; readonly registry: WorkerRegistry; readonly taskQueue: TaskQueue; /** * Resolves once startup task-ledger recovery (WFT-23) has reconstructed * every non-terminal task's in-memory indexes from durable storage; * rejects if the recovery scan itself failed. `dispatchTask`, long-poll * claim/result handling, and worker registration all await this * internally before touching the ledger, so awaiting it explicitly is * optional — it exists for callers (health checks, orchestration) that * want to observe readiness without dispatching a probe task. */ readonly ready: Promise; /** * Drain connected remote workers, then stop the underlying Bun server. * * During the drain, each connected worker receives a shutdown frame and may * still deliver in-flight `taskResult` messages. The drain waits up to * {@link ServeOptions.workerShutdownTimeoutMs}, defaulting to 30 seconds, * before teardown continues. */ stop(): Promise; /** Dispatch a task to the best available worker. Returns true if dispatched. */ dispatchTask(task: TaskDispatch): Promise; /** * Read the current public view of a dispatched task's ledger record. * Returns `null` if no record exists — never dispatched, or a retained * terminal record was already reaped. See {@link TaskResultView}. */ getTaskResult(operationId: string): Promise; /** * Mark a terminal task's result as adopted — the durable assertion that * whatever consumed the result (a workflow's own checkpoint, or other * application logic) has durably incorporated it. Resolved records require * the `resultDigest` returned by {@link getTaskResult}; cancelled and * retry-exhausted records omit their private synthetic digest and expose a * token-safe `adoptionToken` instead. Returns `true` once adopted; `false` if * the record is not currently terminal or the required digest does not * match. Only adopted terminal records become eligible for * {@link ServeOptions.taskRetentionWindowMs} reaping. */ adoptTaskResult(operationId: string, adoptionKey: string): Promise; /** Send a shutdown message to a specific worker and wait for it to disconnect. Returns true if the worker was found. */ shutdownWorker(workerId: string, options?: { timeoutMs?: number; }): Promise; /** Send a shutdown message to all connected workers and wait for them to disconnect. */ shutdownAllWorkers(options?: { timeoutMs?: number; }): Promise; /** Send a cancel message for a specific operation to the worker handling it. Returns true if the worker was found. */ cancelTask(operationId: string): boolean; } /** * Start the Weft HTTP + WebSocket + SSE server. * * `serve()` validates the supplied `auth` configuration synchronously and * throws `Error` before binding the port if any auth setting is invalid. * Every non-terminal task record from previous server runs is recovered from * durable storage on startup so no task is silently lost across restarts — * see `WeftServer.ready`. Task dispatch, claim, completion, and worker * registration all wait for that recovery to finish (or fail loudly if it * doesn't) before touching the ledger. * * The returned `WeftServer.taskQueue` is exposed for inspection, not as a stable * mutation surface — prefer `WeftServer` methods (`dispatchTask`, * `shutdownWorker`, etc.) over reaching into it directly. * * @example * ```ts * import { Engine, MemoryStorage, workflow } from '@lostgradient/weft'; * import { serve } from '@lostgradient/weft/server'; * * await using engine = new Engine({ storage: new MemoryStorage() }); * engine.register( * workflow({ name: 'greet' }).execute(async function* ( * _ctx: import('@lostgradient/weft').WorkflowContext, * input: { name: string }, * ) { * return `Hello, ${input.name}!`; * }), * ); * * await using server = serve({ engine, port: 7233 }); * console.log(`Weft listening on ${server.url}`); * ``` */ export declare function serve(options: ServeOptions): WeftServer;