diff --git a/.npmignore b/.npmignore index 8d98f9d..56e3703 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,2 @@ .* +test/ diff --git a/README.md b/README.md index 7e5fdf0..06735b4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Options: --no-color run without color --passphrase [passphrase] specify a Client SSL Certificate Key's passphrase (--connect only). If you don't provide a value, it will be prompted for + --pretty format received JSON (Ctrl+T toggles during a session) --proxy <[protocol://]host[:port]> connect via a proxy. Proxy must support CONNECT method --slash enable slash commands for control frames (/ping [data], /pong [data], /close [code [, reason]]) (--connect only) @@ -58,6 +59,13 @@ Connected (press CTRL+C to quit) < are you a happy parrot? ``` +## JSON output + +Use `--pretty` to indent and colour received JSON. Press Ctrl+T to turn formatting +off or on during a session. Formatting starts off without `--pretty`. +Use `--no-color` for indentation only. Sent messages, binary messages, and piped +output stay unchanged. + ## License [MIT](LICENSE) diff --git a/bin/wscat b/bin/wscat index fb3d988..0227bde 100755 --- a/bin/wscat +++ b/bin/wscat @@ -4,13 +4,15 @@ const EventEmitter = require('events'); const fs = require('fs'); -const readline = require('readline'); const tty = require('tty'); const WebSocket = require('ws'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { program } = require('commander'); const { read } = require('read'); +const formatJson = require('../lib/format-json'); +const createReadline = require('../lib/pretty-readline'); + /** * InputReader - processes console input. * @@ -23,8 +25,12 @@ class Console extends EventEmitter { this.stdin = process.stdin; this.stdout = process.stdout; this.stderr = process.stderr; + this.pretty = !!programOptions.pretty; - this.readlineInterface = readline.createInterface(this.stdin, this.stdout); + this.readlineInterface = createReadline({ + input: this.stdin, + output: this.stdout + }); this.readlineInterface .on('line', (data) => { @@ -32,6 +38,14 @@ class Console extends EventEmitter { }) .on('close', () => { this.emit('close'); + }) + .on('togglePretty', () => { + this.pretty = !this.pretty; + this.print( + Console.Types.Control, + `JSON formatting ${this.pretty ? 'on' : 'off'}`, + Console.Colors.Green + ); }); this._resetInput = () => { @@ -61,6 +75,16 @@ class Console extends EventEmitter { this.readlineInterface.prompt(true); } + printMessage(data, isBinary) { + if (this.pretty && tty.isatty(1) && !isBinary) { + data = formatJson( + data.toString(), + programOptions.color && !programOptions.execute.length + ); + } + this.print(Console.Types.Incoming, data, Console.Colors.Blue); + } + print(type, msg, color) { if (tty.isatty(1)) { this.clear(); @@ -68,7 +92,9 @@ class Console extends EventEmitter { if (programOptions.execute.length) color = type = ''; else if (!programOptions.color) color = ''; - this.stdout.write(color + type + msg + Console.Colors.Default + '\n'); + const reset = color ? Console.Colors.Default : ''; + + this.stdout.write(color + type + msg + reset + '\n'); this.prompt(); } else if (type === Console.Types.Incoming) { this.stdout.write(msg + '\n'); @@ -129,6 +155,7 @@ program "specify a Client SSL Certificate Key's passphrase (--connect only). " + "If you don't provide a value, it will be prompted for" ) + .option('--pretty', 'format received JSON (Ctrl+T toggles during a session)') .option( '--proxy <[protocol://]host[:port]>', 'connect via a proxy. Proxy must support CONNECT method' @@ -236,8 +263,8 @@ if (programOptions.listen) { wsConsole.print(Console.Types.Error, err.message, Console.Colors.Yellow); }); - ws.on('message', (data) => { - wsConsole.print(Console.Types.Incoming, data, Console.Colors.Blue); + ws.on('message', (data, isBinary) => { + wsConsole.printMessage(data, isBinary); }); }); @@ -375,8 +402,8 @@ if (programOptions.listen) { process.exit(-1); }); - ws.on('message', (data) => { - wsConsole.print(Console.Types.Incoming, data, Console.Colors.Blue); + ws.on('message', (data, isBinary) => { + wsConsole.printMessage(data, isBinary); }); ws.on('ping', (data) => { diff --git a/lib/format-json.js b/lib/format-json.js new file mode 100644 index 0000000..1998767 --- /dev/null +++ b/lib/format-json.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Format valid JSON without changing numbers, keys, or string escapes. + * + * @param {String} text The received message + * @param {Boolean} colors Whether to add terminal colors + * @return {String} The formatted JSON or the original message + */ +function formatJson(text, colors) { + try { + JSON.parse(text); + } catch (err) { + return text; + } + + // Validate first, then keep the original tokens to avoid rounding numbers. + const tokens = text.match(/"(?:\\.|[^"\\])*"|[{}[\],:]|[^\s{}[\],:]+/g); + let depth = 0; + + return tokens + .map((token, i) => { + switch (token) { + case '{': + case '[': + depth++; + return ( + token + + (tokens[i + 1] === '}' || tokens[i + 1] === ']' + ? '' + : '\n' + ' '.repeat(depth)) + ); + case '}': + case ']': + depth--; + return ( + (tokens[i - 1] === '{' || tokens[i - 1] === '[' + ? '' + : '\n' + ' '.repeat(depth)) + token + ); + case ',': + return ',\n' + ' '.repeat(depth); + case ':': + return ': '; + default: { + if (!colors) return token; + + let color = 33; + + if (token[0] === '"') color = tokens[i + 1] === ':' ? 36 : 32; + else if (token === 'null') color = 90; + else if (token === 'true' || token === 'false') color = 35; + + return `\u001b[${color}m${token}\u001b[39m`; + } + } + }) + .join(''); +} + +module.exports = formatJson; diff --git a/lib/pretty-readline.js b/lib/pretty-readline.js new file mode 100644 index 0000000..8bcb0b0 --- /dev/null +++ b/lib/pretty-readline.js @@ -0,0 +1,58 @@ +'use strict'; + +const readline = require('readline'); +const { PassThrough } = require('stream'); + +/** + * Create a readline interface with a Ctrl+T formatting toggle. + * + * @param {Object} options The readline options + * @return {readline.Interface} The readline interface + */ +function createInterface(options) { + const source = options.input; + + if (!source.isTTY || !options.output.isTTY || options.terminal === false) { + return readline.createInterface(options); + } + + // Dispatch keys through write() so Ctrl+T does not edit the current line. + const input = new PassThrough(); + + input.isRaw = source.isRaw; + input.setRawMode = (mode) => { + if (source.setRawMode) source.setRawMode(mode); + input.isRaw = source.isRaw; + return input; + }; + + const rl = readline.createInterface({ ...options, input }); + const onKeypress = (data, key = {}) => { + if (key.ctrl && !key.meta && key.name === 't') rl.emit('togglePretty'); + else rl.write(data, key); + }; + const onEnd = () => input.end(); + const onError = (err) => input.destroy(err); + const onPause = () => source.pause(); + const onResume = () => source.resume(); + + readline.emitKeypressEvents(source); + source.on('keypress', onKeypress); + source.on('end', onEnd); + source.on('error', onError); + rl.on('pause', onPause); + rl.on('resume', onResume); + rl.once('close', () => { + source.removeListener('keypress', onKeypress); + source.removeListener('end', onEnd); + source.removeListener('error', onError); + rl.removeListener('pause', onPause); + rl.removeListener('resume', onResume); + input.destroy(); + }); + source.resume(); + + return rl; +} + +module.exports = createInterface; diff --git a/package.json b/package.json index 972a235..a6b9b53 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "WebSocket cat", "main": "index.js", "scripts": { - "test": "echo \"No test specified\"" + "test": "node --test" }, "repository": { "type": "git", diff --git a/test/format-json.test.js b/test/format-json.test.js new file mode 100644 index 0000000..fb27d7f --- /dev/null +++ b/test/format-json.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const assert = require('assert'); +const { test } = require('node:test'); +const { stripVTControlCharacters } = require('util'); + +const formatJson = require('../lib/format-json'); + +test('JSON formatting indents nested objects and arrays with two spaces', () => { + const input = '{"result":{"items":[1,{"ok":true},[],{}]},"empty":{}}'; + const expected = `{ + "result": { + "items": [ + 1, + { + "ok": true + }, + [], + {} + ] + }, + "empty": {} +}`; + + assert.strictEqual(formatJson(input, false), expected); + assert.strictEqual(formatJson(expected, false), expected); +}); + +test('JSON formatting preserves large numbers, duplicate keys and key order', () => { + const input = + '{"2":9007199254740993,"1":-0,"2":1.2300e+400,"n":-0.000001E-1000}'; + + assert.strictEqual( + formatJson(input, false), + '{\n "2": 9007199254740993,\n "1": -0,\n' + + ' "2": 1.2300e+400,\n "n": -0.000001E-1000\n}' + ); +}); + +test('JSON strings keep escapes, Unicode, spaces and punctuation intact', () => { + const input = String.raw`{"text":"héllo 世界 🍎 [{}],: true 12","quote":"a\"b\\c","escape":"\u001b[31m\n\t\u0061"}`; + const expected = [ + '{', + ' "text": "héllo 世界 🍎 [{}],: true 12",', + String.raw` "quote": "a\"b\\c",`, + String.raw` "escape": "\u001b[31m\n\t\u0061"`, + '}' + ].join('\n'); + + assert.strictEqual(formatJson(input, false), expected); + assert.strictEqual( + stripVTControlCharacters(formatJson(input, true)), + expected + ); +}); + +test('JSON formatting supports empty containers and scalar values', () => { + for (const input of [ + '{}', + '[]', + '42', + '-0', + 'true', + 'false', + 'null', + '"text"' + ]) { + assert.strictEqual(formatJson(' \r\n' + input + '\t ', false), input); + } +}); + +test('invalid JSON and other messages stay unchanged', () => { + for (const input of ['', ' ', 'hello', '{oops}', '[1,]', '{}{}', 'null\0']) { + assert.strictEqual(formatJson(input, false), input); + assert.strictEqual(formatJson(input, true), input); + } +}); + +test('JSON colors distinguish keys, strings, numbers, booleans and null', () => { + const input = '{"key":"text","n":12,"yes":true,"no":false,"nil":null}'; + const result = formatJson(input, true); + + for (const [token, color] of [ + ['"key"', 36], + ['"text"', 32], + ['12', 33], + ['true', 35], + ['false', 35], + ['null', 90] + ]) { + assert.ok(result.includes(`\u001b[${color}m${token}\u001b[39m`)); + } + assert.strictEqual( + stripVTControlCharacters(result), + formatJson(input, false) + ); +}); diff --git a/test/pretty-cli.test.js b/test/pretty-cli.test.js new file mode 100644 index 0000000..af24d57 --- /dev/null +++ b/test/pretty-cli.test.js @@ -0,0 +1,300 @@ +'use strict'; + +const assert = require('assert'); +const { spawn } = require('child_process'); +const { once } = require('events'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { test } = require('node:test'); +const { stripVTControlCharacters } = require('util'); +const WebSocket = require('ws'); + +const root = path.resolve(__dirname, '..'); + +function createFixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wscat-cli-')); + const preload = path.join(directory, 'terminal.js'); + + // Exercise readline's terminal handling using portable child-process pipes. + fs.writeFileSync( + preload, + "process.stdin.isTTY = process.env.TEST_INPUT_TTY === '1';\n" + + "process.stdout.isTTY = process.env.TEST_OUTPUT_TTY === '1';\n" + + "require('tty').isatty = () => process.stdout.isTTY;\n" + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + + return { + directory, + preload + }; +} + +async function createServer(t, host = '127.0.0.1') { + const server = new WebSocket.Server({ port: 0, host }); + + server.on('connection', (socket) => socket.send('ready')); + t.after(() => { + for (const socket of server.clients) socket.terminate(); + return new Promise((resolve) => server.close(resolve)); + }); + await once(server, 'listening'); + return server; +} + +function start(t, fixture, args, options = {}) { + const child = spawn( + process.execPath, + ['--require', fixture.preload, path.join(root, 'bin', 'wscat'), ...args], + { + cwd: fixture.directory, + env: { + ...process.env, + HOME: fixture.directory, + USERPROFILE: fixture.directory, + TEST_INPUT_TTY: options.inputTTY === false ? '0' : '1', + TEST_OUTPUT_TTY: options.outputTTY === false ? '0' : '1' + }, + stdio: ['pipe', 'pipe', 'pipe'] + } + ); + const session = { + child, + output: '', + errors: '', + exited: once(child, 'exit') + }; + + child.stdout.on('data', (data) => { + session.output += data; + }); + child.stderr.on('data', (data) => { + session.errors += data; + }); + t.after(async () => { + child.kill(); + await session.exited; + }); + + return session; +} + +async function waitForOutput(session, text) { + while (!session.output.includes(text)) { + await Promise.race([ + once(session.child.stdout, 'data'), + session.exited.then(() => { + throw new Error(`wscat exited before ${text}: ${session.errors}`); + }) + ]); + } +} + +async function send(session, socket, input) { + const message = once(socket, 'message'); + + session.child.stdin.write(input); + return (await message)[0].toString(); +} + +for (const colors of [true, false]) { + test( + `--pretty starts enabled with colors ${colors ? 'on' : 'off'}`, + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const server = await createServer(t); + const session = start(t, fixture, [ + '--pretty', + ...(colors ? [] : ['--no-color']), + '-c', + `ws://127.0.0.1:${server.address().port}` + ]); + + await waitForOutput(session, 'ready'); + + const socket = [...server.clients][0]; + const payload = '{"id":9007199254740993,"ok":true}'; + + session.output = ''; + socket.send(payload); + await waitForOutput(session, '\n}'); + assert.ok( + stripVTControlCharacters(session.output).includes( + '< {\n "id": 9007199254740993,\n "ok": true\n}' + ) + ); + if (colors) { + assert.ok(session.output.includes('\u001b[36m"id"\u001b[39m')); + assert.ok(session.output.includes('\u001b[35mtrue\u001b[39m')); + } else { + assert.doesNotMatch(session.output, /\u001b\[\d+m/); + } + + session.child.stdin.write('\x14'); + await waitForOutput(session, 'JSON formatting off'); + session.output = ''; + socket.send(payload); + await waitForOutput(session, payload); + assert.ok(session.output.includes('< ' + payload)); + assert.strictEqual(session.errors, ''); + } + ); +} + +test( + 'Ctrl+T enables formatting without --pretty and preserves the draft and cursor', + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const server = await createServer(t); + const session = start(t, fixture, [ + '-c', + `ws://127.0.0.1:${server.address().port}` + ]); + + await waitForOutput(session, 'ready'); + + const socket = [...server.clients][0]; + const payload = '{"reply":true}'; + + session.output = ''; + socket.send(payload); + await waitForOutput(session, payload); + assert.ok(session.output.includes('< ' + payload)); + session.child.stdin.write('{"kept":""}\x1b[D\x1b[D\x14'); + await waitForOutput(session, 'JSON formatting on'); + session.output = ''; + socket.send(payload); + await waitForOutput(session, '\n}'); + assert.ok( + stripVTControlCharacters(session.output).includes( + '< {\n "reply": true\n}' + ) + ); + + session.child.stdin.write('\x14'); + await waitForOutput(session, 'JSON formatting off'); + assert.strictEqual( + await send(session, socket, 'value\r'), + '{"kept":"value"}' + ); + assert.deepStrictEqual(fs.readdirSync(fixture.directory), ['terminal.js']); + assert.strictEqual(session.errors, ''); + } +); + +test( + '--pretty leaves piped output unchanged', + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const server = await createServer(t); + const session = start( + t, + fixture, + ['--pretty', '-c', `ws://127.0.0.1:${server.address().port}`], + { outputTTY: false } + ); + + await waitForOutput(session, 'ready'); + + const payload = ' {"id":9007199254740993,"ok":true} '; + + session.output = ''; + [...server.clients][0].send(payload); + await waitForOutput(session, payload); + assert.strictEqual(session.output, payload + '\n'); + } +); + +test( + '--pretty keeps non-JSON and binary messages unchanged', + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const server = await createServer(t); + const session = start(t, fixture, [ + '--pretty', + '-c', + `ws://127.0.0.1:${server.address().port}` + ]); + + await waitForOutput(session, 'ready'); + + const socket = [...server.clients][0]; + + for (const payload of [ + 'plain text', + '{"broken":}', + Buffer.from('{"binary":true}') + ]) { + session.output = ''; + socket.send(payload); + await waitForOutput(session, payload.toString()); + assert.ok(session.output.includes('< ' + payload)); + } + } +); + +test( + '--pretty formats --execute replies without color codes', + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const server = await createServer(t); + const session = start(t, fixture, [ + '--pretty', + '--execute', + 'request', + '--wait', + '-1', + '-c', + `ws://127.0.0.1:${server.address().port}` + ]); + + await waitForOutput(session, 'ready'); + session.output = ''; + [...server.clients][0].send('{"ok":true}'); + await waitForOutput(session, '\n}'); + assert.ok( + stripVTControlCharacters(session.output).includes('{\n "ok": true\n}') + ); + assert.doesNotMatch(session.output, /\u001b\[\d+m|< /); + } +); + +test( + '--pretty and Ctrl+T work in listen mode', + { timeout: 10000 }, + async (t) => { + const fixture = createFixture(t); + const reservation = await createServer(t); + const port = reservation.address().port; + + await new Promise((resolve) => reservation.close(resolve)); + + const session = start(t, fixture, ['--pretty', '--listen', String(port)]); + + await waitForOutput(session, 'Listening'); + + const peer = new WebSocket(`ws://127.0.0.1:${port}`); + + t.after(() => peer.terminate()); + await once(peer, 'open'); + await waitForOutput(session, 'Client connected'); + session.output = ''; + peer.send('{"ok":true}'); + await waitForOutput(session, '\n}'); + assert.ok( + stripVTControlCharacters(session.output).includes('< {\n "ok": true\n}') + ); + session.child.stdin.write('\x14'); + await waitForOutput(session, 'JSON formatting off'); + session.output = ''; + peer.send('{"ok":false}'); + await waitForOutput(session, '{"ok":false}'); + assert.ok(session.output.includes('< {"ok":false}')); + } +); diff --git a/test/pretty-readline.test.js b/test/pretty-readline.test.js new file mode 100644 index 0000000..08ec416 --- /dev/null +++ b/test/pretty-readline.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const assert = require('assert'); +const { once } = require('events'); +const { PassThrough } = require('stream'); +const { test } = require('node:test'); + +const createReadline = require('../lib/pretty-readline'); + +function createSession(t, history = [], terminal = true) { + const input = new PassThrough(); + const output = new PassThrough(); + const modes = []; + + input.isTTY = output.isTTY = terminal; + input.setRawMode = (mode) => { + input.isRaw = mode; + modes.push(mode); + }; + + const rl = createReadline({ input, output, history: history.slice() }); + const lines = []; + + rl.on('line', (line) => lines.push(line)); + output.resume(); + t.after(() => { + rl.close(); + input.destroy(); + output.destroy(); + }); + + return { input, output, rl, lines, modes }; +} + +test('Ctrl+T toggles without editing or submitting the current draft', (t) => { + const { input, rl, lines } = createSession(t); + let toggles = 0; + + rl.on('togglePretty', () => toggles++); + input.write('draft\x1b[D\x14\x14'); + assert.strictEqual(toggles, 2); + assert.strictEqual(rl.line, 'draft'); + assert.strictEqual(rl.cursor, 4); + assert.deepStrictEqual(lines, []); +}); + +test('Ctrl+T preserves an arrow-recalled command and its cursor', (t) => { + const { input, rl, lines } = createSession(t, ['previous command']); + + input.write('\x1b[A\x1b[D\x14'); + assert.strictEqual(rl.line, 'previous command'); + assert.strictEqual(rl.cursor, 'previous comman'.length); + input.write('!\r'); + assert.deepStrictEqual(lines, ['previous comman!d']); +}); + +test('Ctrl+C keeps its normal exit behavior and restores terminal mode', (t) => { + const { input, rl, lines, modes } = createSession(t); + + input.write('draft\x03'); + assert.strictEqual(rl.closed, true); + assert.deepStrictEqual(lines, []); + assert.deepStrictEqual(modes, [true, false]); + assert.strictEqual(input.listenerCount('keypress'), 0); +}); + +test('ordinary editing, arrow recall and Ctrl+D continue to work', (t) => { + const { input, rl, lines } = createSession(t); + + input.write('hello\x1b[D'); + input.write('!'); + input.write('\r\x1b[A\r\x04'); + assert.deepStrictEqual(lines, ['hell!o', 'hell!o']); + assert.strictEqual(rl.closed, true); +}); + +test('redirected streams preserve literal Ctrl+T input without raw mode', (t) => { + const { input, lines, modes } = createSession(t, [], false); + + input.write('literal\x14payload\n'); + assert.deepStrictEqual(lines, ['literal\x14payload']); + assert.deepStrictEqual(modes, []); +}); + +test('ending input forwards the last line and restores terminal mode', async (t) => { + const { input, rl, lines, modes } = createSession(t); + const closed = once(rl, 'close'); + + input.end('draft'); + await closed; + assert.deepStrictEqual(lines, ['draft']); + assert.deepStrictEqual(modes, [true, false]); +}); + +test('pausing and resuming readline also pauses and resumes terminal input', (t) => { + const { input, rl, lines } = createSession(t); + + rl.pause(); + assert.strictEqual(input.isPaused(), true); + rl.resume(); + assert.strictEqual(input.isPaused(), false); + input.write('hello\r'); + assert.deepStrictEqual(lines, ['hello']); +});