Add docker attach support (#1377)
* Add attach mode * Better UI * Remove unnecessary message * extract + test the docker stream demux; reset terminal mode per session - Pull the Docker hijacked-stream decoder (HTTP header strip, chunked decode, 8-byte multiplexed frame demux + raw/TTY fallback) into a pure src/lib/server/docker-stream-core.ts with unit tests covering raw passthrough, stdout/stderr demux, frames split across socket events, a split header, chunked reassembly, and the raw-fallback path. - vite.config.ts now imports the core instead of carrying its own copy. server.js runs against ./build and can't import the TS core at runtime, so it keeps the inline copy with a note to keep the two in sync. - containers page: default a fresh terminal session to exec so a prior container's Attach choice (shared terminalMode) doesn't carry over. --------- Co-authored-by: Jarek Krochmalski <[email protected]>
This commit is contained in:
1 parent
560b21e03f
commit
0ca3b86f82
10 files changed
+688
-208
No files matched your search
@@ -0,0 +1,98 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
createDockerStreamState,
|
||||
decodeChunkedDockerBody,
|
||||
processDockerStreamChunk
|
||||
} from '../src/lib/server/docker-stream-core';
|
||||
|
||||
const HTTP_HEAD = 'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n';
|
||||
const HTTP_HEAD_CHUNKED = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n';
|
||||
|
||||
/** Build one 8-byte-framed multiplexed frame: type 1=stdout, 2=stderr. */
|
||||
function frame(type: number, payload: string): Buffer {
|
||||
const body = Buffer.from(payload, 'utf-8');
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt8(type, 0);
|
||||
header.writeUInt32BE(body.length, 4);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
describe('processDockerStreamChunk - raw (TTY) stream', () => {
|
||||
test('passes raw body through once HTTP headers are stripped', () => {
|
||||
const st = createDockerStreamState(false);
|
||||
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD + 'hello world'), st);
|
||||
expect(out.join('')).toBe('hello world');
|
||||
});
|
||||
|
||||
test('buffers until the full HTTP header terminator arrives', () => {
|
||||
const st = createDockerStreamState(false);
|
||||
// header split across two socket events - nothing emitted until \r\n\r\n seen
|
||||
expect(processDockerStreamChunk(Buffer.from('HTTP/1.1 200 OK\r\n'), st)).toEqual([]);
|
||||
const out = processDockerStreamChunk(Buffer.from('\r\nlate'), st);
|
||||
expect(out.join('')).toBe('late');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processDockerStreamChunk - multiplexed (non-TTY attach) stream', () => {
|
||||
test('demuxes stdout and stderr frames', () => {
|
||||
const st = createDockerStreamState(true);
|
||||
const data = Buffer.concat([Buffer.from(HTTP_HEAD), frame(1, 'out '), frame(2, 'err')]);
|
||||
expect(processDockerStreamChunk(data, st).join('')).toBe('out err');
|
||||
});
|
||||
|
||||
test('reassembles a frame split across two socket events', () => {
|
||||
const st = createDockerStreamState(true);
|
||||
const f = frame(1, 'hello');
|
||||
// deliver the header + first 2 payload bytes, then the rest
|
||||
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 8 + 2)]);
|
||||
const second = f.slice(8 + 2);
|
||||
expect(processDockerStreamChunk(first, st)).toEqual([]); // incomplete frame -> nothing yet
|
||||
expect(processDockerStreamChunk(second, st).join('')).toBe('hello');
|
||||
});
|
||||
|
||||
test('reassembles a frame whose 8-byte header itself is split', () => {
|
||||
const st = createDockerStreamState(true);
|
||||
const f = frame(2, 'x');
|
||||
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 4)]); // half the header
|
||||
const second = f.slice(4);
|
||||
expect(processDockerStreamChunk(first, st)).toEqual([]);
|
||||
expect(processDockerStreamChunk(second, st).join('')).toBe('x');
|
||||
});
|
||||
|
||||
test('falls back to raw when the framing is not valid multiplexed (proxy stripped it)', () => {
|
||||
const st = createDockerStreamState(true);
|
||||
// a byte-0 stream type of 5 is invalid -> raw fallback, and stays raw after
|
||||
const bogus = Buffer.concat([Buffer.from(HTTP_HEAD), Buffer.from([5, 0, 0, 0, 0, 0, 0, 1]), Buffer.from('raw')]);
|
||||
const out = processDockerStreamChunk(bogus, st);
|
||||
expect(out.join('')).toContain('raw');
|
||||
expect(st.multiplexed).toBe(false);
|
||||
// subsequent bytes now pass through raw
|
||||
expect(processDockerStreamChunk(Buffer.from('more'), st).join('')).toBe('more');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeChunkedDockerBody', () => {
|
||||
test('decodes a chunked body and drops the framing', () => {
|
||||
const st = createDockerStreamState(false);
|
||||
// header says chunked; body: "5\r\nhello\r\n"
|
||||
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhello\r\n'), st);
|
||||
expect(out.join('')).toBe('hello');
|
||||
});
|
||||
|
||||
test('reassembles a chunk whose data is split across two events', () => {
|
||||
const st = createDockerStreamState(false);
|
||||
processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhel'), st);
|
||||
const out = processDockerStreamChunk(Buffer.from('lo\r\n'), st);
|
||||
expect(out.join('')).toBe('hello');
|
||||
});
|
||||
|
||||
test('the 0-size terminating chunk ends the body', () => {
|
||||
const st = createDockerStreamState(false);
|
||||
st.headersStripped = true;
|
||||
st.isChunked = true;
|
||||
const out = decodeChunkedDockerBody(Buffer.from('3\r\nabc\r\n0\r\n\r\n'), st);
|
||||
expect(Buffer.concat(out).toString()).toBe('abc');
|
||||
expect(st.chunkEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user