@m4ike1/ion-server 0.1.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 +74 -0
- package/README.md +99 -0
- package/dist/connection.d.ts +32 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +4 -0
- package/dist/connection.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/listener.d.ts +8 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +2 -0
- package/dist/listener.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +547 -0
- package/dist/server.js.map +1 -0
- 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 +35 -0
- package/dist/testing/client.d.ts.map +1 -0
- package/dist/testing/client.js +138 -0
- package/dist/testing/client.js.map +1 -0
- 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 +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/server.d.ts +13 -0
- package/dist/testing/server.d.ts.map +1 -0
- package/dist/testing/server.js +17 -0
- package/dist/testing/server.js.map +1 -0
- 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 +5 -0
- package/dist/transports/unix/index.d.ts.map +1 -0
- package/dist/transports/unix/index.js +4 -0
- package/dist/transports/unix/index.js.map +1 -0
- package/dist/transports/unix/listener.d.ts +24 -0
- package/dist/transports/unix/listener.d.ts.map +1 -0
- package/dist/transports/unix/listener.js +421 -0
- package/dist/transports/unix/listener.js.map +1 -0
- package/dist/transports/unix/preset.d.ts +7 -0
- package/dist/transports/unix/preset.d.ts.map +1 -0
- package/dist/transports/unix/preset.js +22 -0
- package/dist/transports/unix/preset.js.map +1 -0
- package/dist/transports/unix/types.d.ts +15 -0
- package/dist/transports/unix/types.d.ts.map +1 -0
- package/dist/transports/unix/types.js +2 -0
- package/dist/transports/unix/types.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Restarted the `@m4ike1` version line at 0.1.0. The versions inherited from upstream Pi (`0.85.1`, `0.86.1`) stay on npm and remain installable by exact version, but they are a different line: this release sorts below them, so an existing `^0.86.1` range will not pick it up, and inter-package ranges move to `^0.1.0`. The `latest` dist-tag follows ion's own line from this release onward.
|
|
10
|
+
|
|
11
|
+
## [0.86.1] - 2026-09-24
|
|
12
|
+
|
|
13
|
+
## [0.86.0] - 2026-09-24
|
|
14
|
+
|
|
15
|
+
### Breaking Changes
|
|
16
|
+
|
|
17
|
+
- Renamed product `pi` → `ion` (breaking). Package names `@m4ike1/pi-*` → `@m4ike1/ion-*` (`ionConfig` instead of `piConfig`, `@m4ike1/ion-coding-agent`, `@m4ike1/ion-ai`, etc.), binary `pi` → `ion`, config directory `.pi` → `.ion` (XDG now immediate at `~/.config/ion`, `~/.local/share/ion`, `~/.local/state/ion`, `~/.cache/ion`), environment variables `PI_*` → `ION_*` (`PI_CODING_AGENT_DIR` → `ION_CODING_AGENT_DIR`, `PI_DISABLE_SESSION_INDEX` etc., with `PI_*` fallback for one release), and `pi.dev` → `ion.dev`. Legacy `~/.pi` is migrated on first run if `~/.config/ion` is missing. `APP_TITLE` `π` → `⛬`.
|
|
18
|
+
- Renamed this package from `@maikel/ion-server` to `@m4ike1/ion-server` (breaking). The `@maikel` npm scope belongs to another account, so the fork publishes under `@m4ike1`. Import specifiers and dependency ranges change accordingly. Nothing was ever published under the old scope, so there is no published version to migrate from.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## [0.85.1] - 2026-09-05
|
|
23
|
+
|
|
24
|
+
## [0.85.0] - 2026-09-04
|
|
25
|
+
|
|
26
|
+
## [0.84.4] - 2026-08-28
|
|
27
|
+
|
|
28
|
+
## [0.84.3] - 2026-08-24
|
|
29
|
+
|
|
30
|
+
## [0.84.2] - 2026-08-14
|
|
31
|
+
|
|
32
|
+
## [0.84.1] - 2026-08-07
|
|
33
|
+
|
|
34
|
+
## [0.84.0] - 2026-08-06
|
|
35
|
+
|
|
36
|
+
### Breaking Changes
|
|
37
|
+
|
|
38
|
+
- Changed `toProtocolToolResultMessage()` to require the original `ToolCall` and verify tool result association.
|
|
39
|
+
- Changed `PiServerService.listSessions()` to return durable `SessionMetadata` instead of runtime `SessionSummary` values ([#7708](https://github.com/maikel/pi/pull/7708)).
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Hardened protocol adapters against contradictory lifecycle states, invalid identifiers and timestamps, sparse execution arrays, and additive `pi-ai` contract drift.
|
|
44
|
+
- Sanitized service and runtime failures into stable `not_implemented` and `internal_error` responses without exposing private error details ([#7644](https://github.com/maikel/pi/pull/7644)).
|
|
45
|
+
|
|
46
|
+
## [0.83.0] - 2026-07-29
|
|
47
|
+
|
|
48
|
+
## [0.82.1] - 2026-07-25
|
|
49
|
+
|
|
50
|
+
## [0.82.0] - 2026-07-24
|
|
51
|
+
|
|
52
|
+
## [0.81.1] - 2026-07-21
|
|
53
|
+
|
|
54
|
+
## [0.81.0] - 2026-07-21
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/maikel/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
|
59
|
+
|
|
60
|
+
## [0.80.10] - 2026-07-16
|
|
61
|
+
|
|
62
|
+
## [0.80.9] - 2026-07-16
|
|
63
|
+
|
|
64
|
+
## [0.80.8] - 2026-07-16
|
|
65
|
+
|
|
66
|
+
## [0.80.7] - 2026-07-14
|
|
67
|
+
|
|
68
|
+
## [0.80.6] - 2026-07-09
|
|
69
|
+
|
|
70
|
+
## [0.80.5] - 2026-07-09
|
|
71
|
+
|
|
72
|
+
## [0.80.4] - 2026-07-09
|
|
73
|
+
|
|
74
|
+
## [0.80.3] - 2026-06-30
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @m4ike1/ion-server
|
|
2
|
+
|
|
3
|
+
Experimental local server for the new durable Session and Agent Harness interfaces.
|
|
4
|
+
|
|
5
|
+
`Server` binds one logical `serverId` to one or more `ServerListener` transports, runs the version handshake, and routes opaque service calls to server-scoped and Session-scoped providers. It never decodes business payloads: Chord owns service-control parsing, error codes, snapshots and updates; `@m4ike1/ion-protocol` owns envelope validation, CBOR, and framing. The real `Session` and `AgentHarness` stay process-local; clients only ever hold presentation attachments.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { MemorySessionRepo, type Session } from "@m4ike1/ion-agent-core";
|
|
10
|
+
import {
|
|
11
|
+
type RoutedServerServiceHost,
|
|
12
|
+
type RoutedSessionHandle,
|
|
13
|
+
type ServerHost,
|
|
14
|
+
SessionAmbiguousError,
|
|
15
|
+
SessionNotFoundError,
|
|
16
|
+
} from "@m4ike1/ion-server";
|
|
17
|
+
import { createUnixServer, getUnixSocketPath } from "@m4ike1/ion-server/unix";
|
|
18
|
+
|
|
19
|
+
async function startServer(
|
|
20
|
+
serverServices: RoutedServerServiceHost,
|
|
21
|
+
openRoutedSession: (session: Session) => Promise<RoutedSessionHandle>,
|
|
22
|
+
) {
|
|
23
|
+
const sessions = new MemorySessionRepo();
|
|
24
|
+
const host: ServerHost = {
|
|
25
|
+
serverServices,
|
|
26
|
+
async resolveSession(sessionId, context) {
|
|
27
|
+
const matches = (await sessions.list(undefined, context))
|
|
28
|
+
.filter((metadata) => metadata.id === sessionId);
|
|
29
|
+
if (matches.length === 0) {
|
|
30
|
+
throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
|
|
31
|
+
}
|
|
32
|
+
if (matches.length > 1) throw new SessionAmbiguousError();
|
|
33
|
+
return matches[0];
|
|
34
|
+
},
|
|
35
|
+
async openSession(metadata, context) {
|
|
36
|
+
const session = await sessions.open(metadata, context);
|
|
37
|
+
try {
|
|
38
|
+
return await openRoutedSession(session);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
try {
|
|
41
|
+
await session.close(context);
|
|
42
|
+
} catch (cleanupError) {
|
|
43
|
+
throw new AggregateError(
|
|
44
|
+
[error, cleanupError],
|
|
45
|
+
"Harness creation and Session cleanup failed",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const serverId = randomUUID();
|
|
54
|
+
const server = createUnixServer(host, {
|
|
55
|
+
serverId,
|
|
56
|
+
path: getUnixSocketPath(serverId, "/run/user/1000/ion"),
|
|
57
|
+
});
|
|
58
|
+
await server.start();
|
|
59
|
+
return server;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
Three application-owned capabilities plug into the router:
|
|
66
|
+
|
|
67
|
+
- `serverServices: RoutedServerServiceHost` — creates one connection-scoped server service endpoint per client via `attachClient()`. Its `RoutedServerPresentation` argument is how server service implementations drive Session routing (`attachSession`, `detachSession`, `prepareSessionRemoval`).
|
|
68
|
+
- `resolveSession(sessionId, context)` — maps a durable Session ID to metadata, or throws a bounded routing error (`SessionNotFoundError`, `SessionAmbiguousError`). Session discovery and management are application-owned services; the server only asks the resolver when routing an attachment.
|
|
69
|
+
- `openSession(metadata, context)` — acquires the worker-local Session and returns a `RoutedSessionHandle`. Failures are cleaned up in that worker.
|
|
70
|
+
|
|
71
|
+
Routing is opaque end to end:
|
|
72
|
+
|
|
73
|
+
- Server service calls and subscriptions route through the connection's `RoutedServerServiceAttachment`; Session calls route through `RoutedSessionHandle.attachClient()` presentation attachments via `invokeService()`, which forwards the service/member envelope without server-side business-payload decoding.
|
|
74
|
+
- A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent. Every successful attachment gets a server-generated `attachmentId` delivered only as routing control data in an out-of-band `attachment` message.
|
|
75
|
+
- Session requests carry `{ serverId, sessionId, attachmentId }`; the server rejects stale or mismatched routes with `SessionNotAttachedError` and wrong-server targets with `WrongServerError`.
|
|
76
|
+
- Subscription snapshots are encoded per connection; updates stay scoped to the requesting attachment. Application observations such as transcripts route as ordinary service state without server-owned business schemas.
|
|
77
|
+
|
|
78
|
+
## Lifecycle
|
|
79
|
+
|
|
80
|
+
- `new Server(host, options)` validates options eagerly (`serverId` must be a canonical lowercase UUIDv4). `start()` starts each listener in order; a listener failure closes the listeners that already started and rejects `closed`.
|
|
81
|
+
- `close()` stops listeners, closes connections, waits for admitted service calls to settle, releases attachments, then closes routed Session handles. `closed` resolves after shutdown or rejects when listener or Session cleanup fails.
|
|
82
|
+
- Losing a connection rejects its local in-flight responses but releases its attachment only after admitted service calls settle. Disconnecting never deletes the hosted Session; the host decides when zero presentation demand and worker-local Harness activity permit worker retirement.
|
|
83
|
+
- While draining, new attachments and Session-targeted calls fail with `ServerDrainingError`. `Server.close()` aggregates listener and Session cleanup failures into `AggregateError`.
|
|
84
|
+
- Per-connection handshake: the first client message must be `hello` with a supported protocol version, else the connection is failed with `hello_error`. Handshakes time out after `handshakeTimeoutMs` (default 5,000 ms). Duplicate request IDs and duplicate subscription IDs are rejected per connection; `cancel` envelopes abort the matching in-flight request.
|
|
85
|
+
- `RoutedSessionHandle.terminated` lets the router drop a hosted Session whose worker died unexpectedly; the next `attach` re-opens it through the host.
|
|
86
|
+
|
|
87
|
+
## Transports
|
|
88
|
+
|
|
89
|
+
`serverId` is a logical identity supplied by the launcher, not a socket address. `Server` composes transports through `ServerListener`; peer authentication remains application policy and is not implemented by the experimental Unix transport.
|
|
90
|
+
|
|
91
|
+
The `@m4ike1/ion-server/unix` submodule provides `createUnixListener()` and `createUnixServer()`. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process. Stale socket files are reclaimed only after probing that no live server owns them; non-socket paths are never removed.
|
|
92
|
+
|
|
93
|
+
## Error model
|
|
94
|
+
|
|
95
|
+
Errors that cross the protocol boundary are `ServerError` subclasses with stable codes (`wrong_server`, `session_not_found`, `session_ambiguous`, `session_not_attached`, `server_draining`, plus Chord remote-service codes). Anything else is sanitized to `internal_error` without exposing private details; the original is forwarded to `onError`. Error observers never affect server state.
|
|
96
|
+
|
|
97
|
+
## Testing
|
|
98
|
+
|
|
99
|
+
`@m4ike1/ion-server/testing` provides `createTestServer()` (unstarted `Server` with deterministic defaults), `TestServerHost`/`TestHarness` (in-memory host with gateable open/service/close paths), and `ProtocolTestClient`/`connectUnixTestClient()` wire clients for transport conformance tests. See `docs/reference.md` and `docs/setup.md`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ServiceStateEncoder } from "@m4ike1/chord";
|
|
2
|
+
import type { ClientMessageDecoder, RpcTarget } from "@m4ike1/ion-protocol";
|
|
3
|
+
import type { MaybePromise, RoutedServerServiceAttachment } from "./types.ts";
|
|
4
|
+
/** An established, authorized ordered byte connection. */
|
|
5
|
+
export interface ByteConnection {
|
|
6
|
+
readonly closed: boolean;
|
|
7
|
+
send(chunk: Uint8Array): Promise<void>;
|
|
8
|
+
close(finalChunk?: Uint8Array): MaybePromise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface ByteConnectionHandler {
|
|
11
|
+
onData(chunk: Uint8Array): void;
|
|
12
|
+
onClose(): void;
|
|
13
|
+
onError(error: Error): void;
|
|
14
|
+
}
|
|
15
|
+
export type ByteConnectionAcceptor = (connection: ByteConnection) => ByteConnectionHandler;
|
|
16
|
+
export type ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed";
|
|
17
|
+
export interface ConnectionState {
|
|
18
|
+
connection: ByteConnection;
|
|
19
|
+
decoder: ClientMessageDecoder;
|
|
20
|
+
serviceStateEncoders: Map<string, ServiceStateEncoder>;
|
|
21
|
+
stage: ConnectionStage;
|
|
22
|
+
disconnected: boolean;
|
|
23
|
+
handshake?: Promise<void>;
|
|
24
|
+
handshakeTimeout: NodeJS.Timeout;
|
|
25
|
+
serverServices?: RoutedServerServiceAttachment;
|
|
26
|
+
activeRequests: Map<string, {
|
|
27
|
+
controller: AbortController;
|
|
28
|
+
target: RpcTarget;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export declare function isTerminalConnection(state: ConnectionState): boolean;
|
|
32
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,KAAK,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE5E,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 \"@m4ike1/chord\";\nimport type { ClientMessageDecoder, RpcTarget } from \"@m4ike1/ion-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"]}
|
|
@@ -0,0 +1 @@
|
|
|
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 \"@m4ike1/chord\";\nimport type { ClientMessageDecoder, RpcTarget } from \"@m4ike1/ion-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
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RemoteServiceErrorCode } from "@m4ike1/chord";
|
|
2
|
+
type ServerOperationErrorCode = RemoteServiceErrorCode | "wrong_server" | "session_not_found" | "session_ambiguous" | "session_not_attached" | "server_draining";
|
|
3
|
+
export declare const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
|
|
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);
|
|
8
|
+
}
|
|
9
|
+
export declare class WrongServerError extends ServerError {
|
|
10
|
+
constructor();
|
|
11
|
+
}
|
|
12
|
+
export declare class SessionNotFoundError extends ServerError {
|
|
13
|
+
constructor(message?: string);
|
|
14
|
+
}
|
|
15
|
+
export declare class SessionAmbiguousError extends ServerError {
|
|
16
|
+
constructor();
|
|
17
|
+
}
|
|
18
|
+
export declare class SessionNotAttachedError extends ServerError {
|
|
19
|
+
constructor();
|
|
20
|
+
}
|
|
21
|
+
export declare class ServerDrainingError extends ServerError {
|
|
22
|
+
constructor();
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAE5D,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 \"@m4ike1/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
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
|
|
2
|
+
/** A host or lifecycle error that can safely cross the protocol boundary. */
|
|
3
|
+
export class ServerError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ServerError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class WrongServerError extends ServerError {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("wrong_server", "Request was addressed to another server");
|
|
14
|
+
this.name = "WrongServerError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class SessionNotFoundError extends ServerError {
|
|
18
|
+
constructor(message = "Session was not found") {
|
|
19
|
+
super("session_not_found", message);
|
|
20
|
+
this.name = "SessionNotFoundError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class SessionAmbiguousError extends ServerError {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("session_ambiguous", "Session ID matches more than one session");
|
|
26
|
+
this.name = "SessionAmbiguousError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class SessionNotAttachedError extends ServerError {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("session_not_attached", "Session is not attached to this client");
|
|
32
|
+
this.name = "SessionNotAttachedError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class ServerDrainingError extends ServerError {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("server_draining", "Server is draining");
|
|
38
|
+
this.name = "ServerDrainingError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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 \"@m4ike1/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
ADDED
|
@@ -0,0 +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,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
ADDED
|
@@ -0,0 +1 @@
|
|
|
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"]}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ByteConnectionAcceptor } from "./connection.ts";
|
|
2
|
+
/** Supplies established byte connections after any required transport authentication. */
|
|
3
|
+
export interface ServerListener {
|
|
4
|
+
/** Starts listening and passes authorized connections to accept. */
|
|
5
|
+
start(accept: ByteConnectionAcceptor): Promise<void>;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=listener.d.ts.map
|
|
@@ -0,0 +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,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
ADDED
|
@@ -0,0 +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 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
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SessionMetadata } from "@m4ike1/ion-agent-core";
|
|
2
|
+
import { type ByteConnection, type ByteConnectionHandler } from "./connection.ts";
|
|
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;
|
|
9
|
+
private readonly listeners;
|
|
10
|
+
private readonly maxFrameLength;
|
|
11
|
+
private readonly handshakeTimeoutMs;
|
|
12
|
+
private readonly onConnectionCountChanged;
|
|
13
|
+
private readonly onError;
|
|
14
|
+
private readonly connections;
|
|
15
|
+
private readonly sessions;
|
|
16
|
+
private closing;
|
|
17
|
+
private closePromise?;
|
|
18
|
+
private closedSettled;
|
|
19
|
+
private rejectClosed;
|
|
20
|
+
private resolveClosed;
|
|
21
|
+
private startPromise?;
|
|
22
|
+
private started;
|
|
23
|
+
constructor(host: ServerHost<TMetadata>, options: ServerOptions);
|
|
24
|
+
start(): Promise<this>;
|
|
25
|
+
private startInternal;
|
|
26
|
+
accept(connection: ByteConnection): ByteConnectionHandler;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
private closeInternal;
|
|
29
|
+
private receive;
|
|
30
|
+
private dispatchMessage;
|
|
31
|
+
private finishHandshake;
|
|
32
|
+
private handleCancel;
|
|
33
|
+
private handleRequest;
|
|
34
|
+
private transportClosed;
|
|
35
|
+
private disconnect;
|
|
36
|
+
private sendServiceUpdate;
|
|
37
|
+
private sendMessage;
|
|
38
|
+
private failProtocol;
|
|
39
|
+
private closeServerState;
|
|
40
|
+
private closeConnection;
|
|
41
|
+
private toProtocolError;
|
|
42
|
+
private notifyConnectionCountChanged;
|
|
43
|
+
private reportError;
|
|
44
|
+
private settleClosed;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAUA,OAAO,EAAsB,KAAK,eAAe,EAAiC,MAAM,wBAAwB,CAAC;AAoBjH,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 \"@m4ike1/chord\";\nimport { BACKGROUND_CONTEXT, type SessionMetadata, TODO_CONTEXT, withAbortSignal } from \"@m4ike1/ion-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 \"@m4ike1/ion-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"]}
|