@rmrdeveloper/sideroom-pi 8.12.0 → 8.13.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.
Files changed (31) hide show
  1. package/README.md +16 -0
  2. package/assets/visual-context/browser/INSTALL.md +69 -0
  3. package/assets/visual-context/browser/background.js +578 -0
  4. package/assets/visual-context/browser/content.js +198 -0
  5. package/assets/visual-context/browser/icons/README.md +9 -0
  6. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  7. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  10. package/assets/visual-context/browser/manifest.json +20 -0
  11. package/assets/visual-context/browser/popup.css +19 -0
  12. package/assets/visual-context/browser/popup.html +27 -0
  13. package/assets/visual-context/browser/popup.js +141 -0
  14. package/extensions/visual-context/bridge.ts +240 -0
  15. package/extensions/visual-context/broker.ts +292 -0
  16. package/extensions/visual-context/envelope.ts +34 -0
  17. package/extensions/visual-context/index.ts +328 -0
  18. package/extensions/visual-context/literal.ts +34 -0
  19. package/extensions/visual-context/markup.ts +104 -0
  20. package/extensions/visual-context/match.ts +222 -0
  21. package/extensions/visual-context/model.ts +180 -0
  22. package/extensions/visual-context/pairing.ts +114 -0
  23. package/extensions/visual-context/parse-astro.ts +166 -0
  24. package/extensions/visual-context/parse-html.ts +72 -0
  25. package/extensions/visual-context/parse-jsx.ts +157 -0
  26. package/extensions/visual-context/parse-svelte.ts +128 -0
  27. package/extensions/visual-context/parse-vue.ts +133 -0
  28. package/extensions/visual-context/session.ts +192 -0
  29. package/extensions/visual-context/source.ts +197 -0
  30. package/extensions/visual-context/ui.ts +57 -0
  31. package/package.json +16 -7
