@earendil-works/pi-server 0.84.3 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -1
- package/README.md +66 -37
- package/dist/connection.d.ts +9 -5
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js.map +1 -1
- package/dist/errors.d.ts +16 -18
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +23 -27
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/listener.d.ts +1 -3
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js.map +1 -1
- package/dist/server.d.ts +16 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +268 -86
- package/dist/server.js.map +1 -1
- package/dist/session-router.d.ts +40 -0
- package/dist/session-router.d.ts.map +1 -0
- package/dist/session-router.js +264 -0
- package/dist/session-router.js.map +1 -0
- package/dist/testing/client.d.ts +6 -2
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +23 -3
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/host.d.ts +57 -0
- package/dist/testing/host.d.ts.map +1 -0
- package/dist/testing/host.js +189 -0
- package/dist/testing/host.js.map +1 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/server.d.ts +8 -7
- package/dist/testing/server.d.ts.map +1 -1
- package/dist/testing/server.js +7 -7
- package/dist/testing/server.js.map +1 -1
- package/dist/transports/unix/address.d.ts +3 -0
- package/dist/transports/unix/address.d.ts.map +1 -0
- package/dist/transports/unix/address.js +9 -0
- package/dist/transports/unix/address.js.map +1 -0
- package/dist/transports/unix/index.d.ts +1 -0
- package/dist/transports/unix/index.d.ts.map +1 -1
- package/dist/transports/unix/index.js +1 -0
- package/dist/transports/unix/index.js.map +1 -1
- package/dist/transports/unix/listener.d.ts +2 -3
- package/dist/transports/unix/listener.d.ts.map +1 -1
- package/dist/transports/unix/listener.js +13 -24
- package/dist/transports/unix/listener.js.map +1 -1
- package/dist/transports/unix/preset.d.ts +5 -4
- package/dist/transports/unix/preset.d.ts.map +1 -1
- package/dist/transports/unix/preset.js +5 -4
- package/dist/transports/unix/preset.js.map +1 -1
- package/dist/transports/unix/types.d.ts +2 -2
- package/dist/transports/unix/types.d.ts.map +1 -1
- package/dist/transports/unix/types.js.map +1 -1
- package/dist/types.d.ts +40 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -3
- package/dist/protocol.d.ts +0 -22
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -266
- package/dist/protocol.js.map +0 -1
- package/dist/sessions.d.ts +0 -2174
- package/dist/sessions.d.ts.map +0 -1
- package/dist/sessions.js +0 -305
- package/dist/sessions.js.map +0 -1
- package/dist/snapshots.d.ts +0 -24
- package/dist/snapshots.d.ts.map +0 -1
- package/dist/snapshots.js +0 -39
- package/dist/snapshots.js.map +0 -1
- package/dist/testing/service.d.ts +0 -59
- package/dist/testing/service.d.ts.map +0 -1
- package/dist/testing/service.js +0 -240
- package/dist/testing/service.js.map +0 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,50 +1,79 @@
|
|
|
1
1
|
# @earendil-works/pi-server
|
|
2
2
|
|
|
3
|
-
Experimental
|
|
3
|
+
Experimental local server for the new durable Session and Agent Harness interfaces.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
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.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`;
|
|
8
|
+
- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state;
|
|
9
|
+
- the application-owned `SessionManagement` creates, removes, attaches, and detaches Sessions without exposing route IDs in business results;
|
|
10
|
+
- attachment changes are published out of band after the router installs or clears the live route;
|
|
11
|
+
- Session service calls route through `invokeService` without server-side business-payload decoding;
|
|
12
|
+
- service subscription updates remain scoped to the requesting attachment;
|
|
13
|
+
- application observations such as transcripts route as ordinary service state without server-owned business schemas.
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
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.
|
|
10
16
|
|
|
11
17
|
```ts
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
async createSession(options) {
|
|
23
|
-
return storage.createAndOpen(options);
|
|
24
|
-
},
|
|
25
|
-
async openSession(sessionId) {
|
|
26
|
-
return storage.open(sessionId);
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const server = createUnixServer(service, {
|
|
31
|
-
path: "/tmp/pi/server.sock",
|
|
32
|
-
});
|
|
33
|
-
await server.start();
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@earendil-works/pi-protocol`.
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
|
|
20
|
+
import {
|
|
21
|
+
type RoutedServerServiceHost,
|
|
22
|
+
type RoutedSessionHandle,
|
|
23
|
+
type ServerHost,
|
|
24
|
+
SessionAmbiguousError,
|
|
25
|
+
SessionNotFoundError,
|
|
26
|
+
} from "@earendil-works/pi-server";
|
|
27
|
+
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
|
37
28
|
|
|
38
|
-
|
|
29
|
+
async function startServer(
|
|
30
|
+
serverServices: RoutedServerServiceHost,
|
|
31
|
+
openRoutedSession: (session: Session) => Promise<RoutedSessionHandle>,
|
|
32
|
+
) {
|
|
33
|
+
const sessions = new MemorySessionRepo();
|
|
34
|
+
const host: ServerHost = {
|
|
35
|
+
serverServices,
|
|
36
|
+
async resolveSession(sessionId, context) {
|
|
37
|
+
const matches = (await sessions.list(undefined, context))
|
|
38
|
+
.filter((metadata) => metadata.id === sessionId);
|
|
39
|
+
if (matches.length === 0) {
|
|
40
|
+
throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
|
|
41
|
+
}
|
|
42
|
+
if (matches.length > 1) throw new SessionAmbiguousError();
|
|
43
|
+
return matches[0];
|
|
44
|
+
},
|
|
45
|
+
async openSession(metadata, context) {
|
|
46
|
+
const session = await sessions.open(metadata, context);
|
|
47
|
+
try {
|
|
48
|
+
return await openRoutedSession(session);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
try {
|
|
51
|
+
await session.close(context);
|
|
52
|
+
} catch (cleanupError) {
|
|
53
|
+
throw new AggregateError(
|
|
54
|
+
[error, cleanupError],
|
|
55
|
+
"Harness creation and Session cleanup failed",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
const serverId = randomUUID();
|
|
64
|
+
const server = createUnixServer(host, {
|
|
65
|
+
serverId,
|
|
66
|
+
path: getUnixSocketPath(serverId, "/run/user/1000/pi"),
|
|
67
|
+
});
|
|
68
|
+
await server.start();
|
|
69
|
+
return server;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
43
72
|
|
|
44
|
-
|
|
73
|
+
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.
|
|
45
74
|
|
|
46
|
-
|
|
75
|
+
`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.
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
`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.
|
|
49
78
|
|
|
50
|
-
|
|
79
|
+
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.
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { ServiceStateEncoder } from "@earendil-works/chord";
|
|
2
|
+
import type { ClientMessageDecoder, RpcTarget } from "@earendil-works/pi-protocol";
|
|
3
|
+
import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts";
|
|
3
4
|
/** An established, authorized ordered byte connection. */
|
|
4
5
|
export interface ByteConnection {
|
|
5
6
|
readonly closed: boolean;
|
|
@@ -14,15 +15,18 @@ export interface ByteConnectionHandler {
|
|
|
14
15
|
export type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;
|
|
15
16
|
export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed";
|
|
16
17
|
export interface ConnectionState {
|
|
17
|
-
id: string;
|
|
18
18
|
connection: ByteConnection;
|
|
19
19
|
decoder: ClientMessageDecoder;
|
|
20
|
-
|
|
20
|
+
serviceStateEncoders: Map<string, ServiceStateEncoder>;
|
|
21
21
|
stage: ConnectionStage;
|
|
22
22
|
disconnected: boolean;
|
|
23
|
-
handshakeComplete: boolean;
|
|
24
23
|
handshake?: Promise<void>;
|
|
25
24
|
handshakeTimeout: NodeJS.Timeout;
|
|
25
|
+
serverServices?: RoutedServerServiceAttachment;
|
|
26
|
+
activeRequests: Map<string, {
|
|
27
|
+
controller: AbortController;
|
|
28
|
+
target: RpcTarget;
|
|
29
|
+
}>;
|
|
26
30
|
}
|
|
27
31
|
export declare function isTerminalConnection(state: ConnectionState): boolean;
|
|
28
32
|
//# sourceMappingURL=connection.d.ts.map
|
package/dist/connection.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAEnF,OAAO,KAAK,EAAE,YAAY,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC;AAE9E,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,KAAK,CAAC,UAAU,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,qBAAqB;IACrC,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC,OAAO,IAAI,IAAI,CAAC;IAChB,OAAO,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,MAAM,sBAAsB,GAAG,CAAC,UAAU,EAAE,cAAc,KAAK,qBAAqB,CAAC;AAE3F,MAAM,MAAM,eAAe,GAAG,eAAe,GAAG,aAAa,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE/F,MAAM,WAAW,eAAe;IAC/B,UAAU,EAAE,cAAc,CAAC;IAC3B,OAAO,EAAE,oBAAoB,CAAC;IAC9B,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IACvD,KAAK,EAAE,eAAe,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC,OAAO,CAAC;IACjC,cAAc,CAAC,EAAE,6BAA6B,CAAC;IAC/C,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,eAAe,CAAC;QAAC,MAAM,EAAE,SAAS,CAAA;KAAE,CAAC,CAAC;CAChF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAEpE","sourcesContent":["import type { ServiceStateEncoder } from \"@earendil-works/chord\";\nimport type { ClientMessageDecoder, RpcTarget } from \"@earendil-works/pi-protocol\";\n\nimport type { MaybePromise, RoutedServerServiceAttachment } from \"./types.ts\";\n\n/** An established, authorized ordered byte connection. */\nexport interface ByteConnection {\n\treadonly closed: boolean;\n\tsend(chunk: Uint8Array): Promise<void>;\n\tclose(finalChunk?: Uint8Array): MaybePromise<void>;\n}\n\nexport interface ByteConnectionHandler {\n\tonData(chunk: Uint8Array): void;\n\tonClose(): void;\n\tonError(error: Error): void;\n}\n\nexport type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;\n\nexport type ConnectionStage = \"awaitingHello\" | \"handshaking\" | \"ready\" | \"closing\" | \"closed\";\n\nexport interface ConnectionState {\n\tconnection: ByteConnection;\n\tdecoder: ClientMessageDecoder;\n\tserviceStateEncoders: Map<string, ServiceStateEncoder>;\n\tstage: ConnectionStage;\n\tdisconnected: boolean;\n\thandshake?: Promise<void>;\n\thandshakeTimeout: NodeJS.Timeout;\n\tserverServices?: RoutedServerServiceAttachment;\n\tactiveRequests: Map<string, { controller: AbortController; target: RpcTarget }>;\n}\n\nexport function isTerminalConnection(state: ConnectionState): boolean {\n\treturn state.disconnected || state.stage === \"closing\" || state.stage === \"closed\";\n}\n"]}
|
package/dist/connection.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAkCA,MAAM,UAAU,oBAAoB,CAAC,KAAsB,EAAW;IACrE,OAAO,KAAK,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,KAAK,KAAK,QAAQ,CAAC;AAAA,CACnF","sourcesContent":["import type { ServiceStateEncoder } from \"@earendil-works/chord\";\nimport type { ClientMessageDecoder, RpcTarget } from \"@earendil-works/pi-protocol\";\n\nimport type { MaybePromise, RoutedServerServiceAttachment } from \"./types.ts\";\n\n/** An established, authorized ordered byte connection. */\nexport interface ByteConnection {\n\treadonly closed: boolean;\n\tsend(chunk: Uint8Array): Promise<void>;\n\tclose(finalChunk?: Uint8Array): MaybePromise<void>;\n}\n\nexport interface ByteConnectionHandler {\n\tonData(chunk: Uint8Array): void;\n\tonClose(): void;\n\tonError(error: Error): void;\n}\n\nexport type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;\n\nexport type ConnectionStage = \"awaitingHello\" | \"handshaking\" | \"ready\" | \"closing\" | \"closed\";\n\nexport interface ConnectionState {\n\tconnection: ByteConnection;\n\tdecoder: ClientMessageDecoder;\n\tserviceStateEncoders: Map<string, ServiceStateEncoder>;\n\tstage: ConnectionStage;\n\tdisconnected: boolean;\n\thandshake?: Promise<void>;\n\thandshakeTimeout: NodeJS.Timeout;\n\tserverServices?: RoutedServerServiceAttachment;\n\tactiveRequests: Map<string, { controller: AbortController; target: RpcTarget }>;\n}\n\nexport function isTerminalConnection(state: ConnectionState): boolean {\n\treturn state.disconnected || state.stage === \"closing\" || state.stage === \"closed\";\n}\n"]}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { RemoteServiceErrorCode } from "@earendil-works/chord";
|
|
2
|
+
type ServerOperationErrorCode = RemoteServiceErrorCode | "wrong_server" | "session_not_found" | "session_ambiguous" | "session_not_attached" | "server_draining";
|
|
3
3
|
export declare const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readonly details: JsonValue | undefined;
|
|
9
|
-
constructor(code: PiServerOperationErrorCode, message: string, details?: JsonValue);
|
|
4
|
+
/** A host or lifecycle error that can safely cross the protocol boundary. */
|
|
5
|
+
export declare class ServerError extends Error {
|
|
6
|
+
readonly code: ServerOperationErrorCode;
|
|
7
|
+
constructor(code: ServerOperationErrorCode, message: string);
|
|
10
8
|
}
|
|
11
|
-
export declare class
|
|
12
|
-
constructor(
|
|
9
|
+
export declare class WrongServerError extends ServerError {
|
|
10
|
+
constructor();
|
|
13
11
|
}
|
|
14
|
-
export declare class
|
|
15
|
-
constructor(message?: string
|
|
12
|
+
export declare class SessionNotFoundError extends ServerError {
|
|
13
|
+
constructor(message?: string);
|
|
16
14
|
}
|
|
17
|
-
export declare class
|
|
18
|
-
constructor(
|
|
15
|
+
export declare class SessionAmbiguousError extends ServerError {
|
|
16
|
+
constructor();
|
|
19
17
|
}
|
|
20
|
-
export declare class
|
|
18
|
+
export declare class SessionNotAttachedError extends ServerError {
|
|
21
19
|
constructor();
|
|
22
20
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
constructor(cause: unknown);
|
|
21
|
+
export declare class ServerDrainingError extends ServerError {
|
|
22
|
+
constructor();
|
|
26
23
|
}
|
|
24
|
+
export {};
|
|
27
25
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAEpE,KAAK,wBAAwB,GAC1B,sBAAsB,GACtB,cAAc,GACd,mBAAmB,GACnB,mBAAmB,GACnB,sBAAsB,GACtB,iBAAiB,CAAC;AAErB,eAAO,MAAM,6BAA6B,0BAA0B,CAAC;AAErE,6EAA6E;AAC7E,qBAAa,WAAY,SAAQ,KAAK;IACrC,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IAExC,YAAY,IAAI,EAAE,wBAAwB,EAAE,OAAO,EAAE,MAAM,EAI1D;CACD;AAED,qBAAa,gBAAiB,SAAQ,WAAW;IAChD,cAGC;CACD;AAED,qBAAa,oBAAqB,SAAQ,WAAW;IACpD,YAAY,OAAO,SAA0B,EAG5C;CACD;AAED,qBAAa,qBAAsB,SAAQ,WAAW;IACrD,cAGC;CACD;AAED,qBAAa,uBAAwB,SAAQ,WAAW;IACvD,cAGC;CACD;AAED,qBAAa,mBAAoB,SAAQ,WAAW;IACnD,cAGC;CACD","sourcesContent":["import type { RemoteServiceErrorCode } from \"@earendil-works/chord\";\n\ntype ServerOperationErrorCode =\n\t| RemoteServiceErrorCode\n\t| \"wrong_server\"\n\t| \"session_not_found\"\n\t| \"session_ambiguous\"\n\t| \"session_not_attached\"\n\t| \"server_draining\";\n\nexport const INTERNAL_SERVER_ERROR_MESSAGE = \"Internal server error\";\n\n/** A host or lifecycle error that can safely cross the protocol boundary. */\nexport class ServerError extends Error {\n\treadonly code: ServerOperationErrorCode;\n\n\tconstructor(code: ServerOperationErrorCode, message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"ServerError\";\n\t\tthis.code = code;\n\t}\n}\n\nexport class WrongServerError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"wrong_server\", \"Request was addressed to another server\");\n\t\tthis.name = \"WrongServerError\";\n\t}\n}\n\nexport class SessionNotFoundError extends ServerError {\n\tconstructor(message = \"Session was not found\") {\n\t\tsuper(\"session_not_found\", message);\n\t\tthis.name = \"SessionNotFoundError\";\n\t}\n}\n\nexport class SessionAmbiguousError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"session_ambiguous\", \"Session ID matches more than one session\");\n\t\tthis.name = \"SessionAmbiguousError\";\n\t}\n}\n\nexport class SessionNotAttachedError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"session_not_attached\", \"Session is not attached to this client\");\n\t\tthis.name = \"SessionNotAttachedError\";\n\t}\n}\n\nexport class ServerDrainingError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"server_draining\", \"Server is draining\");\n\t\tthis.name = \"ServerDrainingError\";\n\t}\n}\n"]}
|
package/dist/errors.js
CHANGED
|
@@ -1,45 +1,41 @@
|
|
|
1
1
|
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export class PiServerError extends Error {
|
|
2
|
+
/** A host or lifecycle error that can safely cross the protocol boundary. */
|
|
3
|
+
export class ServerError extends Error {
|
|
5
4
|
code;
|
|
6
|
-
|
|
7
|
-
constructor(code, message, details) {
|
|
5
|
+
constructor(code, message) {
|
|
8
6
|
super(message);
|
|
9
|
-
this.name = "
|
|
7
|
+
this.name = "ServerError";
|
|
10
8
|
this.code = code;
|
|
11
|
-
this.details = details;
|
|
12
9
|
}
|
|
13
10
|
}
|
|
14
|
-
export class
|
|
15
|
-
constructor(
|
|
16
|
-
super("
|
|
17
|
-
this.name = "
|
|
11
|
+
export class WrongServerError extends ServerError {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("wrong_server", "Request was addressed to another server");
|
|
14
|
+
this.name = "WrongServerError";
|
|
18
15
|
}
|
|
19
16
|
}
|
|
20
|
-
export class
|
|
21
|
-
constructor(message = "Session
|
|
22
|
-
super("
|
|
23
|
-
this.name = "
|
|
17
|
+
export class SessionNotFoundError extends ServerError {
|
|
18
|
+
constructor(message = "Session was not found") {
|
|
19
|
+
super("session_not_found", message);
|
|
20
|
+
this.name = "SessionNotFoundError";
|
|
24
21
|
}
|
|
25
22
|
}
|
|
26
|
-
export class
|
|
27
|
-
constructor(
|
|
28
|
-
super("
|
|
29
|
-
this.name = "
|
|
23
|
+
export class SessionAmbiguousError extends ServerError {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("session_ambiguous", "Session ID matches more than one session");
|
|
26
|
+
this.name = "SessionAmbiguousError";
|
|
30
27
|
}
|
|
31
28
|
}
|
|
32
|
-
export class
|
|
29
|
+
export class SessionNotAttachedError extends ServerError {
|
|
33
30
|
constructor() {
|
|
34
|
-
super("
|
|
35
|
-
this.name = "
|
|
31
|
+
super("session_not_attached", "Session is not attached to this client");
|
|
32
|
+
this.name = "SessionNotAttachedError";
|
|
36
33
|
}
|
|
37
34
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.name = "InternalServerError";
|
|
35
|
+
export class ServerDrainingError extends ServerError {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("server_draining", "Server is draining");
|
|
38
|
+
this.name = "ServerDrainingError";
|
|
43
39
|
}
|
|
44
40
|
}
|
|
45
41
|
//# sourceMappingURL=errors.js.map
|
package/dist/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAUA,MAAM,CAAC,MAAM,6BAA6B,GAAG,uBAAuB,CAAC;AAErE,6EAA6E;AAC7E,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC5B,IAAI,CAA2B;IAExC,YAAY,IAA8B,EAAE,OAAe,EAAE;QAC5D,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;QAC1B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IAAA,CACjB;CACD;AAED,MAAM,OAAO,gBAAiB,SAAQ,WAAW;IAChD,cAAc;QACb,KAAK,CAAC,cAAc,EAAE,yCAAyC,CAAC,CAAC;QACjE,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IAAA,CAC/B;CACD;AAED,MAAM,OAAO,oBAAqB,SAAQ,WAAW;IACpD,YAAY,OAAO,GAAG,uBAAuB,EAAE;QAC9C,KAAK,CAAC,mBAAmB,EAAE,OAAO,CAAC,CAAC;QACpC,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IAAA,CACnC;CACD;AAED,MAAM,OAAO,qBAAsB,SAAQ,WAAW;IACrD,cAAc;QACb,KAAK,CAAC,mBAAmB,EAAE,0CAA0C,CAAC,CAAC;QACvE,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IAAA,CACpC;CACD;AAED,MAAM,OAAO,uBAAwB,SAAQ,WAAW;IACvD,cAAc;QACb,KAAK,CAAC,sBAAsB,EAAE,wCAAwC,CAAC,CAAC;QACxE,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IAAA,CACtC;CACD;AAED,MAAM,OAAO,mBAAoB,SAAQ,WAAW;IACnD,cAAc;QACb,KAAK,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAC;QAC/C,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;IAAA,CAClC;CACD","sourcesContent":["import type { RemoteServiceErrorCode } from \"@earendil-works/chord\";\n\ntype ServerOperationErrorCode =\n\t| RemoteServiceErrorCode\n\t| \"wrong_server\"\n\t| \"session_not_found\"\n\t| \"session_ambiguous\"\n\t| \"session_not_attached\"\n\t| \"server_draining\";\n\nexport const INTERNAL_SERVER_ERROR_MESSAGE = \"Internal server error\";\n\n/** A host or lifecycle error that can safely cross the protocol boundary. */\nexport class ServerError extends Error {\n\treadonly code: ServerOperationErrorCode;\n\n\tconstructor(code: ServerOperationErrorCode, message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"ServerError\";\n\t\tthis.code = code;\n\t}\n}\n\nexport class WrongServerError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"wrong_server\", \"Request was addressed to another server\");\n\t\tthis.name = \"WrongServerError\";\n\t}\n}\n\nexport class SessionNotFoundError extends ServerError {\n\tconstructor(message = \"Session was not found\") {\n\t\tsuper(\"session_not_found\", message);\n\t\tthis.name = \"SessionNotFoundError\";\n\t}\n}\n\nexport class SessionAmbiguousError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"session_ambiguous\", \"Session ID matches more than one session\");\n\t\tthis.name = \"SessionAmbiguousError\";\n\t}\n}\n\nexport class SessionNotAttachedError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"session_not_attached\", \"Session is not attached to this client\");\n\t\tthis.name = \"SessionNotAttachedError\";\n\t}\n}\n\nexport class ServerDrainingError extends ServerError {\n\tconstructor() {\n\t\tsuper(\"server_draining\", \"Server is draining\");\n\t\tthis.name = \"ServerDrainingError\";\n\t}\n}\n"]}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC","sourcesContent":["export * from \"./errors.ts\";\nexport * from \"./listener.ts\";\nexport * from \"./server.ts\";\nexport * from \"./types.ts\";\n"]}
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC","sourcesContent":["export * from \"./errors.ts\";\nexport * from \"./listener.ts\";\nexport * from \"./server.ts\";\nexport * from \"./types.ts\";\n"]}
|
package/dist/listener.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ByteConnectionAcceptor } from "./connection.ts";
|
|
2
2
|
/** Supplies established byte connections after any required transport authentication. */
|
|
3
|
-
export interface
|
|
4
|
-
/** Human-readable bound address after startup, when the transport has one. */
|
|
5
|
-
readonly address?: string;
|
|
3
|
+
export interface ServerListener {
|
|
6
4
|
/** Starts listening and passes authorized connections to accept. */
|
|
7
5
|
start(accept: ByteConnectionAcceptor): Promise<void>;
|
|
8
6
|
close(): Promise<void>;
|
package/dist/listener.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAE9D,yFAAyF;AACzF,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAE9D,yFAAyF;AACzF,MAAM,WAAW,cAAc;IAC9B,oEAAoE;IACpE,KAAK,CAAC,MAAM,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB","sourcesContent":["import type { ByteConnectionAcceptor } from \"./connection.ts\";\n\n/** Supplies established byte connections after any required transport authentication. */\nexport interface ServerListener {\n\t/** Starts listening and passes authorized connections to accept. */\n\tstart(accept: ByteConnectionAcceptor): Promise<void>;\n\tclose(): Promise<void>;\n}\n"]}
|
package/dist/listener.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listener.js","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"","sourcesContent":["import type { ByteConnectionAcceptor } from \"./connection.ts\";\n\n/** Supplies established byte connections after any required transport authentication. */\nexport interface
|
|
1
|
+
{"version":3,"file":"listener.js","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"","sourcesContent":["import type { ByteConnectionAcceptor } from \"./connection.ts\";\n\n/** Supplies established byte connections after any required transport authentication. */\nexport interface ServerListener {\n\t/** Starts listening and passes authorized connections to accept. */\n\tstart(accept: ByteConnectionAcceptor): Promise<void>;\n\tclose(): Promise<void>;\n}\n"]}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
+
import { type SessionMetadata } from "@earendil-works/pi-agent-core";
|
|
1
2
|
import { type ByteConnection, type ByteConnectionHandler } from "./connection.ts";
|
|
2
|
-
import type {
|
|
3
|
-
export declare class
|
|
4
|
-
readonly
|
|
3
|
+
import type { ServerHost, ServerOptions } from "./types.ts";
|
|
4
|
+
export declare class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
|
5
|
+
readonly serverId: string;
|
|
6
|
+
/** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */
|
|
7
|
+
readonly closed: Promise<void>;
|
|
8
|
+
private readonly host;
|
|
5
9
|
private readonly listeners;
|
|
6
10
|
private readonly maxFrameLength;
|
|
7
11
|
private readonly handshakeTimeoutMs;
|
|
12
|
+
private readonly onConnectionCountChanged;
|
|
8
13
|
private readonly onError;
|
|
9
14
|
private readonly connections;
|
|
10
15
|
private readonly sessions;
|
|
11
|
-
private readonly snapshots;
|
|
12
16
|
private closing;
|
|
13
17
|
private closePromise?;
|
|
18
|
+
private closedSettled;
|
|
19
|
+
private rejectClosed;
|
|
20
|
+
private resolveClosed;
|
|
14
21
|
private startPromise?;
|
|
15
22
|
private started;
|
|
16
|
-
constructor(
|
|
17
|
-
get addresses(): readonly string[];
|
|
23
|
+
constructor(host: ServerHost<TMetadata>, options: ServerOptions);
|
|
18
24
|
start(): Promise<this>;
|
|
19
25
|
private startInternal;
|
|
20
26
|
accept(connection: ByteConnection): ByteConnectionHandler;
|
|
@@ -23,14 +29,18 @@ export declare class PiServer {
|
|
|
23
29
|
private receive;
|
|
24
30
|
private dispatchMessage;
|
|
25
31
|
private finishHandshake;
|
|
32
|
+
private handleCancel;
|
|
26
33
|
private handleRequest;
|
|
27
34
|
private transportClosed;
|
|
28
35
|
private disconnect;
|
|
36
|
+
private sendServiceUpdate;
|
|
29
37
|
private sendMessage;
|
|
30
38
|
private failProtocol;
|
|
31
39
|
private closeServerState;
|
|
32
40
|
private closeConnection;
|
|
33
41
|
private toProtocolError;
|
|
42
|
+
private notifyConnectionCountChanged;
|
|
34
43
|
private reportError;
|
|
44
|
+
private settleClosed;
|
|
35
45
|
}
|
|
36
46
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAiBA,OAAO,EACN,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAG1B,MAAM,iBAAiB,CAAC;AAUzB,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAMnE,qBAAa,QAAQ;IACpB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8B;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA8B;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,eAAe,EAyB7D;IAED,IAAI,SAAS,IAAI,SAAS,MAAM,EAAE,CAEjC;IAED,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAMrB;YAEa,aAAa;IAmB3B,MAAM,CAAC,UAAU,EAAE,cAAc,GAAG,qBAAqB,CAsCxD;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAK3B;YAEa,aAAa;IAW3B,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,eAAe;YAoCT,eAAe;YA+Bf,aAAa;IAmB3B,OAAO,CAAC,eAAe;YAWT,UAAU;YAWV,WAAW;YAsBX,YAAY;YAeZ,gBAAgB;YAahB,eAAe;IAQ7B,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,WAAW;CAOnB","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n\ttype ClientHello,\n\ttype ClientMessage,\n\tClientMessageDecoder,\n\tDEFAULT_MAX_FRAME_LENGTH,\n\tencodeServerMessage,\n\tisSupportedProtocolVersion,\n\tPROTOCOL_VERSION,\n\ttype ProtocolError,\n\tProtocolValidationError,\n\ttype RequestEnvelope,\n\ttype ResponseEnvelope,\n\ttype ServerHello,\n\ttype ServerHelloError,\n\ttype ServerMessage,\n} from \"@earendil-works/pi-protocol\";\nimport {\n\ttype ByteConnection,\n\ttype ByteConnectionHandler,\n\ttype ConnectionState,\n\tisTerminalConnection,\n} from \"./connection.ts\";\nimport {\n\tINTERNAL_SERVER_ERROR_MESSAGE,\n\tInternalServerError,\n\tNOT_IMPLEMENTED_MESSAGE,\n\tPiServerError,\n} from \"./errors.ts\";\nimport type { PiServerListener } from \"./listener.ts\";\nimport { LiveSessionManager } from \"./sessions.ts\";\nimport { ServerSnapshotPublisher } from \"./snapshots.ts\";\nimport type { PiServerOptions, PiServerService } from \"./types.ts\";\n\nconst DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;\nconst MAX_UINT32 = 0xffff_ffff;\nconst MAX_TIMER_DELAY_MS = 2_147_483_647;\n\nexport class PiServer {\n\treadonly id: string;\n\n\tprivate readonly listeners: readonly PiServerListener[];\n\tprivate readonly maxFrameLength: number;\n\tprivate readonly handshakeTimeoutMs: number;\n\tprivate readonly onError: ((error: Error) => void) | undefined;\n\tprivate readonly connections = new Set<ConnectionState>();\n\tprivate readonly sessions: LiveSessionManager;\n\tprivate readonly snapshots: ServerSnapshotPublisher;\n\tprivate closing = false;\n\tprivate closePromise?: Promise<void>;\n\tprivate startPromise?: Promise<this>;\n\tprivate started = false;\n\n\tconstructor(service: PiServerService, options: PiServerOptions) {\n\t\tconst resolved = resolveOptions(options);\n\t\tthis.listeners = options.listeners;\n\t\tthis.id = options.serverId ?? randomUUID();\n\t\tthis.maxFrameLength = resolved.maxFrameLength;\n\t\tthis.handshakeTimeoutMs = resolved.handshakeTimeoutMs;\n\t\tthis.onError = options.onError;\n\t\tthis.sessions = new LiveSessionManager({\n\t\t\tservice,\n\t\t\tisClosing: () => this.closing,\n\t\t\tsendMessage: (connection, message) => this.sendMessage(connection, message),\n\t\t\tcloseConnection: (connection) => this.closeConnection(connection),\n\t\t\tdisconnect: (connection) => this.disconnect(connection),\n\t\t\tbroadcastServerSnapshot: () => void this.snapshots.broadcast(),\n\t\t\treportError: (error) => this.reportError(error),\n\t\t});\n\t\tthis.snapshots = new ServerSnapshotPublisher({\n\t\t\tserverId: this.id,\n\t\t\tservice,\n\t\t\tconnections: this.connections,\n\t\t\tisClosing: () => this.closing,\n\t\t\tlistSessions: () => this.sessions.listMetadata(),\n\t\t\tsendMessage: (connection, message) => this.sendMessage(connection, message),\n\t\t\treportError: (error) => this.reportError(error),\n\t\t});\n\t}\n\n\tget addresses(): readonly string[] {\n\t\treturn this.listeners.flatMap((listener) => (listener.address === undefined ? [] : [listener.address]));\n\t}\n\n\tstart(): Promise<this> {\n\t\tif (this.started) return Promise.reject(new Error(\"PiServer is already started\"));\n\t\tif (this.startPromise) return Promise.reject(new Error(\"PiServer is already starting\"));\n\t\tif (this.closing) return Promise.reject(new Error(\"PiServer is closing or closed\"));\n\t\tthis.startPromise = this.startInternal();\n\t\treturn this.startPromise;\n\t}\n\n\tprivate async startInternal(): Promise<this> {\n\t\tconst started: PiServerListener[] = [];\n\t\ttry {\n\t\t\tfor (const listener of this.listeners) {\n\t\t\t\tawait listener.start((connection) => this.accept(connection));\n\t\t\t\tstarted.push(listener);\n\t\t\t}\n\t\t\tthis.started = true;\n\t\t\treturn this;\n\t\t} catch (error) {\n\t\t\tthis.closing = true;\n\t\t\tawait Promise.allSettled(started.map((listener) => listener.close()));\n\t\t\tawait this.closeServerState();\n\t\t\tthrow error;\n\t\t} finally {\n\t\t\tthis.startPromise = undefined;\n\t\t}\n\t}\n\n\taccept(connection: ByteConnection): ByteConnectionHandler {\n\t\tif (this.closing) {\n\t\t\tvoid this.closeConnection(connection);\n\t\t\treturn {\n\t\t\t\tonData: () => {},\n\t\t\t\tonClose: () => {},\n\t\t\t\tonError: (error) => this.reportError(error),\n\t\t\t};\n\t\t}\n\n\t\tlet state: ConnectionState;\n\t\tconst handshakeTimeout = setTimeout(() => {\n\t\t\tvoid this.failProtocol(state, {\n\t\t\t\tcode: \"invalid_request\",\n\t\t\t\tmessage: \"Handshake timeout\",\n\t\t\t});\n\t\t}, this.handshakeTimeoutMs);\n\t\thandshakeTimeout.unref();\n\t\tstate = {\n\t\t\tid: randomUUID(),\n\t\t\tconnection,\n\t\t\tdecoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),\n\t\t\tsessionIds: new Set(),\n\t\t\tstage: \"awaitingHello\",\n\t\t\tdisconnected: false,\n\t\t\thandshakeComplete: false,\n\t\t\thandshakeTimeout,\n\t\t};\n\t\tthis.connections.add(state);\n\n\t\treturn {\n\t\t\tonData: (chunk) => this.receive(state, chunk),\n\t\t\tonClose: () => this.transportClosed(state),\n\t\t\tonError: (error) => {\n\t\t\t\tthis.reportError(error);\n\t\t\t\tvoid this.closeConnection(connection).then(() => this.disconnect(state));\n\t\t\t},\n\t\t};\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.closePromise) return this.closePromise;\n\t\tthis.closing = true;\n\t\tthis.closePromise = this.closeInternal();\n\t\treturn this.closePromise;\n\t}\n\n\tprivate async closeInternal(): Promise<void> {\n\t\tconst starting = this.startPromise;\n\t\tif (starting) await starting.catch(() => {});\n\t\ttry {\n\t\t\tawait Promise.all(this.listeners.map((listener) => listener.close()));\n\t\t} finally {\n\t\t\tawait this.closeServerState();\n\t\t\tthis.started = false;\n\t\t}\n\t}\n\n\tprivate receive(state: ConnectionState, chunk: Uint8Array): void {\n\t\tif (isTerminalConnection(state)) return;\n\t\tlet messages: ClientMessage[];\n\t\ttry {\n\t\t\tmessages = state.decoder.push(chunk);\n\t\t} catch (error) {\n\t\t\tvoid this.failProtocol(state, this.toProtocolError(error));\n\t\t\treturn;\n\t\t}\n\t\tfor (const message of messages) {\n\t\t\tif (isTerminalConnection(state)) return;\n\t\t\tthis.dispatchMessage(state, message);\n\t\t}\n\t}\n\n\tprivate dispatchMessage(state: ConnectionState, message: ClientMessage): void {\n\t\tif (state.stage === \"awaitingHello\") {\n\t\t\tif (message.type !== \"hello\") {\n\t\t\t\tvoid this.failProtocol(state, {\n\t\t\t\t\tcode: \"invalid_request\",\n\t\t\t\t\tmessage: \"The first client message must be hello\",\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.stage = \"handshaking\";\n\t\t\tstate.handshake = this.finishHandshake(state, message).catch((error: unknown) =>\n\t\t\t\tthis.failProtocol(state, this.toProtocolError(error)),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.type === \"hello\") {\n\t\t\tvoid this.failProtocol(state, {\n\t\t\t\tcode: \"invalid_request\",\n\t\t\t\tmessage: \"hello may only be sent as the first message\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (state.stage === \"ready\") {\n\t\t\tvoid this.handleRequest(state, message);\n\t\t\treturn;\n\t\t}\n\t\tif (state.stage !== \"handshaking\") return;\n\t\tconst handshake = state.handshake;\n\t\tif (!handshake) return;\n\t\tvoid handshake.then(() => {\n\t\t\tif (state.stage === \"ready\" && !state.disconnected) void this.handleRequest(state, message);\n\t\t});\n\t}\n\n\tprivate async finishHandshake(state: ConnectionState, hello: ClientHello): Promise<void> {\n\t\tif (!isSupportedProtocolVersion(hello.version)) {\n\t\t\tawait this.failProtocol(state, {\n\t\t\t\tcode: \"version\",\n\t\t\t\tmessage: `Unsupported protocol version ${hello.version}; expected ${PROTOCOL_VERSION}`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tconst snapshot = await this.snapshots.get();\n\t\tif (this.closing || state.disconnected || state.stage !== \"handshaking\" || state.connection.closed) return;\n\t\tconst sent = await this.sendMessage(state, {\n\t\t\ttype: \"hello\",\n\t\t\tversion: PROTOCOL_VERSION,\n\t\t\tconnectionId: state.id,\n\t\t\tsnapshot,\n\t\t} satisfies ServerHello);\n\t\tif (sent && !state.disconnected && state.stage === \"handshaking\") {\n\t\t\tstate.handshakeComplete = true;\n\t\t\tstate.stage = \"ready\";\n\t\t\tclearTimeout(state.handshakeTimeout);\n\t\t\tif (snapshot.revision !== this.snapshots.currentRevision) {\n\t\t\t\tconst current = await this.snapshots.get();\n\t\t\t\tawait this.sendMessage(state, {\n\t\t\t\t\ttype: \"event\",\n\t\t\t\t\tevent: { type: \"server_snapshot\", snapshot: current },\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {\n\t\ttry {\n\t\t\tconst result = await this.sessions.executeCommand(state, envelope.request);\n\t\t\tawait this.sendMessage(state, {\n\t\t\t\ttype: \"response\",\n\t\t\t\tid: envelope.id,\n\t\t\t\tok: true,\n\t\t\t\tresult,\n\t\t\t} satisfies ResponseEnvelope);\n\t\t} catch (error) {\n\t\t\tawait this.sendMessage(state, {\n\t\t\t\ttype: \"response\",\n\t\t\t\tid: envelope.id,\n\t\t\t\tok: false,\n\t\t\t\terror: this.toProtocolError(error),\n\t\t\t} satisfies ResponseEnvelope);\n\t\t}\n\t}\n\n\tprivate transportClosed(connection: ConnectionState): void {\n\t\tif (!connection.disconnected && connection.stage !== \"closing\") {\n\t\t\ttry {\n\t\t\t\tconnection.decoder.end();\n\t\t\t} catch (error) {\n\t\t\t\tthis.reportError(error);\n\t\t\t}\n\t\t}\n\t\tvoid this.disconnect(connection);\n\t}\n\n\tprivate async disconnect(connection: ConnectionState): Promise<void> {\n\t\tif (connection.disconnected) return;\n\t\tconst handshakeComplete = connection.handshakeComplete;\n\t\tconnection.disconnected = true;\n\t\tconnection.stage = \"closed\";\n\t\tclearTimeout(connection.handshakeTimeout);\n\t\tthis.connections.delete(connection);\n\t\tawait this.sessions.disconnect(connection);\n\t\tif (!this.closing && handshakeComplete) void this.snapshots.broadcast();\n\t}\n\n\tprivate async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {\n\t\tif (connection.disconnected || connection.connection.closed) return false;\n\t\tlet frame: Uint8Array;\n\t\ttry {\n\t\t\tframe = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t\tawait this.closeConnection(connection.connection);\n\t\t\tawait this.disconnect(connection);\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tawait connection.connection.send(frame);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t\tawait this.closeConnection(connection.connection);\n\t\t\tawait this.disconnect(connection);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate async failProtocol(connection: ConnectionState, error: ProtocolError): Promise<void> {\n\t\tif (connection.disconnected || connection.stage === \"closing\" || connection.stage === \"closed\") return;\n\t\tconnection.stage = \"closing\";\n\t\tclearTimeout(connection.handshakeTimeout);\n\t\tconst message: ServerHelloError = { type: \"hello_error\", error };\n\t\tlet finalFrame: Uint8Array | undefined;\n\t\ttry {\n\t\t\tfinalFrame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });\n\t\t} catch (encodeError) {\n\t\t\tthis.reportError(encodeError);\n\t\t}\n\t\tawait this.closeConnection(connection.connection, finalFrame);\n\t\tawait this.disconnect(connection);\n\t}\n\n\tprivate async closeServerState(): Promise<void> {\n\t\tconst connections = [...this.connections];\n\t\tfor (const connection of connections) {\n\t\t\tconnection.stage = \"closing\";\n\t\t\tclearTimeout(connection.handshakeTimeout);\n\t\t}\n\t\tawait Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));\n\t\tawait Promise.all(connections.map((connection) => this.disconnect(connection)));\n\n\t\tawait this.sessions.close();\n\t\tthis.connections.clear();\n\t}\n\n\tprivate async closeConnection(connection: ByteConnection, finalChunk?: Uint8Array): Promise<void> {\n\t\ttry {\n\t\t\tawait connection.close(finalChunk);\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t}\n\t}\n\n\tprivate toProtocolError(error: unknown): ProtocolError {\n\t\tif (error instanceof InternalServerError) {\n\t\t\tthis.reportError(error.cause);\n\t\t\treturn { code: \"internal_error\", message: INTERNAL_SERVER_ERROR_MESSAGE };\n\t\t}\n\t\tif (error instanceof PiServerError) {\n\t\t\tif (error.code === \"not_implemented\") {\n\t\t\t\treturn { code: \"not_implemented\", message: NOT_IMPLEMENTED_MESSAGE };\n\t\t\t}\n\t\t\treturn error.details === undefined\n\t\t\t\t? { code: error.code, message: error.message }\n\t\t\t\t: { code: error.code, message: error.message, details: error.details };\n\t\t}\n\t\tif (error instanceof ProtocolValidationError) {\n\t\t\treturn { code: \"invalid_request\", message: error.message };\n\t\t}\n\t\tthis.reportError(error);\n\t\treturn { code: \"internal_error\", message: INTERNAL_SERVER_ERROR_MESSAGE };\n\t}\n\n\tprivate reportError(error: unknown): void {\n\t\ttry {\n\t\t\tthis.onError?.(error instanceof Error ? error : new Error(String(error)));\n\t\t} catch {\n\t\t\t// Error observers cannot affect server state.\n\t\t}\n\t}\n}\n\nfunction resolveOptions(options: PiServerOptions): { maxFrameLength: number; handshakeTimeoutMs: number } {\n\tif (!Array.isArray(options.listeners)) throw new TypeError(\"PiServer listeners must be an array\");\n\tif (options.serverId === \"\") throw new TypeError(\"PiServer serverId must not be empty\");\n\tconst maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;\n\tif (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {\n\t\tthrow new TypeError(`PiServer maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);\n\t}\n\tconst handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;\n\tif (\n\t\t!Number.isSafeInteger(handshakeTimeoutMs) ||\n\t\thandshakeTimeoutMs <= 0 ||\n\t\thandshakeTimeoutMs > MAX_TIMER_DELAY_MS\n\t) {\n\t\tthrow new TypeError(`PiServer handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);\n\t}\n\treturn { maxFrameLength, handshakeTimeoutMs };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAUA,OAAO,EAAsB,KAAK,eAAe,EAAiC,MAAM,+BAA+B,CAAC;AAoBxH,OAAO,EACN,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAG1B,MAAM,iBAAiB,CAAC;AAIzB,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAM5D,qBAAa,MAAM,CAAC,SAAS,SAAS,eAAe,GAAG,eAAe;IACtE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAwB;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAwC;IACjF,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA8B;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA2B;IACpD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,YAAY,CAA4B;IAChD,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,IAAI,EAAE,UAAU,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,aAAa,EA0B9D;IAED,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAMrB;YAEa,aAAa;IAiC3B,MAAM,CAAC,UAAU,EAAE,cAAc,GAAG,qBAAqB,CAsCxD;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAK3B;YAEa,aAAa;IAyB3B,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,eAAe;YAuCT,eAAe;IAoC7B,OAAO,CAAC,YAAY;YAQN,aAAa;IAoG3B,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,UAAU;YAqBJ,iBAAiB;YAcjB,WAAW;YAsBX,YAAY;YAeZ,gBAAgB;YAehB,eAAe;IAQ7B,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,4BAA4B;IAQpC,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,YAAY;CAMpB","sourcesContent":["import {\n\tcreateServiceStateEncoder,\n\tdecodeServiceControlCall,\n\ttype JsonValue,\n\tparseServiceCall,\n\tparseServiceSubscriptionSnapshot,\n\tRemoteServiceError,\n\ttype ServiceCall,\n\ttype ServiceProviderUpdate,\n} from \"@earendil-works/chord\";\nimport { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from \"@earendil-works/pi-agent-core\";\nimport {\n\ttype CancelEnvelope,\n\ttype ClientHello,\n\ttype ClientMessage,\n\tClientMessageDecoder,\n\tDEFAULT_MAX_FRAME_LENGTH,\n\tencodeServerMessage,\n\tisServerId,\n\tisSupportedProtocolVersion,\n\tPROTOCOL_VERSION,\n\ttype ProtocolError,\n\tProtocolValidationError,\n\ttype RequestEnvelope,\n\ttype ResponseEnvelope,\n\ttype RpcTarget,\n\ttype ServerHello,\n\ttype ServerHelloError,\n\ttype ServerMessage,\n} from \"@earendil-works/pi-protocol\";\nimport {\n\ttype ByteConnection,\n\ttype ByteConnectionHandler,\n\ttype ConnectionState,\n\tisTerminalConnection,\n} from \"./connection.ts\";\nimport { INTERNAL_SERVER_ERROR_MESSAGE, ServerError, WrongServerError } from \"./errors.ts\";\nimport type { ServerListener } from \"./listener.ts\";\nimport { SessionRouter } from \"./session-router.ts\";\nimport type { ServerHost, ServerOptions } from \"./types.ts\";\n\nconst DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;\nconst MAX_UINT32 = 0xffff_ffff;\nconst MAX_TIMER_DELAY_MS = 2_147_483_647;\n\nexport class Server<TMetadata extends SessionMetadata = SessionMetadata> {\n\treadonly serverId: string;\n\t/** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */\n\treadonly closed: Promise<void>;\n\n\tprivate readonly host: ServerHost<TMetadata>;\n\tprivate readonly listeners: readonly ServerListener[];\n\tprivate readonly maxFrameLength: number;\n\tprivate readonly handshakeTimeoutMs: number;\n\tprivate readonly onConnectionCountChanged: ((count: number) => void) | undefined;\n\tprivate readonly onError: ((error: Error) => void) | undefined;\n\tprivate readonly connections = new Set<ConnectionState>();\n\tprivate readonly sessions: SessionRouter<TMetadata>;\n\tprivate closing = false;\n\tprivate closePromise?: Promise<void>;\n\tprivate closedSettled = false;\n\tprivate rejectClosed!: (error: unknown) => void;\n\tprivate resolveClosed!: () => void;\n\tprivate startPromise?: Promise<this>;\n\tprivate started = false;\n\n\tconstructor(host: ServerHost<TMetadata>, options: ServerOptions) {\n\t\tconst resolved = resolveOptions(options);\n\t\tthis.host = host;\n\t\tthis.listeners = options.listeners;\n\t\tthis.serverId = options.serverId;\n\t\tthis.maxFrameLength = resolved.maxFrameLength;\n\t\tthis.handshakeTimeoutMs = resolved.handshakeTimeoutMs;\n\t\tthis.onConnectionCountChanged = options.onConnectionCountChanged;\n\t\tthis.onError = options.onError;\n\t\tthis.sessions = new SessionRouter({\n\t\t\thost,\n\t\t\tserverId: this.serverId,\n\t\t\tisClosing: () => this.closing,\n\t\t\tpublishAttachment: async (client, attachment) => {\n\t\t\t\tawait this.sendMessage(client as ConnectionState, {\n\t\t\t\t\ttype: \"attachment\",\n\t\t\t\t\tattachment: attachment ?? null,\n\t\t\t\t});\n\t\t\t},\n\t\t\treportError: (error) => this.reportError(error),\n\t\t});\n\t\tthis.closed = new Promise((resolve, reject) => {\n\t\t\tthis.resolveClosed = resolve;\n\t\t\tthis.rejectClosed = reject;\n\t\t});\n\t\tvoid this.closed.catch(() => {});\n\t}\n\n\tstart(): Promise<this> {\n\t\tif (this.started) return Promise.reject(new Error(\"Server is already started\"));\n\t\tif (this.startPromise) return Promise.reject(new Error(\"Server is already starting\"));\n\t\tif (this.closing) return Promise.reject(new Error(\"Server is closing or closed\"));\n\t\tthis.startPromise = this.startInternal();\n\t\treturn this.startPromise;\n\t}\n\n\tprivate async startInternal(): Promise<this> {\n\t\tconst started: ServerListener[] = [];\n\t\ttry {\n\t\t\tfor (const listener of this.listeners) {\n\t\t\t\tawait listener.start((connection) => this.accept(connection));\n\t\t\t\tstarted.push(listener);\n\t\t\t}\n\t\t\tthis.started = true;\n\t\t\treturn this;\n\t\t} catch (error) {\n\t\t\tthis.closing = true;\n\t\t\tconst cleanupErrors: unknown[] = [];\n\t\t\tconst listenerResults = await Promise.allSettled(started.map((listener) => listener.close()));\n\t\t\tfor (const result of listenerResults) {\n\t\t\t\tif (result.status === \"rejected\") cleanupErrors.push(result.reason);\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait this.closeServerState();\n\t\t\t} catch (cleanupError) {\n\t\t\t\tcleanupErrors.push(cleanupError);\n\t\t\t}\n\t\t\tif (cleanupErrors.length > 0) {\n\t\t\t\tconst failure = new AggregateError([error, ...cleanupErrors], \"Server startup and cleanup failed\");\n\t\t\t\tthis.settleClosed(failure);\n\t\t\t\tthrow failure;\n\t\t\t}\n\t\t\tthis.settleClosed();\n\t\t\tthrow error;\n\t\t} finally {\n\t\t\tthis.startPromise = undefined;\n\t\t}\n\t}\n\n\taccept(connection: ByteConnection): ByteConnectionHandler {\n\t\tif (this.closing) {\n\t\t\tvoid this.closeConnection(connection);\n\t\t\treturn {\n\t\t\t\tonData: () => {},\n\t\t\t\tonClose: () => {},\n\t\t\t\tonError: (error) => this.reportError(error),\n\t\t\t};\n\t\t}\n\n\t\tlet state: ConnectionState;\n\t\tconst handshakeTimeout = setTimeout(() => {\n\t\t\tvoid this.failProtocol(state, {\n\t\t\t\tcode: \"invalid_request\",\n\t\t\t\tmessage: \"Handshake timeout\",\n\t\t\t});\n\t\t}, this.handshakeTimeoutMs);\n\t\thandshakeTimeout.unref();\n\t\tstate = {\n\t\t\tconnection,\n\t\t\tdecoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),\n\t\t\tserviceStateEncoders: new Map(),\n\t\t\tstage: \"awaitingHello\",\n\t\t\tdisconnected: false,\n\t\t\thandshakeTimeout,\n\t\t\tactiveRequests: new Map(),\n\t\t};\n\t\tthis.connections.add(state);\n\t\tthis.notifyConnectionCountChanged();\n\n\t\treturn {\n\t\t\tonData: (chunk) => this.receive(state, chunk),\n\t\t\tonClose: () => this.transportClosed(state),\n\t\t\tonError: (error) => {\n\t\t\t\tthis.reportError(error);\n\t\t\t\tvoid this.closeConnection(connection).then(() => this.disconnect(state));\n\t\t\t},\n\t\t};\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.closePromise) return this.closePromise;\n\t\tthis.closing = true;\n\t\tthis.closePromise = this.closeInternal();\n\t\treturn this.closePromise;\n\t}\n\n\tprivate async closeInternal(): Promise<void> {\n\t\tconst starting = this.startPromise;\n\t\tif (starting) await starting.catch(() => {});\n\t\tconst errors: unknown[] = [];\n\t\tconst listenerResults = await Promise.allSettled(this.listeners.map((listener) => listener.close()));\n\t\tfor (const result of listenerResults) {\n\t\t\tif (result.status === \"rejected\") errors.push(result.reason);\n\t\t}\n\t\ttry {\n\t\t\tawait this.closeServerState();\n\t\t} catch (error) {\n\t\t\terrors.push(error);\n\t\t}\n\t\tthis.started = false;\n\t\tif (errors.length > 0) {\n\t\t\tconst failure =\n\t\t\t\terrors.length === 1 && errors[0] instanceof Error\n\t\t\t\t\t? errors[0]\n\t\t\t\t\t: new AggregateError(errors, \"Server shutdown failed\");\n\t\t\tthis.settleClosed(failure);\n\t\t\tthrow failure;\n\t\t}\n\t\tthis.settleClosed();\n\t}\n\n\tprivate receive(state: ConnectionState, chunk: Uint8Array): void {\n\t\tif (isTerminalConnection(state)) return;\n\t\tlet messages: ClientMessage[];\n\t\ttry {\n\t\t\tmessages = state.decoder.push(chunk);\n\t\t} catch (error) {\n\t\t\tvoid this.failProtocol(state, this.toProtocolError(error));\n\t\t\treturn;\n\t\t}\n\t\tfor (const message of messages) {\n\t\t\tif (isTerminalConnection(state)) return;\n\t\t\tthis.dispatchMessage(state, message);\n\t\t}\n\t}\n\n\tprivate dispatchMessage(state: ConnectionState, message: ClientMessage): void {\n\t\tif (state.stage === \"awaitingHello\") {\n\t\t\tif (message.type !== \"hello\") {\n\t\t\t\tvoid this.failProtocol(state, {\n\t\t\t\t\tcode: \"invalid_request\",\n\t\t\t\t\tmessage: \"The first client message must be hello\",\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.stage = \"handshaking\";\n\t\t\tstate.handshake = this.finishHandshake(state, message).catch((error: unknown) =>\n\t\t\t\tthis.failProtocol(state, this.toProtocolError(error)),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.type === \"hello\") {\n\t\t\tvoid this.failProtocol(state, {\n\t\t\t\tcode: \"invalid_request\",\n\t\t\t\tmessage: \"hello may only be sent as the first message\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (state.stage === \"ready\") {\n\t\t\tif (message.type === \"cancel\") this.handleCancel(state, message);\n\t\t\telse void this.handleRequest(state, message);\n\t\t\treturn;\n\t\t}\n\t\tif (state.stage !== \"handshaking\") return;\n\t\tconst handshake = state.handshake;\n\t\tif (!handshake) return;\n\t\tvoid handshake.then(() => {\n\t\t\tif (state.stage !== \"ready\" || state.disconnected) return;\n\t\t\tif (message.type === \"cancel\") this.handleCancel(state, message);\n\t\t\telse void this.handleRequest(state, message);\n\t\t});\n\t}\n\n\tprivate async finishHandshake(state: ConnectionState, hello: ClientHello): Promise<void> {\n\t\tif (!isSupportedProtocolVersion(hello.version)) {\n\t\t\tawait this.failProtocol(state, {\n\t\t\t\tcode: \"version\",\n\t\t\t\tmessage: `Unsupported protocol version ${hello.version}; expected ${PROTOCOL_VERSION}`,\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.closing || state.disconnected || state.stage !== \"handshaking\" || state.connection.closed) return;\n\t\tconst services = await this.host.serverServices.attachClient(\n\t\t\t{\n\t\t\t\tattachSession: async (sessionId, context) => {\n\t\t\t\t\tawait this.sessions.attachClient(state, sessionId, context);\n\t\t\t\t},\n\t\t\t\tdetachSession: (context) => this.sessions.detachClient(state, context),\n\t\t\t\tprepareSessionRemoval: (sessionId, context) => this.sessions.removeSession(sessionId, context),\n\t\t\t},\n\t\t\tTODO_CONTEXT,\n\t\t);\n\t\tif (this.closing || state.disconnected || state.stage !== \"handshaking\" || state.connection.closed) {\n\t\t\tawait services.release(TODO_CONTEXT);\n\t\t\treturn;\n\t\t}\n\t\tstate.serverServices = services;\n\t\tconst sent = await this.sendMessage(state, {\n\t\t\ttype: \"hello\",\n\t\t\tversion: PROTOCOL_VERSION,\n\t\t\tserverId: this.serverId,\n\t\t} satisfies ServerHello);\n\t\tif (sent && !state.disconnected && state.stage === \"handshaking\") {\n\t\t\tstate.stage = \"ready\";\n\t\t\tclearTimeout(state.handshakeTimeout);\n\t\t}\n\t}\n\n\tprivate handleCancel(state: ConnectionState, envelope: CancelEnvelope): void {\n\t\tif (envelope.target.serverId !== this.serverId) return;\n\t\tconst active = state.activeRequests.get(envelope.id);\n\t\tif (active !== undefined && sameTarget(active.target, envelope.target)) {\n\t\t\tactive.controller.abort(new DOMException(\"RPC request cancelled\", \"AbortError\"));\n\t\t}\n\t}\n\n\tprivate async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {\n\t\tif (state.activeRequests.has(envelope.id)) {\n\t\t\tawait this.sendMessage(state, {\n\t\t\t\ttype: \"response\",\n\t\t\t\tid: envelope.id,\n\t\t\t\tok: false,\n\t\t\t\terror: { code: \"invalid_request\", message: \"Request ID is already active\" },\n\t\t\t} satisfies ResponseEnvelope);\n\t\t\treturn;\n\t\t}\n\t\tlet call: ServiceCall;\n\t\ttry {\n\t\t\tcall = parseServiceCall(envelope.call);\n\t\t} catch {\n\t\t\tawait this.sendMessage(state, {\n\t\t\t\ttype: \"response\",\n\t\t\t\tid: envelope.id,\n\t\t\t\tok: false,\n\t\t\t\terror: { code: \"invalid_request\", message: \"Invalid service call\" },\n\t\t\t} satisfies ResponseEnvelope);\n\t\t\treturn;\n\t\t}\n\t\tconst controller = new AbortController();\n\t\tconst active = { controller, target: envelope.target };\n\t\tstate.activeRequests.set(envelope.id, active);\n\t\tconst context = withAbortSignal(controller.signal, TODO_CONTEXT);\n\t\tconst control = decodeServiceControlCall(call);\n\t\tconst subscribing = control?.type === \"subscribe\" ? control : undefined;\n\t\tconst pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];\n\t\tlet subscriptionReady = subscribing === undefined;\n\t\tlet installedSubscriptionEncoder = false;\n\t\tlet responded = false;\n\t\tconst publish = async (subscriptionId: string, update: ServiceProviderUpdate): Promise<void> => {\n\t\t\tif (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {\n\t\t\t\tpendingUpdates.push({ update });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait this.sendServiceUpdate(state, subscriptionId, update);\n\t\t};\n\t\ttry {\n\t\t\tif (envelope.target.serverId !== this.serverId) throw new WrongServerError();\n\t\t\tif (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) {\n\t\t\t\tthrow new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`);\n\t\t\t}\n\t\t\tlet result: JsonValue | undefined;\n\t\t\tif (\"sessionId\" in envelope.target) {\n\t\t\t\tresult = await this.sessions.executeServiceCall(call, envelope.target, state, publish, context);\n\t\t\t} else if (state.serverServices !== undefined) {\n\t\t\t\tresult = await state.serverServices.invokeService(call, publish, context);\n\t\t\t} else {\n\t\t\t\tthrow new ProtocolValidationError(`Unknown service member ${call.serviceId}.${call.member}`);\n\t\t\t}\n\t\t\tif (subscribing !== undefined) {\n\t\t\t\tif (result === undefined)\n\t\t\t\t\tthrow new ProtocolValidationError(\"Service subscription did not return a snapshot\");\n\t\t\t\tconst stateEncoder = createServiceStateEncoder();\n\t\t\t\tresult = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result)) as unknown as JsonValue;\n\t\t\t\tstate.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);\n\t\t\t\tinstalledSubscriptionEncoder = true;\n\t\t\t} else if (control?.type === \"unsubscribe\") {\n\t\t\t\tstate.serviceStateEncoders.delete(control.subscriptionId);\n\t\t\t}\n\t\t\tawait this.sendMessage(\n\t\t\t\tstate,\n\t\t\t\tresult === undefined\n\t\t\t\t\t? { type: \"response\", id: envelope.id, ok: true }\n\t\t\t\t\t: { type: \"response\", id: envelope.id, ok: true, result },\n\t\t\t);\n\t\t\tresponded = true;\n\t\t\tif (subscribing !== undefined) {\n\t\t\t\twhile (pendingUpdates.length > 0) {\n\t\t\t\t\tconst pending = pendingUpdates.shift();\n\t\t\t\t\tif (pending !== undefined)\n\t\t\t\t\t\tawait this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update);\n\t\t\t\t}\n\t\t\t\tsubscriptionReady = true;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (subscribing !== undefined && installedSubscriptionEncoder && !responded) {\n\t\t\t\tstate.serviceStateEncoders.delete(subscribing.subscriptionId);\n\t\t\t}\n\t\t\tif (responded) {\n\t\t\t\tthis.reportError(error);\n\t\t\t\tawait this.closeConnection(state.connection);\n\t\t\t\tthis.disconnect(state);\n\t\t\t} else {\n\t\t\t\tawait this.sendMessage(state, {\n\t\t\t\t\ttype: \"response\",\n\t\t\t\t\tid: envelope.id,\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: controller.signal.aborted\n\t\t\t\t\t\t? { code: \"cancelled\", message: \"RPC request cancelled\" }\n\t\t\t\t\t\t: this.toProtocolError(error),\n\t\t\t\t} satisfies ResponseEnvelope);\n\t\t\t}\n\t\t} finally {\n\t\t\tif (state.activeRequests.get(envelope.id) === active) state.activeRequests.delete(envelope.id);\n\t\t}\n\t}\n\n\tprivate transportClosed(connection: ConnectionState): void {\n\t\tif (!connection.disconnected && connection.stage !== \"closing\") {\n\t\t\ttry {\n\t\t\t\tconnection.decoder.end();\n\t\t\t} catch (error) {\n\t\t\t\tthis.reportError(error);\n\t\t\t}\n\t\t}\n\t\tthis.disconnect(connection);\n\t}\n\n\tprivate disconnect(connection: ConnectionState): void {\n\t\tif (connection.disconnected) return;\n\t\tconnection.disconnected = true;\n\t\tconnection.stage = \"closed\";\n\t\tclearTimeout(connection.handshakeTimeout);\n\t\tfor (const { controller } of connection.activeRequests.values()) {\n\t\t\tcontroller.abort(new Error(\"Client disconnected\"));\n\t\t}\n\t\tconnection.activeRequests.clear();\n\t\tconnection.serviceStateEncoders.clear();\n\t\tif (this.connections.delete(connection)) this.notifyConnectionCountChanged();\n\t\tconst serverServices = connection.serverServices;\n\t\tdelete connection.serverServices;\n\t\tvoid Promise.allSettled([\n\t\t\tthis.sessions.disconnect(connection, TODO_CONTEXT),\n\t\t\tserverServices?.release(TODO_CONTEXT),\n\t\t]).then((results) => {\n\t\t\tfor (const result of results) if (result.status === \"rejected\") this.reportError(result.reason);\n\t\t});\n\t}\n\n\tprivate async sendServiceUpdate(\n\t\tconnection: ConnectionState,\n\t\tsubscriptionId: string,\n\t\tupdate: ServiceProviderUpdate,\n\t): Promise<void> {\n\t\tconst stateEncoder = connection.serviceStateEncoders.get(subscriptionId);\n\t\tif (stateEncoder === undefined) return;\n\t\tawait this.sendMessage(connection, {\n\t\t\ttype: \"service_update\",\n\t\t\tsubscriptionId,\n\t\t\tupdate: stateEncoder.encodeUpdate(update) as unknown as JsonValue,\n\t\t});\n\t}\n\n\tprivate async sendMessage(connection: ConnectionState, message: ServerMessage): Promise<boolean> {\n\t\tif (connection.disconnected || connection.connection.closed) return false;\n\t\tlet frame: Uint8Array;\n\t\ttry {\n\t\t\tframe = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t\tawait this.closeConnection(connection.connection);\n\t\t\tthis.disconnect(connection);\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tawait connection.connection.send(frame);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t\tawait this.closeConnection(connection.connection);\n\t\t\tthis.disconnect(connection);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate async failProtocol(connection: ConnectionState, error: ProtocolError): Promise<void> {\n\t\tif (connection.disconnected || connection.stage === \"closing\" || connection.stage === \"closed\") return;\n\t\tconnection.stage = \"closing\";\n\t\tclearTimeout(connection.handshakeTimeout);\n\t\tconst message: ServerHelloError = { type: \"hello_error\", error };\n\t\tlet finalFrame: Uint8Array | undefined;\n\t\ttry {\n\t\t\tfinalFrame = encodeServerMessage(message, { maxFrameLength: this.maxFrameLength });\n\t\t} catch (encodeError) {\n\t\t\tthis.reportError(encodeError);\n\t\t}\n\t\tawait this.closeConnection(connection.connection, finalFrame);\n\t\tthis.disconnect(connection);\n\t}\n\n\tprivate async closeServerState(): Promise<void> {\n\t\tconst connections = [...this.connections];\n\t\tfor (const connection of connections) {\n\t\t\tconnection.stage = \"closing\";\n\t\t\tclearTimeout(connection.handshakeTimeout);\n\t\t}\n\t\tawait Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));\n\t\tfor (const connection of connections) this.disconnect(connection);\n\t\tconst cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]);\n\t\tthis.connections.clear();\n\t\tconst errors = cleanup.flatMap((result) => (result.status === \"rejected\" ? [result.reason] : []));\n\t\tif (errors.length === 1) throw errors[0];\n\t\tif (errors.length > 1) throw new AggregateError(errors, \"Failed to close server Sessions\");\n\t}\n\n\tprivate async closeConnection(connection: ByteConnection, finalChunk?: Uint8Array): Promise<void> {\n\t\ttry {\n\t\t\tawait connection.close(finalChunk);\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t}\n\t}\n\n\tprivate toProtocolError(error: unknown): ProtocolError {\n\t\tif (error instanceof ServerError || error instanceof RemoteServiceError) {\n\t\t\treturn { code: error.code, message: error.message };\n\t\t}\n\t\tif (error instanceof ProtocolValidationError) {\n\t\t\treturn { code: \"invalid_request\", message: error.message };\n\t\t}\n\t\tthis.reportError(error);\n\t\treturn { code: \"internal_error\", message: INTERNAL_SERVER_ERROR_MESSAGE };\n\t}\n\n\tprivate notifyConnectionCountChanged(): void {\n\t\ttry {\n\t\t\tthis.onConnectionCountChanged?.(this.connections.size);\n\t\t} catch (error) {\n\t\t\tthis.reportError(error);\n\t\t}\n\t}\n\n\tprivate reportError(error: unknown): void {\n\t\ttry {\n\t\t\tthis.onError?.(error instanceof Error ? error : new Error(String(error)));\n\t\t} catch {\n\t\t\t// Error observers cannot affect server state.\n\t\t}\n\t}\n\n\tprivate settleClosed(error?: unknown): void {\n\t\tif (this.closedSettled) return;\n\t\tthis.closedSettled = true;\n\t\tif (error === undefined) this.resolveClosed();\n\t\telse this.rejectClosed(error);\n\t}\n}\n\nfunction sameTarget(left: RpcTarget, right: RpcTarget): boolean {\n\tif (left.serverId !== right.serverId) return false;\n\tif (!(\"sessionId\" in left) || !(\"sessionId\" in right)) {\n\t\treturn !(\"sessionId\" in left) && !(\"sessionId\" in right);\n\t}\n\treturn left.sessionId === right.sessionId && left.attachmentId === right.attachmentId;\n}\n\nfunction resolveOptions(options: ServerOptions): {\n\tmaxFrameLength: number;\n\thandshakeTimeoutMs: number;\n} {\n\tif (!Array.isArray(options.listeners)) throw new TypeError(\"Server listeners must be an array\");\n\tif (!isServerId(options.serverId)) {\n\t\tthrow new TypeError(\"serverId must be a canonical lowercase UUIDv4\");\n\t}\n\tconst maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;\n\tif (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {\n\t\tthrow new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);\n\t}\n\tconst handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;\n\tif (\n\t\t!Number.isSafeInteger(handshakeTimeoutMs) ||\n\t\thandshakeTimeoutMs <= 0 ||\n\t\thandshakeTimeoutMs > MAX_TIMER_DELAY_MS\n\t) {\n\t\tthrow new TypeError(`Server handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);\n\t}\n\treturn { maxFrameLength, handshakeTimeoutMs };\n}\n"]}
|