Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
55
packages/server/CHANGELOG.md
Normal file
55
packages/server/CHANGELOG.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.1] - 2026-09-05
|
||||
|
||||
## [0.85.0] - 2026-09-04
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
## [0.84.3] - 2026-08-24
|
||||
|
||||
## [0.84.2] - 2026-08-14
|
||||
|
||||
## [0.84.1] - 2026-08-07
|
||||
|
||||
## [0.84.0] - 2026-08-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `toProtocolToolResultMessage()` to require the original `ToolCall` and verify tool result association.
|
||||
- Changed `PiServerService.listSessions()` to return durable `SessionMetadata` instead of runtime `SessionSummary` values ([#7708](https://github.com/earendil-works/pi/pull/7708)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hardened protocol adapters against contradictory lifecycle states, invalid identifiers and timestamps, sparse execution arrays, and additive `pi-ai` contract drift.
|
||||
- Sanitized service and runtime failures into stable `not_implemented` and `internal_error` responses without exposing private error details ([#7644](https://github.com/earendil-works/pi/pull/7644)).
|
||||
|
||||
## [0.83.0] - 2026-07-29
|
||||
|
||||
## [0.82.1] - 2026-07-25
|
||||
|
||||
## [0.82.0] - 2026-07-24
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
## [0.81.0] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||
|
||||
## [0.80.10] - 2026-07-16
|
||||
|
||||
## [0.80.9] - 2026-07-16
|
||||
|
||||
## [0.80.8] - 2026-07-16
|
||||
|
||||
## [0.80.7] - 2026-07-14
|
||||
|
||||
## [0.80.6] - 2026-07-09
|
||||
|
||||
## [0.80.5] - 2026-07-09
|
||||
|
||||
## [0.80.4] - 2026-07-09
|
||||
|
||||
## [0.80.3] - 2026-06-30
|
||||
79
packages/server/README.md
Normal file
79
packages/server/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @earendil-works/pi-server
|
||||
|
||||
Experimental local server for the new durable Session and Agent Harness interfaces.
|
||||
|
||||
The current slice supports server- and Session-scoped facet-service routing and multi-presentation attachment. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract.
|
||||
|
||||
- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`;
|
||||
- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state;
|
||||
- the application-owned `SessionManagement` creates, removes, attaches, and detaches Sessions without exposing route IDs in business results;
|
||||
- attachment changes are published out of band after the router installs or clears the live route;
|
||||
- Session service calls route through `invokeService` without server-side business-payload decoding;
|
||||
- service subscription updates remain scoped to the requesting attachment;
|
||||
- application observations such as transcripts route as ordinary service state without server-owned business schemas.
|
||||
|
||||
A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId` delivered only as routing control data. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted service calls settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership.
|
||||
|
||||
```ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type RoutedServerServiceHost,
|
||||
type RoutedSessionHandle,
|
||||
type ServerHost,
|
||||
SessionAmbiguousError,
|
||||
SessionNotFoundError,
|
||||
} from "@earendil-works/pi-server";
|
||||
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-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/pi"),
|
||||
});
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
```
|
||||
|
||||
Applications supply a required server service host, a bounded Session resolver, and a routed Session factory. Session discovery and management are application-owned services; the protocol server only asks the resolver for metadata when routing an attachment. The host owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary.
|
||||
|
||||
`serverId` is a logical identity supplied by the launcher, not a socket address. 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.
|
||||
|
||||
`Server` composes transports through `ServerListener`; peer authentication remains application policy and is not implemented by the experimental Unix transport. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level routed-envelope validation, CBOR, and framing come from `@earendil-works/pi-protocol`; Chord owns service-control parsing, error codes, snapshots and updates, and each subscription's replicated-state encoder.
|
||||
|
||||
Server and worker lifecycle is managed outside the public Pi protocol. The replaceable application server converts connection attachments into private demand updates; the worker combines generation-tagged demand with authoritative Harness activity. The experimental coordinator only supplies stable routing and reports generic server-generation connection changes.
|
||||
58
packages/server/package.json
Normal file
58
packages/server/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.85.1",
|
||||
"description": "experimental server package for pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"import": "./dist/testing/index.js"
|
||||
},
|
||||
"./unix": {
|
||||
"types": "./dist/transports/unix/index.d.ts",
|
||||
"import": "./dist/transports/unix/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -p tsconfig.test.json",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"pi",
|
||||
"server"
|
||||
],
|
||||
"author": "Earendil Works",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.85.1",
|
||||
"@earendil-works/pi-agent-core": "^0.85.1",
|
||||
"@earendil-works/pi-protocol": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
37
packages/server/src/connection.ts
Normal file
37
packages/server/src/connection.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ServiceStateEncoder } from "@earendil-works/chord";
|
||||
import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol";
|
||||
|
||||
import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts";
|
||||
|
||||
/** An established, authorized ordered byte connection. */
|
||||
export interface ByteConnection {
|
||||
readonly closed: boolean;
|
||||
send(chunk: Uint8Array): Promise<void>;
|
||||
close(finalChunk?: Uint8Array): MaybePromise<void>;
|
||||
}
|
||||
|
||||
export interface ByteConnectionHandler {
|
||||
onData(chunk: Uint8Array): void;
|
||||
onClose(): void;
|
||||
onError(error: Error): void;
|
||||
}
|
||||
|
||||
export type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;
|
||||
|
||||
export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed";
|
||||
|
||||
export interface ConnectionState {
|
||||
connection: ByteConnection;
|
||||
decoder: ClientMessageDecoder;
|
||||
serviceStateEncoders: Map<string, ServiceStateEncoder>;
|
||||
stage: ConnectionStage;
|
||||
disconnected: boolean;
|
||||
handshake?: Promise<void>;
|
||||
handshakeTimeout: NodeJS.Timeout;
|
||||
serverServices?: RoutedServerServiceAttachment;
|
||||
activeRequests: Map<string, { controller: AbortController; target: RpcTarget }>;
|
||||
}
|
||||
|
||||
export function isTerminalConnection(state: ConnectionState): boolean {
|
||||
return state.disconnected || state.stage === "closing" || state.stage === "closed";
|
||||
}
|
||||
57
packages/server/src/errors.ts
Normal file
57
packages/server/src/errors.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { RemoteServiceErrorCode } from "@earendil-works/chord";
|
||||
|
||||
type ServerOperationErrorCode =
|
||||
| RemoteServiceErrorCode
|
||||
| "wrong_server"
|
||||
| "session_not_found"
|
||||
| "session_ambiguous"
|
||||
| "session_not_attached"
|
||||
| "server_draining";
|
||||
|
||||
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
|
||||
|
||||
/** A host or lifecycle error that can safely cross the protocol boundary. */
|
||||
export class ServerError extends Error {
|
||||
readonly code: ServerOperationErrorCode;
|
||||
|
||||
constructor(code: ServerOperationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ServerError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class WrongServerError extends ServerError {
|
||||
constructor() {
|
||||
super("wrong_server", "Request was addressed to another server");
|
||||
this.name = "WrongServerError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends ServerError {
|
||||
constructor(message = "Session was not found") {
|
||||
super("session_not_found", message);
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionAmbiguousError extends ServerError {
|
||||
constructor() {
|
||||
super("session_ambiguous", "Session ID matches more than one session");
|
||||
this.name = "SessionAmbiguousError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotAttachedError extends ServerError {
|
||||
constructor() {
|
||||
super("session_not_attached", "Session is not attached to this client");
|
||||
this.name = "SessionNotAttachedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerDrainingError extends ServerError {
|
||||
constructor() {
|
||||
super("server_draining", "Server is draining");
|
||||
this.name = "ServerDrainingError";
|
||||
}
|
||||
}
|
||||
4
packages/server/src/index.ts
Normal file
4
packages/server/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./errors.ts";
|
||||
export * from "./listener.ts";
|
||||
export * from "./server.ts";
|
||||
export * from "./types.ts";
|
||||
8
packages/server/src/listener.ts
Normal file
8
packages/server/src/listener.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ByteConnectionAcceptor } from "./connection.ts";
|
||||
|
||||
/** Supplies established byte connections after any required transport authentication. */
|
||||
export interface ServerListener {
|
||||
/** Starts listening and passes authorized connections to accept. */
|
||||
start(accept: ByteConnectionAcceptor): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
576
packages/server/src/server.ts
Normal file
576
packages/server/src/server.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import {
|
||||
createServiceStateEncoder,
|
||||
decodeServiceControlCall,
|
||||
type JsonValue,
|
||||
parseServiceCall,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
RemoteServiceError,
|
||||
type ServiceCall,
|
||||
type ServiceProviderUpdate,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type CancelEnvelope,
|
||||
type ClientHello,
|
||||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
encodeServerMessage,
|
||||
isServerId,
|
||||
isSupportedProtocolVersion,
|
||||
PROTOCOL_VERSION,
|
||||
type ProtocolError,
|
||||
ProtocolValidationError,
|
||||
type RequestEnvelope,
|
||||
type ResponseEnvelope,
|
||||
type RpcTarget,
|
||||
type ServerHello,
|
||||
type ServerHelloError,
|
||||
type ServerMessage,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
type ByteConnection,
|
||||
type ByteConnectionHandler,
|
||||
type ConnectionState,
|
||||
isTerminalConnection,
|
||||
} from "./connection.ts";
|
||||
import { INTERNAL_SERVER_ERROR_MESSAGE, ServerError, WrongServerError } from "./errors.ts";
|
||||
import type { ServerListener } from "./listener.ts";
|
||||
import { SessionRouter } from "./session-router.ts";
|
||||
import type { ServerHost, ServerOptions } from "./types.ts";
|
||||
|
||||
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
||||
const MAX_UINT32 = 0xffff_ffff;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
readonly serverId: string;
|
||||
/** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
private readonly host: ServerHost<TMetadata>;
|
||||
private readonly listeners: readonly ServerListener[];
|
||||
private readonly maxFrameLength: number;
|
||||
private readonly handshakeTimeoutMs: number;
|
||||
private readonly onConnectionCountChanged: ((count: number) => void) | undefined;
|
||||
private readonly onError: ((error: Error) => void) | undefined;
|
||||
private readonly connections = new Set<ConnectionState>();
|
||||
private readonly sessions: SessionRouter<TMetadata>;
|
||||
private closing = false;
|
||||
private closePromise?: Promise<void>;
|
||||
private closedSettled = false;
|
||||
private rejectClosed!: (error: unknown) => void;
|
||||
private resolveClosed!: () => void;
|
||||
private startPromise?: Promise<this>;
|
||||
private started = false;
|
||||
|
||||
constructor(host: ServerHost<TMetadata>, options: ServerOptions) {
|
||||
const resolved = resolveOptions(options);
|
||||
this.host = host;
|
||||
this.listeners = options.listeners;
|
||||
this.serverId = options.serverId;
|
||||
this.maxFrameLength = resolved.maxFrameLength;
|
||||
this.handshakeTimeoutMs = resolved.handshakeTimeoutMs;
|
||||
this.onConnectionCountChanged = options.onConnectionCountChanged;
|
||||
this.onError = options.onError;
|
||||
this.sessions = new SessionRouter({
|
||||
host,
|
||||
serverId: this.serverId,
|
||||
isClosing: () => this.closing,
|
||||
publishAttachment: async (client, attachment) => {
|
||||
await this.sendMessage(client as ConnectionState, {
|
||||
type: "attachment",
|
||||
attachment: attachment ?? null,
|
||||
});
|
||||
},
|
||||
reportError: (error) => this.reportError(error),
|
||||
});
|
||||
this.closed = new Promise((resolve, reject) => {
|
||||
this.resolveClosed = resolve;
|
||||
this.rejectClosed = reject;
|
||||
});
|
||||
void this.closed.catch(() => {});
|
||||
}
|
||||
|
||||
start(): Promise<this> {
|
||||
if (this.started) return Promise.reject(new Error("Server is already started"));
|
||||
if (this.startPromise) return Promise.reject(new Error("Server is already starting"));
|
||||
if (this.closing) return Promise.reject(new Error("Server is closing or closed"));
|
||||
this.startPromise = this.startInternal();
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async startInternal(): Promise<this> {
|
||||
const started: ServerListener[] = [];
|
||||
try {
|
||||
for (const listener of this.listeners) {
|
||||
await listener.start((connection) => this.accept(connection));
|
||||
started.push(listener);
|
||||
}
|
||||
this.started = true;
|
||||
return this;
|
||||
} catch (error) {
|
||||
this.closing = true;
|
||||
const cleanupErrors: unknown[] = [];
|
||||
const listenerResults = await Promise.allSettled(started.map((listener) => listener.close()));
|
||||
for (const result of listenerResults) {
|
||||
if (result.status === "rejected") cleanupErrors.push(result.reason);
|
||||
}
|
||||
try {
|
||||
await this.closeServerState();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
if (cleanupErrors.length > 0) {
|
||||
const failure = new AggregateError([error, ...cleanupErrors], "Server startup and cleanup failed");
|
||||
this.settleClosed(failure);
|
||||
throw failure;
|
||||
}
|
||||
this.settleClosed();
|
||||
throw error;
|
||||
} finally {
|
||||
this.startPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
accept(connection: ByteConnection): ByteConnectionHandler {
|
||||
if (this.closing) {
|
||||
void this.closeConnection(connection);
|
||||
return {
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onError: (error) => this.reportError(error),
|
||||
};
|
||||
}
|
||||
|
||||
let state: ConnectionState;
|
||||
const handshakeTimeout = setTimeout(() => {
|
||||
void this.failProtocol(state, {
|
||||
code: "invalid_request",
|
||||
message: "Handshake timeout",
|
||||
});
|
||||
}, this.handshakeTimeoutMs);
|
||||
handshakeTimeout.unref();
|
||||
state = {
|
||||
connection,
|
||||
decoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),
|
||||
serviceStateEncoders: new Map(),
|
||||
stage: "awaitingHello",
|
||||
disconnected: false,
|
||||
handshakeTimeout,
|
||||
activeRequests: new Map(),
|
||||
};
|
||||
this.connections.add(state);
|
||||
this.notifyConnectionCountChanged();
|
||||
|
||||
return {
|
||||
onData: (chunk) => this.receive(state, chunk),
|
||||
onClose: () => this.transportClosed(state),
|
||||
onError: (error) => {
|
||||
this.reportError(error);
|
||||
void this.closeConnection(connection).then(() => this.disconnect(state));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closePromise) return this.closePromise;
|
||||
this.closing = true;
|
||||
this.closePromise = this.closeInternal();
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private async closeInternal(): Promise<void> {
|
||||
const starting = this.startPromise;
|
||||
if (starting) await starting.catch(() => {});
|
||||
const errors: unknown[] = [];
|
||||
const listenerResults = await Promise.allSettled(this.listeners.map((listener) => listener.close()));
|
||||
for (const result of listenerResults) {
|
||||
if (result.status === "rejected") errors.push(result.reason);
|
||||
}
|
||||
try {
|
||||
await this.closeServerState();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
this.started = false;
|
||||
if (errors.length > 0) {
|
||||
const failure =
|
||||
errors.length === 1 && errors[0] instanceof Error
|
||||
? errors[0]
|
||||
: new AggregateError(errors, "Server shutdown failed");
|
||||
this.settleClosed(failure);
|
||||
throw failure;
|
||||
}
|
||||
this.settleClosed();
|
||||
}
|
||||
|
||||
private receive(state: ConnectionState, chunk: Uint8Array): void {
|
||||
if (isTerminalConnection(state)) return;
|
||||
let messages: ClientMessage[];
|
||||
try {
|
||||
messages = state.decoder.push(chunk);
|
||||
} catch (error) {
|
||||
void this.failProtocol(state, this.toProtocolError(error));
|
||||
return;
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (isTerminalConnection(state)) return;
|
||||
this.dispatchMessage(state, message);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchMessage(state: ConnectionState, message: ClientMessage): void {
|
||||
if (state.stage === "awaitingHello") {
|
||||
if (message.type !== "hello") {
|
||||
void this.failProtocol(state, {
|
||||
code: "invalid_request",
|
||||
message: "The first client message must be hello",
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.stage = "handshaking";
|
||||
state.handshake = this.finishHandshake(state, message).catch((error: unknown) =>
|
||||
this.failProtocol(state, this.toProtocolError(error)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "hello") {
|
||||
void this.failProtocol(state, {
|
||||
code: "invalid_request",
|
||||
message: "hello may only be sent as the first message",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.stage === "ready") {
|
||||
if (message.type === "cancel") this.handleCancel(state, message);
|
||||
else void this.handleRequest(state, message);
|
||||
return;
|
||||
}
|
||||
if (state.stage !== "handshaking") return;
|
||||
const handshake = state.handshake;
|
||||
if (!handshake) return;
|
||||
void handshake.then(() => {
|
||||
if (state.stage !== "ready" || state.disconnected) return;
|
||||
if (message.type === "cancel") this.handleCancel(state, message);
|
||||
else void this.handleRequest(state, message);
|
||||
});
|
||||
}
|
||||
|
||||
private async finishHandshake(state: ConnectionState, hello: ClientHello): Promise<void> {
|
||||
if (!isSupportedProtocolVersion(hello.version)) {
|
||||
await this.failProtocol(state, {
|
||||
code: "version",
|
||||
message: `Unsupported protocol version ${hello.version}; expected ${PROTOCOL_VERSION}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) return;
|
||||
const services = await this.host.serverServices.attachClient(
|
||||
{
|
||||
attachSession: async (sessionId, context) => {
|
||||
await this.sessions.attachClient(state, sessionId, context);
|
||||
},
|
||||
detachSession: (context) => this.sessions.detachClient(state, context),
|
||||
prepareSessionRemoval: (sessionId, context) => this.sessions.removeSession(sessionId, context),
|
||||
},
|
||||
TODO_CONTEXT,
|
||||
);
|
||||
if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) {
|
||||
await services.release(TODO_CONTEXT);
|
||||
return;
|
||||
}
|
||||
state.serverServices = services;
|
||||
const sent = await this.sendMessage(state, {
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
serverId: this.serverId,
|
||||
} satisfies ServerHello);
|
||||
if (sent && !state.disconnected && state.stage === "handshaking") {
|
||||
state.stage = "ready";
|
||||
clearTimeout(state.handshakeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCancel(state: ConnectionState, envelope: CancelEnvelope): void {
|
||||
if (envelope.target.serverId !== this.serverId) return;
|
||||
const active = state.activeRequests.get(envelope.id);
|
||||
if (active !== undefined && sameTarget(active.target, envelope.target)) {
|
||||
active.controller.abort(new DOMException("RPC request cancelled", "AbortError"));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {
|
||||
if (state.activeRequests.has(envelope.id)) {
|
||||
await this.sendMessage(state, {
|
||||
type: "response",
|
||||
id: envelope.id,
|
||||
ok: false,
|
||||
error: { code: "invalid_request", message: "Request ID is already active" },
|
||||
} satisfies ResponseEnvelope);
|
||||
return;
|
||||
}
|
||||
let call: ServiceCall;
|
||||
try {
|
||||
call = parseServiceCall(envelope.call);
|
||||
} catch {
|
||||
await this.sendMessage(state, {
|
||||
type: "response",
|
||||
id: envelope.id,
|
||||
ok: false,
|
||||
error: { code: "invalid_request", message: "Invalid service call" },
|
||||
} satisfies ResponseEnvelope);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const active = { controller, target: envelope.target };
|
||||
state.activeRequests.set(envelope.id, active);
|
||||
const context = withAbortSignal(controller.signal, TODO_CONTEXT);
|
||||
const control = decodeServiceControlCall(call);
|
||||
const subscribing = control?.type === "subscribe" ? control : undefined;
|
||||
const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];
|
||||
let subscriptionReady = subscribing === undefined;
|
||||
let installedSubscriptionEncoder = false;
|
||||
let responded = false;
|
||||
const publish = async (subscriptionId: string, update: ServiceProviderUpdate): Promise<void> => {
|
||||
if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
|
||||
pendingUpdates.push({ update });
|
||||
return;
|
||||
}
|
||||
await this.sendServiceUpdate(state, subscriptionId, update);
|
||||
};
|
||||
try {
|
||||
if (envelope.target.serverId !== this.serverId) throw new WrongServerError();
|
||||
if (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) {
|
||||
throw new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`);
|
||||
}
|
||||
let result: JsonValue | undefined;
|
||||
if ("sessionId" in envelope.target) {
|
||||
result = await this.sessions.executeServiceCall(call, envelope.target, state, publish, context);
|
||||
} else if (state.serverServices !== undefined) {
|
||||
result = await state.serverServices.invokeService(call, publish, context);
|
||||
} else {
|
||||
throw new ProtocolValidationError(`Unknown service member ${call.serviceId}.${call.member}`);
|
||||
}
|
||||
if (subscribing !== undefined) {
|
||||
if (result === undefined)
|
||||
throw new ProtocolValidationError("Service subscription did not return a snapshot");
|
||||
const stateEncoder = createServiceStateEncoder();
|
||||
result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result)) as unknown as JsonValue;
|
||||
state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
|
||||
installedSubscriptionEncoder = true;
|
||||
} else if (control?.type === "unsubscribe") {
|
||||
state.serviceStateEncoders.delete(control.subscriptionId);
|
||||
}
|
||||
await this.sendMessage(
|
||||
state,
|
||||
result === undefined
|
||||
? { type: "response", id: envelope.id, ok: true }
|
||||
: { type: "response", id: envelope.id, ok: true, result },
|
||||
);
|
||||
responded = true;
|
||||
if (subscribing !== undefined) {
|
||||
while (pendingUpdates.length > 0) {
|
||||
const pending = pendingUpdates.shift();
|
||||
if (pending !== undefined)
|
||||
await this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update);
|
||||
}
|
||||
subscriptionReady = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (subscribing !== undefined && installedSubscriptionEncoder && !responded) {
|
||||
state.serviceStateEncoders.delete(subscribing.subscriptionId);
|
||||
}
|
||||
if (responded) {
|
||||
this.reportError(error);
|
||||
await this.closeConnection(state.connection);
|
||||
this.disconnect(state);
|
||||
} else {
|
||||
await this.sendMessage(state, {
|
||||
type: "response",
|
||||
id: envelope.id,
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? { code: "cancelled", message: "RPC request cancelled" }
|
||||
: this.toProtocolError(error),
|
||||
} satisfies ResponseEnvelope);
|
||||
}
|
||||
} finally {
|
||||
if (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id);
|
||||
}
|
||||
}
|
||||
|
||||
private transportClosed(connection: ConnectionState): void {
|
||||
if (!connection.disconnected && connection.stage !== "closing") {
|
||||
try {
|
||||
connection.decoder.end();
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
this.disconnect(connection);
|
||||
}
|
||||
|
||||
private disconnect(connection: ConnectionState): void {
|
||||
if (connection.disconnected) return;
|
||||
connection.disconnected = true;
|
||||
connection.stage = "closed";
|
||||
clearTimeout(connection.handshakeTimeout);
|
||||
for (const { controller } of connection.activeRequests.values()) {
|
||||
controller.abort(new Error("Client disconnected"));
|
||||
}
|
||||
connection.activeRequests.clear();
|
||||
connection.serviceStateEncoders.clear();
|
||||
if (this.connections.delete(connection)) this.notifyConnectionCountChanged();
|
||||
const serverServices = connection.serverServices;
|
||||
delete connection.serverServices;
|
||||
void Promise.allSettled([
|
||||
this.sessions.disconnect(connection, TODO_CONTEXT),
|
||||
serverServices?.release(TODO_CONTEXT),
|
||||
]).then((results) => {
|
||||
for (const result of results) if (result.status === "rejected") this.reportError(result.reason);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendServiceUpdate(
|
||||
connection: ConnectionState,
|
||||
subscriptionId: string,
|
||||
update: ServiceProviderUpdate,
|
||||
): Promise<void> {
|
||||
const stateEncoder = connection.serviceStateEncoders.get(subscriptionId);
|
||||
if (stateEncoder === undefined) return;
|
||||
await this.sendMessage(connection, {
|
||||
type: "service_update",
|
||||
subscriptionId,
|
||||
update: stateEncoder.encodeUpdate(update) as unknown as JsonValue,
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {
|
||||
if (connection.disconnected || connection.connection.closed) return false;
|
||||
let frame: Uint8Array;
|
||||
try {
|
||||
frame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
await this.closeConnection(connection.connection);
|
||||
this.disconnect(connection);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await connection.connection.send(frame);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
await this.closeConnection(connection.connection);
|
||||
this.disconnect(connection);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async failProtocol(connection: ConnectionState, error: ProtocolError): Promise<void> {
|
||||
if (connection.disconnected || connection.stage === "closing" || connection.stage === "closed") return;
|
||||
connection.stage = "closing";
|
||||
clearTimeout(connection.handshakeTimeout);
|
||||
const message: ServerHelloError = { type: "hello_error", error };
|
||||
let finalFrame: Uint8Array | undefined;
|
||||
try {
|
||||
finalFrame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });
|
||||
} catch (encodeError) {
|
||||
this.reportError(encodeError);
|
||||
}
|
||||
await this.closeConnection(connection.connection, finalFrame);
|
||||
this.disconnect(connection);
|
||||
}
|
||||
|
||||
private async closeServerState(): Promise<void> {
|
||||
const connections = [...this.connections];
|
||||
for (const connection of connections) {
|
||||
connection.stage = "closing";
|
||||
clearTimeout(connection.handshakeTimeout);
|
||||
}
|
||||
await Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));
|
||||
for (const connection of connections) this.disconnect(connection);
|
||||
const cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]);
|
||||
this.connections.clear();
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to close server Sessions");
|
||||
}
|
||||
|
||||
private async closeConnection(connection: ByteConnection, finalChunk?: Uint8Array): Promise<void> {
|
||||
try {
|
||||
await connection.close(finalChunk);
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private toProtocolError(error: unknown): ProtocolError {
|
||||
if (error instanceof ServerError || error instanceof RemoteServiceError) {
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
if (error instanceof ProtocolValidationError) {
|
||||
return { code: "invalid_request", message: error.message };
|
||||
}
|
||||
this.reportError(error);
|
||||
return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE };
|
||||
}
|
||||
|
||||
private notifyConnectionCountChanged(): void {
|
||||
try {
|
||||
this.onConnectionCountChanged?.(this.connections.size);
|
||||
} catch (error) {
|
||||
this.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private reportError(error: unknown): void {
|
||||
try {
|
||||
this.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
} catch {
|
||||
// Error observers cannot affect server state.
|
||||
}
|
||||
}
|
||||
|
||||
private settleClosed(error?: unknown): void {
|
||||
if (this.closedSettled) return;
|
||||
this.closedSettled = true;
|
||||
if (error === undefined) this.resolveClosed();
|
||||
else this.rejectClosed(error);
|
||||
}
|
||||
}
|
||||
|
||||
function sameTarget(left: RpcTarget, right: RpcTarget): boolean {
|
||||
if (left.serverId !== right.serverId) return false;
|
||||
if (!("sessionId" in left) || !("sessionId" in right)) {
|
||||
return !("sessionId" in left) && !("sessionId" in right);
|
||||
}
|
||||
return left.sessionId === right.sessionId && left.attachmentId === right.attachmentId;
|
||||
}
|
||||
|
||||
function resolveOptions(options: ServerOptions): {
|
||||
maxFrameLength: number;
|
||||
handshakeTimeoutMs: number;
|
||||
} {
|
||||
if (!Array.isArray(options.listeners)) throw new TypeError("Server listeners must be an array");
|
||||
if (!isServerId(options.serverId)) {
|
||||
throw new TypeError("serverId must be a canonical lowercase UUIDv4");
|
||||
}
|
||||
const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
|
||||
throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
|
||||
}
|
||||
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(handshakeTimeoutMs) ||
|
||||
handshakeTimeoutMs <= 0 ||
|
||||
handshakeTimeoutMs > MAX_TIMER_DELAY_MS
|
||||
) {
|
||||
throw new TypeError(`Server handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
||||
}
|
||||
return { maxFrameLength, handshakeTimeoutMs };
|
||||
}
|
||||
312
packages/server/src/session-router.ts
Normal file
312
packages/server/src/session-router.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type Context, type SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
import { ServerDrainingError, SessionNotAttachedError } from "./errors.ts";
|
||||
import type { RoutedSessionAttachment, RoutedSessionHandle, ServerHost } from "./types.ts";
|
||||
|
||||
class SessionCleanupError extends AggregateError {}
|
||||
|
||||
interface ClientAttachment {
|
||||
readonly id: string;
|
||||
readonly client: object;
|
||||
readonly session: HostedSession;
|
||||
readonly operations: Set<Promise<unknown>>;
|
||||
acquiring?: Promise<RoutedSessionAttachment>;
|
||||
lease?: RoutedSessionAttachment;
|
||||
releasing?: Promise<void>;
|
||||
}
|
||||
|
||||
interface HostedSession {
|
||||
readonly id: string;
|
||||
readonly handle: RoutedSessionHandle;
|
||||
readonly attachments: Set<ClientAttachment>;
|
||||
}
|
||||
|
||||
interface SessionRouterOptions<TMetadata extends SessionMetadata> {
|
||||
host: ServerHost<TMetadata>;
|
||||
serverId: string;
|
||||
isClosing: () => boolean;
|
||||
publishAttachment(client: object, attachment: SessionTarget | undefined, context: Context): Promise<void>;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
private readonly options: SessionRouterOptions<TMetadata>;
|
||||
private readonly hostedSessions = new Map<string, HostedSession>();
|
||||
private readonly openingSessions = new Map<string, Promise<HostedSession>>();
|
||||
private readonly attachmentsByClient = new Map<object, ClientAttachment>();
|
||||
private readonly disconnectedClients = new Set<object>();
|
||||
private readonly clientOperations = new Map<object, Promise<void>>();
|
||||
private closePromise?: Promise<void>;
|
||||
|
||||
constructor(options: SessionRouterOptions<TMetadata>) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async executeServiceCall(
|
||||
call: ServiceCall,
|
||||
target: RpcTarget,
|
||||
client: object,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
|
||||
context: Context,
|
||||
): Promise<JsonValue | undefined> {
|
||||
const admitted = await this.runForClient(client, () =>
|
||||
this.startServiceCall(client, target, call, publish, context),
|
||||
);
|
||||
return admitted.result;
|
||||
}
|
||||
|
||||
attachClient(client: object, sessionId: string, context: Context): Promise<void> {
|
||||
if (this.options.isClosing()) return Promise.reject(new ServerDrainingError());
|
||||
return this.runForClient(client, () => this.attachClientNow(client, sessionId, context));
|
||||
}
|
||||
|
||||
detachClient(client: object, context: Context): Promise<void> {
|
||||
return this.runForClient(client, async () => {
|
||||
const attachment = this.attachmentsByClient.get(client);
|
||||
if (attachment) await this.releaseAttachment(attachment, context);
|
||||
});
|
||||
}
|
||||
|
||||
async removeSession(sessionId: string, context: Context): Promise<void> {
|
||||
if (this.options.isClosing()) throw new ServerDrainingError();
|
||||
const hosted = this.hostedSessions.get(sessionId);
|
||||
if (hosted === undefined) return;
|
||||
const errors: unknown[] = [];
|
||||
const releases = await Promise.allSettled(
|
||||
[...hosted.attachments].map((attachment) => this.releaseAttachment(attachment, context)),
|
||||
);
|
||||
for (const result of releases) if (result.status === "rejected") errors.push(result.reason);
|
||||
try {
|
||||
await hosted.handle.close(context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (this.hostedSessions.get(sessionId) === hosted) this.hostedSessions.delete(sessionId);
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, `Failed to close Session ${sessionId}`);
|
||||
}
|
||||
|
||||
async disconnect(client: object, context: Context): Promise<void> {
|
||||
this.disconnectedClients.add(client);
|
||||
try {
|
||||
await this.runForClient(client, async () => {
|
||||
const attachment = this.attachmentsByClient.get(client);
|
||||
if (attachment) await this.releaseAttachment(attachment, context, false);
|
||||
});
|
||||
} finally {
|
||||
this.disconnectedClients.delete(client);
|
||||
}
|
||||
}
|
||||
|
||||
close(context: Context): Promise<void> {
|
||||
this.closePromise ??= this.closeInternal(context);
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private async closeInternal(context: Context): Promise<void> {
|
||||
const operationPromises = [...this.clientOperations.values()];
|
||||
const openingPromises = [...this.openingSessions.values()];
|
||||
const [operationResults, openingResults] = await Promise.all([
|
||||
Promise.allSettled(operationPromises),
|
||||
Promise.allSettled(openingPromises),
|
||||
]);
|
||||
const closeErrors: unknown[] = [];
|
||||
for (const result of [...operationResults, ...openingResults]) {
|
||||
if (result.status !== "rejected") continue;
|
||||
this.options.reportError(result.reason);
|
||||
if (result.reason instanceof SessionCleanupError) closeErrors.push(result.reason);
|
||||
}
|
||||
const attachmentResults = await Promise.allSettled(
|
||||
[...this.hostedSessions.values()].flatMap((session) =>
|
||||
[...session.attachments].map((attachment) => this.releaseAttachment(attachment, context)),
|
||||
),
|
||||
);
|
||||
for (const result of attachmentResults) {
|
||||
if (result.status === "rejected") closeErrors.push(result.reason);
|
||||
}
|
||||
const hosted = [...this.hostedSessions.values()];
|
||||
const closeResults = await Promise.allSettled(hosted.map(({ handle }) => handle.close(context)));
|
||||
for (let index = 0; index < closeResults.length; index++) {
|
||||
const result = closeResults[index]!;
|
||||
const session = hosted[index]!;
|
||||
if (result.status === "fulfilled") {
|
||||
if (this.hostedSessions.get(session.id) === session) this.hostedSessions.delete(session.id);
|
||||
continue;
|
||||
}
|
||||
this.options.reportError(result.reason);
|
||||
closeErrors.push(result.reason);
|
||||
}
|
||||
this.attachmentsByClient.clear();
|
||||
this.clientOperations.clear();
|
||||
if (closeErrors.length > 0) throw new AggregateError(closeErrors, "Failed to close routed Sessions");
|
||||
}
|
||||
|
||||
private runForClient<T>(client: object, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.clientOperations.get(client) ?? Promise.resolve();
|
||||
const result = previous.catch(() => {}).then(operation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.clientOperations.set(client, tail);
|
||||
void tail.finally(() => {
|
||||
if (this.clientOperations.get(client) === tail) this.clientOperations.delete(client);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async attachClientNow(client: object, sessionId: string, context: Context): Promise<void> {
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
const current = this.attachmentsByClient.get(client);
|
||||
if (current?.session.id === sessionId) return;
|
||||
const hosted = await this.acquire(sessionId, context);
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
if (current) await this.releaseAttachment(current, context, false);
|
||||
const attachment: ClientAttachment = {
|
||||
id: randomUUID(),
|
||||
client,
|
||||
session: hosted,
|
||||
operations: new Set(),
|
||||
};
|
||||
hosted.attachments.add(attachment);
|
||||
try {
|
||||
const acquiring = Promise.resolve(hosted.handle.attachClient(context));
|
||||
attachment.acquiring = acquiring;
|
||||
attachment.lease = await acquiring;
|
||||
} catch (error) {
|
||||
hosted.attachments.delete(attachment);
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
this.hostedSessions.get(hosted.id) !== hosted ||
|
||||
!hosted.attachments.has(attachment) ||
|
||||
this.disconnectedClients.has(client) ||
|
||||
this.options.isClosing()
|
||||
) {
|
||||
await this.releaseAttachment(attachment, context);
|
||||
throw new ServerDrainingError();
|
||||
}
|
||||
this.attachmentsByClient.set(client, attachment);
|
||||
await this.options.publishAttachment(
|
||||
client,
|
||||
{ serverId: this.options.serverId, sessionId, attachmentId: attachment.id },
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private async startServiceCall(
|
||||
client: object,
|
||||
target: RpcTarget,
|
||||
call: ServiceCall,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => Promise<void>,
|
||||
context: Context,
|
||||
): Promise<{ result: Promise<JsonValue | undefined> }> {
|
||||
const attachment = this.requireAttachment(client, target);
|
||||
const result = attachment.lease!.invokeService(
|
||||
call,
|
||||
(subscriptionId, update, updateContext) => publish(subscriptionId, update, updateContext),
|
||||
context,
|
||||
);
|
||||
this.trackOperation(attachment, result);
|
||||
return { result };
|
||||
}
|
||||
|
||||
private trackOperation(attachment: ClientAttachment, result: Promise<unknown>): void {
|
||||
attachment.operations.add(result);
|
||||
const remove = (): void => {
|
||||
attachment.operations.delete(result);
|
||||
};
|
||||
void result.then(remove, remove);
|
||||
}
|
||||
|
||||
private requireAttachment(client: object, target: RpcTarget): ClientAttachment {
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
if (!("sessionId" in target)) throw new SessionNotAttachedError();
|
||||
const attachment = this.attachmentsByClient.get(client);
|
||||
if (!attachment || attachment.session.id !== target.sessionId || attachment.id !== target.attachmentId) {
|
||||
throw new SessionNotAttachedError();
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
private releaseAttachment(attachment: ClientAttachment, context: Context, publish = true): Promise<void> {
|
||||
attachment.releasing ??= (async () => {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await Promise.allSettled(attachment.operations);
|
||||
try {
|
||||
const lease = attachment.lease ?? (await attachment.acquiring);
|
||||
if (lease) await lease.release(context);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to release Session attachment");
|
||||
} finally {
|
||||
await this.clearAttachment(attachment, context, publish);
|
||||
}
|
||||
})();
|
||||
return attachment.releasing;
|
||||
}
|
||||
|
||||
private async clearAttachment(attachment: ClientAttachment, context: Context, publish: boolean): Promise<void> {
|
||||
attachment.session.attachments.delete(attachment);
|
||||
if (this.attachmentsByClient.get(attachment.client) === attachment) {
|
||||
this.attachmentsByClient.delete(attachment.client);
|
||||
if (publish) await this.options.publishAttachment(attachment.client, undefined, context);
|
||||
}
|
||||
}
|
||||
|
||||
private async acquire(sessionId: string, context: Context): Promise<HostedSession> {
|
||||
const existing = this.hostedSessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
const opening = this.openingSessions.get(sessionId);
|
||||
if (opening) return opening;
|
||||
const pending = this.open(sessionId, context);
|
||||
this.openingSessions.set(sessionId, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
if (this.openingSessions.get(sessionId) === pending) this.openingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async open(sessionId: string, context: Context): Promise<HostedSession> {
|
||||
const metadata = await this.options.host.resolveSession(sessionId, context);
|
||||
const handle = await this.options.host.openSession(metadata, context);
|
||||
if (this.options.isClosing()) {
|
||||
try {
|
||||
await handle.close(context);
|
||||
} catch (error) {
|
||||
this.options.reportError(error);
|
||||
throw new SessionCleanupError(
|
||||
[new ServerDrainingError(), error],
|
||||
"Failed to close routed Session acquired while draining",
|
||||
);
|
||||
}
|
||||
throw new ServerDrainingError();
|
||||
}
|
||||
const hosted: HostedSession = { id: metadata.id, handle, attachments: new Set() };
|
||||
this.hostedSessions.set(hosted.id, hosted);
|
||||
if (handle.terminated) {
|
||||
void handle.terminated.then(
|
||||
(error) => this.invalidate(hosted, error),
|
||||
(error: unknown) => this.invalidate(hosted, error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
}
|
||||
return hosted;
|
||||
}
|
||||
|
||||
private invalidate(hosted: HostedSession, error: Error | undefined): void {
|
||||
if (this.hostedSessions.get(hosted.id) !== hosted) return;
|
||||
this.hostedSessions.delete(hosted.id);
|
||||
for (const attachment of hosted.attachments) {
|
||||
void this.releaseAttachment(attachment, BACKGROUND_CONTEXT).catch((releaseError: unknown) =>
|
||||
this.options.reportError(releaseError),
|
||||
);
|
||||
}
|
||||
if (error) this.options.reportError(error);
|
||||
}
|
||||
}
|
||||
183
packages/server/src/testing/client.ts
Normal file
183
packages/server/src/testing/client.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { once } from "node:events";
|
||||
import { createConnection, type Socket } from "node:net";
|
||||
import type { JsonValue, ServiceCall } from "@earendil-works/chord";
|
||||
import {
|
||||
type ClientMessage,
|
||||
encodeClientMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type ResponseEnvelope,
|
||||
type RpcTarget,
|
||||
type ServerMessage,
|
||||
ServerMessageDecoder,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { Deferred } from "./host.ts";
|
||||
|
||||
interface MessageWaiter {
|
||||
predicate: (message: ServerMessage) => boolean;
|
||||
resolve: (message: ServerMessage) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface WireChannel {
|
||||
send(chunk: Uint8Array): Promise<void>;
|
||||
sendFragmented(chunk: Uint8Array, splitAt: number): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ProtocolTestClient {
|
||||
readonly messages: ServerMessage[] = [];
|
||||
private readonly channel: WireChannel;
|
||||
private readonly decoder = new ServerMessageDecoder();
|
||||
private readonly waiters = new Set<MessageWaiter>();
|
||||
private readonly closedDeferred = new Deferred<void>();
|
||||
private requestSequence = 0;
|
||||
private attachment: { sessionId: string; attachmentId: string } | undefined;
|
||||
private closedValue = false;
|
||||
|
||||
constructor(channel: WireChannel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.closedValue;
|
||||
}
|
||||
|
||||
hello(version: number = PROTOCOL_VERSION): Promise<ServerMessage> {
|
||||
const response = this.next((message) => message.type === "hello" || message.type === "hello_error");
|
||||
void this.sendMessage({ type: "hello", version });
|
||||
return response;
|
||||
}
|
||||
|
||||
async requestService(
|
||||
target: RpcTarget,
|
||||
call: ServiceCall,
|
||||
id = `request-${++this.requestSequence}`,
|
||||
): Promise<ResponseEnvelope> {
|
||||
const response = this.next(
|
||||
(message): message is ResponseEnvelope => message.type === "response" && message.id === id,
|
||||
);
|
||||
await this.sendMessage({ type: "request", id, target, call: call as unknown as JsonValue });
|
||||
return (await response) as ResponseEnvelope;
|
||||
}
|
||||
|
||||
attach(serverId: string, sessionId: string): Promise<ResponseEnvelope> {
|
||||
return this.requestService(
|
||||
{ serverId },
|
||||
{ serviceId: "pi.session-management", member: "attach", args: [sessionId] },
|
||||
);
|
||||
}
|
||||
|
||||
requestSessionService(
|
||||
serverId: string,
|
||||
sessionId: string,
|
||||
call: ServiceCall,
|
||||
id?: string,
|
||||
): Promise<ResponseEnvelope> {
|
||||
const attachment = this.attachment;
|
||||
const target: RpcTarget =
|
||||
attachment === undefined || attachment.sessionId !== sessionId
|
||||
? { serverId, sessionId, attachmentId: "missing-attachment" }
|
||||
: { serverId, ...attachment };
|
||||
return this.requestService(target, call, id);
|
||||
}
|
||||
|
||||
sendMessage(message: ClientMessage): Promise<void> {
|
||||
return this.channel.send(encodeClientMessage(message));
|
||||
}
|
||||
|
||||
sendBytes(chunk: Uint8Array): Promise<void> {
|
||||
return this.channel.send(chunk);
|
||||
}
|
||||
|
||||
sendFragmentedMessage(message: ClientMessage, splitAt: number): Promise<void> {
|
||||
return this.channel.sendFragmented(encodeClientMessage(message), splitAt);
|
||||
}
|
||||
|
||||
next(predicate: (message: ServerMessage) => boolean): Promise<ServerMessage> {
|
||||
return this.nextFrom(0, predicate);
|
||||
}
|
||||
|
||||
nextFrom(index: number, predicate: (message: ServerMessage) => boolean): Promise<ServerMessage> {
|
||||
const existing = this.messages.slice(index).find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
if (this.closedValue) return Promise.reject(new Error("Wire client is closed"));
|
||||
return new Promise((resolve, reject) => this.waiters.add({ predicate, resolve, reject }));
|
||||
}
|
||||
|
||||
waitForClose(): Promise<void> {
|
||||
return this.closedValue ? Promise.resolve() : this.closedDeferred.promise;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.channel.close();
|
||||
}
|
||||
|
||||
receive(chunk: Uint8Array): void {
|
||||
try {
|
||||
for (const message of this.decoder.push(chunk)) {
|
||||
if (message.type === "attachment") {
|
||||
this.attachment =
|
||||
message.attachment === null
|
||||
? undefined
|
||||
: {
|
||||
sessionId: message.attachment.sessionId,
|
||||
attachmentId: message.attachment.attachmentId,
|
||||
};
|
||||
}
|
||||
this.messages.push(message);
|
||||
for (const waiter of this.waiters) {
|
||||
if (!waiter.predicate(message)) continue;
|
||||
this.waiters.delete(waiter);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.fail(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
markClosed(): void {
|
||||
if (this.closedValue) return;
|
||||
this.closedValue = true;
|
||||
this.closedDeferred.resolve(undefined);
|
||||
this.fail(new Error("Wire connection closed"));
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
for (const waiter of this.waiters) waiter.reject(error);
|
||||
this.waiters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectUnixTestClient(path: string): Promise<ProtocolTestClient> {
|
||||
const socket = createConnection(path);
|
||||
await once(socket, "connect");
|
||||
const client = new ProtocolTestClient({
|
||||
send: (chunk) => writeSocket(socket, chunk),
|
||||
async sendFragmented(chunk, splitAt) {
|
||||
await writeSocket(socket, chunk.subarray(0, splitAt));
|
||||
await writeSocket(socket, chunk.subarray(splitAt));
|
||||
},
|
||||
async close() {
|
||||
if (socket.destroyed) return;
|
||||
const closed = once(socket, "close");
|
||||
socket.destroy();
|
||||
await closed;
|
||||
},
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
client.receive(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
||||
});
|
||||
socket.on("error", (error) => client.fail(error));
|
||||
socket.once("close", () => client.markClosed());
|
||||
return client;
|
||||
}
|
||||
|
||||
function writeSocket(socket: Socket, chunk: Uint8Array): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket.write(chunk, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
215
packages/server/src/testing/host.ts
Normal file
215
packages/server/src/testing/host.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { JsonValue, ServiceCall } from "@earendil-works/chord";
|
||||
import type { Context, Session, SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import { BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { SessionAmbiguousError, SessionNotFoundError } from "../errors.ts";
|
||||
import type { RoutedServerServiceHost, RoutedSessionHandle, ServerHost } from "../types.ts";
|
||||
|
||||
export class Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
private resolvePromise!: (value: T) => void;
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise<T>((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
resolve(value: T): void {
|
||||
this.resolvePromise(value);
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenGate {
|
||||
entered: Deferred<void>;
|
||||
release: Deferred<void>;
|
||||
}
|
||||
|
||||
export class TestHarness {
|
||||
readonly session: Session;
|
||||
readonly closed = new Deferred<void>();
|
||||
readonly #termination = new Deferred<Error | undefined>();
|
||||
readonly terminated = this.#termination.promise;
|
||||
attachedClients = 0;
|
||||
attachmentReleaseCount = 0;
|
||||
closeCount = 0;
|
||||
readonly serviceCalls: ServiceCall[] = [];
|
||||
failAttachmentRelease?: Error;
|
||||
failClose?: Error;
|
||||
nextServiceError?: Error;
|
||||
nextServiceResult: JsonValue | undefined = { ok: true };
|
||||
private nextCloseGate?: OpenGate;
|
||||
private nextServiceGate?: OpenGate;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
attachClient(_context: Context): {
|
||||
invokeService: TestHarness["invokeService"];
|
||||
release(context: Context): void;
|
||||
} {
|
||||
this.attachedClients += 1;
|
||||
let released = false;
|
||||
return {
|
||||
invokeService: (call) => this.invokeService(call),
|
||||
release: (_context) => {
|
||||
if (released) return;
|
||||
this.attachmentReleaseCount += 1;
|
||||
if (this.failAttachmentRelease) throw this.failAttachmentRelease;
|
||||
released = true;
|
||||
this.attachedClients -= 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async invokeService(call: ServiceCall): Promise<JsonValue | undefined> {
|
||||
this.serviceCalls.push(call);
|
||||
if (this.nextServiceError) {
|
||||
const error = this.nextServiceError;
|
||||
this.nextServiceError = undefined;
|
||||
throw error;
|
||||
}
|
||||
const gate = this.nextServiceGate;
|
||||
if (gate) {
|
||||
this.nextServiceGate = undefined;
|
||||
gate.entered.resolve(undefined);
|
||||
await gate.release.promise;
|
||||
}
|
||||
const result = this.nextServiceResult;
|
||||
this.nextServiceResult = { ok: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
async close(context: Context): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
const gate = this.nextCloseGate;
|
||||
if (gate) {
|
||||
this.nextCloseGate = undefined;
|
||||
gate.entered.resolve(undefined);
|
||||
await gate.release.promise;
|
||||
}
|
||||
if (this.failClose) {
|
||||
const error = this.failClose;
|
||||
this.failClose = undefined;
|
||||
throw error;
|
||||
}
|
||||
await this.session.close(context);
|
||||
this.closed.resolve(undefined);
|
||||
this.#termination.resolve(undefined);
|
||||
}
|
||||
|
||||
async terminate(error: Error): Promise<void> {
|
||||
await this.session.close(BACKGROUND_CONTEXT);
|
||||
this.#termination.resolve(error);
|
||||
}
|
||||
|
||||
gateNextClose(): OpenGate {
|
||||
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
|
||||
this.nextCloseGate = gate;
|
||||
return gate;
|
||||
}
|
||||
|
||||
gateNextServiceCall(): OpenGate {
|
||||
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
|
||||
this.nextServiceGate = gate;
|
||||
return gate;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestServerServices(): RoutedServerServiceHost {
|
||||
return {
|
||||
attachClient(presentation) {
|
||||
return {
|
||||
async invokeService(call, _publish, context) {
|
||||
if (
|
||||
call.instance === undefined &&
|
||||
call.serviceId === "pi.session-management" &&
|
||||
call.member === "attach" &&
|
||||
call.args.length === 1 &&
|
||||
typeof call.args[0] === "string"
|
||||
) {
|
||||
await presentation.attachSession(call.args[0], context);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
call.instance === undefined &&
|
||||
call.serviceId === "pi.session-management" &&
|
||||
call.member === "detach" &&
|
||||
call.args.length === 0
|
||||
) {
|
||||
await presentation.detachSession(context);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Unsupported test server service ${call.serviceId}.${call.member}`);
|
||||
},
|
||||
release() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class TestServerHost implements ServerHost {
|
||||
readonly serverServices = createTestServerServices();
|
||||
readonly repo = new MemorySessionRepo({ now: () => 1 });
|
||||
readonly harnesses = new Map<string, TestHarness[]>();
|
||||
openSessionCount = 0;
|
||||
nextOpenSessionError?: Error;
|
||||
nextHarnessCloseError?: Error;
|
||||
private nextOpenSessionGate?: OpenGate;
|
||||
|
||||
async resolveSession(sessionId: string, context: Context): Promise<SessionMetadata> {
|
||||
const matches = (await this.repo.list(undefined, context)).filter(({ id }) => 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: SessionMetadata, context: Context): Promise<RoutedSessionHandle> {
|
||||
this.openSessionCount += 1;
|
||||
const gate = this.nextOpenSessionGate;
|
||||
if (gate) {
|
||||
this.nextOpenSessionGate = undefined;
|
||||
gate.entered.resolve(undefined);
|
||||
await gate.release.promise;
|
||||
}
|
||||
const session = await this.repo.open(metadata, context);
|
||||
try {
|
||||
if (this.nextOpenSessionError) {
|
||||
const error = this.nextOpenSessionError;
|
||||
this.nextOpenSessionError = undefined;
|
||||
throw error;
|
||||
}
|
||||
const harness = new TestHarness(session);
|
||||
if (this.nextHarnessCloseError) {
|
||||
harness.failClose = this.nextHarnessCloseError;
|
||||
this.nextHarnessCloseError = undefined;
|
||||
}
|
||||
const harnesses = this.harnesses.get(metadata.id) ?? [];
|
||||
harnesses.push(harness);
|
||||
this.harnesses.set(metadata.id, harnesses);
|
||||
return harness;
|
||||
} catch (error) {
|
||||
await session.close(context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async seed(id = "session-1", parentSessionId?: string): Promise<SessionMetadata> {
|
||||
const session = await this.repo.create({ id, parentSessionId }, BACKGROUND_CONTEXT);
|
||||
const metadata = session.metadata;
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
gateNextOpenSession(): OpenGate {
|
||||
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
|
||||
this.nextOpenSessionGate = gate;
|
||||
return gate;
|
||||
}
|
||||
|
||||
latestHarness(id: string): TestHarness {
|
||||
const harnesses = this.harnesses.get(id);
|
||||
if (!harnesses?.length) throw new Error(`No harness for ${id}`);
|
||||
return harnesses.at(-1)!;
|
||||
}
|
||||
}
|
||||
5
packages/server/src/testing/index.ts
Normal file
5
packages/server/src/testing/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { WireChannel } from "./client.ts";
|
||||
export { connectUnixTestClient, ProtocolTestClient } from "./client.ts";
|
||||
export { createTestServerServices, Deferred, TestHarness, TestServerHost } from "./host.ts";
|
||||
export type { TestServer, TestServerOptions } from "./server.ts";
|
||||
export { createTestServer } from "./server.ts";
|
||||
28
packages/server/src/testing/server.ts
Normal file
28
packages/server/src/testing/server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Server } from "../server.ts";
|
||||
import type { ServerHost, ServerOptions } from "../types.ts";
|
||||
import { TestServerHost } from "./host.ts";
|
||||
|
||||
export interface TestServerOptions extends Omit<ServerOptions, "serverId"> {
|
||||
host?: ServerHost;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
server: Server;
|
||||
host: ServerHost;
|
||||
}
|
||||
|
||||
/** Create an unstarted Server with deterministic defaults for transport conformance tests. */
|
||||
export function createTestServer(options: TestServerOptions): TestServer {
|
||||
const host = options.host ?? new TestServerHost();
|
||||
return {
|
||||
server: new Server(host, {
|
||||
listeners: options.listeners,
|
||||
maxFrameLength: options.maxFrameLength,
|
||||
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
||||
serverId: options.serverId ?? "00000000-0000-4000-8000-000000000001",
|
||||
onError: options.onError,
|
||||
}),
|
||||
host,
|
||||
};
|
||||
}
|
||||
9
packages/server/src/transports/unix/address.ts
Normal file
9
packages/server/src/transports/unix/address.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Derive the local Unix socket path for one logical server identity. */
|
||||
export function getUnixSocketPath(serverId: string, serverDirectory: string): string {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(serverId)) {
|
||||
throw new TypeError("Unix serverId must be a canonical lowercase UUIDv4");
|
||||
}
|
||||
return join(serverDirectory, `${serverId}.sock`);
|
||||
}
|
||||
4
packages/server/src/transports/unix/index.ts
Normal file
4
packages/server/src/transports/unix/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { getUnixSocketPath } from "./address.ts";
|
||||
export { createUnixListener } from "./listener.ts";
|
||||
export { createUnixServer } from "./preset.ts";
|
||||
export type { UnixListenerOptions, UnixServerOptions } from "./types.ts";
|
||||
421
packages/server/src/transports/unix/listener.ts
Normal file
421
packages/server/src/transports/unix/listener.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import { chmod, link, lstat, mkdir, rename, unlink } from "node:fs/promises";
|
||||
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DEFAULT_MAX_FRAME_LENGTH } from "@earendil-works/pi-protocol";
|
||||
import type { ByteConnection, ByteConnectionAcceptor } from "../../connection.ts";
|
||||
import type { ServerListener } from "../../listener.ts";
|
||||
import type { UnixListenerOptions } from "./types.ts";
|
||||
|
||||
const DEFAULT_SOCKET_MODE = 0o600;
|
||||
const DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS = 5_000;
|
||||
const MAX_UINT32 = 0xffff_ffff;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
const SOCKET_PROBE_TIMEOUT_MS = 1_000;
|
||||
|
||||
interface ResolvedUnixListenerOptions {
|
||||
path: string;
|
||||
mode: number;
|
||||
gracefulCloseTimeoutMs: number;
|
||||
maxPendingBytes: number;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
class UnixListener implements ServerListener {
|
||||
private readonly options: ResolvedUnixListenerOptions;
|
||||
private readonly path: string;
|
||||
private readonly mode: number;
|
||||
private readonly connections = new Set<UnixByteConnection>();
|
||||
private server?: Server;
|
||||
private socketIdentity?: FileIdentity;
|
||||
private ownedBindPath?: string;
|
||||
private closing = false;
|
||||
private closePromise?: Promise<void>;
|
||||
private accept?: ByteConnectionAcceptor;
|
||||
|
||||
constructor(options: UnixListenerOptions) {
|
||||
this.options = resolveUnixListenerOptions(options);
|
||||
this.path = this.options.path;
|
||||
this.mode = this.options.mode;
|
||||
}
|
||||
|
||||
async start(accept: ByteConnectionAcceptor): Promise<void> {
|
||||
if (this.server) throw new Error("Unix listener is already started");
|
||||
if (this.closing) throw new Error("Unix listener is closing or closed");
|
||||
this.accept = accept;
|
||||
|
||||
const ownedBindPath = getOwnedBindPath(this.path);
|
||||
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
||||
await removeStaleSocket(this.path);
|
||||
await removeStaleSocket(ownedBindPath);
|
||||
this.ownedBindPath = ownedBindPath;
|
||||
const server = createServer((socket) => this.acceptSocket(socket));
|
||||
server.on("error", (error) => this.reportError(error));
|
||||
this.server = server;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = (): void => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(ownedBindPath);
|
||||
});
|
||||
const stats = await lstat(ownedBindPath);
|
||||
if (!stats.isSocket()) throw new Error(`Unix listener path is not a socket after binding: ${ownedBindPath}`);
|
||||
this.socketIdentity = { dev: stats.dev, ino: stats.ino };
|
||||
await link(ownedBindPath, this.path);
|
||||
await setSocketMode(this.path, this.mode);
|
||||
await removePath(ownedBindPath);
|
||||
this.ownedBindPath = undefined;
|
||||
} catch (error) {
|
||||
await this.closeServerAndCleanup(server);
|
||||
this.server = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closePromise) return this.closePromise;
|
||||
this.closing = true;
|
||||
this.closePromise = this.closeInternal();
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private acceptSocket(socket: Socket): void {
|
||||
if (this.closing) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const connection = new UnixByteConnection(
|
||||
socket,
|
||||
this.options.gracefulCloseTimeoutMs,
|
||||
this.options.maxPendingBytes,
|
||||
);
|
||||
this.connections.add(connection);
|
||||
const accept = this.accept;
|
||||
if (!accept) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const handler = accept(connection);
|
||||
socket.on("data", (chunk) => {
|
||||
handler.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
handler.onError(error);
|
||||
socket.destroy();
|
||||
});
|
||||
socket.once("close", () => {
|
||||
connection.markClosed();
|
||||
this.connections.delete(connection);
|
||||
handler.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
private async closeInternal(): Promise<void> {
|
||||
const serverClosed = this.server ? this.closeServerAndCleanup(this.server) : this.cleanupOwnedSocket();
|
||||
await Promise.all([...this.connections].map((connection) => connection.close()));
|
||||
await serverClosed;
|
||||
if (this.ownedBindPath) await removePath(this.ownedBindPath);
|
||||
this.ownedBindPath = undefined;
|
||||
this.connections.clear();
|
||||
this.server = undefined;
|
||||
}
|
||||
|
||||
private async closeServerAndCleanup(server: Server): Promise<void> {
|
||||
try {
|
||||
await closeNetServer(server, (error) => this.reportError(error));
|
||||
} finally {
|
||||
// Remove an unpublished startup bind path before the public route.
|
||||
if (this.ownedBindPath) await removePath(this.ownedBindPath);
|
||||
this.ownedBindPath = undefined;
|
||||
await this.cleanupOwnedSocket();
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupOwnedSocket(): Promise<void> {
|
||||
const identity = this.socketIdentity;
|
||||
this.socketIdentity = undefined;
|
||||
if (!identity) return;
|
||||
let current: Stats;
|
||||
try {
|
||||
current = await lstat(this.path);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (!current.isSocket() || current.dev !== identity.dev || current.ino !== identity.ino) return;
|
||||
|
||||
const preserved = join(dirname(this.path), `cleanup-${randomUUID().slice(0, 6)}`);
|
||||
try {
|
||||
await rename(this.path, preserved);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
const moved = await lstat(preserved);
|
||||
if (moved.isSocket() && moved.dev === identity.dev && moved.ino === identity.ino) {
|
||||
await removePath(preserved);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await lstat(this.path);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) await rename(preserved, this.path);
|
||||
else throw error;
|
||||
}
|
||||
throw new Error(`Unix listener path changed during cleanup; preserved replacement at ${preserved}`);
|
||||
}
|
||||
|
||||
private reportError(error: unknown): void {
|
||||
try {
|
||||
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
} catch {
|
||||
// Error observers cannot affect listener state.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported only for transport-level verification. */
|
||||
export class UnixByteConnection implements ByteConnection {
|
||||
private readonly socket: Socket;
|
||||
private readonly gracefulCloseTimeoutMs: number;
|
||||
private readonly maxPendingBytes: number;
|
||||
private pendingBytes = 0;
|
||||
private closedValue = false;
|
||||
private closing = false;
|
||||
private writeTail: Promise<void> = Promise.resolve();
|
||||
private closePromise?: Promise<void>;
|
||||
private resolveClose?: () => void;
|
||||
|
||||
constructor(socket: Socket, gracefulCloseTimeoutMs: number, maxPendingBytes: number) {
|
||||
this.socket = socket;
|
||||
this.gracefulCloseTimeoutMs = gracefulCloseTimeoutMs;
|
||||
this.maxPendingBytes = maxPendingBytes;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.closedValue;
|
||||
}
|
||||
|
||||
send(chunk: Uint8Array): Promise<void> {
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
return Promise.reject(new TypeError("Unix connection chunks must be Uint8Array"));
|
||||
}
|
||||
if (this.closedValue || this.closing) return Promise.reject(new Error("Unix connection is closed"));
|
||||
if (this.pendingBytes + chunk.byteLength > this.maxPendingBytes) {
|
||||
return Promise.reject(new Error("Unix connection exceeded its pending byte limit"));
|
||||
}
|
||||
this.pendingBytes += chunk.byteLength;
|
||||
const bytes = chunk.slice();
|
||||
const write = this.writeTail.then(() => this.write(bytes));
|
||||
const tracked = write.finally(() => {
|
||||
this.pendingBytes -= bytes.byteLength;
|
||||
});
|
||||
this.writeTail = tracked.catch(() => {});
|
||||
return tracked;
|
||||
}
|
||||
|
||||
close(finalChunk?: Uint8Array): Promise<void> {
|
||||
if (this.closedValue || this.socket.destroyed) {
|
||||
this.markClosed();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.closePromise) return this.closePromise;
|
||||
this.closing = true;
|
||||
const finalBytes = finalChunk?.slice();
|
||||
this.closePromise = new Promise<void>((resolve) => {
|
||||
this.resolveClose = resolve;
|
||||
const timer = setTimeout(() => {
|
||||
if (!this.socket.destroyed) this.socket.destroy();
|
||||
this.markClosed();
|
||||
}, this.gracefulCloseTimeoutMs);
|
||||
timer.unref();
|
||||
this.socket.once("close", () => clearTimeout(timer));
|
||||
void this.writeTail.then(() => {
|
||||
if (this.socket.destroyed) {
|
||||
this.markClosed();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (finalBytes) this.socket.end(finalBytes);
|
||||
else this.socket.end();
|
||||
} catch {
|
||||
this.socket.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
markClosed(): void {
|
||||
if (this.closedValue) return;
|
||||
this.closedValue = true;
|
||||
this.closing = true;
|
||||
this.resolveClose?.();
|
||||
this.resolveClose = undefined;
|
||||
}
|
||||
|
||||
private write(chunk: Uint8Array): Promise<void> {
|
||||
if (this.closedValue || this.closing || !this.socket.writable) {
|
||||
return Promise.reject(new Error("Unix connection is closed"));
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onClose = (): void => finish(new Error("Unix connection closed during write"));
|
||||
const finish = (error?: Error | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket.off("close", onClose);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
this.socket.once("close", onClose);
|
||||
try {
|
||||
this.socket.write(chunk, finish);
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnedBindPath(path: string): string {
|
||||
const suffix = createHash("sha256").update(path).digest("hex").slice(0, 8);
|
||||
return join(dirname(path), `bind-${suffix}`);
|
||||
}
|
||||
|
||||
async function removeStaleSocket(path: string): Promise<void> {
|
||||
let original: Stats;
|
||||
try {
|
||||
original = await lstat(path);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (!original.isSocket()) throw new Error(`Refusing to remove non-socket Unix listener path: ${path}`);
|
||||
if (await isSocketLive(path)) throw new Error(`Unix listener is already running: ${path}`);
|
||||
|
||||
const preserved = join(dirname(path), `stale-${randomUUID().slice(0, 6)}`);
|
||||
try {
|
||||
await rename(path, preserved);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
const current = await lstat(preserved);
|
||||
if (!current.isSocket() || current.dev !== original.dev || current.ino !== original.ino) {
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) await rename(preserved, path);
|
||||
else throw error;
|
||||
}
|
||||
throw new Error(`Unix listener path changed while checking for a stale socket: ${path}`);
|
||||
}
|
||||
await removePath(preserved);
|
||||
}
|
||||
|
||||
async function removePath(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (error) {
|
||||
if (!isErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isSocketLive(path: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const socket = createConnection(path);
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const finish = (result: boolean, error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
};
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||
if (["ECONNREFUSED", "ENOENT", "EPIPE", "ECONNRESET"].includes(error.code ?? "")) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
finish(false, error);
|
||||
});
|
||||
timer = setTimeout(() => finish(true), SOCKET_PROBE_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
|
||||
async function setSocketMode(path: string, mode: number): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
try {
|
||||
await chmod(path, mode);
|
||||
} catch (error) {
|
||||
if (!isErrorCode(error, "ENOSYS") && !isErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function closeNetServer(server: Server, reportError: (error: Error) => void): Promise<void> {
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
server.close((error) => {
|
||||
if (error) reportError(error);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
export function createUnixListener(options: UnixListenerOptions): ServerListener {
|
||||
return new UnixListener(options);
|
||||
}
|
||||
|
||||
function resolveUnixListenerOptions(options: UnixListenerOptions): ResolvedUnixListenerOptions {
|
||||
if (!options.path) throw new TypeError("Server Unix socket path must not be empty");
|
||||
const mode = options.mode ?? DEFAULT_SOCKET_MODE;
|
||||
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
||||
throw new TypeError("Server Unix socket mode must be an integer between 0 and 0o777");
|
||||
}
|
||||
const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
|
||||
throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
|
||||
}
|
||||
const maxPendingBytes = options.maxPendingBytes ?? maxFrameLength * 4;
|
||||
if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes < maxFrameLength + 4) {
|
||||
throw new TypeError("Server maxPendingBytes must be a safe integer at least maxFrameLength + 4");
|
||||
}
|
||||
const gracefulCloseTimeoutMs = options.gracefulCloseTimeoutMs ?? DEFAULT_GRACEFUL_CLOSE_TIMEOUT_MS;
|
||||
if (
|
||||
!Number.isSafeInteger(gracefulCloseTimeoutMs) ||
|
||||
gracefulCloseTimeoutMs <= 0 ||
|
||||
gracefulCloseTimeoutMs > MAX_TIMER_DELAY_MS
|
||||
) {
|
||||
throw new TypeError(`Server gracefulCloseTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
||||
}
|
||||
return {
|
||||
path: options.path,
|
||||
mode,
|
||||
maxPendingBytes,
|
||||
gracefulCloseTimeoutMs,
|
||||
onError: options.onError,
|
||||
};
|
||||
}
|
||||
28
packages/server/src/transports/unix/preset.ts
Normal file
28
packages/server/src/transports/unix/preset.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import { Server } from "../../server.ts";
|
||||
import type { ServerHost } from "../../types.ts";
|
||||
import { createUnixListener } from "./listener.ts";
|
||||
import type { UnixServerOptions } from "./types.ts";
|
||||
|
||||
/** Compose Server with one Unix-domain socket listener. */
|
||||
export function createUnixServer<TMetadata extends SessionMetadata>(
|
||||
host: ServerHost<TMetadata>,
|
||||
options: UnixServerOptions,
|
||||
): Server<TMetadata> {
|
||||
const listener = createUnixListener({
|
||||
path: options.path,
|
||||
mode: options.mode,
|
||||
maxFrameLength: options.maxFrameLength,
|
||||
maxPendingBytes: options.maxPendingBytes,
|
||||
gracefulCloseTimeoutMs: options.gracefulCloseTimeoutMs,
|
||||
onError: options.onError,
|
||||
});
|
||||
return new Server(host, {
|
||||
listeners: [listener],
|
||||
maxFrameLength: options.maxFrameLength,
|
||||
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
||||
onConnectionCountChanged: options.onConnectionCountChanged,
|
||||
serverId: options.serverId,
|
||||
onError: options.onError,
|
||||
});
|
||||
}
|
||||
15
packages/server/src/transports/unix/types.ts
Normal file
15
packages/server/src/transports/unix/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ServerOptions } from "../../types.ts";
|
||||
|
||||
export interface UnixListenerOptions {
|
||||
path: string;
|
||||
/** Socket filesystem permissions. Defaults to owner read/write only (0o600). */
|
||||
mode?: number;
|
||||
/** Maximum framed bytes queued per connection before a slow peer is disconnected. */
|
||||
maxPendingBytes?: number;
|
||||
gracefulCloseTimeoutMs?: number;
|
||||
/** Used to derive and validate maxPendingBytes. Must match the server when customized. */
|
||||
maxFrameLength?: number;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface UnixServerOptions extends Omit<ServerOptions, "listeners">, UnixListenerOptions {}
|
||||
64
packages/server/src/types.ts
Normal file
64
packages/server/src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { JsonValue, ServiceCall, ServiceProviderUpdate } from "@earendil-works/chord";
|
||||
import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { ServerListener } from "./listener.ts";
|
||||
|
||||
export interface ServerOptions {
|
||||
listeners: readonly ServerListener[];
|
||||
/** Stable logical server identity supplied by the installation or profile. */
|
||||
serverId: string;
|
||||
maxFrameLength?: number;
|
||||
handshakeTimeoutMs?: number;
|
||||
onConnectionCountChanged?: (count: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
/** One presentation connection's live capability for a hosted Session. */
|
||||
export interface RoutedSessionAttachment {
|
||||
/** Route one contract-agnostic service operation to the attached Session endpoint. */
|
||||
invokeService(
|
||||
call: ServiceCall,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise<void>,
|
||||
context: Context,
|
||||
): Promise<JsonValue | undefined>;
|
||||
release(context: Context): MaybePromise<void>;
|
||||
}
|
||||
|
||||
/** Presentation-scoped routing capabilities available to server service implementations. */
|
||||
export interface RoutedServerPresentation {
|
||||
attachSession(sessionId: string, context: Context): Promise<void>;
|
||||
detachSession(context: Context): Promise<void>;
|
||||
/** Release routed attachments and handles before the application deletes durable metadata. */
|
||||
prepareSessionRemoval(sessionId: string, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** One connection's server-scoped service endpoint. */
|
||||
export interface RoutedServerServiceAttachment {
|
||||
invokeService(
|
||||
call: ServiceCall,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => MaybePromise<void>,
|
||||
context: Context,
|
||||
): Promise<JsonValue | undefined>;
|
||||
release(context: Context): MaybePromise<void>;
|
||||
}
|
||||
|
||||
export interface RoutedServerServiceHost {
|
||||
attachClient(presentation: RoutedServerPresentation, context: Context): MaybePromise<RoutedServerServiceAttachment>;
|
||||
}
|
||||
|
||||
/** A process-safe handle that acquires presentation-scoped Session capabilities. */
|
||||
export interface RoutedSessionHandle {
|
||||
attachClient(context: Context): MaybePromise<RoutedSessionAttachment>;
|
||||
/** Resolves with an error for unexpected termination, or undefined after an expected close. */
|
||||
readonly terminated?: Promise<Error | undefined>;
|
||||
close(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** Application capabilities used by server-wide management and Session routing. */
|
||||
export interface ServerHost<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
readonly serverServices: RoutedServerServiceHost;
|
||||
/** Resolve one durable Session ID or throw a bounded routing error. */
|
||||
resolveSession(sessionId: string, context: Context): Promise<TMetadata>;
|
||||
openSession(metadata: TMetadata, context: Context): Promise<RoutedSessionHandle>;
|
||||
}
|
||||
502
packages/server/test/conformance.test.ts
Normal file
502
packages/server/test/conformance.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import type { ServiceCall } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT, type SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
|
||||
import { SessionAmbiguousError } from "../src/errors.ts";
|
||||
import { Server } from "../src/server.ts";
|
||||
import {
|
||||
createTestServerServices,
|
||||
Deferred,
|
||||
ProtocolTestClient,
|
||||
TestServerHost,
|
||||
type WireChannel,
|
||||
} from "../src/testing/index.ts";
|
||||
import type { ServerHost } from "../src/types.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const servers = new Set<Server>();
|
||||
|
||||
function createServer(host: ServerHost, id = serverId): Server {
|
||||
const server = new Server(host, { listeners: [], serverId: id });
|
||||
servers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
function connect(server: Server): ProtocolTestClient {
|
||||
let handler: ByteConnectionHandler;
|
||||
let client: ProtocolTestClient;
|
||||
let closed = false;
|
||||
const connection: ByteConnection = {
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
async send(chunk) {
|
||||
client.receive(chunk);
|
||||
},
|
||||
close(finalChunk) {
|
||||
if (finalChunk) client.receive(finalChunk);
|
||||
closed = true;
|
||||
client.markClosed();
|
||||
},
|
||||
};
|
||||
const channel: WireChannel = {
|
||||
async send(chunk) {
|
||||
handler.onData(chunk);
|
||||
},
|
||||
async sendFragmented(chunk, splitAt) {
|
||||
handler.onData(chunk.subarray(0, splitAt));
|
||||
handler.onData(chunk.subarray(splitAt));
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
handler.onClose();
|
||||
client.markClosed();
|
||||
},
|
||||
};
|
||||
client = new ProtocolTestClient(channel);
|
||||
handler = server.accept(connection);
|
||||
return client;
|
||||
}
|
||||
|
||||
function sessionCall(member: string, args: ServiceCall["args"] = []): ServiceCall {
|
||||
return { serviceId: "test.session", member, args };
|
||||
}
|
||||
|
||||
function latestAttachmentId(client: ProtocolTestClient, sessionId: string): string {
|
||||
for (const message of [...client.messages].reverse()) {
|
||||
if (message.type === "attachment" && message.attachment?.sessionId === sessionId) {
|
||||
return message.attachment.attachmentId;
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing attachment for ${sessionId}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...servers].map((server) => server.close()));
|
||||
servers.clear();
|
||||
});
|
||||
|
||||
describe("Session protocol", () => {
|
||||
test("handshake identifies the logical server without listing sessions", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed();
|
||||
const client = connect(createServer(host));
|
||||
|
||||
expect(await client.hello()).toMatchObject({ type: "hello", serverId });
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects a semantically invalid service call after envelope decoding", async () => {
|
||||
const host = new TestServerHost();
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
const response = client.next((message) => message.type === "response" && message.id === "invalid-call");
|
||||
await client.sendMessage({
|
||||
type: "request",
|
||||
id: "invalid-call",
|
||||
target: { serverId },
|
||||
call: { arbitrary: true },
|
||||
});
|
||||
expect(await response).toMatchObject({ ok: false, error: { code: "invalid_request" } });
|
||||
});
|
||||
|
||||
test("attach passes concrete repository metadata to the Harness host", async () => {
|
||||
type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number };
|
||||
const metadata: BackendMetadata = {
|
||||
id: "session-1",
|
||||
createdAt: 1,
|
||||
storageVersion: 1,
|
||||
cwd: "/workspace",
|
||||
path: "/sessions/session-1.jsonl",
|
||||
modifiedAt: 2,
|
||||
};
|
||||
let received: BackendMetadata | undefined;
|
||||
const host: ServerHost<BackendMetadata> = {
|
||||
serverServices: createTestServerServices(),
|
||||
resolveSession: async () => metadata,
|
||||
openSession: async (candidate) => {
|
||||
received = candidate;
|
||||
return {
|
||||
attachClient: () => ({ invokeService: async () => undefined, release() {} }),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(received).toBe(metadata);
|
||||
});
|
||||
|
||||
test("routes opaque server services and publishes attachment changes out of band", async () => {
|
||||
const backing = new TestServerHost();
|
||||
await backing.seed("session-1");
|
||||
let releaseCount = 0;
|
||||
const host: ServerHost = {
|
||||
resolveSession: (sessionId, context) => backing.resolveSession(sessionId, context),
|
||||
openSession: (metadata, context) => backing.openSession(metadata, context),
|
||||
serverServices: {
|
||||
attachClient(presentation) {
|
||||
return {
|
||||
async invokeService(call, _publish, context) {
|
||||
if (call.serviceId !== "pi.session-management") throw new Error("Unexpected service");
|
||||
if (call.member === "attach" && typeof call.args[0] === "string") {
|
||||
await presentation.attachSession(call.args[0], context);
|
||||
return null;
|
||||
}
|
||||
if (call.member === "detach") {
|
||||
await presentation.detachSession(context);
|
||||
return null;
|
||||
}
|
||||
throw new Error("Unexpected service member");
|
||||
},
|
||||
release() {
|
||||
releaseCount += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
const attached = client.next((message) => message.type === "attachment" && message.attachment !== null);
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
await expect(attached).resolves.toMatchObject({
|
||||
type: "attachment",
|
||||
attachment: { sessionId: "session-1", attachmentId: expect.any(String) },
|
||||
});
|
||||
expect(backing.latestHarness("session-1").attachedClients).toBe(1);
|
||||
|
||||
const detached = client.next((message) => message.type === "attachment" && message.attachment === null);
|
||||
await expect(
|
||||
client.requestService({ serverId }, { serviceId: "pi.session-management", member: "detach", args: [] }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(detached).resolves.toMatchObject({ type: "attachment", attachment: null });
|
||||
expect(backing.latestHarness("session-1").attachedClients).toBe(0);
|
||||
await client.close();
|
||||
await expect.poll(() => releaseCount).toBe(1);
|
||||
});
|
||||
|
||||
test("permits multiple client attachments per Session", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const server = createServer(host);
|
||||
const first = connect(server);
|
||||
const second = connect(server);
|
||||
await Promise.all([first.hello(), second.hello()]);
|
||||
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.latestHarness("session-1").attachedClients).toBe(1);
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(1);
|
||||
expect(host.latestHarness("session-1").attachedClients).toBe(2);
|
||||
|
||||
await first.close();
|
||||
await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(1);
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("clears connection ownership when attachment release fails", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const errors: Error[] = [];
|
||||
const server = new Server(host, { listeners: [], serverId, onError: (error) => errors.push(error) });
|
||||
servers.add(server);
|
||||
const first = connect(server);
|
||||
const second = connect(server);
|
||||
await Promise.all([first.hello(), second.hello()]);
|
||||
await first.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const releaseError = new Error("release failed");
|
||||
harness.failAttachmentRelease = releaseError;
|
||||
|
||||
await first.close();
|
||||
await expect.poll(() => harness.attachmentReleaseCount).toBe(1);
|
||||
await expect.poll(() => errors).toContain(releaseError);
|
||||
harness.failAttachmentRelease = undefined;
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("requires the requesting client to hold the targeted Session attachment", async () => {
|
||||
const host = new TestServerHost();
|
||||
await Promise.all([host.seed("session-1"), host.seed("session-2")]);
|
||||
const server = createServer(host);
|
||||
const attached = connect(server);
|
||||
const unattached = connect(server);
|
||||
await Promise.all([attached.hello(), unattached.hello()]);
|
||||
|
||||
await expect(unattached.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_attached" },
|
||||
});
|
||||
await attached.attach(serverId, "session-1");
|
||||
await expect(attached.requestSessionService(serverId, "session-2", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_attached" },
|
||||
});
|
||||
await expect(
|
||||
attached.requestSessionService(serverId, "session-1", sessionCall("run", ["Hello"])),
|
||||
).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
||||
expect(host.latestHarness("session-1").serviceCalls).toEqual([sessionCall("run", ["Hello"])]);
|
||||
});
|
||||
|
||||
test("rejects a stale attachment route after switching Sessions", async () => {
|
||||
const host = new TestServerHost();
|
||||
await Promise.all([host.seed("session-1"), host.seed("session-2")]);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const firstAttachmentId = latestAttachmentId(client, "session-1");
|
||||
await client.attach(serverId, "session-2");
|
||||
|
||||
await expect(
|
||||
client.requestService(
|
||||
{ serverId, sessionId: "session-1", attachmentId: firstAttachmentId },
|
||||
sessionCall("run", ["stale"]),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
||||
expect(host.latestHarness("session-1").serviceCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves opaque service results and bounds adapter defects", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
harness.nextServiceResult = { accepted: false, reason: "closed" };
|
||||
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { accepted: false, reason: "closed" },
|
||||
});
|
||||
|
||||
harness.nextServiceError = new Error("private adapter detail");
|
||||
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "internal_error", message: "Internal server error" },
|
||||
});
|
||||
});
|
||||
|
||||
test("admits concurrent service calls to the attached Session", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const gate = harness.gateNextServiceCall();
|
||||
const first = client.requestSessionService(serverId, "session-1", sessionCall("run", ["first"]));
|
||||
await gate.entered.promise;
|
||||
const second = client.requestSessionService(serverId, "session-1", sessionCall("run", ["second"]));
|
||||
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
expect(harness.serviceCalls).toEqual([sessionCall("run", ["first"]), sessionCall("run", ["second"])]);
|
||||
gate.release.resolve(undefined);
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("keeps attachment demand until an accepted service call settles after disconnect", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const gate = harness.gateNextServiceCall();
|
||||
const calling = client.requestSessionService(serverId, "session-1", sessionCall("run"));
|
||||
const disconnectedCall = expect(calling).rejects.toThrow(/closed/i);
|
||||
await gate.entered.promise;
|
||||
|
||||
await client.close();
|
||||
expect(harness.attachedClients).toBe(1);
|
||||
gate.release.resolve(undefined);
|
||||
await disconnectedCall;
|
||||
await expect.poll(() => harness.attachedClients).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects requests addressed to another server before repository access", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(client.attach("00000000-0000-4000-8000-000000000002", "session-1")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "wrong_server" },
|
||||
});
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
test("reports an unknown session without creating a Harness", async () => {
|
||||
const host = new TestServerHost();
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(client.attach(serverId, "missing")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_found" },
|
||||
});
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects an ambiguous session ID without creating a Harness", async () => {
|
||||
type BackendMetadata = SessionMetadata & { path: string };
|
||||
const host: ServerHost<BackendMetadata> = {
|
||||
serverServices: createTestServerServices(),
|
||||
resolveSession: async () => {
|
||||
throw new SessionAmbiguousError();
|
||||
},
|
||||
openSession: async () => {
|
||||
throw new Error("must not create a Harness for an ambiguous session");
|
||||
},
|
||||
};
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(client.attach(serverId, "duplicate")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_ambiguous" },
|
||||
});
|
||||
});
|
||||
|
||||
test("invalidates a terminated Harness handle and allows a later attach", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const firstHarness = host.latestHarness("session-1");
|
||||
|
||||
await firstHarness.terminate(new Error("worker crashed"));
|
||||
await firstHarness.terminated;
|
||||
await expect.poll(() => firstHarness.attachedClients).toBe(0);
|
||||
expect(firstHarness.attachmentReleaseCount).toBe(1);
|
||||
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("connection loss releases its attachment, while server shutdown closes the Harness", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
await client.hello();
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
|
||||
await client.close();
|
||||
await expect.poll(() => harness.attachedClients).toBe(0);
|
||||
expect(harness.closeCount).toBe(0);
|
||||
await server.close();
|
||||
expect(harness.closeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routed Session acquisition failures", () => {
|
||||
test("releases a lease acquired concurrently with Harness termination", async () => {
|
||||
const metadata: SessionMetadata = { id: "session-1", createdAt: 1, storageVersion: 1 };
|
||||
const acquiring = new Deferred<void>();
|
||||
const continueAcquiring = new Deferred<void>();
|
||||
const terminated = new Deferred<Error | undefined>();
|
||||
let releaseCount = 0;
|
||||
const host: ServerHost = {
|
||||
serverServices: createTestServerServices(),
|
||||
resolveSession: async () => metadata,
|
||||
openSession: async () => ({
|
||||
terminated: terminated.promise,
|
||||
attachClient: async () => {
|
||||
acquiring.resolve(undefined);
|
||||
await continueAcquiring.promise;
|
||||
return {
|
||||
invokeService: async () => undefined,
|
||||
release: () => {
|
||||
releaseCount += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
};
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await acquiring.promise;
|
||||
|
||||
terminated.resolve(new Error("worker crashed"));
|
||||
continueAcquiring.resolve(undefined);
|
||||
await expect(attach).resolves.toMatchObject({ ok: false, error: { code: "server_draining" } });
|
||||
expect(releaseCount).toBe(1);
|
||||
});
|
||||
|
||||
test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
host.nextOpenSessionError = new Error("Harness creation failed");
|
||||
const server = createServer(host);
|
||||
const first = connect(server);
|
||||
const second = connect(server);
|
||||
await Promise.all([first.hello(), second.hello()]);
|
||||
const gate = host.gateNextOpenSession();
|
||||
|
||||
const firstAttach = first.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
const secondAttach = second.attach(serverId, "session-1");
|
||||
gate.release.resolve(undefined);
|
||||
|
||||
await expect(Promise.all([firstAttach, secondAttach])).resolves.toMatchObject([
|
||||
{ ok: false, error: { code: "internal_error" } },
|
||||
{ ok: false, error: { code: "internal_error" } },
|
||||
]);
|
||||
expect(host.openSessionCount).toBe(1);
|
||||
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.openSessionCount).toBe(2);
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("closes a Harness acquired while server shutdown is in progress", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
await client.hello();
|
||||
const gate = host.gateNextOpenSession();
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
const closing = server.close();
|
||||
gate.release.resolve(undefined);
|
||||
|
||||
await closing;
|
||||
await expect(attach).rejects.toThrow(/closed/i);
|
||||
expect(host.latestHarness("session-1").closeCount).toBe(1);
|
||||
});
|
||||
|
||||
test("fails shutdown when an in-flight acquisition cannot release its Harness", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const cleanupError = new Error("close failed");
|
||||
host.nextHarnessCloseError = cleanupError;
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
await client.hello();
|
||||
const gate = host.gateNextOpenSession();
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
|
||||
const closing = server.close();
|
||||
gate.release.resolve(undefined);
|
||||
|
||||
await expect(closing).rejects.toThrow(/Failed to close routed Sessions/);
|
||||
await expect(server.closed).rejects.toThrow(/Failed to close routed Sessions/);
|
||||
await expect(attach).rejects.toThrow(/closed/i);
|
||||
expect(host.latestHarness("session-1").closeCount).toBe(1);
|
||||
|
||||
servers.delete(server);
|
||||
await host.latestHarness("session-1").close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
9
packages/server/test/fixtures/stale-socket-server.mjs
vendored
Normal file
9
packages/server/test/fixtures/stale-socket-server.mjs
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const path = process.argv[2];
|
||||
if (!path) throw new Error("Socket path argument is required");
|
||||
|
||||
const server = createServer();
|
||||
server.listen(path, () => {
|
||||
if (process.send) process.send("listening");
|
||||
});
|
||||
52
packages/server/test/listener.test.ts
Normal file
52
packages/server/test/listener.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ByteConnectionAcceptor } from "../src/connection.ts";
|
||||
import type { ServerListener } from "../src/listener.ts";
|
||||
import { createTestServer } from "../src/testing/index.ts";
|
||||
|
||||
class TestListener implements ServerListener {
|
||||
accept?: ByteConnectionAcceptor;
|
||||
startCount = 0;
|
||||
closeCount = 0;
|
||||
readonly startError: Error | undefined;
|
||||
|
||||
constructor(startError?: Error) {
|
||||
this.startError = startError;
|
||||
}
|
||||
|
||||
async start(accept: ByteConnectionAcceptor): Promise<void> {
|
||||
this.startCount += 1;
|
||||
this.accept = accept;
|
||||
if (this.startError) throw this.startError;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Server listener composition", () => {
|
||||
test("starts and closes every configured listener", async () => {
|
||||
const first = new TestListener();
|
||||
const second = new TestListener();
|
||||
const { server } = createTestServer({ listeners: [first, second] });
|
||||
|
||||
await server.start();
|
||||
expect(first.accept).toBeTypeOf("function");
|
||||
expect(second.accept).toBeTypeOf("function");
|
||||
|
||||
await server.close();
|
||||
expect(first.closeCount).toBe(1);
|
||||
expect(second.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
test("closes previously started listeners when startup fails", async () => {
|
||||
const first = new TestListener();
|
||||
const failure = new Error("listener failed");
|
||||
const second = new TestListener(failure);
|
||||
const { server } = createTestServer({ listeners: [first, second] });
|
||||
|
||||
await expect(server.start()).rejects.toBe(failure);
|
||||
expect(first.closeCount).toBe(1);
|
||||
expect(second.closeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
168
packages/server/test/protocol.test.ts
Normal file
168
packages/server/test/protocol.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { encodeCbor, encodeClientMessage, encodeFrame, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
|
||||
import { Server } from "../src/server.ts";
|
||||
import { ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts";
|
||||
|
||||
let server: Server | undefined;
|
||||
|
||||
function connect(): ProtocolTestClient {
|
||||
server = new Server(new TestServerHost(), { listeners: [], serverId: "00000000-0000-4000-8000-000000000001" });
|
||||
let handler: ByteConnectionHandler;
|
||||
let client: ProtocolTestClient;
|
||||
let closed = false;
|
||||
const connection: ByteConnection = {
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
async send(chunk) {
|
||||
client.receive(chunk);
|
||||
},
|
||||
close(finalChunk) {
|
||||
if (finalChunk) client.receive(finalChunk);
|
||||
closed = true;
|
||||
client.markClosed();
|
||||
},
|
||||
};
|
||||
const channel: WireChannel = {
|
||||
async send(chunk) {
|
||||
handler.onData(chunk);
|
||||
},
|
||||
async sendFragmented(chunk, splitAt) {
|
||||
handler.onData(chunk.subarray(0, splitAt));
|
||||
handler.onData(chunk.subarray(splitAt));
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
handler.onClose();
|
||||
client.markClosed();
|
||||
},
|
||||
};
|
||||
client = new ProtocolTestClient(channel);
|
||||
handler = server.accept(connection);
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
test("requires hello as the first message", async () => {
|
||||
const client = connect();
|
||||
await client.sendMessage({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-directory", member: "list", args: [] },
|
||||
});
|
||||
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
|
||||
type: "hello_error",
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
await client.waitForClose();
|
||||
});
|
||||
|
||||
test("rejects unsupported protocol versions", async () => {
|
||||
const client = connect();
|
||||
await expect(client.hello(PROTOCOL_VERSION + 1)).resolves.toMatchObject({
|
||||
type: "hello_error",
|
||||
error: { code: "version" },
|
||||
});
|
||||
await client.waitForClose();
|
||||
});
|
||||
|
||||
test("accepts fragmented hello and request frames", async () => {
|
||||
const client = connect();
|
||||
const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION });
|
||||
const helloResponse = client.next((message) => message.type === "hello");
|
||||
await client.sendFragmentedMessage({ type: "hello", version: PROTOCOL_VERSION }, Math.floor(hello.byteLength / 2));
|
||||
await expect(helloResponse).resolves.toMatchObject({
|
||||
type: "hello",
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
|
||||
const response = client.next((message) => message.type === "response");
|
||||
const request = {
|
||||
type: "request" as const,
|
||||
id: "request-1",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-directory" as const, member: "list" as const, args: [] as [] },
|
||||
};
|
||||
const frame = encodeClientMessage(request);
|
||||
await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2));
|
||||
await expect(response).resolves.toMatchObject({ ok: false, error: { code: "internal_error" } });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["malformed CBOR", encodeFrame(Uint8Array.of(0xff))],
|
||||
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 1, extra: true }))],
|
||||
["oversized frame", new Uint8Array([1, 0, 0, 1])],
|
||||
] as const)("rejects hostile framed input: %s", async (_label, bytes) => {
|
||||
const client = connect();
|
||||
await client.sendBytes(bytes);
|
||||
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
|
||||
type: "hello_error",
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
await client.waitForClose();
|
||||
});
|
||||
|
||||
test("rejects a second hello after completing the handshake", async () => {
|
||||
const client = connect();
|
||||
await client.hello();
|
||||
await client.sendMessage({ type: "hello", version: PROTOCOL_VERSION });
|
||||
await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({
|
||||
type: "hello_error",
|
||||
error: { code: "invalid_request", message: expect.stringMatching(/first message/) },
|
||||
});
|
||||
await client.waitForClose();
|
||||
});
|
||||
|
||||
test("processes a hello and request coalesced in one byte chunk", async () => {
|
||||
const client = connect();
|
||||
const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION });
|
||||
const request = encodeClientMessage({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-directory", member: "list", args: [] },
|
||||
});
|
||||
const wire = new Uint8Array(hello.byteLength + request.byteLength);
|
||||
wire.set(hello);
|
||||
wire.set(request, hello.byteLength);
|
||||
|
||||
await client.sendBytes(wire);
|
||||
await expect(client.next((message) => message.type === "hello")).resolves.toMatchObject({ type: "hello" });
|
||||
await expect(client.next((message) => message.type === "response")).resolves.toMatchObject({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: false,
|
||||
error: { code: "internal_error" },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports a truncated final frame when the peer closes", async () => {
|
||||
const errors: Error[] = [];
|
||||
server = new Server(new TestServerHost(), {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
let closed = false;
|
||||
const connection: ByteConnection = {
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
async send() {},
|
||||
close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
const handler: ByteConnectionHandler = server.accept(connection);
|
||||
handler.onData(new Uint8Array([0, 0, 0, 2, 1]));
|
||||
handler.onClose();
|
||||
|
||||
expect(closed).toBe(false);
|
||||
expect(errors).toEqual([expect.objectContaining({ message: expect.stringMatching(/truncated/i) })]);
|
||||
});
|
||||
127
packages/server/test/server.test.ts
Normal file
127
packages/server/test/server.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ServerMessageDecoder } from "@earendil-works/pi-protocol";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { ByteConnection } from "../src/connection.ts";
|
||||
import { Server } from "../src/index.ts";
|
||||
import type { ServerListener } from "../src/listener.ts";
|
||||
import { TestServerHost } from "../src/testing/index.ts";
|
||||
import { createUnixServer } from "../src/transports/unix/index.ts";
|
||||
|
||||
const host = new TestServerHost();
|
||||
|
||||
let server: Server | undefined;
|
||||
let tempDirectory: string | undefined;
|
||||
|
||||
async function makeSocketPath(): Promise<string> {
|
||||
tempDirectory = await mkdtemp(join(tmpdir(), "pss-"));
|
||||
return join(tempDirectory, "server.sock");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
if (tempDirectory) await rm(tempDirectory, { recursive: true, force: true });
|
||||
server = undefined;
|
||||
tempDirectory = undefined;
|
||||
});
|
||||
|
||||
test("requires explicit listeners and a canonical UUIDv4 server identity", () => {
|
||||
expect(() => Reflect.construct(Server, [host, {}])).toThrow(/listeners/);
|
||||
expect(() => new Server(host, { listeners: [], serverId: "" })).toThrow(/serverId/);
|
||||
expect(() => new Server(host, { listeners: [], serverId: "invalid-server" })).toThrow(/serverId/);
|
||||
});
|
||||
|
||||
test("rejects concurrent start calls without leaking the Unix listener", async () => {
|
||||
const path = await makeSocketPath();
|
||||
server = createUnixServer(host, { path, serverId: "00000000-0000-4000-8000-000000000001" });
|
||||
const starting = server.start();
|
||||
await expect(server.start()).rejects.toThrow(/starting/);
|
||||
await starting;
|
||||
await server.close();
|
||||
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("handshake timeout closes with a final hello_error frame", async () => {
|
||||
let resolveClosed: (() => void) | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve;
|
||||
});
|
||||
class TimedOutConnection implements ByteConnection {
|
||||
closed = false;
|
||||
finalChunk?: Uint8Array;
|
||||
|
||||
send(): Promise<void> {
|
||||
return Promise.reject(new Error("handshake timeout must use the terminal close frame"));
|
||||
}
|
||||
|
||||
close(finalChunk?: Uint8Array): void {
|
||||
this.finalChunk = finalChunk;
|
||||
this.closed = true;
|
||||
resolveClosed?.();
|
||||
}
|
||||
}
|
||||
const core = new Server(host, {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
maxFrameLength: 1024,
|
||||
handshakeTimeoutMs: 10,
|
||||
});
|
||||
const connection = new TimedOutConnection();
|
||||
core.accept(connection);
|
||||
|
||||
await closed;
|
||||
expect(connection.closed).toBe(true);
|
||||
expect(connection.finalChunk).toBeInstanceOf(Uint8Array);
|
||||
const messages = new ServerMessageDecoder().push(connection.finalChunk!);
|
||||
expect(messages).toMatchObject([{ type: "hello_error", error: { code: "invalid_request" } }]);
|
||||
await core.close();
|
||||
});
|
||||
|
||||
test("rejects timeout values above Node's maximum timer delay", () => {
|
||||
const path = "/tmp/pi-server-timeout-test.sock";
|
||||
expect(() =>
|
||||
createUnixServer(host, {
|
||||
path,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
handshakeTimeoutMs: 2_147_483_648,
|
||||
}),
|
||||
).toThrow(/handshakeTimeoutMs/);
|
||||
expect(() =>
|
||||
createUnixServer(host, {
|
||||
path,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
gracefulCloseTimeoutMs: 2_147_483_648,
|
||||
}),
|
||||
).toThrow(/gracefulCloseTimeoutMs/);
|
||||
});
|
||||
|
||||
test("rejects pending-byte limits smaller than one maximum frame", async () => {
|
||||
const path = await makeSocketPath();
|
||||
expect(() =>
|
||||
createUnixServer(host, {
|
||||
path,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
maxFrameLength: 128,
|
||||
maxPendingBytes: 131,
|
||||
}),
|
||||
).toThrow(/maxPendingBytes/);
|
||||
});
|
||||
|
||||
test("rejects close and closed when listener shutdown fails", async () => {
|
||||
const failure = new Error("listener close failed");
|
||||
const listener: ServerListener = {
|
||||
start: async () => {},
|
||||
close: async () => {
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
const core = new Server(host, {
|
||||
listeners: [listener],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
await core.start();
|
||||
|
||||
await expect(core.close()).rejects.toBe(failure);
|
||||
await expect(core.closed).rejects.toBe(failure);
|
||||
});
|
||||
65
packages/server/test/unix-connection.test.ts
Normal file
65
packages/server/test/unix-connection.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Socket } from "node:net";
|
||||
import { encodeServerMessage, type ServerHelloError, ServerMessageDecoder } from "@earendil-works/pi-protocol";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { UnixByteConnection } from "../src/transports/unix/listener.ts";
|
||||
|
||||
class ControlledSocket extends EventEmitter {
|
||||
destroyed = false;
|
||||
writable = true;
|
||||
writableLength = 0;
|
||||
ended = false;
|
||||
finalChunk?: Uint8Array;
|
||||
private writeCallback?: (error?: Error | null) => void;
|
||||
|
||||
write(_chunk: Uint8Array, callback: (error?: Error | null) => void): boolean {
|
||||
this.writableLength = 1;
|
||||
this.writeCallback = callback;
|
||||
return false;
|
||||
}
|
||||
|
||||
end(chunk?: Uint8Array): this {
|
||||
this.ended = true;
|
||||
this.finalChunk = chunk?.slice();
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
if (this.destroyed) return this;
|
||||
this.destroyed = true;
|
||||
this.writable = false;
|
||||
this.emit("close");
|
||||
return this;
|
||||
}
|
||||
|
||||
completeWrite(): void {
|
||||
const callback = this.writeCallback;
|
||||
if (!callback) throw new Error("No pending write");
|
||||
this.writeCallback = undefined;
|
||||
this.writableLength = 0;
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
test("queues a final protocol error behind pending output before closing", async () => {
|
||||
const socket = new ControlledSocket();
|
||||
const connection = new UnixByteConnection(socket as unknown as Socket, 1_000, 64 * 1024);
|
||||
const pendingWrite = connection.send(new Uint8Array([1, 2, 3]));
|
||||
await vi.waitFor(() => expect(socket.writableLength).toBe(1));
|
||||
const finalMessage: ServerHelloError = {
|
||||
type: "hello_error",
|
||||
error: { code: "invalid_request", message: "Protocol violation" },
|
||||
};
|
||||
const closing = connection.close(encodeServerMessage(finalMessage));
|
||||
|
||||
expect(socket.ended).toBe(false);
|
||||
expect(socket.destroyed).toBe(false);
|
||||
|
||||
socket.completeWrite();
|
||||
await pendingWrite;
|
||||
await vi.waitFor(() => expect(socket.ended).toBe(true));
|
||||
expect(new ServerMessageDecoder().push(socket.finalChunk!)).toEqual([finalMessage]);
|
||||
|
||||
connection.markClosed();
|
||||
await closing;
|
||||
});
|
||||
143
packages/server/test/unix.test.ts
Normal file
143
packages/server/test/unix.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { type ChildProcess, fork } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { lstat, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { Server } from "../src/index.ts";
|
||||
import { connectUnixTestClient, type ProtocolTestClient, TestServerHost } from "../src/testing/index.ts";
|
||||
import { createUnixServer, getUnixSocketPath } from "../src/transports/unix/index.ts";
|
||||
|
||||
const servers = new Set<Server>();
|
||||
const clients = new Set<ProtocolTestClient>();
|
||||
const children = new Set<ChildProcess>();
|
||||
const tempDirectories = new Set<string>();
|
||||
|
||||
async function makeSocketPath(nested = false): Promise<string> {
|
||||
const directory = await mkdtemp(join("/tmp", "ps-"));
|
||||
tempDirectories.add(directory);
|
||||
return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock");
|
||||
}
|
||||
|
||||
function makeServer(path: string): Server {
|
||||
const server = createUnixServer(new TestServerHost(), { path, serverId: "00000000-0000-4000-8000-000000000001" });
|
||||
servers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
await Promise.all([...children].map((child) => (child.exitCode === null ? once(child, "exit") : undefined)));
|
||||
children.clear();
|
||||
await Promise.all([...clients].map((client) => client.close()));
|
||||
clients.clear();
|
||||
await Promise.all([...servers].map((server) => server.close()));
|
||||
servers.clear();
|
||||
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
tempDirectories.clear();
|
||||
});
|
||||
|
||||
test("creates an in-memory server ID and derives its explicit Unix socket path", async () => {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-server-"));
|
||||
tempDirectories.add(directory);
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const path = getUnixSocketPath(serverId, directory);
|
||||
|
||||
expect(path).toBe(join(directory, `${serverId}.sock`));
|
||||
const first = createUnixServer(new TestServerHost(), { serverId, path });
|
||||
servers.add(first);
|
||||
await first.start();
|
||||
const firstClient = await connectUnixTestClient(path);
|
||||
clients.add(firstClient);
|
||||
expect(await firstClient.hello()).toMatchObject({ serverId });
|
||||
await firstClient.close();
|
||||
clients.delete(firstClient);
|
||||
await first.close();
|
||||
servers.delete(first);
|
||||
|
||||
const replacement = createUnixServer(new TestServerHost(), { serverId, path });
|
||||
servers.add(replacement);
|
||||
await replacement.start();
|
||||
const replacementClient = await connectUnixTestClient(path);
|
||||
clients.add(replacementClient);
|
||||
expect(await replacementClient.hello()).toMatchObject({ serverId });
|
||||
});
|
||||
|
||||
describe("Unix listener filesystem lifecycle", () => {
|
||||
test("rejects a live listener without unlinking it", async () => {
|
||||
const path = await makeSocketPath();
|
||||
const first = makeServer(path);
|
||||
await first.start();
|
||||
const firstIdentity = await lstat(path);
|
||||
|
||||
const second = makeServer(path);
|
||||
await expect(second.start()).rejects.toThrow(/already running/);
|
||||
const currentIdentity = await lstat(path);
|
||||
expect(currentIdentity.isSocket()).toBe(true);
|
||||
expect({ dev: currentIdentity.dev, ino: currentIdentity.ino }).toEqual({
|
||||
dev: firstIdentity.dev,
|
||||
ino: firstIdentity.ino,
|
||||
});
|
||||
|
||||
const client = await connectUnixTestClient(path);
|
||||
clients.add(client);
|
||||
expect(await client.hello()).toMatchObject({ type: "hello" });
|
||||
});
|
||||
|
||||
test("never unlinks a regular file at the configured path", async () => {
|
||||
const path = await makeSocketPath();
|
||||
await writeFile(path, "do not remove", { mode: 0o640 });
|
||||
const server = makeServer(path);
|
||||
await expect(server.start()).rejects.toThrow(/non-socket/);
|
||||
expect(await readFile(path, "utf8")).toBe("do not remove");
|
||||
});
|
||||
|
||||
test("creates nested temp parents, restricts permissions, and removes its own socket", async () => {
|
||||
const path = await makeSocketPath(true);
|
||||
const server = makeServer(path);
|
||||
await server.start();
|
||||
const stats = await lstat(path);
|
||||
expect(stats.isSocket()).toBe(true);
|
||||
if (process.platform !== "win32") expect(stats.mode & 0o777).toBe(0o600);
|
||||
expect(await readdir(dirname(path))).toEqual(["server.sock"]);
|
||||
|
||||
await server.close();
|
||||
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("does not remove a replacement inode during shutdown", async () => {
|
||||
const path = await makeSocketPath();
|
||||
const server = makeServer(path);
|
||||
await server.start();
|
||||
await unlink(path);
|
||||
await writeFile(path, "replacement");
|
||||
|
||||
const closing = server.close();
|
||||
expect(await readFile(path, "utf8")).toBe("replacement");
|
||||
await closing;
|
||||
expect(await readFile(path, "utf8")).toBe("replacement");
|
||||
});
|
||||
|
||||
test("removes a genuinely stale socket before binding", async () => {
|
||||
const path = await makeSocketPath();
|
||||
const child = fork(new URL("fixtures/stale-socket-server.mjs", import.meta.url), [path], {
|
||||
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
||||
});
|
||||
children.add(child);
|
||||
await once(child, "message");
|
||||
const staleIdentity = await lstat(path);
|
||||
expect(staleIdentity.isSocket()).toBe(true);
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit");
|
||||
children.delete(child);
|
||||
|
||||
const server = makeServer(path);
|
||||
await server.start();
|
||||
const liveIdentity = await lstat(path);
|
||||
expect(liveIdentity.isSocket()).toBe(true);
|
||||
const client = await connectUnixTestClient(path);
|
||||
clients.add(client);
|
||||
expect(await client.hello()).toMatchObject({ type: "hello" });
|
||||
});
|
||||
});
|
||||
9
packages/server/tsconfig.build.json
Normal file
9
packages/server/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
17
packages/server/tsconfig.test.json
Normal file
17
packages/server/tsconfig.test.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "vitest"],
|
||||
"paths": {
|
||||
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
|
||||
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
|
||||
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
|
||||
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"],
|
||||
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
28
packages/server/vitest.config.ts
Normal file
28
packages/server/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const src = (path: string): string => fileURLToPath(new URL(path, import.meta.url));
|
||||
|
||||
/**
|
||||
* Exact matches for bare specifiers, plus one rule per package for subpath exports such as
|
||||
* `@earendil-works/pi-ai/utils/uuid`. A prefix alias would rewrite those onto `index.ts/utils/uuid`.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
},
|
||||
resolve: {
|
||||
conditions: ["source"],
|
||||
alias: [
|
||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") },
|
||||
{ find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` },
|
||||
{ find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") },
|
||||
{ find: /^@earendil-works\/pi-ai\/(.+)$/, replacement: `${src("../ai/src/")}$1.ts` },
|
||||
{ find: /^@earendil-works\/pi-telemetry$/, replacement: src("../telemetry/src/index.ts") },
|
||||
{ find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") },
|
||||
],
|
||||
},
|
||||
ssr: { resolve: { conditions: ["source"] } },
|
||||
});
|
||||
Reference in New Issue
Block a user