@earendil-works/pi-server 0.84.4 → 0.85.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +66 -37
  3. package/dist/connection.d.ts +9 -5
  4. package/dist/connection.d.ts.map +1 -1
  5. package/dist/connection.js.map +1 -1
  6. package/dist/errors.d.ts +16 -18
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +23 -27
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +0 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +0 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/listener.d.ts +1 -3
  15. package/dist/listener.d.ts.map +1 -1
  16. package/dist/listener.js.map +1 -1
  17. package/dist/server.d.ts +16 -6
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +268 -86
  20. package/dist/server.js.map +1 -1
  21. package/dist/session-router.d.ts +40 -0
  22. package/dist/session-router.d.ts.map +1 -0
  23. package/dist/session-router.js +264 -0
  24. package/dist/session-router.js.map +1 -0
  25. package/dist/testing/client.d.ts +6 -2
  26. package/dist/testing/client.d.ts.map +1 -1
  27. package/dist/testing/client.js +23 -3
  28. package/dist/testing/client.js.map +1 -1
  29. package/dist/testing/host.d.ts +57 -0
  30. package/dist/testing/host.d.ts.map +1 -0
  31. package/dist/testing/host.js +189 -0
  32. package/dist/testing/host.js.map +1 -0
  33. package/dist/testing/index.d.ts +1 -1
  34. package/dist/testing/index.d.ts.map +1 -1
  35. package/dist/testing/index.js +1 -1
  36. package/dist/testing/index.js.map +1 -1
  37. package/dist/testing/server.d.ts +8 -7
  38. package/dist/testing/server.d.ts.map +1 -1
  39. package/dist/testing/server.js +7 -7
  40. package/dist/testing/server.js.map +1 -1
  41. package/dist/transports/unix/address.d.ts +3 -0
  42. package/dist/transports/unix/address.d.ts.map +1 -0
  43. package/dist/transports/unix/address.js +9 -0
  44. package/dist/transports/unix/address.js.map +1 -0
  45. package/dist/transports/unix/index.d.ts +1 -0
  46. package/dist/transports/unix/index.d.ts.map +1 -1
  47. package/dist/transports/unix/index.js +1 -0
  48. package/dist/transports/unix/index.js.map +1 -1
  49. package/dist/transports/unix/listener.d.ts +2 -3
  50. package/dist/transports/unix/listener.d.ts.map +1 -1
  51. package/dist/transports/unix/listener.js +13 -24
  52. package/dist/transports/unix/listener.js.map +1 -1
  53. package/dist/transports/unix/preset.d.ts +5 -4
  54. package/dist/transports/unix/preset.d.ts.map +1 -1
  55. package/dist/transports/unix/preset.js +5 -4
  56. package/dist/transports/unix/preset.js.map +1 -1
  57. package/dist/transports/unix/types.d.ts +2 -2
  58. package/dist/transports/unix/types.d.ts.map +1 -1
  59. package/dist/transports/unix/types.js.map +1 -1
  60. package/dist/types.d.ts +40 -47
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js.map +1 -1
  63. package/package.json +4 -3
  64. package/dist/protocol.d.ts +0 -22
  65. package/dist/protocol.d.ts.map +0 -1
  66. package/dist/protocol.js +0 -266
  67. package/dist/protocol.js.map +0 -1
  68. package/dist/sessions.d.ts +0 -2174
  69. package/dist/sessions.d.ts.map +0 -1
  70. package/dist/sessions.js +0 -305
  71. package/dist/sessions.js.map +0 -1
  72. package/dist/snapshots.d.ts +0 -24
  73. package/dist/snapshots.d.ts.map +0 -1
  74. package/dist/snapshots.js +0 -39
  75. package/dist/snapshots.js.map +0 -1
  76. package/dist/testing/service.d.ts +0 -59
  77. package/dist/testing/service.d.ts.map +0 -1
  78. package/dist/testing/service.js +0 -240
  79. package/dist/testing/service.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,61 +1,65 @@
1
- import { randomUUID } from "node:crypto";
2
- import { ClientMessageDecoder, DEFAULT_MAX_FRAME_LENGTH, encodeServerMessage, isSupportedProtocolVersion, PROTOCOL_VERSION, ProtocolValidationError, } from "@earendil-works/pi-protocol";
1
+ import { createServiceStateEncoder, decodeServiceControlCall, parseServiceCall, parseServiceSubscriptionSnapshot, RemoteServiceError, } from "@earendil-works/chord";
2
+ import { BACKGROUND_CONTEXT, TODO_CONTEXT, withAbortSignal } from "@earendil-works/pi-agent-core";
3
+ import { ClientMessageDecoder, DEFAULT_MAX_FRAME_LENGTH, encodeServerMessage, isServerId, isSupportedProtocolVersion, PROTOCOL_VERSION, ProtocolValidationError, } from "@earendil-works/pi-protocol";
3
4
  import { isTerminalConnection, } from "./connection.js";
