@volter/teams 0.1.69 → 0.1.70

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 (2) hide show
  1. package/browser-channel.mjs +34 -16
  2. package/package.json +1 -1
@@ -104,14 +104,16 @@ export class BrowserChannel extends Emitter {
104
104
 
105
105
  /** Open a named stream; resolves once the machine accepts it, rejects with its refusal. */
106
106
  open(name, params = {}) {
107
+ if (this.closed || this.socket.readyState !== 1 || !this.hello) return Promise.reject(new Error('the machine channel is not connected'));
107
108
  const id = this.next; this.next += 2;
108
109
  const stream = new BrowserStream(this, id, name);
109
110
  this.streams.set(id, stream);
110
111
  return new Promise((resolve, reject) => {
111
112
  // The refusal listener leaves once the stream opens, so a close after that is the caller's.
112
- const refused = (reason) => reject(new Error(`${name} refused: ${reason ?? 'closed'}`));
113
+ const timer = setTimeout(() => stream.close('stream opening timed out'), 15_000);
114
+ const refused = (reason) => { clearTimeout(timer); reject(new Error(`${name} refused: ${reason ?? 'closed'}`)); };
113
115
  stream.on('close', refused);
114
- stream.once('opened', () => { stream.off('close', refused); resolve(stream); });
116
+ stream.once('opened', () => { clearTimeout(timer); stream.off('close', refused); resolve(stream); });
115
117
  this.send({ ch: id, t: 'open', name, params });
116
118
  });
117
119
  }
@@ -119,6 +121,30 @@ export class BrowserChannel extends Emitter {
119
121
  close() { this.socket.close(1000, 'closed'); }
120
122
  }
121
123
 
124
+ // The HTTP/WebSocket opening and the machine's hello are both required. A
125
+ // failed opening owns its cleanup; callers never inherit an abandoned socket.
126
+ function connectBrowserChannel(socket, channel, unreachable, closed) {
127
+ return new Promise((resolve, reject) => {
128
+ let settled = false;
129
+ const finish = error => {
130
+ if (settled) return;
131
+ settled = true; clearTimeout(timer);
132
+ channel.off('hello', hello);
133
+ socket.removeEventListener('open', opened); socket.removeEventListener('error', failed); socket.removeEventListener('close', ended);
134
+ if (error) { try { channel.close(); } finally { reject(error); } } else resolve(channel);
135
+ };
136
+ const opened = () => { try { socket.send(frame({ p: PROTOCOL, nv: NODE_PROTOCOL })); } catch (error) { finish(error); } };
137
+ const hello = value => finish(value.error ? new Error(value.error) : null);
138
+ const failed = () => finish(new Error(unreachable));
139
+ const ended = () => finish(new Error(closed));
140
+ const timer = setTimeout(() => finish(new Error('The machine channel did not connect before the deadline')), 15_000);
141
+ socket.addEventListener('open', opened, { once: true });
142
+ channel.on('hello', hello);
143
+ socket.addEventListener('error', failed, { once: true });
144
+ socket.addEventListener('close', ended, { once: true });
145
+ });
146
+ }
147
+
122
148
  /**
123
149
  * Machine `machineId` of team `teamId` on the Teams server at `origin`, reached with the
124
150
  * caller's own `token`. `WebSocketImpl` defaults to the browser's WebSocket.
@@ -127,12 +153,7 @@ export function openBrowserMachine({ origin, teamId, machineId, token }, { WebSo
127
153
  const url = `${String(origin).replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/machines/${encodeURIComponent(machineId)}/channel`;
128
154
  const socket = new WebSocketImpl(url, [CHANNEL_PROTOCOL, `bearer.${token}`]);
129
155
  const channel = new BrowserChannel(socket);
130
- return new Promise((resolve, reject) => {
131
- socket.addEventListener('open', () => socket.send(frame({ p: PROTOCOL, nv: NODE_PROTOCOL })), { once: true });
132
- channel.once('hello', (hello) => (hello.error ? reject(new Error(hello.error)) : resolve(channel)));
133
- socket.addEventListener('error', () => reject(new Error(`could not reach machine ${machineId} through ${origin}`)), { once: true });
134
- socket.addEventListener('close', () => reject(new Error('the server closed the machine channel')), { once: true });
135
- });
156
+ return connectBrowserChannel(socket, channel, `could not reach machine ${machineId} through ${origin}`, 'the server closed the machine channel');
136
157
  }
137
158
 
138
159
  /**
@@ -143,12 +164,7 @@ export function openBrowserSession({ origin, teamId, sessionId, token }, { WebSo
143
164
  const url = `${String(origin).replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(sessionId)}/channel`;
144
165
  const socket = new WebSocketImpl(url, [CHANNEL_PROTOCOL, `bearer.${token}`]);
145
166
  const channel = new BrowserChannel(socket);
146
- return new Promise((resolve, reject) => {
147
- socket.addEventListener('open', () => socket.send(frame({ p: PROTOCOL, nv: NODE_PROTOCOL })), { once: true });
148
- channel.once('hello', (hello) => (hello.error ? reject(new Error(hello.error)) : resolve(channel)));
149
- socket.addEventListener('error', () => reject(new Error(`could not reach session ${sessionId} through ${origin}`)), { once: true });
150
- socket.addEventListener('close', () => reject(new Error('the server closed the session channel')), { once: true });
151
- });
167
+ return connectBrowserChannel(socket, channel, `could not reach session ${sessionId} through ${origin}`, 'the server closed the session channel');
152
168
  }
153
169
 
154
170
  /**
@@ -172,8 +188,10 @@ export async function readBrowserMail(channel) {
172
188
  const stream = await channel.open('mail', { request: { op: 'history' } });
173
189
  return new Promise((resolve, reject) => {
174
190
  let answer = '';
175
- const timer = setTimeout(() => { stream.close(); reject(new Error('Mailbox read timed out')); }, 25_000);
176
- stream.on('data', chunk => { answer += chunk; if (answer.length > 8_000_000) { stream.close(); reject(new Error('Mailbox response too large')); } });
191
+ // Reject before closing: close emits synchronously and its parser must
192
+ // not turn a timed-out or oversized response into a successful receipt.
193
+ const timer = setTimeout(() => { reject(new Error('Mailbox read timed out')); stream.close('mailbox read timed out'); }, 25_000);
194
+ stream.on('data', chunk => { answer += chunk; if (answer.length > 8_000_000) { reject(new Error('Mailbox response too large')); stream.close('mailbox response too large'); } });
177
195
  stream.on('close', () => {
178
196
  clearTimeout(timer);
179
197
  try { const body = JSON.parse(answer); if (body.code !== 0) throw new Error(body.text || 'Mailbox unavailable'); resolve(body.rows); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",