@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.
@@ -8,14 +8,22 @@
8
8
  */
9
9
 
10
10
  import { WebSocketServer, WebSocket } from "ws";
11
- import type { Server as HttpServer } from "node:http";
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 ? (addr as AddressInfo).port : 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
- const wss = new WebSocketServer({ server: httpServer, maxPayload: 16 * 1024 * 1024 });
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: `${host}.${op}`,
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(new Error(`office-bridge: ${op} timed out after ${timeoutMs / 1000}s`));
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(new Error("office-bridge: pane disconnected before the tool call was sent"));
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((a, b) => b.connectedAt - a.connectedAt);
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(new Error("office-bridge: pane disconnected before answering"));
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(new Error(`office-bridge: ${msg.error ?? "office tool failed"}`));
585
+ call.reject(
586
+ new Error(`office-bridge: ${msg.error ?? "office tool failed"}`),
587
+ );
345
588
  return;
346
589
  }
347
590
 
348
- const text = msg.text.length > MAX_TEXT_CHARS
349
- ? `${msg.text.slice(0, MAX_TEXT_CHARS)}\n…[truncated: ${msg.text.length - MAX_TEXT_CHARS} chars]`
350
- : msg.text;
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 = { truncated: true, note: `details exceeded ${MAX_DETAILS_BYTES} bytes` };
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(new Error("office-bridge: pane disconnected while the tool was running"));
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(pane: WebSocket | AttachedPane, message: ServerMessage): boolean {
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 {