@m4ike1/ion-server
v0.1.1
Published
experimental server package for ion
Readme
@m4ike1/ion-server
Experimental local server for the new durable Session and Agent Harness interfaces.
Server binds one logical serverId to one or more ServerListener transports, runs the version handshake, and routes opaque service calls to server-scoped and Session-scoped providers. It never decodes business payloads: Chord owns service-control parsing, error codes, snapshots and updates; @m4ike1/ion-protocol owns envelope validation, CBOR, and framing. The real Session and AgentHarness stay process-local; clients only ever hold presentation attachments.
import { randomUUID } from "node:crypto";
import { MemorySessionRepo, type Session } from "@m4ike1/ion-agent-core";
import {
type RoutedServerServiceHost,
type RoutedSessionHandle,
type ServerHost,
SessionAmbiguousError,
SessionNotFoundError,
} from "@m4ike1/ion-server";
import { createUnixServer, getUnixSocketPath } from "@m4ike1/ion-server/unix";
async function startServer(
serverServices: RoutedServerServiceHost,
openRoutedSession: (session: Session) => Promise<RoutedSessionHandle>,
) {
const sessions = new MemorySessionRepo();
const host: ServerHost = {
serverServices,
async resolveSession(sessionId, context) {
const matches = (await sessions.list(undefined, context))
.filter((metadata) => metadata.id === sessionId);
if (matches.length === 0) {
throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
}
if (matches.length > 1) throw new SessionAmbiguousError();
return matches[0];
},
async openSession(metadata, context) {
const session = await sessions.open(metadata, context);
try {
return await openRoutedSession(session);
} catch (error) {
try {
await session.close(context);
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Harness creation and Session cleanup failed",
);
}
throw error;
}
},
};
const serverId = randomUUID();
const server = createUnixServer(host, {
serverId,
path: getUnixSocketPath(serverId, "/run/user/1000/ion"),
});
await server.start();
return server;
}How it works
Three application-owned capabilities plug into the router:
serverServices: RoutedServerServiceHost— creates one connection-scoped server service endpoint per client viaattachClient(). ItsRoutedServerPresentationargument is how server service implementations drive Session routing (attachSession,detachSession,prepareSessionRemoval).resolveSession(sessionId, context)— maps a durable Session ID to metadata, or throws a bounded routing error (SessionNotFoundError,SessionAmbiguousError). Session discovery and management are application-owned services; the server only asks the resolver when routing an attachment.openSession(metadata, context)— acquires the worker-local Session and returns aRoutedSessionHandle. Failures are cleaned up in that worker.
Routing is opaque end to end:
- Server service calls and subscriptions route through the connection's
RoutedServerServiceAttachment; Session calls route throughRoutedSessionHandle.attachClient()presentation attachments viainvokeService(), which forwards the service/member envelope without server-side business-payload decoding. - A Session may have multiple presentation attachments. Repeating
attachfrom one connection is idempotent. Every successful attachment gets a server-generatedattachmentIddelivered only as routing control data in an out-of-bandattachmentmessage. - Session requests carry
{ serverId, sessionId, attachmentId }; the server rejects stale or mismatched routes withSessionNotAttachedErrorand wrong-server targets withWrongServerError. - Subscription snapshots are encoded per connection; updates stay scoped to the requesting attachment. Application observations such as transcripts route as ordinary service state without server-owned business schemas.
Lifecycle
new Server(host, options)validates options eagerly (serverIdmust be a canonical lowercase UUIDv4).start()starts each listener in order; a listener failure closes the listeners that already started and rejectsclosed.close()stops listeners, closes connections, waits for admitted service calls to settle, releases attachments, then closes routed Session handles.closedresolves after shutdown or rejects when listener or Session cleanup fails.- Losing a connection rejects its local in-flight responses but releases its attachment only after admitted service calls settle. Disconnecting never deletes the hosted Session; the host decides when zero presentation demand and worker-local Harness activity permit worker retirement.
- While draining, new attachments and Session-targeted calls fail with
ServerDrainingError.Server.close()aggregates listener and Session cleanup failures intoAggregateError. - Per-connection handshake: the first client message must be
hellowith a supported protocol version, else the connection is failed withhello_error. Handshakes time out afterhandshakeTimeoutMs(default 5,000 ms). Duplicate request IDs and duplicate subscription IDs are rejected per connection;cancelenvelopes abort the matching in-flight request. RoutedSessionHandle.terminatedlets the router drop a hosted Session whose worker died unexpectedly; the nextattachre-opens it through the host.
Transports
serverId is a logical identity supplied by the launcher, not a socket address. Server composes transports through ServerListener; peer authentication remains application policy and is not implemented by the experimental Unix transport.
The @m4ike1/ion-server/unix submodule provides createUnixListener() and createUnixServer(). The Unix preset requires an explicit physical path; getUnixSocketPath() derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process. Stale socket files are reclaimed only after probing that no live server owns them; non-socket paths are never removed.
Error model
Errors that cross the protocol boundary are ServerError subclasses with stable codes (wrong_server, session_not_found, session_ambiguous, session_not_attached, server_draining, plus Chord remote-service codes). Anything else is sanitized to internal_error without exposing private details; the original is forwarded to onError. Error observers never affect server state.
Testing
@m4ike1/ion-server/testing provides createTestServer() (unstarted Server with deterministic defaults), TestServerHost/TestHarness (in-memory host with gateable open/service/close paths), and ProtocolTestClient/connectUnixTestClient() wire clients for transport conformance tests. See docs/reference.md and docs/setup.md.
