@openchamber/web 1.9.2 → 1.9.3

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 (32) hide show
  1. package/dist/assets/ToolOutputDialog-iiUOHO3c.js +16 -0
  2. package/dist/assets/index-BZ8pfXBh.css +1 -0
  3. package/dist/assets/index-DEj7Q-1y.js +2 -0
  4. package/dist/assets/{main-BFP0Fw2a.js → main-Ba2uuSTQ.js} +119 -119
  5. package/dist/assets/{vendor-.bun-CjZZibdK.js → vendor-.bun-B34wtB0D.js} +39 -39
  6. package/dist/index.html +3 -3
  7. package/package.json +1 -1
  8. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  9. package/server/lib/fs/routes.js +48 -0
  10. package/server/lib/opencode/proxy.js +106 -2
  11. package/server/lib/quota/DOCUMENTATION.md +1 -0
  12. package/server/lib/quota/index.js +2 -1
  13. package/server/lib/quota/providers/copilot.js +1 -1
  14. package/server/lib/quota/providers/index.js +8 -0
  15. package/server/lib/quota/providers/minimax-cn-coding-plan.js +141 -15
  16. package/server/lib/quota/providers/minimax-coding-plan.js +139 -15
  17. package/server/lib/quota/providers/zhipuai.js +114 -0
  18. package/server/lib/terminal/DOCUMENTATION.md +41 -80
  19. package/server/lib/terminal/index.js +27 -8
  20. package/server/lib/terminal/output-replay-buffer.js +66 -0
  21. package/server/lib/terminal/output-replay-buffer.test.js +66 -0
  22. package/server/lib/terminal/runtime.js +107 -20
  23. package/server/lib/terminal/{input-ws-protocol.js → terminal-ws-protocol.js} +13 -11
  24. package/server/lib/terminal/{input-ws-protocol.test.js → terminal-ws-protocol.test.js} +39 -32
  25. package/server/opencode-proxy.test.js +83 -0
  26. package/server/proxy-headers.js +61 -0
  27. package/server/proxy-headers.test.js +58 -0
  28. package/dist/assets/ToolOutputDialog-DwlX_M_n.js +0 -16
  29. package/dist/assets/index-BQqVuvn2.js +0 -2
  30. package/dist/assets/index-CH1IFYgs.css +0 -1
  31. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +0 -44
  32. package/server/lib/quota/providers/minimax-shared.js +0 -136
