@dieulc/pi-office-bridge 0.1.0 → 0.3.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/README.md +180 -97
- package/package.json +64 -60
- package/src/active-tools.ts +79 -0
- package/src/bridge-server.ts +283 -16
- package/src/index.ts +165 -16
- package/src/office-tools.ts +39 -238
- package/src/protocol.ts +5 -2
package/src/bridge-server.ts
CHANGED
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { WebSocketServer, WebSocket } from "ws";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
IncomingMessage,
|
|
13
|
+
Server as HttpServer,
|
|
14
|
+
ServerResponse,
|
|
15
|
+
} from "node:http";
|
|
12
16
|
import { createServer } from "node:http";
|
|
13
17
|
import type { AddressInfo } from "node:net";
|
|
14
18
|
|
|
15
19
|
import {
|
|
16
20
|
BRIDGE_PROTOCOL_VERSION,
|
|
21
|
+
CATALOG_VERSION,
|
|
22
|
+
LEGACY_V1_OPS,
|
|
23
|
+
OFFICE_CATALOG_BY_OP,
|
|
17
24
|
nextCallId,
|
|
18
25
|
parseClientMessage,
|
|
26
|
+
type BridgeCapability,
|
|
19
27
|
type ClientMessage,
|
|
20
28
|
type OfficeHostApp,
|
|
21
29
|
type ServerMessage,
|
|
@@ -31,11 +39,26 @@ export interface AttachedPane {
|
|
|
31
39
|
lastSeen: number;
|
|
32
40
|
model?: string;
|
|
33
41
|
provider?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Ops this pane advertised in `hello` (validated against the server catalog
|
|
44
|
+
* and normalized to `<host>.<op>` ids). `null` means a legacy pane that did
|
|
45
|
+
* not advertise an `ops` list — only the v1 op set is allowed for it.
|
|
46
|
+
*/
|
|
47
|
+
ops: readonly string[] | null;
|
|
48
|
+
/** Catalog version the pane derived its ops from, when advertised. */
|
|
49
|
+
catalogVersion: number | null;
|
|
50
|
+
/** Count of advertised ops dropped because they are unknown to this server. */
|
|
51
|
+
opsIgnoredCount: number;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export interface BridgeServerHandlers {
|
|
37
55
|
/** A user typed a prompt in the add-in sidebar. */
|
|
38
56
|
onUserMessage(text: string, pane: AttachedPane): void;
|
|
57
|
+
/**
|
|
58
|
+
* A pane attached or detached. Lets the host (Pi) refresh its status line
|
|
59
|
+
* immediately instead of waiting for the next session event.
|
|
60
|
+
*/
|
|
61
|
+
onPanesChanged?(panes: readonly AttachedPane[]): void;
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
export interface CallOfficeToolResult {
|
|
@@ -49,6 +72,16 @@ const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
|
49
72
|
const MAX_TEXT_CHARS = 50_000;
|
|
50
73
|
const MAX_DETAILS_BYTES = 1_000_000;
|
|
51
74
|
|
|
75
|
+
/** Extra browser origins for the /health CORS gate, from env (comma-separated). */
|
|
76
|
+
function parseExtraOrigins(): string[] {
|
|
77
|
+
const raw = process.env.PI_OFFICE_BRIDGE_ALLOWED_ORIGINS;
|
|
78
|
+
if (!raw) return [];
|
|
79
|
+
return raw
|
|
80
|
+
.split(/[,\s]+/u)
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter((s) => s.length > 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
interface PendingCall {
|
|
53
86
|
resolve(result: CallOfficeToolResult): void;
|
|
54
87
|
reject(error: Error): void;
|
|
@@ -56,10 +89,15 @@ interface PendingCall {
|
|
|
56
89
|
}
|
|
57
90
|
|
|
58
91
|
export class OfficeBridgeServer {
|
|
92
|
+
/** Capabilities advertised in `welcome` and `GET /health`. */
|
|
93
|
+
static readonly CAPABILITIES: readonly BridgeCapability[] = ["http-health"];
|
|
94
|
+
|
|
59
95
|
private readonly port: number;
|
|
60
96
|
private readonly handlers: BridgeServerHandlers;
|
|
61
97
|
private readonly serverName: string;
|
|
98
|
+
private readonly serverVersion: string;
|
|
62
99
|
private readonly piVersion: string | null;
|
|
100
|
+
private readonly startedAt = Date.now();
|
|
63
101
|
|
|
64
102
|
private httpServer: HttpServer | null = null;
|
|
65
103
|
private wss: WebSocketServer | null = null;
|
|
@@ -71,11 +109,13 @@ export class OfficeBridgeServer {
|
|
|
71
109
|
constructor(options: {
|
|
72
110
|
port: number;
|
|
73
111
|
serverName?: string;
|
|
112
|
+
serverVersion?: string;
|
|
74
113
|
piVersion?: string | null;
|
|
75
114
|
handlers: BridgeServerHandlers;
|
|
76
115
|
}) {
|
|
77
116
|
this.port = options.port;
|
|
78
117
|
this.serverName = options.serverName ?? "pi-office-bridge";
|
|
118
|
+
this.serverVersion = options.serverVersion ?? "unknown";
|
|
79
119
|
this.piVersion = options.piVersion ?? null;
|
|
80
120
|
this.handlers = options.handlers;
|
|
81
121
|
}
|
|
@@ -86,7 +126,9 @@ export class OfficeBridgeServer {
|
|
|
86
126
|
|
|
87
127
|
get actualPort(): number | null {
|
|
88
128
|
const addr = this.httpServer?.address();
|
|
89
|
-
return typeof addr === "object" && addr !== null
|
|
129
|
+
return typeof addr === "object" && addr !== null
|
|
130
|
+
? (addr as AddressInfo).port
|
|
131
|
+
: null;
|
|
90
132
|
}
|
|
91
133
|
|
|
92
134
|
/** Pane list copy (ordered by most recent connection first). */
|
|
@@ -98,8 +140,13 @@ export class OfficeBridgeServer {
|
|
|
98
140
|
start(): Promise<void> {
|
|
99
141
|
if (this.wss) return Promise.resolve();
|
|
100
142
|
|
|
101
|
-
const httpServer = createServer()
|
|
102
|
-
|
|
143
|
+
const httpServer = createServer((req, res) =>
|
|
144
|
+
this.handleHttpRequest(req, res),
|
|
145
|
+
);
|
|
146
|
+
const wss = new WebSocketServer({
|
|
147
|
+
server: httpServer,
|
|
148
|
+
maxPayload: 16 * 1024 * 1024,
|
|
149
|
+
});
|
|
103
150
|
|
|
104
151
|
this.httpServer = httpServer;
|
|
105
152
|
this.wss = wss;
|
|
@@ -153,6 +200,7 @@ export class OfficeBridgeServer {
|
|
|
153
200
|
pane.ws.close(1001, "bridge shutting down");
|
|
154
201
|
}
|
|
155
202
|
this.panes.length = 0;
|
|
203
|
+
this.notifyPanesChanged();
|
|
156
204
|
|
|
157
205
|
return new Promise((resolve) => {
|
|
158
206
|
wss.close(() => resolve());
|
|
@@ -184,18 +232,45 @@ export class OfficeBridgeServer {
|
|
|
184
232
|
);
|
|
185
233
|
}
|
|
186
234
|
|
|
235
|
+
const opId = `${host}.${op}`;
|
|
236
|
+
// Capability gate: a pane may only execute ops it advertised (or, for
|
|
237
|
+
// legacy panes that advertise nothing, only the v1 op set).
|
|
238
|
+
if (pane.ops !== null) {
|
|
239
|
+
if (!pane.ops.includes(opId)) {
|
|
240
|
+
return Promise.reject(
|
|
241
|
+
new Error(
|
|
242
|
+
`office-bridge: the attached ${host} pane does not advertise "${opId}" ` +
|
|
243
|
+
`(it supports ${pane.ops.length} ops). ` +
|
|
244
|
+
"Reload the pi-for-office add-in to enable this tool.",
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} else if (!LEGACY_V1_OPS.includes(opId)) {
|
|
249
|
+
return Promise.reject(
|
|
250
|
+
new Error(
|
|
251
|
+
`office-bridge: "${opId}" requires a newer add-in. ` +
|
|
252
|
+
"The attached pane is a legacy client (no capability list); update " +
|
|
253
|
+
"pi-for-office and reload the document to enable this tool.",
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
187
258
|
const id = nextCallId("tool");
|
|
188
259
|
const message: ServerMessage = {
|
|
189
260
|
type: "tool_call",
|
|
190
261
|
id,
|
|
191
|
-
tool:
|
|
262
|
+
tool: opId,
|
|
192
263
|
args,
|
|
193
264
|
};
|
|
194
265
|
|
|
195
266
|
return new Promise<CallOfficeToolResult>((resolve, reject) => {
|
|
196
267
|
const timer = setTimeout(() => {
|
|
197
268
|
this.pending.delete(id);
|
|
198
|
-
reject(
|
|
269
|
+
reject(
|
|
270
|
+
new Error(
|
|
271
|
+
`office-bridge: ${op} timed out after ${timeoutMs / 1000}s`,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
199
274
|
}, timeoutMs);
|
|
200
275
|
|
|
201
276
|
this.pending.set(id, { resolve, reject, timer });
|
|
@@ -217,7 +292,11 @@ export class OfficeBridgeServer {
|
|
|
217
292
|
if (!this.sendToPane(pane, message)) {
|
|
218
293
|
clearTimeout(timer);
|
|
219
294
|
this.pending.delete(id);
|
|
220
|
-
reject(
|
|
295
|
+
reject(
|
|
296
|
+
new Error(
|
|
297
|
+
"office-bridge: pane disconnected before the tool call was sent",
|
|
298
|
+
),
|
|
299
|
+
);
|
|
221
300
|
}
|
|
222
301
|
});
|
|
223
302
|
}
|
|
@@ -229,13 +308,164 @@ export class OfficeBridgeServer {
|
|
|
229
308
|
}
|
|
230
309
|
}
|
|
231
310
|
|
|
311
|
+
/* ── HTTP (health/diagnostics, loopback-only) ─────────────────────── */
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Browser origins allowed to read this loopback HTTP surface. The add-in
|
|
315
|
+
* task pane runs at these origins (dev Vite server + hosted GitHub Pages).
|
|
316
|
+
* Extend with the PI_OFFICE_BRIDGE_ALLOWED_ORIGINS env var
|
|
317
|
+
* (comma-separated) when the add-in is hosted elsewhere.
|
|
318
|
+
*/
|
|
319
|
+
private static readonly ALLOWED_ORIGINS: ReadonlySet<string> = new Set([
|
|
320
|
+
"https://localhost:3141",
|
|
321
|
+
"https://pi-excel.localhost",
|
|
322
|
+
"https://dieuluucanh.github.io",
|
|
323
|
+
...parseExtraOrigins(),
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
private static resolveAllowOrigin(req: IncomingMessage): string | null {
|
|
327
|
+
const origin = req.headers.origin;
|
|
328
|
+
if (typeof origin !== "string" || origin.trim().length === 0) {
|
|
329
|
+
// Non-browser client (curl / tests / server tooling) — no CORS gate.
|
|
330
|
+
return "*";
|
|
331
|
+
}
|
|
332
|
+
if (OfficeBridgeServer.ALLOWED_ORIGINS.has(origin)) return origin;
|
|
333
|
+
return null; // Unknown browser origin → omit header; browser blocks.
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Minimal loopback HTTP surface used by the add-in's "Test connection"
|
|
338
|
+
* probe and by curl. WebSocket upgrades are handled by `ws` at the server
|
|
339
|
+
* level; ordinary requests (GET /health, OPTIONS preflight) land here.
|
|
340
|
+
* The endpoint is unauthenticated but exposes only bridge metadata.
|
|
341
|
+
*/
|
|
342
|
+
private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
343
|
+
const urlRaw = req.url ?? "/";
|
|
344
|
+
let url: URL;
|
|
345
|
+
try {
|
|
346
|
+
url = new URL(urlRaw, "http://127.0.0.1");
|
|
347
|
+
} catch {
|
|
348
|
+
this.writeHttp(res, 400, { ok: false, error: "bad_request" }, req);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const allowOrigin = OfficeBridgeServer.resolveAllowOrigin(req);
|
|
352
|
+
|
|
353
|
+
if (req.method === "OPTIONS") {
|
|
354
|
+
this.writeHttp(res, 204, null, req, allowOrigin);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
359
|
+
const panes = this.attachedPanes().map((p) => ({
|
|
360
|
+
host: p.host,
|
|
361
|
+
paneId: p.paneId,
|
|
362
|
+
clientName: p.clientName,
|
|
363
|
+
connectedAt: p.connectedAt,
|
|
364
|
+
lastSeen: p.lastSeen,
|
|
365
|
+
model: p.model,
|
|
366
|
+
provider: p.provider,
|
|
367
|
+
ops: p.ops,
|
|
368
|
+
catalogVersion: p.catalogVersion,
|
|
369
|
+
opsIgnoredCount: p.opsIgnoredCount,
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
this.writeHttp(
|
|
373
|
+
res,
|
|
374
|
+
200,
|
|
375
|
+
{
|
|
376
|
+
ok: true,
|
|
377
|
+
service: this.serverName,
|
|
378
|
+
serverVersion: this.serverVersion,
|
|
379
|
+
capabilities: OfficeBridgeServer.CAPABILITIES,
|
|
380
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
381
|
+
piVersion: this.piVersion,
|
|
382
|
+
port: this.actualPort,
|
|
383
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
384
|
+
catalogVersion: CATALOG_VERSION,
|
|
385
|
+
panes,
|
|
386
|
+
},
|
|
387
|
+
req,
|
|
388
|
+
allowOrigin,
|
|
389
|
+
);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.writeHttp(
|
|
394
|
+
res,
|
|
395
|
+
404,
|
|
396
|
+
{ ok: false, error: "not_found" },
|
|
397
|
+
req,
|
|
398
|
+
allowOrigin,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private writeHttp(
|
|
403
|
+
res: ServerResponse,
|
|
404
|
+
status: number,
|
|
405
|
+
body: unknown,
|
|
406
|
+
_req?: IncomingMessage,
|
|
407
|
+
allowOrigin: string | null = "*",
|
|
408
|
+
): void {
|
|
409
|
+
const headers: Record<string, string> = {
|
|
410
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
411
|
+
"Cache-Control": "no-store",
|
|
412
|
+
// Loopback-only server. The Private-Network header keeps the probe
|
|
413
|
+
// working from the hosted GitHub Pages origin (public → localhost).
|
|
414
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
415
|
+
"Access-Control-Allow-Headers": "*",
|
|
416
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
417
|
+
};
|
|
418
|
+
if (allowOrigin !== null) {
|
|
419
|
+
headers["Access-Control-Allow-Origin"] = allowOrigin;
|
|
420
|
+
headers["Vary"] = "Origin";
|
|
421
|
+
}
|
|
422
|
+
res.writeHead(status, headers);
|
|
423
|
+
if (status === 204 || body === null) {
|
|
424
|
+
res.end();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
res.end(JSON.stringify(body));
|
|
428
|
+
}
|
|
429
|
+
|
|
232
430
|
/* ── Internals ─────────────────────────────────────────────────────── */
|
|
233
431
|
|
|
234
432
|
private findPane(host: OfficeHostApp): AttachedPane | null {
|
|
235
|
-
const sorted = [...this.panes].sort(
|
|
433
|
+
const sorted = [...this.panes].sort(
|
|
434
|
+
(a, b) => b.connectedAt - a.connectedAt,
|
|
435
|
+
);
|
|
236
436
|
return sorted.find((p) => p.host === host) ?? null;
|
|
237
437
|
}
|
|
238
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Normalize a pane's advertised ops against the server catalog: keep only
|
|
441
|
+
* ops that exist in the catalog and belong to the pane's host; return null
|
|
442
|
+
* when the pane advertised nothing (legacy client).
|
|
443
|
+
*/
|
|
444
|
+
private normalizeAdvertisedOps(
|
|
445
|
+
host: OfficeHostApp,
|
|
446
|
+
advertised: readonly string[] | undefined,
|
|
447
|
+
): readonly string[] | null {
|
|
448
|
+
if (advertised === undefined || advertised.length === 0) return null;
|
|
449
|
+
const known = new Set<string>();
|
|
450
|
+
for (const op of advertised) {
|
|
451
|
+
const entry = OFFICE_CATALOG_BY_OP.get(op);
|
|
452
|
+
if (entry && entry.host === host) known.add(op);
|
|
453
|
+
}
|
|
454
|
+
return known.size > 0 ? [...known] : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Count ops the pane advertised that this server dropped as unknown/host-mismatched. */
|
|
458
|
+
private countIgnoredOps(
|
|
459
|
+
host: OfficeHostApp,
|
|
460
|
+
advertised: readonly string[] | undefined,
|
|
461
|
+
): number {
|
|
462
|
+
if (advertised === undefined) return 0;
|
|
463
|
+
return advertised.filter((op) => {
|
|
464
|
+
const entry = OFFICE_CATALOG_BY_OP.get(op);
|
|
465
|
+
return entry === undefined || entry.host !== host;
|
|
466
|
+
}).length;
|
|
467
|
+
}
|
|
468
|
+
|
|
239
469
|
private handleConnection(ws: WebSocket): void {
|
|
240
470
|
let pane: AttachedPane | null = null;
|
|
241
471
|
|
|
@@ -272,6 +502,12 @@ export class OfficeBridgeServer {
|
|
|
272
502
|
clientName: msg.clientName,
|
|
273
503
|
connectedAt: Date.now(),
|
|
274
504
|
lastSeen: Date.now(),
|
|
505
|
+
// Normalize advertised ops against the server catalog: entries the
|
|
506
|
+
// server does not know (or that belong to a different host) are
|
|
507
|
+
// dropped and counted so the operator can see the mismatch.
|
|
508
|
+
ops: this.normalizeAdvertisedOps(msg.host, msg.ops),
|
|
509
|
+
catalogVersion: msg.catalogVersion ?? null,
|
|
510
|
+
opsIgnoredCount: this.countIgnoredOps(msg.host, msg.ops),
|
|
275
511
|
};
|
|
276
512
|
// A pane reconnecting replaces any older pane with the same paneId.
|
|
277
513
|
this.panes.splice(
|
|
@@ -286,7 +522,10 @@ export class OfficeBridgeServer {
|
|
|
286
522
|
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
287
523
|
piVersion: this.piVersion,
|
|
288
524
|
serverName: this.serverName,
|
|
525
|
+
serverVersion: this.serverVersion,
|
|
526
|
+
capabilities: [...OfficeBridgeServer.CAPABILITIES],
|
|
289
527
|
});
|
|
528
|
+
this.notifyPanesChanged();
|
|
290
529
|
break;
|
|
291
530
|
}
|
|
292
531
|
case "ping": {
|
|
@@ -333,7 +572,9 @@ export class OfficeBridgeServer {
|
|
|
333
572
|
// Only the pane that received the call may answer it.
|
|
334
573
|
const pane = this.panes.find((p) => p.ws === ws);
|
|
335
574
|
if (!pane) {
|
|
336
|
-
call.reject(
|
|
575
|
+
call.reject(
|
|
576
|
+
new Error("office-bridge: pane disconnected before answering"),
|
|
577
|
+
);
|
|
337
578
|
return;
|
|
338
579
|
}
|
|
339
580
|
|
|
@@ -341,25 +582,42 @@ export class OfficeBridgeServer {
|
|
|
341
582
|
this.pending.delete(msg.id);
|
|
342
583
|
|
|
343
584
|
if (!msg.ok) {
|
|
344
|
-
call.reject(
|
|
585
|
+
call.reject(
|
|
586
|
+
new Error(`office-bridge: ${msg.error ?? "office tool failed"}`),
|
|
587
|
+
);
|
|
345
588
|
return;
|
|
346
589
|
}
|
|
347
590
|
|
|
348
|
-
const text =
|
|
349
|
-
|
|
350
|
-
|
|
591
|
+
const text =
|
|
592
|
+
msg.text.length > MAX_TEXT_CHARS
|
|
593
|
+
? `${msg.text.slice(0, MAX_TEXT_CHARS)}\n…[truncated: ${msg.text.length - MAX_TEXT_CHARS} chars]`
|
|
594
|
+
: msg.text;
|
|
351
595
|
|
|
352
596
|
let details: unknown = msg.details;
|
|
353
597
|
if (details !== undefined) {
|
|
354
598
|
const bytes = Buffer.byteLength(JSON.stringify(details));
|
|
355
599
|
if (bytes > MAX_DETAILS_BYTES) {
|
|
356
|
-
details = {
|
|
600
|
+
details = {
|
|
601
|
+
truncated: true,
|
|
602
|
+
note: `details exceeded ${MAX_DETAILS_BYTES} bytes`,
|
|
603
|
+
};
|
|
357
604
|
}
|
|
358
605
|
}
|
|
359
606
|
|
|
360
607
|
call.resolve({ text, details });
|
|
361
608
|
}
|
|
362
609
|
|
|
610
|
+
/** Notify the host that the attached-pane set changed. Never throws. */
|
|
611
|
+
private notifyPanesChanged(): void {
|
|
612
|
+
try {
|
|
613
|
+
this.handlers.onPanesChanged?.(this.attachedPanes());
|
|
614
|
+
} catch (error) {
|
|
615
|
+
console.error(
|
|
616
|
+
`[office-bridge] onPanesChanged handler failed: ${String(error)}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
363
621
|
private detachPane(pane: AttachedPane): void {
|
|
364
622
|
const idx = this.panes.findIndex((p) => p === pane);
|
|
365
623
|
if (idx >= 0) this.panes.splice(idx, 1);
|
|
@@ -368,9 +626,15 @@ export class OfficeBridgeServer {
|
|
|
368
626
|
// reject them — tracked separately so we sweep all on disconnect).
|
|
369
627
|
for (const [id, call] of this.pending) {
|
|
370
628
|
clearTimeout(call.timer);
|
|
371
|
-
call.reject(
|
|
629
|
+
call.reject(
|
|
630
|
+
new Error(
|
|
631
|
+
"office-bridge: pane disconnected while the tool was running",
|
|
632
|
+
),
|
|
633
|
+
);
|
|
372
634
|
this.pending.delete(id);
|
|
373
635
|
}
|
|
636
|
+
|
|
637
|
+
this.notifyPanesChanged();
|
|
374
638
|
}
|
|
375
639
|
|
|
376
640
|
private startHeartbeat(): void {
|
|
@@ -387,7 +651,10 @@ export class OfficeBridgeServer {
|
|
|
387
651
|
}, HEARTBEAT_INTERVAL_MS);
|
|
388
652
|
}
|
|
389
653
|
|
|
390
|
-
private sendToPane(
|
|
654
|
+
private sendToPane(
|
|
655
|
+
pane: WebSocket | AttachedPane,
|
|
656
|
+
message: ServerMessage,
|
|
657
|
+
): boolean {
|
|
391
658
|
const ws = pane instanceof WebSocket ? pane : pane.ws;
|
|
392
659
|
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
393
660
|
try {
|