export class ConnectionManager {
/**
* The three-arm connections map. Module-private; the only mutators are
* `addUnauthenticated` / `authenticate` / `rollbackToUnauthenticated` /
* `removeAndReturn` below.
*/
private readonly connectionsRef: Ref.Ref<
HashMap.HashMap<ConnectionId, Connection>
> = Effect.runSync(Ref.make(HashMap.empty<ConnectionId, Connection>()));
/**
* Insert a fresh `UnauthenticatedConnection`. Called by the socket handler
* at WebSocket open. The Connect handler promotes it to the agent/app arm.
*/
addUnauthenticated(
connId: ConnectionId,
socket: WebSocketRef,
originator: Originator,
): Effect.Effect<void> {
return Ref.update(this.connectionsRef, (map) =>
HashMap.set(
map,
connId,
new UnauthenticatedConnection({ connId, socket, originator }),
),
);
}
/** Non-mutating read. Callers discriminate on the returned arm's `_tag`. */
peek(connId: ConnectionId): Effect.Effect<Option.Option<Connection>> {
return Ref.get(this.connectionsRef).pipe(
Effect.map((map) => HashMap.get(map, connId)),
);
}
/**
* Snapshot of every connection arm. Callers iterate + discriminate on `_tag`
* (e.g. the shutdown loop reads `arm.socket.shutdown`).
*/
allConnections(): Effect.Effect<readonly Connection[]> {
return Ref.get(this.connectionsRef).pipe(
Effect.map((map) => Array.from(HashMap.values(map))),
);
}
/** Current connection count. */
currentSize(): Effect.Effect<number> {
return Ref.get(this.connectionsRef).pipe(
Effect.map((map) => HashMap.size(map)),
);
}
/**
* Atomic per-connection authentication gate. Pattern-matches on
* `auth._tag` once to decide which arm to mint. Returns a split-per-arm
* `TransitionOutcome` so callers narrow without a cast.
*/
authenticate(
connId: ConnectionId,
auth: AgentContext | AppContext,
): Effect.Effect<TransitionOutcome> {
return Ref.modify(this.connectionsRef, (map) => {
const current = HashMap.get(map, connId);
if (Option.isNone(current)) {
return [{ kind: "not-connected" } as const, map];
}
return Match.value(current.value).pipe(
Match.tag(
"AgentConnection",
(existing): [TransitionOutcome, typeof map] => [
{ kind: "already-connected", existing },
map,
],
),
Match.tag(
"AppConnection",
(existing): [TransitionOutcome, typeof map] => [
{ kind: "already-connected", existing },
map,
],
),
Match.tag(
"UnauthenticatedConnection",
(unauth): [TransitionOutcome, typeof map] => {
const { outcome, minted } = mintAuthedArm(
{
connId: unauth.connId,
socket: unauth.socket,
originator: unauth.originator,
},
auth,
);
return [outcome, HashMap.set(map, connId, minted)];
},
),
Match.exhaustive,
);
});
}
/**
* Roll an authenticated arm back to `UnauthenticatedConnection` on a
* post-auth failure. Idempotent: no-op when the entry is absent or already
* unauthenticated — safe against a racing close handler.
*/
rollbackToUnauthenticated(connId: ConnectionId): Effect.Effect<void> {
return Ref.update(this.connectionsRef, (map) => {
const current = HashMap.get(map, connId);
if (Option.isNone(current)) return map;
// Auth fields are dropped; the shared socket/originator fields remain.
const demote = (authed: AgentConnection | AppConnection) =>
HashMap.set(
map,
connId,
new UnauthenticatedConnection({
connId: authed.connId,
socket: authed.socket,
originator: authed.originator,
}),
);