@@ -2,8 +2,12 @@ import { WebSocketServer } from 'ws';
2
2
  import {
3
3
  TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
4
4
  TERMINAL_INPUT_WS_PATH,
5
+ TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
6
+ appendTerminalOutputReplayChunk,
7
+ createTerminalOutputReplayBuffer,
5
8
  createTerminalInputWsControlFrame,
6
9
  isRebindRateLimited,
10
+ listTerminalOutputReplayChunksSince,
7
11
  normalizeTerminalInputWsMessageToText,
8
12
  parseRequestPathname,
9
13
  pruneRebindTimestamps,
@@ -150,8 +154,10 @@ export function createTerminalRuntime({
150
154
  };
151
155
 
152
156
  const terminalSessions = new Map();
157
+ const terminalWsConnections = new Set();
153
158
  const MAX_TERMINAL_SESSIONS = 20;
154
159
  const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
160
+ const terminalRuntimeName = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
155
161
  const sanitizeTerminalEnv = (env) => {
156
162
  const next = { ...env };
157
163
  delete next.BASH_XTRACEFD;
@@ -159,13 +165,22 @@ export function createTerminalRuntime({
159
165
  delete next.ENV;
160
166
  return next;
161
167
  };
162
- const terminalInputCapabilities = {
168
+ const terminalTransportCapabilities = {
163
169
  input: {
164
170
  preferred: 'ws',
165
171
  transports: ['http', 'ws'],
166
172
  ws: {
167
173
  path: TERMINAL_INPUT_WS_PATH,
168
- v: 1,
174
+ v: 2,
175
+ enc: 'text+json-bin-control',
176
+ },
177
+ },
178
+ stream: {
179
+ preferred: 'ws',
180
+ transports: ['sse', 'ws'],
181
+ ws: {
182
+ path: TERMINAL_INPUT_WS_PATH,
183
+ v: 2,
169
184
  enc: 'text+json-bin-control',
170
185
  },
171
186
  },
@@ -189,13 +204,17 @@ export function createTerminalRuntime({
189
204
 
190
205
  terminalInputWsServer.on('connection', (socket) => {
191
206
  const connectionState = {
207
+ socket,
192
208
  boundSessionId: null,
193
209
  invalidFrames: 0,
194
210
  rebindTimestamps: [],
211
+ replayCursorBySession: new Map(),
195
212
  lastActivityAt: Date.now(),
196
213
  };
197
214
 
198
- sendTerminalInputWsControl(socket, { t: 'ok', v: 1 });
215
+ terminalWsConnections.add(connectionState);
216
+
217
+ sendTerminalInputWsControl(socket, { t: 'ok', v: 2 });
199
218
 
200
219
  const heartbeatInterval = setInterval(() => {
201
220
  if (socket.readyState !== 1) {
@@ -231,7 +250,7 @@ export function createTerminalRuntime({
231
250
  }
232
251
 
233
252
  if (controlMessage.t === 'p') {
234
- sendTerminalInputWsControl(socket, { t: 'po', v: 1 });
253
+ sendTerminalInputWsControl(socket, { t: 'po', v: 2 });
235
254
  return;
236
255
  }
237
256
 
@@ -268,9 +287,32 @@ export function createTerminalRuntime({
268
287
  return;
269
288
  }
270
289
 
290
+ const replaySinceRaw =
291
+ typeof controlMessage.r === 'number' && Number.isFinite(controlMessage.r)
292
+ ? Math.max(0, Math.trunc(controlMessage.r))
293
+ : 0;
294
+ const rememberedReplayCursor = connectionState.replayCursorBySession.get(nextSessionId) ?? 0;
295
+ const replaySince = Math.max(replaySinceRaw, rememberedReplayCursor);
296
+
271
297
  connectionState.rebindTimestamps.push(now);
272
298
  connectionState.boundSessionId = nextSessionId;
273
- sendTerminalInputWsControl(socket, { t: 'bok', v: 1 });
299
+ sendTerminalInputWsControl(socket, {
300
+ t: 'bok',
301
+ v: 2,
302
+ s: nextSessionId,
303
+ runtime: terminalRuntimeName,
304
+ ptyBackend: targetSession.ptyBackend || 'unknown',
305
+ });
306
+
307
+ const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince);
308
+ for (const replayChunk of replayChunks) {
309
+ try {
310
+ socket.send(replayChunk.data);
311
+ connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id);
312
+ } catch {
313
+ break;
314
+ }
315
+ }
274
316
  return;
275
317
  }
276
318
 
@@ -301,6 +343,8 @@ export function createTerminalRuntime({
301
343
 
302
344
  socket.on('close', () => {
303
345
  clearInterval(heartbeatInterval);
346
+ connectionState.boundSessionId = null;
347
+ terminalWsConnections.delete(connectionState);
304
348
  });
305
349
 
306
350
  socket.on('error', (error) => {
@@ -347,6 +391,56 @@ export function createTerminalRuntime({
347
391
  void handleUpgrade();
348
392
  });
349
393
 
394
+ const wireTerminalSession = (sessionId, session) => {
395
+ session.ptyProcess.onData((data) => {
396
+ session.lastActivity = Date.now();
397
+ const replayChunk = appendTerminalOutputReplayChunk(
398
+ session.outputReplayBuffer,
399
+ data,
400
+ TERMINAL_OUTPUT_REPLAY_MAX_BYTES
401
+ );
402
+
403
+ for (const wsConnection of terminalWsConnections) {
404
+ if (wsConnection.boundSessionId !== sessionId) {
405
+ continue;
406
+ }
407
+
408
+ if (!wsConnection.socket || wsConnection.socket.readyState !== 1) {
409
+ continue;
410
+ }
411
+
412
+ try {
413
+ wsConnection.socket.send(data);
414
+ if (replayChunk) {
415
+ wsConnection.replayCursorBySession.set(sessionId, replayChunk.id);
416
+ }
417
+ } catch {
418
+ }
419
+ }
420
+ });
421
+
422
+ session.ptyProcess.onExit(({ exitCode, signal }) => {
423
+ console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
424
+ for (const wsConnection of terminalWsConnections) {
425
+ if (wsConnection.boundSessionId !== sessionId) {
426
+ continue;
427
+ }
428
+
429
+ wsConnection.boundSessionId = null;
430
+ wsConnection.replayCursorBySession.delete(sessionId);
431
+ sendTerminalInputWsControl(wsConnection.socket, {
432
+ t: 'x',
433
+ v: 2,
434
+ s: sessionId,
435
+ exitCode,
436
+ signal,
437
+ });
438
+ }
439
+
440
+ terminalSessions.delete(sessionId);
441
+ });
442
+ };
443
+
350
444
  const idleSweepInterval = setInterval(() => {
351
445
  const now = Date.now();
352
446
  for (const [sessionId, session] of terminalSessions.entries()) {
@@ -399,17 +493,14 @@ export function createTerminalRuntime({
399
493
  cwd,
400
494
  lastActivity: Date.now(),
401
495
  clients: new Set(),
496
+ outputReplayBuffer: createTerminalOutputReplayBuffer(),
402
497
  };
403
498
 
404
499
  terminalSessions.set(sessionId, session);
405
-
406
- ptyProcess.onExit(({ exitCode, signal }) => {
407
- console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
408
- terminalSessions.delete(sessionId);
409
- });
500
+ wireTerminalSession(sessionId, session);
410
501
 
411
502
  console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`);
412
- res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
503
+ res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
413
504
  } catch (error) {
414
505
  console.error('Failed to create terminal session:', error);
415
506
  res.status(500).json({ error: error.message || 'Failed to create terminal session' });
@@ -433,9 +524,8 @@ export function createTerminalRuntime({
433
524
  session.clients.add(clientId);
434
525
  session.lastActivity = Date.now();
435
526
 
436
- const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
437
527
  const ptyBackend = session.ptyBackend || 'unknown';
438
- res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
528
+ res.write(`data: ${JSON.stringify({ type: 'connected', runtime: terminalRuntimeName, ptyBackend })}\n\n`);
439
529
 
440
530
  const heartbeatInterval = setInterval(() => {
441
531
  try {
@@ -501,7 +591,7 @@ export function createTerminalRuntime({
501
591
  req.on('close', cleanup);
502
592
  req.on('error', cleanup);
503
593
 
504
- console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
594
+ console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${terminalRuntimeName} pty=${ptyBackend}`);
505
595
  });
506
596
 
507
597
  app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
@@ -613,17 +703,14 @@ export function createTerminalRuntime({
613
703
  cwd,
614
704
  lastActivity: Date.now(),
615
705
  clients: new Set(),
706
+ outputReplayBuffer: createTerminalOutputReplayBuffer(),
616
707
  };
617
708
 
618
709
  terminalSessions.set(newSessionId, session);
619
-
620
- ptyProcess.onExit(({ exitCode, signal }) => {
621
- console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
622
- terminalSessions.delete(newSessionId);
623
- });
710
+ wireTerminalSession(newSessionId, session);
624
711
 
625
712
  console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`);
626
- res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
713
+ res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
627
714
  } catch (error) {
628
715
  console.error('Failed to restart terminal session:', error);
629
716
  res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
@@ -1,6 +1,6 @@
1
- export const TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
2
- export const TERMINAL_INPUT_WS_CONTROL_TAG_JSON = 0x01;
3
- export const TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
1
+ export const TERMINAL_WS_PATH = '/api/terminal/ws';
2
+ export const TERMINAL_WS_CONTROL_TAG_JSON = 0x01;
3
+ export const TERMINAL_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
4
4
 
5
5
  export const parseRequestPathname = (requestUrl) => {
6
6
  if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
@@ -14,7 +14,9 @@ export const parseRequestPathname = (requestUrl) => {
14
14
  }
15
15
  };
16
16
 
17
- export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
17
+ export const isTerminalWsPathname = (pathname) => pathname === TERMINAL_WS_PATH;
18
+
19
+ export const normalizeTerminalWsMessageToBuffer = (rawData) => {
18
20
  if (Buffer.isBuffer(rawData)) {
19
21
  return rawData;
20
22
  }
@@ -26,21 +28,21 @@ export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
26
28
  return Buffer.from(rawData);
27
29
  };
28
30
 
29
- export const normalizeTerminalInputWsMessageToText = (rawData) => {
31
+ export const normalizeTerminalWsMessageToText = (rawData) => {
30
32
  if (typeof rawData === 'string') {
31
33
  return rawData;
32
34
  }
33
35
 
34
- return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
36
+ return normalizeTerminalWsMessageToBuffer(rawData).toString('utf8');
35
37
  };
36
38
 
37
- export const readTerminalInputWsControlFrame = (rawData) => {
39
+ export const readTerminalWsControlFrame = (rawData) => {
38
40
  if (!rawData) {
39
41
  return null;
40
42
  }
41
43
 
42
- const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
43
- if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_WS_CONTROL_TAG_JSON) {
44
+ const buffer = normalizeTerminalWsMessageToBuffer(rawData);
45
+ if (buffer.length < 2 || buffer[0] !== TERMINAL_WS_CONTROL_TAG_JSON) {
44
46
  return null;
45
47
  }
46
48
 
@@ -55,9 +57,9 @@ export const readTerminalInputWsControlFrame = (rawData) => {
55
57
  }
56
58
  };
57
59
 
58
- export const createTerminalInputWsControlFrame = (payload) => {
60
+ export const createTerminalWsControlFrame = (payload) => {
59
61
  const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
60
- return Buffer.concat([Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]), jsonBytes]);
62
+ return Buffer.concat([Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]), jsonBytes]);
61
63
  };
62
64
 
63
65
  export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
@@ -1,86 +1,93 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
3
  import {
4
- TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
5
- TERMINAL_INPUT_WS_PATH,
6
- createTerminalInputWsControlFrame,
4
+ TERMINAL_WS_PATH,
5
+ TERMINAL_WS_CONTROL_TAG_JSON,
6
+ createTerminalWsControlFrame,
7
+ isTerminalWsPathname,
7
8
  isRebindRateLimited,
8
- normalizeTerminalInputWsMessageToBuffer,
9
- normalizeTerminalInputWsMessageToText,
9
+ normalizeTerminalWsMessageToBuffer,
10
+ normalizeTerminalWsMessageToText,
10
11
  parseRequestPathname,
11
12
  pruneRebindTimestamps,
12
- readTerminalInputWsControlFrame,
13
- } from './input-ws-protocol.js';
13
+ readTerminalWsControlFrame,
14
+ } from './terminal-ws-protocol.js';
14
15
 
15
- describe('terminal input websocket protocol', () => {
16
- it('uses fixed websocket path', () => {
17
- expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
16
+ describe('terminal websocket protocol', () => {
17
+ it('uses fixed websocket paths', () => {
18
+ expect(TERMINAL_WS_PATH).toBe('/api/terminal/ws');
19
+ });
20
+
21
+ it('matches supported websocket pathnames', () => {
22
+ expect(isTerminalWsPathname('/api/terminal/ws')).toBe(true);
23
+ expect(isTerminalWsPathname('/api/terminal/input-ws')).toBe(false);
24
+ expect(isTerminalWsPathname('/api/terminal/other')).toBe(false);
18
25
  });
19
26
 
20
27
  it('encodes control frames with control tag prefix', () => {
21
- const frame = createTerminalInputWsControlFrame({ t: 'ok', v: 1 });
22
- expect(frame[0]).toBe(TERMINAL_INPUT_WS_CONTROL_TAG_JSON);
28
+ const frame = createTerminalWsControlFrame({ t: 'ok', v: 1 });
29
+ expect(frame[0]).toBe(TERMINAL_WS_CONTROL_TAG_JSON);
23
30
  });
24
31
 
25
32
  it('roundtrips control frame payload', () => {
26
33
  const payload = { t: 'b', s: 'abc123', v: 1 };
27
- const frame = createTerminalInputWsControlFrame(payload);
28
- expect(readTerminalInputWsControlFrame(frame)).toEqual(payload);
34
+ const frame = createTerminalWsControlFrame(payload);
35
+ expect(readTerminalWsControlFrame(frame)).toEqual(payload);
29
36
  });
30
37
 
31
38
  it('rejects control frame without protocol tag', () => {
32
39
  const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
33
- expect(readTerminalInputWsControlFrame(frame)).toBeNull();
40
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
34
41
  });
35
42
 
36
43
  it('rejects malformed control json', () => {
37
44
  const frame = Buffer.concat([
38
- Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
45
+ Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
39
46
  Buffer.from('{not json', 'utf8'),
40
47
  ]);
41
- expect(readTerminalInputWsControlFrame(frame)).toBeNull();
48
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
42
49
  });
43
50
 
44
51
  it('rejects empty control payloads', () => {
45
- expect(readTerminalInputWsControlFrame(null)).toBeNull();
46
- expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
47
- expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
52
+ expect(readTerminalWsControlFrame(null)).toBeNull();
53
+ expect(readTerminalWsControlFrame(undefined)).toBeNull();
54
+ expect(readTerminalWsControlFrame(Buffer.alloc(0))).toBeNull();
48
55
  });
49
56
 
50
57
  it('rejects control json that is not object', () => {
51
58
  const frame = Buffer.concat([
52
- Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
59
+ Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
53
60
  Buffer.from('"str"', 'utf8'),
54
61
  ]);
55
- expect(readTerminalInputWsControlFrame(frame)).toBeNull();
62
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
56
63
  });
57
64
 
58
65
  it('parses control frame from chunk arrays', () => {
59
- const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
66
+ const frame = createTerminalWsControlFrame({ t: 'bok', v: 1 });
60
67
  const chunks = [frame.subarray(0, 2), frame.subarray(2)];
61
- expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
68
+ expect(readTerminalWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
62
69
  });
63
70
 
64
71
  it('normalizes buffer passthrough', () => {
65
72
  const raw = Buffer.from('abc', 'utf8');
66
- const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
73
+ const normalized = normalizeTerminalWsMessageToBuffer(raw);
67
74
  expect(normalized).toBe(raw);
68
75
  expect(normalized.toString('utf8')).toBe('abc');
69
76
  });
70
77
 
71
78
  it('normalizes uint8 arrays', () => {
72
- const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
79
+ const normalized = normalizeTerminalWsMessageToBuffer(new Uint8Array([97, 98, 99]));
73
80
  expect(normalized.toString('utf8')).toBe('abc');
74
81
  });
75
82
 
76
83
  it('normalizes array buffer payloads', () => {
77
84
  const source = new Uint8Array([97, 98, 99]).buffer;
78
- const normalized = normalizeTerminalInputWsMessageToBuffer(source);
85
+ const normalized = normalizeTerminalWsMessageToBuffer(source);
79
86
  expect(normalized.toString('utf8')).toBe('abc');
80
87
  });
81
88
 
82
89
  it('normalizes chunk array payloads', () => {
83
- const normalized = normalizeTerminalInputWsMessageToBuffer([
90
+ const normalized = normalizeTerminalWsMessageToBuffer([
84
91
  Buffer.from('ab', 'utf8'),
85
92
  Buffer.from('c', 'utf8'),
86
93
  ]);
@@ -88,19 +95,19 @@ describe('terminal input websocket protocol', () => {
88
95
  });
89
96
 
90
97
  it('normalizes text payload from string', () => {
91
- expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
98
+ expect(normalizeTerminalWsMessageToText('\u001b[A')).toBe('\u001b[A');
92
99
  });
93
100
 
94
101
  it('normalizes text payload from binary data', () => {
95
- expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
102
+ expect(normalizeTerminalWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
96
103
  });
97
104
 
98
105
  it('parses relative request pathname', () => {
99
- expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
106
+ expect(parseRequestPathname('/api/terminal/ws?x=1')).toBe('/api/terminal/ws');
100
107
  });
101
108
 
102
109
  it('parses absolute request pathname', () => {
103
- expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
110
+ expect(parseRequestPathname('http://localhost:3000/api/terminal/ws')).toBe('/api/terminal/ws');
104
111
  });
105
112
 
106
113
  it('returns empty pathname for non-string request url', () => {
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import express from 'express';
3
+ import path from 'path';
4
+
5
+ import { registerOpenCodeProxy } from './lib/opencode/proxy.js';
6
+
7
+ const listen = (app, host = '127.0.0.1') => new Promise((resolve, reject) => {
8
+ const server = app.listen(0, host, () => resolve(server));
9
+ server.once('error', reject);
10
+ });
11
+
12
+ const closeServer = (server) => new Promise((resolve, reject) => {
13
+ if (!server) {
14
+ resolve();
15
+ return;
16
+ }
17
+ server.close((error) => {
18
+ if (error) {
19
+ reject(error);
20
+ return;
21
+ }
22
+ resolve();
23
+ });
24
+ });
25
+
26
+ describe('OpenCode proxy SSE forwarding', () => {
27
+ let upstreamServer;
28
+ let proxyServer;
29
+
30
+ afterEach(async () => {
31
+ await closeServer(proxyServer);
32
+ await closeServer(upstreamServer);
33
+ proxyServer = undefined;
34
+ upstreamServer = undefined;
35
+ });
36
+
37
+ it('forwards event streams with nginx-safe headers', async () => {
38
+ let seenAuthorization = null;
39
+
40
+ const upstream = express();
41
+ upstream.get('/global/event', (req, res) => {
42
+ seenAuthorization = req.headers.authorization ?? null;
43
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
44
+ res.setHeader('Cache-Control', 'private, max-age=0');
45
+ res.setHeader('X-Upstream-Test', 'ok');
46
+ res.write('data: {"ok":true}\n\n');
47
+ res.end();
48
+ });
49
+ upstreamServer = await listen(upstream);
50
+ const upstreamPort = upstreamServer.address().port;
51
+
52
+ const app = express();
53
+ registerOpenCodeProxy(app, {
54
+ fs: {},
55
+ os: {},
56
+ path,
57
+ OPEN_CODE_READY_GRACE_MS: 0,
58
+ getRuntime: () => ({
59
+ openCodePort: upstreamPort,
60
+ isOpenCodeReady: true,
61
+ openCodeNotReadySince: 0,
62
+ isRestartingOpenCode: false,
63
+ }),
64
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
65
+ buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`,
66
+ ensureOpenCodeApiPrefix: () => {},
67
+ });
68
+ proxyServer = await listen(app);
69
+ const proxyPort = proxyServer.address().port;
70
+
71
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/api/global/event`, {
72
+ headers: { Accept: 'text/event-stream' },
73
+ });
74
+
75
+ expect(response.status).toBe(200);
76
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
77
+ expect(response.headers.get('cache-control')).toBe('no-cache');
78
+ expect(response.headers.get('x-accel-buffering')).toBe('no');
79
+ expect(response.headers.get('x-upstream-test')).toBe('ok');
80
+ expect(await response.text()).toBe('data: {"ok":true}\n\n');
81
+ expect(seenAuthorization).toBe('Bearer test-token');
82
+ });
83
+ });
@@ -0,0 +1,61 @@
1
+ const filteredRequestHeaders = new Set([
2
+ 'host',
3
+ 'connection',
4
+ 'content-length',
5
+ 'transfer-encoding',
6
+ 'keep-alive',
7
+ 'te',
8
+ 'trailer',
9
+ 'upgrade',
10
+ 'accept-encoding',
11
+ ]);
12
+
13
+ const filteredResponseHeaders = new Set([
14
+ 'connection',
15
+ 'content-length',
16
+ 'transfer-encoding',
17
+ 'keep-alive',
18
+ 'te',
19
+ 'trailer',
20
+ 'upgrade',
21
+ 'www-authenticate',
22
+ 'content-encoding',
23
+ ]);
24
+
25
+ export const collectForwardProxyHeaders = (requestHeaders, authHeaders = {}) => {
26
+ const headers = {};
27
+
28
+ for (const [key, value] of Object.entries(requestHeaders || {})) {
29
+ if (!value) continue;
30
+ const normalizedKey = key.toLowerCase();
31
+ if (filteredRequestHeaders.has(normalizedKey)) continue;
32
+ headers[normalizedKey] = Array.isArray(value) ? value.join(', ') : String(value);
33
+ }
34
+
35
+ if (authHeaders.Authorization) {
36
+ headers.Authorization = authHeaders.Authorization;
37
+ }
38
+
39
+ return headers;
40
+ };
41
+
42
+ export const shouldForwardProxyResponseHeader = (key) => {
43
+ if (typeof key !== 'string' || key.trim().length === 0) {
44
+ return false;
45
+ }
46
+
47
+ return !filteredResponseHeaders.has(key.toLowerCase());
48
+ };
49
+
50
+ export const applyForwardProxyResponseHeaders = (responseHeaders, response) => {
51
+ if (!responseHeaders || typeof response?.setHeader !== 'function') {
52
+ return;
53
+ }
54
+
55
+ for (const [key, value] of responseHeaders.entries()) {
56
+ if (!shouldForwardProxyResponseHeader(key)) {
57
+ continue;
58
+ }
59
+ response.setHeader(key, value);
60
+ }
61
+ };
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ applyForwardProxyResponseHeaders,
5
+ collectForwardProxyHeaders,
6
+ shouldForwardProxyResponseHeader,
7
+ } from './proxy-headers.js';
8
+
9
+ describe('OpenCode proxy header handling', () => {
10
+ it('drops accept-encoding from forwarded request headers', () => {
11
+ const headers = collectForwardProxyHeaders({
12
+ accept: 'application/json',
13
+ 'accept-encoding': 'gzip, deflate, br',
14
+ connection: 'keep-alive',
15
+ });
16
+
17
+ expect(headers.accept).toBe('application/json');
18
+ expect(headers['accept-encoding']).toBeUndefined();
19
+ });
20
+
21
+ it('drops content-encoding from forwarded response headers', () => {
22
+ expect(shouldForwardProxyResponseHeader('content-encoding')).toBe(false);
23
+ expect(shouldForwardProxyResponseHeader('Content-Encoding')).toBe(false);
24
+ });
25
+
26
+ it('drops transfer-encoding from forwarded response headers', () => {
27
+ expect(shouldForwardProxyResponseHeader('transfer-encoding')).toBe(false);
28
+ expect(shouldForwardProxyResponseHeader('Transfer-Encoding')).toBe(false);
29
+ });
30
+
31
+ it('still keeps ordinary response headers', () => {
32
+ expect(shouldForwardProxyResponseHeader('content-type')).toBe(true);
33
+ expect(shouldForwardProxyResponseHeader('etag')).toBe(true);
34
+ });
35
+
36
+ it('applies upstream response headers to express response without content-encoding', () => {
37
+ const applied = [];
38
+ const response = {
39
+ setHeader(key, value) {
40
+ applied.push([key, value]);
41
+ },
42
+ };
43
+
44
+ applyForwardProxyResponseHeaders(
45
+ new Headers({
46
+ 'content-type': 'application/json',
47
+ etag: 'W/"abc"',
48
+ 'content-encoding': 'gzip',
49
+ }),
50
+ response,
51
+ );
52
+
53
+ expect(applied).toEqual([
54
+ ['content-type', 'application/json'],
55
+ ['etag', 'W/"abc"'],
56
+ ]);
57
+ });
58
+ });