4
- import { INTERNAL_SERVER_ERROR_MESSAGE, InternalServerError, NOT_IMPLEMENTED_MESSAGE, PiServerError, } from "./errors.js";
5
- import { LiveSessionManager } from "./sessions.js";
6
- import { ServerSnapshotPublisher } from "./snapshots.js";
5
+ import { INTERNAL_SERVER_ERROR_MESSAGE, ServerError, WrongServerError } from "./errors.js";
6
+ import { SessionRouter } from "./session-router.js";
7
7
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
8
8
  const MAX_UINT32 = 0xffff_ffff;
9
9
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
10
- export class PiServer {
11
- id;
10
+ export class Server {
11
+ serverId;
12
+ /** Resolves after shutdown, or rejects when listener or routed-Session cleanup fails. */
13
+ closed;
14
+ host;
12
15
  listeners;
13
16
  maxFrameLength;
14
17
  handshakeTimeoutMs;
18
+ onConnectionCountChanged;
15
19
  onError;
16
20
  connections = new Set();
17
21
  sessions;
18
- snapshots;
19
22
  closing = false;
20
23
  closePromise;
24
+ closedSettled = false;
25
+ rejectClosed;
26
+ resolveClosed;
21
27
  startPromise;
22
28
  started = false;
23
- constructor(service, options) {
29
+ constructor(host, options) {
24
30
  const resolved = resolveOptions(options);
31
+ this.host = host;
25
32
  this.listeners = options.listeners;
26
- this.id = options.serverId ?? randomUUID();
33
+ this.serverId = options.serverId;
27
34
  this.maxFrameLength = resolved.maxFrameLength;
28
35
  this.handshakeTimeoutMs = resolved.handshakeTimeoutMs;
36
+ this.onConnectionCountChanged = options.onConnectionCountChanged;
29
37
  this.onError = options.onError;
30
- this.sessions = new LiveSessionManager({
31
- service,
38
+ this.sessions = new SessionRouter({
39
+ host,
40
+ serverId: this.serverId,
32
41
  isClosing: () => this.closing,
33
- sendMessage: (connection, message) => this.sendMessage(connection, message),
34
- closeConnection: (connection) => this.closeConnection(connection),
35
- disconnect: (connection) => this.disconnect(connection),
36
- broadcastServerSnapshot: () => void this.snapshots.broadcast(),
42
+ publishAttachment: async (client, attachment) => {
43
+ await this.sendMessage(client, {
44
+ type: "attachment",
45
+ attachment: attachment ?? null,
46
+ });
47
+ },
37
48
  reportError: (error) => this.reportError(error),
38
49
  });
39
- this.snapshots = new ServerSnapshotPublisher({
40
- serverId: this.id,
41
- service,
42
- connections: this.connections,
43
- isClosing: () => this.closing,
44
- listSessions: () => this.sessions.listMetadata(),
45
- sendMessage: (connection, message) => this.sendMessage(connection, message),
46
- reportError: (error) => this.reportError(error),
50
+ this.closed = new Promise((resolve, reject) => {
51
+ this.resolveClosed = resolve;
52
+ this.rejectClosed = reject;
47
53
  });
48
- }
49
- get addresses() {
50
- return this.listeners.flatMap((listener) => (listener.address === undefined ? [] : [listener.address]));
54
+ void this.closed.catch(() => { });
51
55
  }
52
56
  start() {
53
57
  if (this.started)
54
- return Promise.reject(new Error("PiServer is already started"));
58
+ return Promise.reject(new Error("Server is already started"));
55
59
  if (this.startPromise)
56
- return Promise.reject(new Error("PiServer is already starting"));
60
+ return Promise.reject(new Error("Server is already starting"));
57
61
  if (this.closing)
58
- return Promise.reject(new Error("PiServer is closing or closed"));
62
+ return Promise.reject(new Error("Server is closing or closed"));
59
63
  this.startPromise = this.startInternal();
60
64
  return this.startPromise;
61
65
  }
@@ -71,8 +75,24 @@ export class PiServer {
71
75
  }
72
76
  catch (error) {
73
77
  this.closing = true;
74
- await Promise.allSettled(started.map((listener) => listener.close()));
75
- await this.closeServerState();
78
+ const cleanupErrors = [];
79
+ const listenerResults = await Promise.allSettled(started.map((listener) => listener.close()));
80
+ for (const result of listenerResults) {
81
+ if (result.status === "rejected")
82
+ cleanupErrors.push(result.reason);
83
+ }
84
+ try {
85
+ await this.closeServerState();
86
+ }
87
+ catch (cleanupError) {
88
+ cleanupErrors.push(cleanupError);
89
+ }
90
+ if (cleanupErrors.length > 0) {
91
+ const failure = new AggregateError([error, ...cleanupErrors], "Server startup and cleanup failed");
92
+ this.settleClosed(failure);
93
+ throw failure;
94
+ }
95
+ this.settleClosed();
76
96
  throw error;
77
97
  }
78
98
  finally {
@@ -97,16 +117,16 @@ export class PiServer {
97
117
  }, this.handshakeTimeoutMs);
98
118
  handshakeTimeout.unref();
99
119
  state = {
100
- id: randomUUID(),
101
120
  connection,
102
121
  decoder: new ClientMessageDecoder({ maxFrameLength: this.maxFrameLength }),
103
- sessionIds: new Set(),
122
+ serviceStateEncoders: new Map(),
104
123
  stage: "awaitingHello",
105
124
  disconnected: false,
106
- handshakeComplete: false,
107
125
  handshakeTimeout,
126
+ activeRequests: new Map(),
108
127
  };
109
128
  this.connections.add(state);
129
+ this.notifyConnectionCountChanged();
110
130
  return {
111
131
  onData: (chunk) => this.receive(state, chunk),
112
132
  onClose: () => this.transportClosed(state),
@@ -127,13 +147,27 @@ export class PiServer {
127
147
  const starting = this.startPromise;
128
148
  if (starting)
129
149
  await starting.catch(() => { });
130
- try {
131
- await Promise.all(this.listeners.map((listener) => listener.close()));
150
+ const errors = [];
151
+ const listenerResults = await Promise.allSettled(this.listeners.map((listener) => listener.close()));
152
+ for (const result of listenerResults) {
153
+ if (result.status === "rejected")
154
+ errors.push(result.reason);
132
155
  }
133
- finally {
156
+ try {
134
157
  await this.closeServerState();
135
- this.started = false;
136
158
  }
159
+ catch (error) {
160
+ errors.push(error);
161
+ }
162
+ this.started = false;
163
+ if (errors.length > 0) {
164
+ const failure = errors.length === 1 && errors[0] instanceof Error
165
+ ? errors[0]
166
+ : new AggregateError(errors, "Server shutdown failed");
167
+ this.settleClosed(failure);
168
+ throw failure;
169
+ }
170
+ this.settleClosed();
137
171
  }
138
172
  receive(state, chunk) {
139
173
  if (isTerminalConnection(state))
@@ -173,7 +207,10 @@ export class PiServer {
173
207
  return;
174
208
  }
175
209
  if (state.stage === "ready") {
176
- void this.handleRequest(state, message);
210
+ if (message.type === "cancel")
211
+ this.handleCancel(state, message);
212
+ else
213
+ void this.handleRequest(state, message);
177
214
  return;
178
215
  }
179
216
  if (state.stage !== "handshaking")
@@ -182,7 +219,11 @@ export class PiServer {
182
219
  if (!handshake)
183
220
  return;
184
221
  void handshake.then(() => {
185
- if (state.stage === "ready" && !state.disconnected)
222
+ if (state.stage !== "ready" || state.disconnected)
223
+ return;
224
+ if (message.type === "cancel")
225
+ this.handleCancel(state, message);
226
+ else
186
227
  void this.handleRequest(state, message);
187
228
  });
188
229
  }
@@ -194,45 +235,141 @@ export class PiServer {
194
235
  });
195
236
  return;
196
237
  }
197
- const snapshot = await this.snapshots.get();
198
238
  if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed)
199
239
  return;
240
+ const services = await this.host.serverServices.attachClient({
241
+ attachSession: async (sessionId, context) => {
242
+ await this.sessions.attachClient(state, sessionId, context);
243
+ },
244
+ detachSession: (context) => this.sessions.detachClient(state, context),
245
+ prepareSessionRemoval: (sessionId, context) => this.sessions.removeSession(sessionId, context),
246
+ }, TODO_CONTEXT);
247
+ if (this.closing || state.disconnected || state.stage !== "handshaking" || state.connection.closed) {
248
+ await services.release(TODO_CONTEXT);
249
+ return;
250
+ }
251
+ state.serverServices = services;
200
252
  const sent = await this.sendMessage(state, {
201
253
  type: "hello",
202
254
  version: PROTOCOL_VERSION,
203
- connectionId: state.id,
204
- snapshot,
255
+ serverId: this.serverId,
205
256
  });
206
257
  if (sent && !state.disconnected && state.stage === "handshaking") {
207
- state.handshakeComplete = true;
208
258
  state.stage = "ready";
209
259
  clearTimeout(state.handshakeTimeout);
210
- if (snapshot.revision !== this.snapshots.currentRevision) {
211
- const current = await this.snapshots.get();
212
- await this.sendMessage(state, {
213
- type: "event",
214
- event: { type: "server_snapshot", snapshot: current },
215
- });
216
- }
260
+ }
261
+ }
262
+ handleCancel(state, envelope) {
263
+ if (envelope.target.serverId !== this.serverId)
264
+ return;
265
+ const active = state.activeRequests.get(envelope.id);
266
+ if (active !== undefined && sameTarget(active.target, envelope.target)) {
267
+ active.controller.abort(new DOMException("RPC request cancelled", "AbortError"));
217
268
  }
218
269
  }
219
270
  async handleRequest(state, envelope) {
220
- try {
221
- const result = await this.sessions.executeCommand(state, envelope.request);
271
+ if (state.activeRequests.has(envelope.id)) {
222
272
  await this.sendMessage(state, {
223
273
  type: "response",
224
274
  id: envelope.id,
225
- ok: true,
226
- result,
275
+ ok: false,
276
+ error: { code: "invalid_request", message: "Request ID is already active" },
227
277
  });
278
+ return;
228
279
  }
229
- catch (error) {
280
+ let call;
281
+ try {
282
+ call = parseServiceCall(envelope.call);
283
+ }
284
+ catch {
230
285
  await this.sendMessage(state, {
231
286
  type: "response",
232
287
  id: envelope.id,
233
288
  ok: false,
234
- error: this.toProtocolError(error),
289
+ error: { code: "invalid_request", message: "Invalid service call" },
235
290
  });
291
+ return;
292
+ }
293
+ const controller = new AbortController();
294
+ const active = { controller, target: envelope.target };
295
+ state.activeRequests.set(envelope.id, active);
296
+ const context = withAbortSignal(controller.signal, TODO_CONTEXT);
297
+ const control = decodeServiceControlCall(call);
298
+ const subscribing = control?.type === "subscribe" ? control : undefined;
299
+ const pendingUpdates = [];
300
+ let subscriptionReady = subscribing === undefined;
301
+ let installedSubscriptionEncoder = false;
302
+ let responded = false;
303
+ const publish = async (subscriptionId, update) => {
304
+ if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
305
+ pendingUpdates.push({ update });
306
+ return;
307
+ }
308
+ await this.sendServiceUpdate(state, subscriptionId, update);
309
+ };
310
+ try {
311
+ if (envelope.target.serverId !== this.serverId)
312
+ throw new WrongServerError();
313
+ if (subscribing !== undefined && state.serviceStateEncoders.has(subscribing.subscriptionId)) {
314
+ throw new ProtocolValidationError(`Duplicate service subscription ${subscribing.subscriptionId}`);
315
+ }
316
+ let result;
317
+ if ("sessionId" in envelope.target) {
318
+ result = await this.sessions.executeServiceCall(call, envelope.target, state, publish, context);
319
+ }
320
+ else if (state.serverServices !== undefined) {
321
+ result = await state.serverServices.invokeService(call, publish, context);
322
+ }
323
+ else {
324
+ throw new ProtocolValidationError(`Unknown service member ${call.serviceId}.${call.member}`);
325
+ }
326
+ if (subscribing !== undefined) {
327
+ if (result === undefined)
328
+ throw new ProtocolValidationError("Service subscription did not return a snapshot");
329
+ const stateEncoder = createServiceStateEncoder();
330
+ result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result));
331
+ state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
332
+ installedSubscriptionEncoder = true;
333
+ }
334
+ else if (control?.type === "unsubscribe") {
335
+ state.serviceStateEncoders.delete(control.subscriptionId);
336
+ }
337
+ await this.sendMessage(state, result === undefined
338
+ ? { type: "response", id: envelope.id, ok: true }
339
+ : { type: "response", id: envelope.id, ok: true, result });
340
+ responded = true;
341
+ if (subscribing !== undefined) {
342
+ while (pendingUpdates.length > 0) {
343
+ const pending = pendingUpdates.shift();
344
+ if (pending !== undefined)
345
+ await this.sendServiceUpdate(state, subscribing.subscriptionId, pending.update);
346
+ }
347
+ subscriptionReady = true;
348
+ }
349
+ }
350
+ catch (error) {
351
+ if (subscribing !== undefined && installedSubscriptionEncoder && !responded) {
352
+ state.serviceStateEncoders.delete(subscribing.subscriptionId);
353
+ }
354
+ if (responded) {
355
+ this.reportError(error);
356
+ await this.closeConnection(state.connection);
357
+ this.disconnect(state);
358
+ }
359
+ else {
360
+ await this.sendMessage(state, {
361
+ type: "response",
362
+ id: envelope.id,
363
+ ok: false,
364
+ error: controller.signal.aborted
365
+ ? { code: "cancelled", message: "RPC request cancelled" }
366
+ : this.toProtocolError(error),
367
+ });
368
+ }
369
+ }
370
+ finally {
371
+ if (state.activeRequests.get(envelope.id) === active)
372
+ state.activeRequests.delete(envelope.id);
236
373
  }
237
374
  }
238
375
  transportClosed(connection) {
@@ -244,19 +381,41 @@ export class PiServer {
244
381
  this.reportError(error);
245
382
  }
246
383
  }
247
- void this.disconnect(connection);
384
+ this.disconnect(connection);
248
385
  }
249
- async disconnect(connection) {
386
+ disconnect(connection) {
250
387
  if (connection.disconnected)
251
388
  return;
252
- const handshakeComplete = connection.handshakeComplete;
253
389
  connection.disconnected = true;
254
390
  connection.stage = "closed";
255
391
  clearTimeout(connection.handshakeTimeout);
256
- this.connections.delete(connection);
257
- await this.sessions.disconnect(connection);
258
- if (!this.closing && handshakeComplete)
259
- void this.snapshots.broadcast();
392
+ for (const { controller } of connection.activeRequests.values()) {
393
+ controller.abort(new Error("Client disconnected"));
394
+ }
395
+ connection.activeRequests.clear();
396
+ connection.serviceStateEncoders.clear();
397
+ if (this.connections.delete(connection))
398
+ this.notifyConnectionCountChanged();
399
+ const serverServices = connection.serverServices;
400
+ delete connection.serverServices;
401
+ void Promise.allSettled([
402
+ this.sessions.disconnect(connection, TODO_CONTEXT),
403
+ serverServices?.release(TODO_CONTEXT),
404
+ ]).then((results) => {
405
+ for (const result of results)
406
+ if (result.status === "rejected")
407
+ this.reportError(result.reason);
408
+ });
409
+ }
410
+ async sendServiceUpdate(connection, subscriptionId, update) {
411
+ const stateEncoder = connection.serviceStateEncoders.get(subscriptionId);
412
+ if (stateEncoder === undefined)
413
+ return;
414
+ await this.sendMessage(connection, {
415
+ type: "service_update",
416
+ subscriptionId,
417
+ update: stateEncoder.encodeUpdate(update),
418
+ });
260
419
  }
261
420
  async sendMessage(connection, message) {
262
421
  if (connection.disconnected || connection.connection.closed)
@@ -268,7 +427,7 @@ export class PiServer {
268
427
  catch (error) {
269
428
  this.reportError(error);
270
429
  await this.closeConnection(connection.connection);
271
- await this.disconnect(connection);
430
+ this.disconnect(connection);
272
431
  return false;
273
432
  }
274
433
  try {
@@ -278,7 +437,7 @@ export class PiServer {
278
437
  catch (error) {
279
438
  this.reportError(error);
280
439
  await this.closeConnection(connection.connection);
281
- await this.disconnect(connection);
440
+ this.disconnect(connection);
282
441
  return false;
283
442
  }
284
443
  }
@@ -296,7 +455,7 @@ export class PiServer {
296
455
  this.reportError(encodeError);
297
456
  }
298
457
  await this.closeConnection(connection.connection, finalFrame);
299
- await this.disconnect(connection);
458
+ this.disconnect(connection);
300
459
  }
301
460
  async closeServerState() {
302
461
  const connections = [...this.connections];
@@ -305,9 +464,15 @@ export class PiServer {
305
464
  clearTimeout(connection.handshakeTimeout);
306
465
  }
307
466
  await Promise.all(connections.map((connection) => this.closeConnection(connection.connection)));
308
- await Promise.all(connections.map((connection) => this.disconnect(connection)));
309
- await this.sessions.close();
467
+ for (const connection of connections)
468
+ this.disconnect(connection);
469
+ const cleanup = await Promise.allSettled([this.sessions.close(BACKGROUND_CONTEXT)]);
310
470
  this.connections.clear();
471
+ const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
472
+ if (errors.length === 1)
473
+ throw errors[0];
474
+ if (errors.length > 1)
475
+ throw new AggregateError(errors, "Failed to close server Sessions");
311
476
  }
312
477
  async closeConnection(connection, finalChunk) {
313
478
  try {
@@ -318,17 +483,8 @@ export class PiServer {
318
483
  }
319
484
  }
320
485
  toProtocolError(error) {
321
- if (error instanceof InternalServerError) {
322
- this.reportError(error.cause);
323
- return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE };
324
- }
325
- if (error instanceof PiServerError) {
326
- if (error.code === "not_implemented") {
327
- return { code: "not_implemented", message: NOT_IMPLEMENTED_MESSAGE };
328
- }
329
- return error.details === undefined
330
- ? { code: error.code, message: error.message }
331
- : { code: error.code, message: error.message, details: error.details };
486
+ if (error instanceof ServerError || error instanceof RemoteServiceError) {
487
+ return { code: error.code, message: error.message };
332
488
  }
333
489
  if (error instanceof ProtocolValidationError) {
334
490
  return { code: "invalid_request", message: error.message };
@@ -336,6 +492,14 @@ export class PiServer {
336
492
  this.reportError(error);
337
493
  return { code: "internal_error", message: INTERNAL_SERVER_ERROR_MESSAGE };
338
494
  }
495
+ notifyConnectionCountChanged() {
496
+ try {
497
+ this.onConnectionCountChanged?.(this.connections.size);
498
+ }
499
+ catch (error) {
500
+ this.reportError(error);
501
+ }
502
+ }
339
503
  reportError(error) {
340
504
  try {
341
505
  this.onError?.(error instanceof Error ? error : new Error(String(error)));
@@ -344,21 +508,39 @@ export class PiServer {
344
508
  // Error observers cannot affect server state.
345
509
  }
346
510
  }
511
+ settleClosed(error) {
512
+ if (this.closedSettled)
513
+ return;
514
+ this.closedSettled = true;
515
+ if (error === undefined)
516
+ this.resolveClosed();
517
+ else
518
+ this.rejectClosed(error);
519
+ }
520
+ }
521
+ function sameTarget(left, right) {
522
+ if (left.serverId !== right.serverId)
523
+ return false;
524
+ if (!("sessionId" in left) || !("sessionId" in right)) {
525
+ return !("sessionId" in left) && !("sessionId" in right);
526
+ }
527
+ return left.sessionId === right.sessionId && left.attachmentId === right.attachmentId;
347
528
  }
348
529
  function resolveOptions(options) {
349
530
  if (!Array.isArray(options.listeners))
350
- throw new TypeError("PiServer listeners must be an array");
351
- if (options.serverId === "")
352
- throw new TypeError("PiServer serverId must not be empty");
531
+ throw new TypeError("Server listeners must be an array");
532
+ if (!isServerId(options.serverId)) {
533
+ throw new TypeError("serverId must be a canonical lowercase UUIDv4");
534
+ }
353
535
  const maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
354
536
  if (!Number.isSafeInteger(maxFrameLength) || maxFrameLength <= 0 || maxFrameLength > MAX_UINT32) {
355
- throw new TypeError(`PiServer maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
537
+ throw new TypeError(`Server maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
356
538
  }
357
539
  const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
358
540
  if (!Number.isSafeInteger(handshakeTimeoutMs) ||
359
541
  handshakeTimeoutMs <= 0 ||
360
542
  handshakeTimeoutMs > MAX_TIMER_DELAY_MS) {
361
- throw new TypeError(`PiServer handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
543
+ throw new TypeError(`Server handshakeTimeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
362
544
  }
363
545
  return { maxFrameLength, handshakeTimeoutMs };
364
546
  }