@earendil-works/pi-server 0.84.4 → 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 +2 -0
- 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/dist/server.js
CHANGED
|
@@ -1,61 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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,
|
|
5
|
-
import {
|
|
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
|
|
11
|
-
|
|
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(
|
|
29
|
+
constructor(host, options) {
|
|
24
30
|
const resolved = resolveOptions(options);
|
|
31
|
+
this.host = host;
|
|
25
32
|
this.listeners = options.listeners;
|
|
26
|
-
this.
|
|
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
|
|
31
|
-
|
|
38
|
+
this.sessions = new SessionRouter({
|
|
39
|
+
host,
|
|
40
|
+
serverId: this.serverId,
|
|
32
41
|
isClosing: () => this.closing,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
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("
|
|
58
|
+
return Promise.reject(new Error("Server is already started"));
|
|
55
59
|
if (this.startPromise)
|
|
56
|
-
return Promise.reject(new Error("
|
|
60
|
+
return Promise.reject(new Error("Server is already starting"));
|
|
57
61
|
if (this.closing)
|
|
58
|
-
return Promise.reject(new Error("
|
|
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
|
-
|
|
75
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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:
|
|
226
|
-
|
|
275
|
+
ok: false,
|
|
276
|
+
error: { code: "invalid_request", message: "Request ID is already active" },
|
|
227
277
|
});
|
|
278
|
+
return;
|
|
228
279
|
}
|
|
229
|
-
|
|
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:
|
|
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
|
-
|
|
384
|
+
this.disconnect(connection);
|
|
248
385
|
}
|
|
249
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
322
|
-
|
|
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("
|
|
351
|
-
if (options.serverId
|
|
352
|
-
throw new TypeError("
|
|
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(`
|
|
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(`
|
|
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
|
}
|