Signatures quoted from source. Wire: pinboard registry. Related: pinned (Python), pinboard. Source: packages/pinned/pinned-js.
Entry points
| Import from | Exports |
|---|---|
@assistant-ui/pinned | pinned, pinnedWorker, upgradeResponse, Pinned, Cluster, mintAttestation, verifyAttestation, attestedCaller, Attestation, StateStore, memoryStateStore, FenceLost, ALARM_TRAY, parseRoute, invalidIdReason |
@assistant-ui/pinned/node | serve, envOptions, Serve, listenFetch, toRequest, writeResponse, writeRefusal |
Runtime: Node 20+ (node:http, node:crypto, node:async_hooks, ws); no workerd or Durable Objects. Peer dependency: hono ^4.13.13. Pinning comes from pinboard placement leases.
pinned
const pinned: <A extends Pinned.App>(setup: Pinned.Setup<A>) => Pinned.Object<A>;
type Setup<A> = (ctx: Pinned.Context) => A | Promise<A>;
type App = { fetch: (request: Request, env: { pinned: Pinned.Call }) => Response | Promise<Response> };setupruns on the first request for an id (activation) and returns the app serving that instance: a Hono app, or any{ fetch }. A setup that returns anything else answers 500.- The app sees the path below
/<namespace>/<id>, as under Hono'smount:/counter/a/incrementmatches.post("/increment"), the bare instance URL matches"/". Query, headers, body, andsignalare the original request's. c.env.pinnedis aPinned.Call:{ ctx, attestation, epoch };new Hono<Pinned.Env>()types it.- Lifecycle hooks register on
ctx:ctx.onStop,ctx.onAlarm. - The namespace binds at hosting:
hosts: { "/counter": Counter }. One object may host under several namespaces. - Errors follow Hono: an
HTTPExceptionanswers its response, otherErrors reach the app'sonError(Hono's default answers500 Internal Server Error). A thrownResponseanswers as-is. Pinned.AppOf<typeof Counter>is the app type.hc<Pinned.AppOf<typeof Counter>>(instanceUrl)types a client from the routes in that type: Hono records routes in the type only when they are chained onto the returned app.
import { pinned } from "@assistant-ui/pinned";
import { Hono } from "hono";
export const Counter = pinned((ctx) => {
let value = 0;
ctx.onStop(() => console.log(`evicted ${ctx.id}`));
const app = new Hono();
app.post("/increment", (c) => c.json({ value: ++value }));
app.get("/value", (c) => c.json({ value }));
app.get("/peer", () => ctx.cluster.fetch(`/counter/${ctx.id}-peer/value`));
return app;
});pinnedWorker
const pinnedWorker: (opts: Pinned.Options) => Pinned.Worker;Pinned.Options:
hosts: { [namespace: string]: Pinned.Object | Pinned.Host }: namespace (/plus one path segment,^/[^/\s]+$; anything else throws) to apinnedobject, or{ object, idleTimeoutMs (300000), maxInstances (10000), releasePlacementOnIdle (true), sweepIntervalMs (5000) }. A value not made bypinnedthrows; an empty map throws.pinboardUrl,token,advertiseUrl: control-plane mode;pinboardUrlwithouttokenoradvertiseUrlthrows, as dotokenoradvertiseUrlwithoutpinboardUrland an emptypinboardUrl. WithoutpinboardUrlthe worker serves locally with attestation checks off: dev mode, which logs aDEV MODEwarning and throws whenNODE_ENV,ENVIRONMENTorAPP_ENVisproduction/produnlessPINBOARD_ALLOW_DEV_IN_PRODUCTION=1.hostname,env,envs: () => Record<string, string>,takeover: "wind_down" | "deny",epoch(integer ≥ 0): register fields;envandtakeoverwithouthostnamethrow.clusterHost: "hostname" | "pinboard"(default"hostname"): how a cluster call through pinboard names its world."pinboard"keeps the pinboard URL'sHostand sendshostnameinPinboard-Hostname, for a pinboard behind a front door that routes byHost(serveMicrovmsets it). Any other value throws.store?: StateStore: exposed to instances asctx.store; the worker reads and writes only the alarm row.heartbeatIntervalMs(3000),leaseBufferMs(5000),headroom: () => number(cgroup limit minus usage, floored byos.freemem(), in MB),log(console).
Pinned.Worker:
fetch(request: Request): Promise<Response>: routes/_pinned/challenge,/_pinned/stats, and/<namespace>/<id>/<rest>.app: Hono: the same routes (/_pinned/*,/<namespace>/*) as a Hono app, foruserApp.route("/", worker.app)next to the user's routes. Serve that app withlistenFetch(userApp.fetch, { port })(@assistant-ui/pinned/node) so WebSocket upgrades complete, thenworker.start().start(): starts the idle sweeps and the registration loop.drain(deadlineMs: number | null): Promise<void>: refuses new activations, evicts idle instances now, aborts the rest at the deadline (Infinitynever aborts), resolves once every instance closed;nullrevokes.close(): Promise<void>: closes every instance (releasing placements), deregisters, stops heartbeats.ready(registered and inside the lease),superseded,draining,drained,lease.healthy,lease.expired,lease.flaps,registration.state.{sessionId, reregistrations, draining},secret.
Request handling, in order:
/_pinned/*requiresPinboard-Proxy-Secretor a valid attestation, else403 {"detail":"invalid proxy secret"};/_pinned/challenge?challenge=answers{"value": ...}for the current pair, else404 {"detail":"unknown challenge"}.- Instance routes in control-plane mode require a valid
Pinboard-Attestation(audiences: the session id and each host's local audience), else403 {"detail":"invalid attestation"}. - Invalid ids answer 400 with the same messages as Python (
id must be non-empty,id must be at most 256 characters,id must contain only [A-Za-z0-9._~-]); a non-digitPinboard-Epochanswers400 {"detail":"malformed placement epoch"}. - 503 refusals carry
Retry-After: 1:lease expired; not serving,stale placement epoch; retry,released placement epoch; retry,host draining; retry,instance capacity reached; retry,instance past its wind-down deadline; retry,instance closing; retry. A refused activation while the lease is unhealthy addsPinned-Refusal: lease-unhealthy. - In control-plane mode with a
store,POST /<namespace>/<id>/_wakeis pinboard's durable wake: it activates the instance at thePinboard-Epochand answers200 {"ok": true}once it is resident, with the same 503 refusals as requests; other methods answer405. Without astorethe path reaches the instance. - In control-plane mode,
POST /<namespace>/<id>/_alarmis pinboard's alarm fire: it needsPinboard-Proxy-Secret(403 {"detail":"invalid proxy secret"}) and runs the instance's due alarm at thePinboard-Epoch, answering200 {"fired": boolean}, or202while a run is in progress. A store fence loss answers409; a throwing handler answers500. - Exceptions that escape the app (a thrown non-
Error, a throwingonError, a failed setup) are logged and answer500 {"detail":"Internal Server Error"}.
Registration mirrors the Python RegistrationClient: fresh challenge pair per send, fixed-rate loop anchored at send time, 503 Retry-After deferral and jittered exponential backoff, duplicates quit before the lease renews, messages applied in order with contiguous-prefix acks, draining echo applied as a control-plane drain, 409 reregister re-registers at once with prevSessionId, 409 superseded aborts every instance and ends the loop. Releases batch through POST /registry/release with epoch as a decimal string; release, tag, and deregister retry three times.
upgradeResponse
const upgradeResponse: (upgrade: Pinned.Upgrade) => Response;
type Upgrade = { protocol?: string; handler: (socket: WebSocket) => void };- Returned from a route handler for a request carrying
Upgrade: websocket;servecompletes the handshake withws(acceptingprotocolwhen the client offered it, no subprotocol otherwise) and hands the openws.WebSockettohandler. - Attestation, id validation, epoch fencing, and 503 refusals run for upgrades exactly as for requests; a non-upgrade Response to an upgrade request is written as a plain HTTP response (status, headers, body) and the socket closed, so a
Pinned-Refusalrides back to the pinboard for re-placement. - An accepted socket keeps the instance in flight until it closes: idle eviction and drain wait for it, the drain deadline aborts the instance (
signal, then theonStophandlers) and closes sockets still open with1001 instance closed. - Returned to a request without an upgrade,
serveanswers500. - Middleware must leave the response untouched: one that copies it (
c.header()afternext(),c.res = …) loses the upgrade, and the request answers500. Hono gives the same rule for its own WebSocket helper.
app.get("/ws", () =>
upgradeResponse({
protocol: "echo.v1",
handler: (ws) => ws.on("message", (data) => ws.send(String(data))),
}),
);Pinned.Context
type Context = {
id: string;
ns: string;
signal: AbortSignal;
cluster: Cluster;
store: StateStore | null;
readonly epoch: number | null;
readonly windingDown: boolean;
abort: () => void;
onStop: (handler: () => void | Promise<void>) => void;
onAlarm: (handler: () => void | Promise<void>) => void;
waitUntil: (promise: Promise<unknown>) => void;
windDown: (deadlineMs?: number) => void;
tag: (tag: string) => Promise<void>;
alarm: { get: () => Promise<number | null>; set: (at: Date | number | null) => Promise<void> };
};
type Cluster = { fetch: (path: string, init?: RequestInit) => Promise<Response> };onStop(handler)runshandleron eviction, after open work settled; handlers run last-registered first, a throw is logged.signalaborts on eviction: idle timeout, wind-down deadline, drain deadline, duplicate quit, supersession,abort(),close().tag(tag)stamps the owner tag on the instance's placement throughPOST /registry/tagat the admitted epoch, forGET /registry/overview?tag=, and stamps it again when a later request admits a newer epoch; a no-op without a control plane, throws before any request was admitted or when the pinboard stays unreachable.- A response whose body is still streaming, or an accepted WebSocket, keeps the instance in flight until the body ends, the client cancels it, or the socket closes; idle eviction and drain wait for it, the drain deadline aborts it through
signal. waitUntil(promise)keeps the instance in flight untilpromisesettles: idle eviction and drain wait for it. Teardown abortssignal, waits for every pendingwaitUntil, then runs theonStophandlers. A rejection is logged. A non-promise throws. Pending ones count astasksin/_pinned/stats. Python's equivalent iscreate_task.cluster.fetch("/<namespace>/<id>/<rest>"): a target namespace attached to this worker with the id resident (or no pinboard) dispatches in process; otherwise the call goes throughpinboardUrlwithPinboard-From,Pinboard-Session,Pinboard-Session-Secret,Pinboard-Subrequest-Depth, andHostset tohostname(or the pinboard host; withclusterHost: "pinboard", the pinboard host plusPinboard-Hostname). TLS always names the pinboard URL's host. InboundPinboard-*andHostheaders are dropped. Depth past 50 throws.- The target sees
c.env.pinned.attestationas{ kind: "instance", ns, id, depth }.
Alarms
- One alarm per instance.
alarm.set(at)writes theALARM_TRAYrow (("pinned", "alarm"), unmarked,doc: { at, nonce }: epoch ms rounded up, and a fresh id per write) throughstoreat the instance's epoch (0before any admitted request) and resolves once it commits; a latersetreplaces it,set(null)deletes it. An invalidDateor a non-finite number throws; without astore,setandgetthrow. alarm.get()reads the stored time;nullwhen none, or while its handler runs unless the handler set a new one.ctx.onAlarm(handler)sets the handler, once per instance (a second call throws), usually insetup. Once due, it runs; a due alarm without one throws. The row is deleted after it resolves, unless asetreplaced it; a throw or a crash leaves the row, and the alarm fires again. Delivery is at-least-once: handlers must be idempotent. Retries and backoff are the handler's: catch andseta later time.- Runs are serialized per instance and keep it in flight, like a request. A run reads the row and later clears it, taking turns with
setwrites; the clear deletes only the row whosenoncethe run fired. - Control-plane mode: a resident instance's alarm fires from its owner's timer at the due time, while the worker is inside its lease. Eviction drops the timer; activation re-arms it from the store. Pinboard's sweeper fires the rest through
_alarm(sweep), activating an evicted id on any live worker, within onesweepIntervalMs. A request below the admitted epoch answers503 stale placement epoch; retry; an epoch the store superseded answers409; a run already in progress answers202 {"fired": false, "running": true}. Both paths land on the owner's serialized runs, and each run re-reads the row, so an alarm the timer committed finds nothing due for the sweep. - Local mode: a process timer fires due alarms, activating evicted ids. A failed run fires again after the namespace's
sweepIntervalMs. After a restart, a stored alarm re-arms on its id's next activation.
const Reminder = pinned((ctx) => {
ctx.onAlarm(() => ctx.cluster.fetch(`/inbox/${ctx.id}/remind`, { method: "POST" }));
const app = new Hono();
app.post("/in-a-minute", async (c) => {
await ctx.alarm.set(Date.now() + 60_000);
return c.json({ ok: true });
});
return app;
});Pinned types
Pinned.Object<A>:{ readonly setup: Pinned.Setup<A> }, whatpinnedreturns.Pinned.AppOf<O>: its app type.Pinned.Host:{ object: Pinned.Object; idleTimeoutMs?; maxInstances?; releasePlacementOnIdle?; sweepIntervalMs? }.Pinned.App:{ fetch: (request: Request, env: Pinned.Env["Bindings"]) => Response | Promise<Response> }; a Hono app is one.Pinned.Env:{ Bindings: { pinned: Pinned.Call } }, the Hono env of an instance app.Pinned.Alarm:{ get: () => Promise<number | null>; set: (at: Date | number | null) => Promise<void> }, the type ofctx.alarm.Pinned.Call:{ ctx: Pinned.Context; attestation: Attestation.Instance | Attestation.Account | null; epoch: number | null };epochis the placement epoch the request was admitted at,nullwithout a control plane.Pinned.Upgrade:{ protocol?: string; handler: (socket: WebSocket) => void }, the argument ofupgradeResponse.
Attestation
const mintAttestation: (secret: string, claims: { aud: string; now: number; caller?: string; depth?: number }) => string;
const verifyAttestation: (token: string, opts: { secret: string; audiences: Iterable<string>; now: number }) => Attestation.Claims | null;
const attestedCaller: (claims: Attestation.Claims | null) => Attestation.Instance | Attestation.Account | null;- Compact JWS, header
{"alg":"HS256","typ":"pinboard+jwt"},exp = iat + 30, ±30 s clock skew, constant-time MAC check; byte-identical topinned.pinboard.attestation(nowin seconds). Attestation.Instance:{ kind: "instance"; ns; id; depth }.Attestation.Account:{ kind: "account"; sub; claims; scopes }.
StateStore
type StateStore = {
setTray: (ns, id, trayNs, trayId, marked: boolean, doc, epoch: number) => Promise<void>;
deleteTray: (ns, id, trayNs, trayId, epoch) => Promise<void>;
getTray: (ns, id, trayNs, trayId, epoch) => Promise<{ marked: boolean; doc } | null>;
list: (ns, id, epoch, filters?: { trayNs?: string; marked?: boolean }) => Promise<StateStore.Row[]>;
};
const memoryStateStore: () => StateStore;
class FenceLost extends Error { key: string; epoch: number }An epoch below the stored instance epoch throws FenceLost; at or above advances it. No tray runtime ships in this package: the store is a seam for instance code, plus the alarm row ALARM_TRAY = { trayNs: "pinned", trayId: "alarm" }. Pinboard's DurabilityStore.listDue must read the rows this store commits. ctx.store rejects setTray and deleteTray in the reserved "pinned" tray namespace.
serve
const serve: (options: Serve.Options) => Promise<{ worker: Pinned.Worker; server: http.Server; port: number; close: () => Promise<void> }>;
const envOptions: (env: Record<string, string | undefined>) => Omit<Serve.Options, "hosts">;Serve.Options = Pinned.Options & { port?: number; host?: string };portdefaults to 0 (ephemeral).- Options default from
envOptions(process.env); explicit options override it.envOptionsreadsPORT,PINBOARD_URL,PINBOARD_TOKEN,ADVERTISE_URL,PINNED_HOSTNAME; a non-integerPORTthrows. servebinds first, thenworker.start();close()closes the worker, terminates sockets still open, then the server.node:httpupgradeevents go throughworker.fetchlike requests; anupgradeResponsecompletes the handshake, any other Response is written bywriteRefusal(socket, response).
import { serve } from "@assistant-ui/pinned/node";
const { port } = await serve({ hosts: { "/counter": Counter } });