Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.*
test/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
41 changes: 34 additions & 7 deletions bin/wscat
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -23,15 +25,27 @@ 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) => {
this.emit('line', data);
})
.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 = () => {
Expand Down Expand Up @@ -61,14 +75,26 @@ 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();

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');
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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) => {
Expand Down
61 changes: 61 additions & 0 deletions lib/format-json.js
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 58 additions & 0 deletions lib/pretty-readline.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "WebSocket cat",
"main": "index.js",
"scripts": {
"test": "echo \"No test specified\""
"test": "node --test"
},
"repository": {
"type": "git",
Expand Down
97 changes: 97 additions & 0 deletions test/format-json.test.js
Original file line number Diff line number Diff line change
@@ -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)
);
});
Loading