@@ -0,0 +1,198 @@
1
+ (() => {
2
+ const INSPECTOR_KEY = '__sideroomVisualInspector';
3
+ if (globalThis[INSPECTOR_KEY]) {
4
+ return;
5
+ }
6
+ const MAX_TEXT = 160;
7
+ const MAX_CLASSES = 32;
8
+ const MAX_ANCESTORS = 4;
9
+ const BORDER = '#ffb340';
10
+ let active = false;
11
+ let root;
12
+ let box;
13
+ let caption;
14
+ let exitHint = 'Esc to exit';
15
+
16
+ function clean(text) {
17
+ return text
18
+ .split('')
19
+ .map((character) =>
20
+ character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127
21
+ ? ' '
22
+ : character,
23
+ )
24
+ .join('')
25
+ .replace(/\s+/gu, ' ')
26
+ .trim()
27
+ .slice(0, MAX_TEXT);
28
+ }
29
+
30
+ function describe(element) {
31
+ return {
32
+ tag: element.localName,
33
+ elementId: clean(element.id ?? ''),
34
+ classes: [...element.classList].slice(0, MAX_CLASSES).map(clean),
35
+ };
36
+ }
37
+
38
+ function visibleText(element) {
39
+ if (element.closest('input,textarea,select,[contenteditable]')) {
40
+ return '';
41
+ }
42
+ const clone = element.cloneNode(true);
43
+ for (const child of clone.querySelectorAll(
44
+ 'script,style,input,textarea,select,[contenteditable],[hidden]',
45
+ )) {
46
+ child.remove();
47
+ }
48
+ return clean(clone.textContent ?? '');
49
+ }
50
+
51
+ function selectedElement(event) {
52
+ return event
53
+ .composedPath()
54
+ .find((node) => node instanceof Element && node !== root);
55
+ }
56
+
57
+ function highlight(event) {
58
+ const element = selectedElement(event);
59
+ if (!active || !element || element === document.documentElement) {
60
+ return;
61
+ }
62
+ const bounds = element.getBoundingClientRect();
63
+ Object.assign(box.style, {
64
+ left: `${bounds.left}px`,
65
+ top: `${bounds.top}px`,
66
+ width: `${bounds.width}px`,
67
+ height: `${bounds.height}px`,
68
+ });
69
+ caption.textContent = `${element.localName} · click to attach · ${exitHint}`;
70
+ }
71
+
72
+ function intercept(event) {
73
+ if (active) {
74
+ event.preventDefault();
75
+ event.stopImmediatePropagation();
76
+ }
77
+ }
78
+
79
+ async function select(event) {
80
+ if (!active || !event.isTrusted) {
81
+ return;
82
+ }
83
+ intercept(event);
84
+ const element = selectedElement(event);
85
+ if (!element || element === root) {
86
+ return;
87
+ }
88
+ const ancestors = [];
89
+ let parent = element.parentElement;
90
+ while (parent && ancestors.length < MAX_ANCESTORS) {
91
+ ancestors.push(describe(parent));
92
+ parent = parent.parentElement;
93
+ }
94
+ const selection = {
95
+ id: crypto.randomUUID(),
96
+ page: `${location.origin}${location.pathname}`,
97
+ element: describe(element),
98
+ text: visibleText(element),
99
+ ancestors,
100
+ };
101
+ caption.textContent = 'Sending selection to Pi…';
102
+ try {
103
+ const response = await chrome.runtime.sendMessage({
104
+ action: 'selection',
105
+ selection,
106
+ });
107
+ if (!response.ok) {
108
+ throw new Error(response.message);
109
+ }
110
+ } catch (cause) {
111
+ if (caption) {
112
+ caption.textContent = cause.message;
113
+ }
114
+ }
115
+ }
116
+
117
+ function keydown(event) {
118
+ if (event.key === 'Escape') {
119
+ intercept(event);
120
+ stop();
121
+ chrome.runtime.sendMessage({ action: 'stopped' }).catch((cause) => {
122
+ console.error('Sideroom inspector could not report its exit:', cause);
123
+ });
124
+ }
125
+ }
126
+
127
+ function start(shortcut) {
128
+ stop();
129
+ exitHint = shortcut ? `Esc or ${shortcut} to exit` : 'Esc to exit';
130
+ active = true;
131
+ root = document.createElement('div');
132
+ root.setAttribute('data-sideroom-inspector', '');
133
+ Object.assign(root.style, {
134
+ position: 'fixed',
135
+ inset: '0',
136
+ zIndex: '2147483647',
137
+ pointerEvents: 'none',
138
+ });
139
+ const shadow = root.attachShadow({ mode: 'closed' });
140
+ box = document.createElement('div');
141
+ Object.assign(box.style, {
142
+ position: 'fixed',
143
+ boxSizing: 'border-box',
144
+ border: `2px solid ${BORDER}`,
145
+ background: 'rgba(255,179,64,.08)',
146
+ });
147
+ caption = document.createElement('div');
148
+ caption.setAttribute('role', 'status');
149
+ caption.setAttribute('aria-live', 'polite');
150
+ caption.textContent = `Select an element · ${exitHint}`;
151
+ Object.assign(caption.style, {
152
+ position: 'fixed',
153
+ bottom: '16px',
154
+ left: '16px',
155
+ maxWidth: 'calc(100vw - 32px)',
156
+ padding: '10px 14px',
157
+ background: '#131211',
158
+ color: '#f2ede3',
159
+ border: `1px solid ${BORDER}`,
160
+ font: '13px ui-monospace, monospace',
161
+ });
162
+ shadow.append(box, caption);
163
+ document.documentElement.append(root);
164
+ window.addEventListener('pointermove', highlight, true);
165
+ window.addEventListener('pointerdown', intercept, true);
166
+ window.addEventListener('mousedown', intercept, true);
167
+ window.addEventListener('click', select, true);
168
+ window.addEventListener('keydown', keydown, true);
169
+ window.addEventListener('pagehide', stop);
170
+ }
171
+
172
+ function stop() {
173
+ active = false;
174
+ root?.remove();
175
+ root = undefined;
176
+ box = undefined;
177
+ caption = undefined;
178
+ window.removeEventListener('pointermove', highlight, true);
179
+ window.removeEventListener('pointerdown', intercept, true);
180
+ window.removeEventListener('mousedown', intercept, true);
181
+ window.removeEventListener('click', select, true);
182
+ window.removeEventListener('keydown', keydown, true);
183
+ window.removeEventListener('pagehide', stop);
184
+ }
185
+
186
+ chrome.runtime.onMessage.addListener((message) => {
187
+ if (message.action === 'start') {
188
+ start(message.shortcut);
189
+ }
190
+ if (message.action === 'stop') {
191
+ stop();
192
+ }
193
+ if (message.action === 'receipt' && caption) {
194
+ caption.textContent = message.message;
195
+ }
196
+ });
197
+ globalThis[INSPECTOR_KEY] = { stop };
198
+ })();
@@ -0,0 +1,9 @@
1
+ # Browser icon
2
+
3
+ White `S` on opaque black, rendered with **Archivo Variable**, the text family
4
+ in Sideroom's `DESIGN.md` and `site/src/styles/site.css`. Weight: 700. Source:
5
+ `site/node_modules/@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2`.
6
+
7
+ The PNGs are 16, 32, 48 and 128 pixels. Each canvas uses `#000000`, a `#ffffff`
8
+ glyph at 86% of canvas size, and centers the glyph's actual ink bounds. These
9
+ are rendered font glyphs; the browser does not need the font installed.
@@ -0,0 +1,20 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Sideroom Visual Context",
4
+ "version": "1.1.0",
5
+ "description": "Select a page element and attach local source candidates to your Pi session.",
6
+ "minimum_chrome_version": "116",
7
+ "permissions": ["activeTab", "scripting", "storage", "alarms"],
8
+ "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" },
9
+ "background": { "service_worker": "background.js" },
10
+ "commands": {
11
+ "toggle-inspector": {
12
+ "suggested_key": "Alt+Shift+S",
13
+ "description": "Start or stop selecting elements for Pi"
14
+ }
15
+ },
16
+ "action": { "default_popup": "popup.html", "default_title": "Select an element for Pi", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png" } },
17
+ "content_security_policy": {
18
+ "extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* ws://localhost:*"
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ :root { color-scheme: dark; font-family: "Helvetica Neue", Arial, sans-serif; color: #f2ede3; background: #131211; }
2
+ * { box-sizing: border-box; }
3
+ body { width: 360px; margin: 0; padding: 24px; }
4
+ header { border-top: 2px solid #f2ede3; padding-top: 16px; }
5
+ header img { display: block; }
6
+ details { margin: 16px 0; }
7
+ summary { cursor: pointer; color: #a49d8f; font-size: 13px; }
8
+ h1 { font-size: 30px; line-height: 1.05; letter-spacing: -.035em; margin: 14px 0 18px; }
9
+ .intro, footer { color: #a49d8f; font-size: 13px; line-height: 1.6; }
10
+ label { display: block; margin: 22px 0 8px; font: 11px ui-monospace, monospace; }
11
+ input, select { width: 100%; background: #0b0a09; border: 1px solid #514b42; border-radius: 0; padding: 11px; color: #f2ede3; font: 12px ui-monospace, monospace; }
12
+ button { display: block; width: 100%; border: 1px solid #f2ede3; border-radius: 0; padding: 12px; margin-top: 10px; background: #f2ede3; color: #131211; font: 600 13px "Helvetica Neue", Arial, sans-serif; cursor: pointer; }
13
+ button:hover { background: #fff; }
14
+ button:disabled { opacity: .4; cursor: default; }
15
+ .secondary { background: transparent; border-color: #514b42; color: #a49d8f; }
16
+ .secondary:hover { background: #1b1917; }
17
+ :focus-visible { outline: 2px solid #ffb340; outline-offset: 3px; }
18
+ #status { min-height: 40px; margin: 18px 0; font: 12px/1.6 ui-monospace, monospace; color: #ffb340; overflow-wrap: anywhere; }
19
+ footer { border-top: 1px solid #514b42; margin-top: 22px; padding-top: 14px; font-size: 11px; }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Sideroom — Browser context</title>
7
+ <link rel="stylesheet" href="popup.css" />
8
+ <script src="popup.js" defer></script>
9
+ </head>
10
+ <body>
11
+ <header><img src="icons/icon-48.png" width="32" height="32" alt="Sideroom" /><h1>Point to the code.</h1></header>
12
+ <p class="intro">Select an element in this tab. Its source candidates appear in your Pi session.</p>
13
+ <details id="pairing-details" open><summary>Authorize Windows or WSL</summary><form id="pairing-form">
14
+ <label for="address">Pair once · address from Pi (F7)</label>
15
+ <input id="address" type="text" autocomplete="off" spellcheck="false" placeholder="ws://127.0.0.1:…" required />
16
+ <button id="connect" type="submit">Connect to Pi</button>
17
+ </form></details>
18
+ <p id="status" role="status" aria-live="polite">Checking connection…</p>
19
+ <label for="destination">Send selections to</label>
20
+ <select id="destination" disabled><option value="">Waiting for Pi…</option></select>
21
+ <button id="inspect" type="button" disabled>Select an element</button>
22
+ <p id="shortcut-hint">Escape exits selection. Change the shortcut in your browser's shortcuts page.</p>
23
+ <button id="disconnect" class="secondary" type="button" disabled>Disconnect</button>
24
+ <button id="forget" class="secondary" type="button" disabled>Forget authorizations</button>
25
+ <footer>Click attaches context. Send your instruction in Pi to begin.</footer>
26
+ </body>
27
+ </html>
@@ -0,0 +1,141 @@
1
+ const form = document.querySelector('#pairing-form');
2
+ const address = document.querySelector('#address');
3
+ const status = document.querySelector('#status');
4
+ const inspect = document.querySelector('#inspect');
5
+ const disconnect = document.querySelector('#disconnect');
6
+ const forget = document.querySelector('#forget');
7
+ const destination = document.querySelector('#destination');
8
+ const connect = document.querySelector('#connect');
9
+ // Edge keeps the Chromium shortcut page under its own scheme; every other
10
+ // Chromium browser the companion supports answers on chrome://.
11
+ const SHORTCUT_PAGE = navigator.userAgent.includes('Edg/')
12
+ ? 'edge://extensions/shortcuts'
13
+ : 'chrome://extensions/shortcuts';
14
+ let busy = false;
15
+ let pairingShown = false;
16
+
17
+ function showStatus(receipt) {
18
+ status.textContent = receipt.notice ?? receipt.message;
19
+ if (receipt.connected !== undefined) {
20
+ inspect.disabled = !receipt.connected || !receipt.selectedId;
21
+ disconnect.disabled = !receipt.connected;
22
+ }
23
+ if (receipt.address !== undefined) {
24
+ if (!pairingShown) {
25
+ document.querySelector('#pairing-details').open = !receipt.address;
26
+ pairingShown = true;
27
+ }
28
+ if (document.activeElement !== address) {
29
+ address.value = receipt.address;
30
+ }
31
+ forget.disabled = !receipt.address;
32
+ }
33
+ if (receipt.sessions) {
34
+ destination.replaceChildren();
35
+ const placeholder = document.createElement('option');
36
+ placeholder.value = '';
37
+ placeholder.textContent = receipt.sessions.length
38
+ ? 'Choose a Pi session…'
39
+ : 'Waiting for Pi…';
40
+ destination.append(placeholder);
41
+ for (const session of receipt.sessions) {
42
+ const option = document.createElement('option');
43
+ option.value = session.id;
44
+ option.textContent = `${session.environment} · ${session.name} · ${session.project}`;
45
+ destination.append(option);
46
+ }
47
+ destination.value = receipt.selectedId;
48
+ destination.disabled = !receipt.connected || !receipt.sessions.length;
49
+ }
50
+ }
51
+
52
+ async function request(message) {
53
+ const receipt = await chrome.runtime.sendMessage(message);
54
+ if (!receipt.ok) {
55
+ throw new Error(receipt.message);
56
+ }
57
+ return receipt;
58
+ }
59
+
60
+ form.addEventListener('submit', async (event) => {
61
+ event.preventDefault();
62
+ busy = true;
63
+ connect.disabled = true;
64
+ status.textContent = 'Connecting…';
65
+ try {
66
+ showStatus(await request({ action: 'connect', address: address.value }));
67
+ } catch (cause) {
68
+ showStatus({ message: cause.message, connected: false });
69
+ } finally {
70
+ busy = false;
71
+ connect.disabled = false;
72
+ }
73
+ });
74
+
75
+ destination.addEventListener('change', async () => {
76
+ busy = true;
77
+ try {
78
+ showStatus(await request({ action: 'choose', id: destination.value }));
79
+ } catch (cause) {
80
+ showStatus({ message: cause.message });
81
+ } finally {
82
+ busy = false;
83
+ }
84
+ });
85
+
86
+ inspect.addEventListener('click', async () => {
87
+ try {
88
+ await request({ action: 'inspect' });
89
+ window.close();
90
+ } catch (cause) {
91
+ showStatus({ message: cause.message });
92
+ }
93
+ });
94
+
95
+ for (const button of [disconnect, forget]) {
96
+ button.addEventListener('click', async () => {
97
+ busy = true;
98
+ try {
99
+ showStatus(
100
+ await request({ action: button === forget ? 'forget' : 'disconnect' }),
101
+ );
102
+ if (button === forget) {
103
+ address.value = '';
104
+ document.querySelector('#pairing-details').open = true;
105
+ }
106
+ } catch (cause) {
107
+ showStatus({ message: cause.message });
108
+ } finally {
109
+ busy = false;
110
+ }
111
+ });
112
+ }
113
+
114
+ async function refresh() {
115
+ if (busy) {
116
+ return;
117
+ }
118
+ try {
119
+ showStatus(await request({ action: 'status' }));
120
+ } catch (cause) {
121
+ showStatus({ message: cause.message, connected: false });
122
+ }
123
+ }
124
+ async function showShortcut() {
125
+ const hint = document.querySelector('#shortcut-hint');
126
+ try {
127
+ const commands = await chrome.commands.getAll();
128
+ const shortcut = commands.find(
129
+ (command) => command.name === 'toggle-inspector',
130
+ )?.shortcut;
131
+ hint.textContent = shortcut
132
+ ? `${shortcut} starts or stops selection. Escape also exits. Change it in ${SHORTCUT_PAGE}.`
133
+ : `No selection shortcut is assigned. Set one in ${SHORTCUT_PAGE}. Escape exits selection.`;
134
+ } catch (cause) {
135
+ hint.textContent = `Could not read the shortcut: ${cause.message}. Check ${SHORTCUT_PAGE}.`;
136
+ }
137
+ }
138
+ void showShortcut();
139
+ void refresh();
140
+ const poll = setInterval(refresh, 1_000);
141
+ window.addEventListener('pagehide', () => clearInterval(poll));
@@ -0,0 +1,240 @@
1
+ import { WebSocket } from 'ws';
2
+ import { type LocalBroker, startBroker } from './broker.ts';
3
+ import { parseEnvelope, send } from './envelope.ts';
4
+ import {
5
+ MESSAGE_TYPE,
6
+ PEER_ROLE,
7
+ PI_ORIGIN,
8
+ PROTOCOL_VERSION,
9
+ parseSelection,
10
+ type SessionDescriptor,
11
+ type VisualSelection,
12
+ } from './model.ts';
13
+ import { endpoint, hasCode, type PairingIdentity } from './pairing.ts';
14
+
15
+ const RECONNECT_MS = 1_000;
16
+ const HEARTBEAT_MS = 20_000;
17
+ const STARTUP_TIMEOUT_MS = 10_000;
18
+ export interface VisualBridge {
19
+ update(session: SessionDescriptor): void;
20
+ close(): Promise<void>;
21
+ }
22
+ interface SessionConnectionOptions {
23
+ readonly identity: PairingIdentity;
24
+ readonly session: SessionDescriptor;
25
+ readonly onSelection: (
26
+ selection: VisualSelection,
27
+ revision: string,
28
+ ) => {
29
+ readonly candidates: number;
30
+ };
31
+ readonly onError: (error: Error) => void;
32
+ }
33
+
34
+ export async function connectSession(
35
+ options: SessionConnectionOptions,
36
+ ): Promise<VisualBridge> {
37
+ let session = options.session;
38
+ let broker: LocalBroker | undefined;
39
+ let socket: WebSocket | undefined;
40
+ let retry: NodeJS.Timeout | undefined;
41
+ let heartbeat: NodeJS.Timeout | undefined;
42
+ let closed = false;
43
+ let authenticated = false;
44
+ let registered = false;
45
+ let resolveStartup: () => void;
46
+ let rejectStartup: (error: Error) => void;
47
+ const startup = new Promise<void>((resolve, reject) => {
48
+ resolveStartup = resolve;
49
+ rejectStartup = reject;
50
+ });
51
+ const report = (cause: unknown): void => {
52
+ if (closed) {
53
+ return;
54
+ }
55
+ const error =
56
+ cause instanceof Error
57
+ ? cause
58
+ : new Error('Cannot connect the Pi session.', { cause });
59
+ if (!registered) {
60
+ rejectStartup(error);
61
+ } else {
62
+ options.onError(error);
63
+ }
64
+ };
65
+ const register = (): void => {
66
+ if (socket && authenticated) {
67
+ send(socket, { type: MESSAGE_TYPE.register, session });
68
+ }
69
+ };
70
+ async function attach(): Promise<void> {
71
+ if (closed) {
72
+ return;
73
+ }
74
+ if (!broker) {
75
+ try {
76
+ const created = await startBroker({
77
+ identity: options.identity,
78
+ onError: options.onError,
79
+ });
80
+ if (closed) {
81
+ await created.close();
82
+ return;
83
+ }
84
+ broker = created;
85
+ } catch (cause) {
86
+ if (!hasCode(cause, 'EADDRINUSE')) {
87
+ report(cause);
88
+ scheduleRetry();
89
+ return;
90
+ }
91
+ }
92
+ }
93
+ if (closed) {
94
+ return;
95
+ }
96
+ const connection = new WebSocket(endpoint(options.identity), {
97
+ origin: PI_ORIGIN,
98
+ maxPayload: 32_768,
99
+ });
100
+ socket = connection;
101
+ authenticated = false;
102
+ const authenticationTimeout = setTimeout(() => {
103
+ report(new Error('The local browser bridge did not authenticate.'));
104
+ connection.terminate();
105
+ }, STARTUP_TIMEOUT_MS);
106
+ authenticationTimeout.unref();
107
+ connection.on('open', () =>
108
+ send(connection, {
109
+ type: MESSAGE_TYPE.authenticate,
110
+ role: PEER_ROLE.pi,
111
+ version: PROTOCOL_VERSION,
112
+ token: options.identity.token,
113
+ }),
114
+ );
115
+ connection.on('error', report);
116
+ connection.on('close', () => {
117
+ clearTimeout(authenticationTimeout);
118
+ clearInterval(heartbeat);
119
+ authenticated = false;
120
+ scheduleRetry();
121
+ });
122
+ connection.on('message', (bytes, binary) => {
123
+ try {
124
+ const message = parseEnvelope(bytes.toString(), binary);
125
+ if (
126
+ message.type === MESSAGE_TYPE.ready &&
127
+ message.version === PROTOCOL_VERSION
128
+ ) {
129
+ clearTimeout(authenticationTimeout);
130
+ authenticated = true;
131
+ heartbeat = setInterval(
132
+ () => send(connection, { type: MESSAGE_TYPE.ping }),
133
+ HEARTBEAT_MS,
134
+ );
135
+ heartbeat.unref();
136
+ register();
137
+ return;
138
+ }
139
+ if (
140
+ message.type === MESSAGE_TYPE.register &&
141
+ message.revision === session.revision
142
+ ) {
143
+ registered = true;
144
+ resolveStartup();
145
+ return;
146
+ }
147
+ if (message.type === MESSAGE_TYPE.error) {
148
+ report(new Error(String(message.message)));
149
+ return;
150
+ }
151
+ if (message.type !== MESSAGE_TYPE.deliver) {
152
+ return;
153
+ }
154
+ try {
155
+ if (closed || message.revision !== session.revision) {
156
+ throw new Error(
157
+ 'The Pi conversation changed. Select the element again.',
158
+ );
159
+ }
160
+ const receipt = options.onSelection(
161
+ parseSelection(message.selection),
162
+ session.revision,
163
+ );
164
+ send(connection, {
165
+ type: MESSAGE_TYPE.result,
166
+ requestId: message.requestId,
167
+ candidates: receipt.candidates,
168
+ });
169
+ } catch (cause) {
170
+ const error =
171
+ cause instanceof Error
172
+ ? cause
173
+ : new Error('Cannot receive browser selection.', { cause });
174
+ options.onError(error);
175
+ send(connection, {
176
+ type: MESSAGE_TYPE.result,
177
+ requestId: message.requestId,
178
+ error: error.message,
179
+ });
180
+ }
181
+ } catch (cause) {
182
+ report(cause);
183
+ }
184
+ });
185
+ }
186
+ function scheduleRetry(): void {
187
+ if (closed || retry) {
188
+ return;
189
+ }
190
+ retry = setTimeout(() => {
191
+ retry = undefined;
192
+ void attach().catch(report);
193
+ }, RECONNECT_MS);
194
+ retry.unref();
195
+ }
196
+ let closing: Promise<void> | undefined;
197
+ const bridge: VisualBridge = {
198
+ update(next) {
199
+ session = next;
200
+ register();
201
+ },
202
+ close() {
203
+ if (closing) {
204
+ return closing;
205
+ }
206
+ closed = true;
207
+ clearTimeout(retry);
208
+ clearInterval(heartbeat);
209
+ closing = (async () => {
210
+ if (socket && socket.readyState !== WebSocket.CLOSED) {
211
+ const disconnected = new Promise<void>((resolve) =>
212
+ socket?.once('close', resolve),
213
+ );
214
+ socket.terminate();
215
+ await disconnected;
216
+ }
217
+ await broker?.close();
218
+ })();
219
+ return closing;
220
+ },
221
+ };
222
+ const deadline = setTimeout(
223
+ () =>
224
+ rejectStartup(
225
+ new Error('The Pi session could not join the local browser bridge.'),
226
+ ),
227
+ STARTUP_TIMEOUT_MS,
228
+ );
229
+ deadline.unref();
230
+ void attach().catch(report);
231
+ try {
232
+ await startup;
233
+ return bridge;
234
+ } catch (cause) {
235
+ await bridge.close();
236
+ throw cause;
237
+ } finally {
238
+ clearTimeout(deadline);
239
+ }
240
+ }