feat: Implement ds-screencast engine, panel preview, and Waveshare ESP-NOW communication.

This commit is contained in:
enzotar 2026-03-07 02:34:57 -08:00
parent bf2b7c3cd5
commit 9e2cb29dd9
95 changed files with 50095 additions and 74 deletions

View file

@ -973,10 +973,16 @@
} }
// ── File loading ──────────────────────────────────── // ── File loading ────────────────────────────────────
// Load from URL param
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const fileUrl = params.get('file'); const fileUrl = params.get('file');
if (fileUrl) { const wsUrl = params.get('ws'); // e.g. ?ws=ws://localhost:9201
if (wsUrl) {
// ── WebSocket Binary Bridge Mode ─────────────────
// Connects to a relay that bridges UDP ↔ WebSocket.
// Receives binary signal frames from hub in real-time.
connectWebSocket(wsUrl);
} else if (fileUrl) {
fetch(fileUrl) fetch(fileUrl)
.then(r => r.json()) .then(r => r.json())
.then(ir => { buildUI(ir); log(`Loaded from ${fileUrl}`); }) .then(ir => { buildUI(ir); log(`Loaded from ${fileUrl}`); })
@ -1003,13 +1009,126 @@
} }
}); });
// Also try loading app.ir.json from same directory // Auto-load app.ir.json (file mode fallback)
if (!fileUrl) { if (!fileUrl && !wsUrl) {
fetch('app.ir.json') fetch('app.ir.json')
.then(r => r.json()) .then(r => r.json())
.then(ir => { buildUI(ir); log('Auto-loaded app.ir.json'); }) .then(ir => { buildUI(ir); log('Auto-loaded app.ir.json'); })
.catch(() => log('No app.ir.json found. Drag-drop an IR file or use ?file=URL')); .catch(() => log('No app.ir.json found. Drag-drop an IR file or use ?file=URL or ?ws=ws://host:port'));
} }
// ── WebSocket Binary Bridge ──────────────────────────
// Frame types must match ds_espnow.h
const DS_NOW_SIG = 0x20;
const DS_NOW_SIG_BATCH = 0x21;
const DS_NOW_ACTION = 0x31;
const DS_NOW_PING = 0xFE;
const DS_NOW_PONG = 0xFD;
const DS_UDP_IR_PUSH = 0x40;
let ws = null;
let wsSeq = 0;
function connectWebSocket(url) {
log(`Connecting to ${url}...`, 'sig');
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
log('WebSocket connected — receiving live signals', 'sig');
document.getElementById('status').textContent = '🟢 Live';
document.querySelector('.dot').style.background = '#22c55e';
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
// JSON message — treat as IR push
try {
const ir = JSON.parse(event.data);
buildUI(ir);
log('IR push received via WebSocket');
} catch (e) {
log(`WS JSON error: ${e}`);
}
return;
}
// Binary message
const buf = new DataView(event.data);
if (buf.byteLength < 1) return;
const type = buf.getUint8(0);
switch (type) {
case DS_NOW_SIG:
if (buf.byteLength >= 7) {
const sigId = buf.getUint16(1, true);
const value = buf.getInt32(3, true);
updateSignal(sigId, value);
}
break;
case DS_NOW_SIG_BATCH:
if (buf.byteLength >= 3) {
const count = buf.getUint8(1);
for (let i = 0; i < count; i++) {
const offset = 3 + i * 6;
if (offset + 6 > buf.byteLength) break;
const sigId = buf.getUint16(offset, true);
const value = buf.getInt32(offset + 2, true);
updateSignal(sigId, value);
}
}
break;
case DS_NOW_PING: {
// Respond with pong
const pong = new Uint8Array([DS_NOW_PONG, buf.getUint8(1)]);
ws.send(pong.buffer);
break;
}
case DS_UDP_IR_PUSH:
// Binary IR push: [magic:2][type][0][len:u16][json...]
if (buf.byteLength >= 6) {
const len = buf.getUint16(4, true);
const jsonBytes = new Uint8Array(event.data, 6, len);
const json = new TextDecoder().decode(jsonBytes);
try {
const ir = JSON.parse(json);
buildUI(ir);
log(`IR push received (${len} bytes)`);
} catch (e) {
log(`IR parse error: ${e}`);
}
}
break;
default:
log(`Unknown binary frame: 0x${type.toString(16)}`, 'evt');
}
};
ws.onclose = () => {
log('WebSocket disconnected — reconnecting in 3s...', 'evt');
document.getElementById('status').textContent = '🔴 Disconnected';
document.querySelector('.dot').style.background = '#ef4444';
setTimeout(() => connectWebSocket(url), 3000);
};
ws.onerror = (e) => {
log(`WebSocket error: ${e}`, 'evt');
};
}
// Send action event to hub (when button clicked in previewer)
function sendActionToHub(nodeId, actionType) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array([DS_NOW_ACTION, nodeId, actionType, wsSeq++ & 0xFF]);
ws.send(buf.buffer);
}
// Expose globally so buildButton can call it
window.sendActionToHub = sendActionToHub;
</script> </script>
</body> </body>

29
devices/panel-preview/node_modules/.package-lock.json generated vendored Normal file
View file

@ -0,0 +1,29 @@
{
"name": "panel-preview",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

20
devices/panel-preview/node_modules/ws/LICENSE generated vendored Normal file
View file

@ -0,0 +1,20 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

548
devices/panel-preview/node_modules/ws/README.md generated vendored Normal file
View file

@ -0,0 +1,548 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a backend with the role of a client in the WebSocket communication.
Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [Round-trip time](#round-trip-time)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
[bufferutil][] is an optional module that can be installed alongside the ws
module:
```
npm install --save-optional bufferutil
```
This is a binary addon that improves the performance of certain operations such
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
binaries are available for the most popular platforms, so you don't necessarily
need to have a C++ compiler installed on your machine.
To force ws to not use bufferutil, use the
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
can be useful to enhance security in systems where a user can put a package in
the package search path of an application of another user, due to how the
Node.js resolver algorithm works.
#### Legacy opt-in for performance
If you are running on an old version of Node.js (prior to v18.14.0), ws also
supports the [utf-8-validate][] module:
```
npm install --save-optional utf-8-validate
```
This contains a binary polyfill for [`buffer.isUtf8()`][].
To force ws not to use utf-8-validate, use the
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client, set the
`perMessageDeflate` option to `false`.
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function message(data) {
console.log('received: %s', data);
});
```
### Sending binary data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
```
### External HTTP/S server
```js
import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/path/to/cert.pem'),
key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'wss://base.url');
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
function onSocketError(err) {
console.error(err);
}
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log(`Received message ${data} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
socket.on('error', onSocketError);
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
socket.removeListener('error', onSocketError);
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
### Round-trip time
```js
import WebSocket from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
ws.on('error', console.error);
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function message(data) {
console.log(`Round-trip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
import WebSocket, { createWebSocketStream } from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
duplex.on('error', console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
ws.on('error', console.error);
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
ws.on('error', console.error);
});
```
### How to detect and close broken connections?
Sometimes, the link between the server and the client can be interrupted in a
way that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases, ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
import { WebSocketServer } from 'ws';
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('error', console.error);
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above, your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
import WebSocket from 'ws';
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://websocket-echo.com/');
client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
[bufferutil]: https://github.com/websockets/bufferutil
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[utf-8-validate]: https://github.com/websockets/utf-8-validate
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback

8
devices/panel-preview/node_modules/ws/browser.js generated vendored Normal file
View file

@ -0,0 +1,8 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};

13
devices/panel-preview/node_modules/ws/index.js generated vendored Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket;

View file

@ -0,0 +1,131 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}

19
devices/panel-preview/node_modules/ws/lib/constants.js generated vendored Normal file
View file

@ -0,0 +1,19 @@
'use strict';
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
const hasBlob = typeof Blob !== 'undefined';
if (hasBlob) BINARY_TYPES.push('blob');
module.exports = {
BINARY_TYPES,
CLOSE_TIMEOUT: 30000,
EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob,
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
kListener: Symbol('kListener'),
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
NOOP: () => {}
};

View file

@ -0,0 +1,292 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}

203
devices/panel-preview/node_modules/ws/lib/extension.js generated vendored Normal file
View file

@ -0,0 +1,203 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };

55
devices/panel-preview/node_modules/ws/lib/limiter.js generated vendored Normal file
View file

@ -0,0 +1,55 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;

View file

@ -0,0 +1,528 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed if context takeover is disabled
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._isServer = !!isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) {
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
}
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007;
this[kCallback](err);
}

706
devices/panel-preview/node_modules/ws/lib/receiver.js generated vendored Normal file
View file

@ -0,0 +1,706 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const FastBuffer = Buffer[Symbol.species];
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
const DEFER_EVENT = 6;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {Object} [options] Options object
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {String} [options.binaryType=nodebuffer] The type for binary data
* @param {Object} [options.extensions] An object containing the negotiated
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
*/
constructor(options = {}) {
super();
this._allowSynchronousEvents =
options.allowSynchronousEvents !== undefined
? options.allowSynchronousEvents
: true;
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._errored = false;
this._loop = false;
this._state = GET_INFO;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
return new FastBuffer(buf.buffer, buf.byteOffset, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
this.getInfo(cb);
break;
case GET_PAYLOAD_LENGTH_16:
this.getPayloadLength16(cb);
break;
case GET_PAYLOAD_LENGTH_64:
this.getPayloadLength64(cb);
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
this.getData(cb);
break;
case INFLATING:
case DEFER_EVENT:
this._loop = false;
return;
}
} while (this._loop);
if (!this._errored) cb();
}
/**
* Reads the first two bytes of a frame.
*
* @param {Function} cb Callback
* @private
*/
getInfo(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
const error = this.createError(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
cb(error);
return;
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (!this._fragmented) {
const error = this.createError(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
const error = this.createError(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
cb(error);
return;
}
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (
this._payloadLength > 0x7d ||
(this._opcode === 0x08 && this._payloadLength === 1)
) {
const error = this.createError(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
cb(error);
return;
}
} else {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
const error = this.createError(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
cb(error);
return;
}
} else if (this._masked) {
const error = this.createError(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
cb(error);
return;
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else this.haveLength(cb);
}
/**
* Gets extended payload length (7+16).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength16(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
this.haveLength(cb);
}
/**
* Gets extended payload length (7+64).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength64(cb) {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
const error = this.createError(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
cb(error);
return;
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
this.haveLength(cb);
}
/**
* Payload length has been read.
*
* @param {Function} cb Callback
* @private
*/
haveLength(cb) {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (
this._masked &&
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
) {
unmask(data, this._mask);
}
}
if (this._opcode > 0x07) {
this.controlMessage(data, cb);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its length is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
this.dataMessage(cb);
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
this._fragments.push(buf);
}
this.dataMessage(cb);
if (this._state === GET_INFO) this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @param {Function} cb Callback
* @private
*/
dataMessage(cb) {
if (!this._fin) {
this._state = GET_INFO;
return;
}
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else if (this._binaryType === 'blob') {
data = new Blob(fragments);
} else {
data = fragments;
}
if (this._allowSynchronousEvents) {
this.emit('message', data, true);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', data, true);
this._state = GET_INFO;
this.startLoop(cb);
});
}
} else {
const buf = concat(fragments, messageLength);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
if (this._state === INFLATING || this._allowSynchronousEvents) {
this.emit('message', buf, false);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', buf, false);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data, cb) {
if (this._opcode === 0x08) {
if (data.length === 0) {
this._loop = false;
this.emit('conclude', 1005, EMPTY_BUFFER);
this.end();
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
const error = this.createError(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
cb(error);
return;
}
const buf = new FastBuffer(
data.buffer,
data.byteOffset + 2,
data.length - 2
);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
this._loop = false;
this.emit('conclude', code, buf);
this.end();
}
this._state = GET_INFO;
return;
}
if (this._allowSynchronousEvents) {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
this._loop = false;
this._errored = true;
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, this.createError);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}
}
module.exports = Receiver;

602
devices/panel-preview/node_modules/ws/lib/sender.js generated vendored Normal file
View file

@ -0,0 +1,602 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
'use strict';
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
const { isBlob, isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const kByteLength = Symbol('kByteLength');
const maskBuffer = Buffer.alloc(4);
const RANDOM_POOL_SIZE = 8 * 1024;
let randomPool;
let randomPoolPointer = RANDOM_POOL_SIZE;
const DEFAULT = 0;
const DEFLATING = 1;
const GET_BLOB_DATA = 2;
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {Duplex} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Function} [generateMask] The function used to generate the masking
* key
*/
constructor(socket, extensions, generateMask) {
this._extensions = extensions || {};
if (generateMask) {
this._generateMask = generateMask;
this._maskBuffer = Buffer.alloc(4);
}
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._queue = [];
this._state = DEFAULT;
this.onerror = NOOP;
this[kWebSocket] = undefined;
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {(Buffer|String)} data The data to frame
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {(Buffer|String)[]} The framed data
* @public
*/
static frame(data, options) {
let mask;
let merge = false;
let offset = 2;
let skipMasking = false;
if (options.mask) {
mask = options.maskBuffer || maskBuffer;
if (options.generateMask) {
options.generateMask(mask);
} else {
if (randomPoolPointer === RANDOM_POOL_SIZE) {
/* istanbul ignore else */
if (randomPool === undefined) {
//
// This is lazily initialized because server-sent frames must not
// be masked so it may never be used.
//
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
}
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
randomPoolPointer = 0;
}
mask[0] = randomPool[randomPoolPointer++];
mask[1] = randomPool[randomPoolPointer++];
mask[2] = randomPool[randomPoolPointer++];
mask[3] = randomPool[randomPoolPointer++];
}
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
offset = 6;
}
let dataLength;
if (typeof data === 'string') {
if (
(!options.mask || skipMasking) &&
options[kByteLength] !== undefined
) {
dataLength = options[kByteLength];
} else {
data = Buffer.from(data);
dataLength = data.length;
}
} else {
dataLength = data.length;
merge = options.mask && options.readOnly && !skipMasking;
}
let payloadLength = dataLength;
if (dataLength >= 65536) {
offset += 8;
payloadLength = 127;
} else if (dataLength > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(dataLength, 2);
} else if (payloadLength === 127) {
target[2] = target[3] = 0;
target.writeUIntBE(dataLength, 4, 6);
}
if (!options.mask) return [target, data];
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (skipMasking) return [target, data];
if (merge) {
applyMask(data, mask, target, offset, dataLength);
return [target];
}
applyMask(data, mask, data, 0, dataLength);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {(String|Buffer)} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || !data.length) {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
if (typeof data === 'string') {
buf.write(data, 2);
} else {
buf.set(data, 2);
}
}
const options = {
[kByteLength]: buf.length,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x08,
readOnly: false,
rsv1: false
};
if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, buf, false, options, cb]);
} else {
this.sendFrame(Sender.frame(buf, options), cb);
}
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x09,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x0a,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (this._firstFragment) {
this._firstFragment = false;
if (
rsv1 &&
perMessageDeflate &&
perMessageDeflate.params[
perMessageDeflate._isServer
? 'server_no_context_takeover'
: 'client_no_context_takeover'
]
) {
rsv1 = byteLength >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
const opts = {
[kByteLength]: byteLength,
fin: options.fin,
generateMask: this._generateMask,
mask: options.mask,
maskBuffer: this._maskBuffer,
opcode,
readOnly,
rsv1
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
} else {
this.getBlobData(data, this._compress, opts, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
} else {
this.dispatch(data, this._compress, opts, cb);
}
}
/**
* Gets the contents of a blob as binary data.
*
* @param {Blob} blob The blob
* @param {Boolean} [compress=false] Specifies whether or not to compress
* the data
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
getBlobData(blob, compress, options, cb) {
this._bufferedBytes += options[kByteLength];
this._state = GET_BLOB_DATA;
blob
.arrayBuffer()
.then((arrayBuffer) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while the blob was being read'
);
//
// `callCallbacks` is called in the next tick to ensure that errors
// that might be thrown in the callbacks behave like errors thrown
// outside the promise chain.
//
process.nextTick(callCallbacks, this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
const data = toBuffer(arrayBuffer);
if (!compress) {
this._state = DEFAULT;
this.sendFrame(Sender.frame(data, options), cb);
this.dequeue();
} else {
this.dispatch(data, compress, options, cb);
}
})
.catch((err) => {
//
// `onError` is called in the next tick for the same reason that
// `callCallbacks` above is.
//
process.nextTick(onError, this, err, cb);
});
}
/**
* Dispatches a message.
*
* @param {(Buffer|String)} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += options[kByteLength];
this._state = DEFLATING;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
callCallbacks(this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
this._state = DEFAULT;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (this._state === DEFAULT && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[3][kByteLength];
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[3][kByteLength];
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;
/**
* Calls queued callbacks with an error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error to call the callbacks with
* @param {Function} [cb] The first callback
* @private
*/
function callCallbacks(sender, err, cb) {
if (typeof cb === 'function') cb(err);
for (let i = 0; i < sender._queue.length; i++) {
const params = sender._queue[i];
const callback = params[params.length - 1];
if (typeof callback === 'function') callback(err);
}
}
/**
* Handles a `Sender` error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error
* @param {Function} [cb] The first pending callback
* @private
*/
function onError(sender, err, cb) {
callCallbacks(sender, err, cb);
sender.onerror(err);
}

161
devices/panel-preview/node_modules/ws/lib/stream.js generated vendored Normal file
View file

@ -0,0 +1,161 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let terminateOnDestroy = true;
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg, isBinary) {
const data =
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
if (!duplex.push(data)) ws.pause();
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (ws.isPaused) ws.resume();
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;

View file

@ -0,0 +1,62 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };

152
devices/panel-preview/node_modules/ws/lib/validation.js generated vendored Normal file
View file

@ -0,0 +1,152 @@
'use strict';
const { isUtf8 } = require('buffer');
const { hasBlob } = require('./constants');
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
/**
* Determines whether a value is a `Blob`.
*
* @param {*} value The value to be tested
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
* @private
*/
function isBlob(value) {
return (
hasBlob &&
typeof value === 'object' &&
typeof value.arrayBuffer === 'function' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
(value[Symbol.toStringTag] === 'Blob' ||
value[Symbol.toStringTag] === 'File')
);
}
module.exports = {
isBlob,
isValidStatusCode,
isValidUTF8: _isValidUTF8,
tokenChars
};
if (isUtf8) {
module.exports.isValidUTF8 = function (buf) {
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
};
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
try {
const isValidUTF8 = require('utf-8-validate');
module.exports.isValidUTF8 = function (buf) {
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
};
} catch (e) {
// Continue regardless of the error.
}
}

View file

@ -0,0 +1,554 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const { Duplex } = require('stream');
const { createHash } = require('crypto');
const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket');
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
* wait for the closing handshake to finish after `websocket.close()` is
* called
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
* class to use. It must be the `WebSocket` class or class that extends it
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
allowSynchronousEvents: true,
autoPong: true,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
closeTimeout: CLOSE_TIMEOUT,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
WebSocket,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) {
this.clients = new Set();
this._shouldEmitClose = false;
}
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Stop the server from accepting new connections and emit the `'close'` event
* when all existing connections are closed.
*
* @param {Function} [cb] A one-time listener for the `'close'` event
* @public
*/
close(cb) {
if (this._state === CLOSED) {
if (cb) {
this.once('close', () => {
cb(new Error('The server is not running'));
});
}
process.nextTick(emitClose, this);
return;
}
if (cb) this.once('close', cb);
if (this._state === CLOSING) return;
this._state = CLOSING;
if (this.options.noServer || this.options.server) {
if (this._server) {
this._removeListeners();
this._removeListeners = this._server = null;
}
if (this.clients) {
if (!this.clients.size) {
process.nextTick(emitClose, this);
} else {
this._shouldEmitClose = true;
}
} else {
process.nextTick(emitClose, this);
}
} else {
const server = this._server;
this._removeListeners();
this._removeListeners = this._server = null;
//
// The HTTP/S server was created internally. Close it, and rely on its
// `'close'` event.
//
server.close(() => {
emitClose(this);
});
}
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key = req.headers['sec-websocket-key'];
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
if (req.method !== 'GET') {
const message = 'Invalid HTTP method';
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
return;
}
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
const message = 'Invalid Upgrade header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (key === undefined || !keyRegex.test(key)) {
const message = 'Missing or invalid Sec-WebSocket-Key header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (version !== 13 && version !== 8) {
const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
'Sec-WebSocket-Version': '13, 8'
});
return;
}
if (!this.shouldHandle(req)) {
abortHandshake(socket, 400);
return;
}
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
let protocols = new Set();
if (secWebSocketProtocol !== undefined) {
try {
protocols = subprotocol.parse(secWebSocketProtocol);
} catch (err) {
const message = 'Invalid Sec-WebSocket-Protocol header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
const extensions = {};
if (
this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined
) {
const perMessageDeflate = new PerMessageDeflate(
this.options.perMessageDeflate,
true,
this.options.maxPayload
);
try {
const offers = extension.parse(secWebSocketExtensions);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
const message =
'Invalid or unacceptable Sec-WebSocket-Extensions header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(
extensions,
key,
protocols,
req,
socket,
head,
cb
);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {Object} extensions The accepted extensions
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Set} protocols The subprotocols
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new this.options.WebSocket(null, undefined, this.options);
if (protocols.size) {
//
// Optionally call external protocol selection handler.
//
const protocol = this.options.handleProtocols
? this.options.handleProtocols(protocols, req)
: protocols.values().next().value;
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = extension.format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => {
this.clients.delete(ws);
if (this._shouldEmitClose && !this.clients.size) {
process.nextTick(emitClose, this);
}
});
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private
*/
function abortHandshakeOrEmitwsClientError(
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) {
const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req);
} else {
abortHandshake(socket, code, message, headers);
}
}

1393
devices/panel-preview/node_modules/ws/lib/websocket.js generated vendored Normal file

File diff suppressed because it is too large Load diff

69
devices/panel-preview/node_modules/ws/package.json generated vendored Normal file
View file

@ -0,0 +1,69 @@
{
"name": "ws",
"version": "8.19.0",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
"Push",
"RFC-6455",
"WebSocket",
"WebSockets",
"real-time"
],
"homepage": "https://github.com/websockets/ws",
"bugs": "https://github.com/websockets/ws/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/websockets/ws.git"
},
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"exports": {
".": {
"browser": "./browser.js",
"import": "./wrapper.mjs",
"require": "./index.js"
},
"./package.json": "./package.json"
},
"browser": "browser.js",
"engines": {
"node": ">=10.0.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js",
"wrapper.mjs"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
},
"devDependencies": {
"benchmark": "^2.1.4",
"bufferutil": "^4.0.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^16.0.0",
"mocha": "^8.4.0",
"nyc": "^15.0.0",
"prettier": "^3.0.0",
"utf-8-validate": "^6.0.0"
}
}

8
devices/panel-preview/node_modules/ws/wrapper.mjs generated vendored Normal file
View file

@ -0,0 +1,8 @@
import createWebSocketStream from './lib/stream.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
export default WebSocket;

37
devices/panel-preview/package-lock.json generated Normal file
View file

@ -0,0 +1,37 @@
{
"name": "panel-preview",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "panel-preview",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"ws": "^8.19.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"name": "panel-preview",
"version": "1.0.0",
"main": "relay-bridge.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"ws": "^8.19.0"
}
}

View file

@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* DreamStack Relay Bridge UDP WebSocket
*
* Bridges the hub's UDP binary frames to the browser previewer
* via WebSocket. This lets you test the full signal pipeline
* without ESP32 hardware.
*
* Architecture:
* ds-hub (Rust) UDP:9200 [this relay] WS:9201 browser previewer
* browser WS:9201 [this relay] UDP:9200 ds-hub
*
* Usage:
* node relay-bridge.js
* Then open previewer with: ?ws=ws://localhost:9201
*/
const dgram = require('dgram');
const { WebSocketServer } = require('ws');
const http = require('http');
const fs = require('fs');
const path = require('path');
const UDP_PORT = 9200;
const WS_PORT = 9201;
const HTTP_PORT = 9876; // Serve previewer HTML too
// ─── WebSocket Server ───
const wss = new WebSocketServer({ host: '0.0.0.0', port: WS_PORT });
const clients = new Set();
wss.on('connection', (ws, req) => {
clients.add(ws);
console.log(`[WS] Client connected (${clients.size} total)`);
ws.on('message', (data) => {
// Forward binary messages from browser → UDP (hub)
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
const buf = Buffer.from(data);
udp.send(buf, 0, buf.length, UDP_PORT, '127.0.0.1', (err) => {
if (err) console.error('[UDP] Send error:', err);
});
}
});
ws.on('close', () => {
clients.delete(ws);
console.log(`[WS] Client disconnected (${clients.size} remaining)`);
});
// If we have a cached IR, send it immediately
if (cachedIR) {
ws.send(cachedIR);
console.log('[WS] Sent cached IR to new client');
}
});
// ─── UDP Receiver ───
const udp = dgram.createSocket('udp4');
let cachedIR = null; // Cache latest IR push for new WS clients
let hubAddr = null; // Remember hub address for replies
udp.on('message', (msg, rinfo) => {
hubAddr = rinfo;
// Check if this is an IR push (has magic bytes + IR type)
if (msg.length >= 6 && msg[0] === 0xD5 && msg[1] === 0x7A && msg[2] === 0x40) {
// Extract and cache the IR JSON
const len = msg.readUInt16LE(4);
const json = msg.slice(6, 6 + len).toString();
cachedIR = json;
console.log(`[UDP] IR push received (${len} bytes), broadcasting to ${clients.size} WS clients`);
// Send as JSON text to all WS clients
for (const ws of clients) {
if (ws.readyState === 1) ws.send(json);
}
return;
}
// Forward all other binary frames to WS clients
for (const ws of clients) {
if (ws.readyState === 1) ws.send(msg);
}
// Log signal updates
if (msg[0] === 0x20 && msg.length >= 7) {
const sigId = msg.readUInt16LE(1);
const value = msg.readInt32LE(3);
// Only log occasionally to avoid spam
if (sigId === 0 || value % 10 === 0) {
console.log(`[UDP] Signal ${sigId} = ${value}`);
}
}
});
udp.on('error', (err) => {
console.error('[UDP] Error:', err);
});
udp.bind(UDP_PORT, () => {
console.log(`[UDP] Listening on port ${UDP_PORT}`);
});
// ─── HTTP Server (serve previewer) ───
const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url.split('?')[0]);
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
}[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
});
server.listen(HTTP_PORT, '0.0.0.0', () => {
console.log(`\n DreamStack Relay Bridge`);
console.log(` ─────────────────────────`);
console.log(` HTTP: http://localhost:${HTTP_PORT}/`);
console.log(` WebSocket: ws://localhost:${WS_PORT}`);
console.log(` UDP: port ${UDP_PORT}`);
console.log(`\n Open previewer in live mode:`);
console.log(` http://localhost:${HTTP_PORT}/index.html?ws=ws://localhost:${WS_PORT}`);
console.log(`\n Or file mode (no hub needed):`);
console.log(` http://localhost:${HTTP_PORT}/index.html`);
console.log('');
});

View file

@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Test script simulates a hub sending signals to the relay bridge.
*
* Usage:
* 1. Start relay: node relay-bridge.js
* 2. Open browser: http://localhost:9876/index.html?ws=ws://localhost:9201
* 3. Run this: node test-hub.js
*
* This will:
* 1. Push IR JSON (from app.ir.json) via UDP
* 2. Send signal updates every 250ms
* 3. Listen for panel events (action/touch)
*/
const dgram = require('dgram');
const fs = require('fs');
const path = require('path');
const UDP_PORT = 9200;
const client = dgram.createSocket('udp4');
// ─── Push IR JSON ───
const irPath = path.join(__dirname, 'app.ir.json');
if (fs.existsSync(irPath)) {
const json = fs.readFileSync(irPath, 'utf8');
const data = Buffer.from(json);
// Build IR push frame: [D5][7A][40][00][len:u16LE][json...]
const header = Buffer.alloc(6);
header[0] = 0xD5; // magic
header[1] = 0x7A; // magic
header[2] = 0x40; // DS_UDP_IR_PUSH
header[3] = 0x00; // reserved
header.writeUInt16LE(data.length, 4);
const frame = Buffer.concat([header, data]);
client.send(frame, 0, frame.length, UDP_PORT, '127.0.0.1', () => {
console.log(`[Hub] IR pushed (${data.length} bytes)`);
});
} else {
console.log('[Hub] No app.ir.json found, skipping IR push');
}
// ─── Send periodic signal updates ───
let tick = 0;
setInterval(() => {
tick++;
// Signal 6 (ticks) — increment every second
if (tick % 4 === 0) {
const sigFrame = Buffer.alloc(7);
sigFrame[0] = 0x20; // DS_NOW_SIG
sigFrame.writeUInt16LE(6, 1); // signal_id = 6
sigFrame.writeInt32LE(Math.floor(tick / 4), 3); // value = seconds
client.send(sigFrame, 0, 7, UDP_PORT, '127.0.0.1');
}
// Signal 2 (score) — increment every 10 ticks
if (tick % 40 === 0) {
const sigFrame = Buffer.alloc(7);
sigFrame[0] = 0x20;
sigFrame.writeUInt16LE(2, 1); // signal_id = 2 (score)
sigFrame.writeInt32LE(Math.floor(tick / 40), 3);
client.send(sigFrame, 0, 7, UDP_PORT, '127.0.0.1');
console.log(`[Hub] Score → ${Math.floor(tick / 40)}`);
}
// Batch update: signals 0,1 (head position) every 250ms
if (tick % 1 === 0) {
const batchFrame = Buffer.alloc(3 + 2 * 6); // 2 entries
batchFrame[0] = 0x21; // DS_NOW_SIG_BATCH
batchFrame[1] = 2; // count
batchFrame[2] = tick & 0xFF; // seq
// Signal 0 (headX)
batchFrame.writeUInt16LE(0, 3);
batchFrame.writeInt32LE(4 + (tick % 8), 5);
// Signal 1 (headY)
batchFrame.writeUInt16LE(1, 9);
batchFrame.writeInt32LE(4, 11);
client.send(batchFrame, 0, batchFrame.length, UDP_PORT, '127.0.0.1');
}
}, 250);
// ─── Listen for events from panel/browser ───
const listener = dgram.createSocket('udp4');
// Note: relay bridge sends events back to the source address,
// so we'd need to be listening on the same port. For testing,
// the relay bridge console logs will show events.
console.log('[Hub] Sending signals every 250ms...');
console.log('[Hub] Press Ctrl+C to stop');

View file

@ -1,5 +1,5 @@
idf_component_register( idf_component_register(
SRCS "main.c" "ds_codec.c" SRCS "main.c" "ds_codec.c" "ds_espnow.c" "ds_runtime.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES REQUIRES
esp_wifi esp_wifi
@ -8,4 +8,6 @@ idf_component_register(
esp_timer esp_timer
nvs_flash nvs_flash
esp_psram esp_psram
esp_now
lwip
) )

View file

@ -0,0 +1,248 @@
/**
* DreamStack ESP-NOW Transport Implementation
*
* Handles ESP-NOW receive/send for binary signal frames,
* and UDP listener for IR JSON push.
*/
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_now.h"
#include "lwip/sockets.h"
#include "ds_espnow.h"
static const char *TAG = "ds-espnow";
// ─── State ───
static ds_espnow_config_t s_config;
static uint8_t s_seq = 0;
static int s_udp_sock = -1;
static TaskHandle_t s_udp_task = NULL;
// ─── IR fragment reassembly ───
#define MAX_IR_SIZE 16384
static uint8_t s_ir_buf[MAX_IR_SIZE];
static size_t s_ir_len = 0;
static uint8_t s_frag_received = 0;
static uint8_t s_frag_total = 0;
static uint8_t s_frag_seq = 0xFF;
// ─── ESP-NOW Receive Callback ───
static void espnow_recv_cb(const esp_now_recv_info_t *recv_info,
const uint8_t *data, int len) {
if (len < 1) return;
uint8_t type = data[0];
switch (type) {
case DS_NOW_SIG:
if (len >= sizeof(ds_sig_frame_t)) {
const ds_sig_frame_t *f = (const ds_sig_frame_t *)data;
if (s_config.on_signal) {
s_config.on_signal(f->signal_id, f->value);
}
}
break;
case DS_NOW_SIG_BATCH:
if (len >= sizeof(ds_sig_batch_t)) {
const ds_sig_batch_t *b = (const ds_sig_batch_t *)data;
const ds_sig_entry_t *entries = (const ds_sig_entry_t *)(data + sizeof(ds_sig_batch_t));
size_t expected = sizeof(ds_sig_batch_t) + b->count * sizeof(ds_sig_entry_t);
if (len >= expected && s_config.on_signal) {
for (int i = 0; i < b->count; i++) {
s_config.on_signal(entries[i].id, entries[i].val);
}
}
}
break;
case DS_NOW_PING: {
// Respond with pong
ds_heartbeat_t pong = { .type = DS_NOW_PONG, .seq = data[1] };
esp_now_send(recv_info->src_addr, (const uint8_t *)&pong, sizeof(pong));
break;
}
case DS_NOW_PONG:
ESP_LOGD(TAG, "Pong received (seq=%d)", data[1]);
break;
default:
ESP_LOGW(TAG, "Unknown ESP-NOW frame type: 0x%02x", type);
break;
}
}
// ─── ESP-NOW Send Callback ───
static void espnow_send_cb(const uint8_t *mac_addr, esp_now_send_status_t status) {
if (status != ESP_NOW_SEND_SUCCESS) {
ESP_LOGW(TAG, "ESP-NOW send failed");
}
}
// ─── UDP Listener Task ───
// Receives IR JSON push and fragmented IR over UDP
static void udp_listener_task(void *arg) {
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(DS_UDP_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
s_udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s_udp_sock < 0) {
ESP_LOGE(TAG, "Failed to create UDP socket");
vTaskDelete(NULL);
return;
}
if (bind(s_udp_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
ESP_LOGE(TAG, "Failed to bind UDP port %d", DS_UDP_PORT);
close(s_udp_sock);
s_udp_sock = -1;
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "UDP listener on port %d", DS_UDP_PORT);
uint8_t buf[1500];
while (1) {
int len = recvfrom(s_udp_sock, buf, sizeof(buf), 0, NULL, NULL);
if (len < 4) continue;
// Check magic
if (buf[0] != 0xD5 || buf[1] != 0x7A) continue;
uint8_t frame_type = buf[2];
if (frame_type == DS_UDP_IR_PUSH) {
// Non-fragmented IR push
const ds_ir_push_t *hdr = (const ds_ir_push_t *)buf;
size_t json_len = hdr->length;
if (json_len <= len - sizeof(ds_ir_push_t) && json_len < MAX_IR_SIZE) {
memcpy(s_ir_buf, buf + sizeof(ds_ir_push_t), json_len);
s_ir_buf[json_len] = '\0';
ESP_LOGI(TAG, "IR push received (%zu bytes)", json_len);
if (s_config.on_ir_push) {
s_config.on_ir_push((const char *)s_ir_buf, json_len);
}
}
} else if (frame_type == DS_UDP_IR_FRAG) {
// Fragmented IR push
if (len < sizeof(ds_ir_frag_t)) continue;
const ds_ir_frag_t *frag = (const ds_ir_frag_t *)buf;
size_t payload_len = len - sizeof(ds_ir_frag_t);
const uint8_t *payload = buf + sizeof(ds_ir_frag_t);
// New fragment group?
if (frag->seq != s_frag_seq) {
s_frag_seq = frag->seq;
s_frag_received = 0;
s_frag_total = frag->frag_total;
s_ir_len = 0;
}
// Append fragment (assume ordered delivery)
if (s_ir_len + payload_len < MAX_IR_SIZE) {
memcpy(s_ir_buf + s_ir_len, payload, payload_len);
s_ir_len += payload_len;
s_frag_received++;
if (s_frag_received >= s_frag_total) {
s_ir_buf[s_ir_len] = '\0';
ESP_LOGI(TAG, "IR reassembled (%zu bytes, %d frags)",
s_ir_len, s_frag_total);
if (s_config.on_ir_push) {
s_config.on_ir_push((const char *)s_ir_buf, s_ir_len);
}
}
}
}
}
}
// ─── Public API ───
esp_err_t ds_espnow_init(const ds_espnow_config_t *config) {
s_config = *config;
if (s_config.channel == 0) s_config.channel = DS_ESPNOW_CHANNEL;
// Initialize ESP-NOW
esp_err_t ret = esp_now_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "ESP-NOW init failed: %s", esp_err_to_name(ret));
return ret;
}
esp_now_register_recv_cb(espnow_recv_cb);
esp_now_register_send_cb(espnow_send_cb);
// Add hub as peer
esp_now_peer_info_t peer = {
.channel = s_config.channel,
.ifidx = WIFI_IF_STA,
.encrypt = false,
};
memcpy(peer.peer_addr, s_config.hub_mac, 6);
ret = esp_now_add_peer(&peer);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Add peer failed (may already exist): %s", esp_err_to_name(ret));
}
ESP_LOGI(TAG, "ESP-NOW initialized (ch=%d, hub=%02x:%02x:%02x:%02x:%02x:%02x)",
s_config.channel,
s_config.hub_mac[0], s_config.hub_mac[1], s_config.hub_mac[2],
s_config.hub_mac[3], s_config.hub_mac[4], s_config.hub_mac[5]);
// Start UDP listener
xTaskCreate(udp_listener_task, "ds_udp", 4096, NULL, 5, &s_udp_task);
return ESP_OK;
}
esp_err_t ds_espnow_send_action(uint8_t node_id, uint8_t action) {
ds_action_frame_t frame = {
.type = DS_NOW_ACTION,
.node_id = node_id,
.action = action,
.seq = s_seq++,
};
return esp_now_send(s_config.hub_mac, (const uint8_t *)&frame, sizeof(frame));
}
esp_err_t ds_espnow_send_touch(uint8_t node_id, uint8_t event,
uint16_t x, uint16_t y) {
ds_touch_now_t frame = {
.type = DS_NOW_TOUCH,
.node_id = node_id,
.event = event,
.seq = s_seq++,
.x = x,
.y = y,
};
return esp_now_send(s_config.hub_mac, (const uint8_t *)&frame, sizeof(frame));
}
esp_err_t ds_espnow_send_ping(void) {
ds_heartbeat_t ping = { .type = DS_NOW_PING, .seq = s_seq++ };
return esp_now_send(s_config.hub_mac, (const uint8_t *)&ping, sizeof(ping));
}
void ds_espnow_deinit(void) {
if (s_udp_task) {
vTaskDelete(s_udp_task);
s_udp_task = NULL;
}
if (s_udp_sock >= 0) {
close(s_udp_sock);
s_udp_sock = -1;
}
esp_now_deinit();
ESP_LOGI(TAG, "ESP-NOW deinitialized");
}

View file

@ -0,0 +1,143 @@
/**
* DreamStack ESP-NOW Transport Ultra-Low Latency Binary Protocol
*
* Sub-1ms signal delivery over ESP-NOW (WiFi direct, no router).
* Binary packed frames instead of JSON for minimal overhead.
*
* Transport strategy:
* ESP-NOW: real-time signals + events (<1ms, 250 bytes)
* UDP: initial IR push + large payloads (~2ms, 1472 bytes)
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include "esp_now.h"
// ─── ESP-NOW Frame Types ───
#define DS_NOW_SIG 0x20 // Single signal update (hub → panel)
#define DS_NOW_SIG_BATCH 0x21 // Batch signal update (hub → panel)
#define DS_NOW_TOUCH 0x30 // Touch event (panel → hub)
#define DS_NOW_ACTION 0x31 // Button/widget action (panel → hub)
#define DS_NOW_PING 0xFE // Heartbeat (bidirectional)
#define DS_NOW_PONG 0xFD // Heartbeat response
// ─── UDP Frame Types ───
#define DS_UDP_IR_PUSH 0x40 // Full IR JSON push (hub → panel)
#define DS_UDP_IR_FRAG 0x41 // IR fragment for payloads > MTU
#define DS_UDP_DISCOVER 0x42 // Panel discovery broadcast
// ─── UDP Port ───
#define DS_UDP_PORT 9200
// ─── ESP-NOW Channel ───
#define DS_ESPNOW_CHANNEL 1
// ─── Max ESP-NOW payload ───
#define DS_ESPNOW_MAX_DATA 250
// ─── Signal Update Frame (7 bytes) ───
// Hub → Panel: update a single signal value
typedef struct __attribute__((packed)) {
uint8_t type; // DS_NOW_SIG
uint16_t signal_id; // which signal (0-65535)
int32_t value; // new value
} ds_sig_frame_t;
// ─── Signal Batch Frame (3 + 6*N bytes) ───
// Hub → Panel: update multiple signals at once
typedef struct __attribute__((packed)) {
uint16_t id;
int32_t val;
} ds_sig_entry_t;
typedef struct __attribute__((packed)) {
uint8_t type; // DS_NOW_SIG_BATCH
uint8_t count; // number of signals (max ~40 in 250B)
uint8_t seq; // sequence number (wrapping u8)
// followed by `count` ds_sig_entry_t entries
} ds_sig_batch_t;
// ─── Touch Event Frame (8 bytes) ───
// Panel → Hub: touch on the display
typedef struct __attribute__((packed)) {
uint8_t type; // DS_NOW_TOUCH
uint8_t node_id; // which UI node (from IR)
uint8_t event; // 0=click, 1=long_press, 2=release, 3=drag
uint8_t seq; // sequence number
uint16_t x; // touch X coordinate
uint16_t y; // touch Y coordinate
} ds_touch_now_t;
// ─── Action Event Frame (4 bytes) ───
// Panel → Hub: widget action (button click, toggle, etc.)
typedef struct __attribute__((packed)) {
uint8_t type; // DS_NOW_ACTION
uint8_t node_id; // which widget
uint8_t action; // 0=click, 1=toggle, 2=slide_change
uint8_t seq; // sequence number
} ds_action_frame_t;
// ─── Heartbeat Frame (2 bytes) ───
typedef struct __attribute__((packed)) {
uint8_t type; // DS_NOW_PING or DS_NOW_PONG
uint8_t seq; // echo back on pong
} ds_heartbeat_t;
// ─── UDP IR Push Header (4 bytes + payload) ───
typedef struct __attribute__((packed)) {
uint8_t magic[2]; // 0xD5, 0x7A
uint16_t length; // JSON payload length
// followed by `length` bytes of IR JSON
} ds_ir_push_t;
// ─── UDP IR Fragment Header (6 bytes + payload) ───
typedef struct __attribute__((packed)) {
uint8_t magic[2]; // 0xD5, 0x7A
uint8_t type; // DS_UDP_IR_FRAG
uint8_t frag_id; // fragment index (0-based)
uint8_t frag_total; // total fragments
uint8_t seq; // group sequence
// followed by fragment data (up to 1466 bytes)
} ds_ir_frag_t;
// ─── Callbacks ───
typedef void (*ds_signal_cb_t)(uint16_t signal_id, int32_t value);
typedef void (*ds_ir_cb_t)(const char *ir_json, size_t length);
// ─── Configuration ───
typedef struct {
uint8_t hub_mac[6]; // Hub MAC address (set to FF:FF:FF:FF:FF:FF for broadcast)
uint8_t channel; // WiFi channel (default: DS_ESPNOW_CHANNEL)
ds_signal_cb_t on_signal; // Called when a signal update arrives
ds_ir_cb_t on_ir_push; // Called when a full IR JSON arrives
} ds_espnow_config_t;
/**
* Initialize ESP-NOW transport.
* Sets up ESP-NOW, registers peer, starts UDP listener.
* WiFi must be initialized first (STA or AP mode, no connection needed).
*/
esp_err_t ds_espnow_init(const ds_espnow_config_t *config);
/**
* Send an action event to the hub (panel hub).
* Encodes as ds_action_frame_t and sends via ESP-NOW.
*/
esp_err_t ds_espnow_send_action(uint8_t node_id, uint8_t action);
/**
* Send a touch event to the hub (panel hub).
*/
esp_err_t ds_espnow_send_touch(uint8_t node_id, uint8_t event,
uint16_t x, uint16_t y);
/**
* Send a heartbeat ping.
*/
esp_err_t ds_espnow_send_ping(void);
/**
* Deinitialize ESP-NOW transport.
*/
void ds_espnow_deinit(void);

View file

@ -0,0 +1,458 @@
/**
* DreamStack Panel IR Runtime Implementation
*
* Parses Panel IR JSON (using cJSON) and creates LVGL 9 widgets.
* This is the C port of the browser-based panel previewer logic.
*
* Key features:
* - Signal table with text template expansion ({0} value)
* - Reactive updates: signal change refresh bound labels
* - Event dispatch: button click action opcode ESP-NOW
* - Timer execution from IR timers[] array
*/
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "esp_log.h"
#include "cJSON.h"
#include "lvgl.h"
#include "ds_runtime.h"
static const char *TAG = "ds-runtime";
// ─── Signal Table ───
static ds_signal_t s_signals[DS_MAX_SIGNALS];
static uint16_t s_signal_count = 0;
// ─── Timer Table ───
static ds_timer_t s_timers[DS_MAX_TIMERS];
static uint8_t s_timer_count = 0;
// ─── Text Binding Table ───
// Maps LVGL label objects to their IR text templates
typedef struct {
lv_obj_t *label; // LVGL label widget
char template[128]; // Text template with {N} placeholders
bool used;
} ds_binding_t;
static ds_binding_t s_bindings[DS_MAX_BINDINGS];
static uint16_t s_binding_count = 0;
// ─── Parent and Callback ───
static lv_obj_t *s_parent = NULL;
static lv_obj_t *s_root = NULL;
static ds_action_cb_t s_action_cb = NULL;
// ─── Forward Declarations ───
static lv_obj_t *build_node(cJSON *node, lv_obj_t *parent);
static void expand_template(const char *tpl, char *out, size_t out_len);
static void execute_action(cJSON *action);
static void refresh_bindings(void);
// ─── Action opcodes (match IR spec) ───
#define OP_INC 1
#define OP_DEC 2
#define OP_ADD 3
#define OP_SUB 4
#define OP_SET 5
#define OP_TOGGLE 6
static uint8_t parse_op(const char *op_str) {
if (strcmp(op_str, "inc") == 0) return OP_INC;
if (strcmp(op_str, "dec") == 0) return OP_DEC;
if (strcmp(op_str, "add") == 0) return OP_ADD;
if (strcmp(op_str, "sub") == 0) return OP_SUB;
if (strcmp(op_str, "set") == 0) return OP_SET;
if (strcmp(op_str, "toggle") == 0) return OP_TOGGLE;
return 0;
}
// ─── Timer Callback ───
static void timer_cb(lv_timer_t *timer) {
ds_timer_t *t = (ds_timer_t *)lv_timer_get_user_data(timer);
if (!t) return;
int32_t val = s_signals[t->action_sig].i;
switch (t->action_op) {
case OP_INC: val++; break;
case OP_DEC: val--; break;
case OP_ADD: val += t->action_val; break;
case OP_SUB: val -= t->action_val; break;
case OP_SET: val = t->action_val; break;
case OP_TOGGLE: val = val ? 0 : 1; break;
}
ds_signal_update(t->action_sig, val);
}
// ─── Button Event Handler ───
typedef struct {
cJSON *action; // JSON action object (kept alive while UI exists)
uint8_t node_id;
} btn_user_data_t;
static void btn_click_cb(lv_event_t *e) {
btn_user_data_t *ud = (btn_user_data_t *)lv_event_get_user_data(e);
if (ud && ud->action) {
execute_action(ud->action);
if (s_action_cb) {
s_action_cb(ud->node_id, 0); // notify ESP-NOW
}
}
}
// ─── Build Helpers ───
static lv_obj_t *build_container(cJSON *node, lv_obj_t *parent, bool is_row) {
lv_obj_t *cont = lv_obj_create(parent);
lv_obj_set_size(cont, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(cont, is_row ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
lv_obj_set_style_pad_all(cont, 4, 0);
cJSON *gap = cJSON_GetObjectItem(node, "gap");
if (gap) {
lv_obj_set_style_pad_gap(cont, gap->valueint, 0);
}
cJSON *children = cJSON_GetObjectItem(node, "c");
if (children && cJSON_IsArray(children)) {
cJSON *child;
cJSON_ArrayForEach(child, children) {
build_node(child, cont);
}
}
return cont;
}
static lv_obj_t *build_label(cJSON *node, lv_obj_t *parent) {
lv_obj_t *lbl = lv_label_create(parent);
cJSON *text = cJSON_GetObjectItem(node, "text");
if (text && text->valuestring) {
char expanded[256];
expand_template(text->valuestring, expanded, sizeof(expanded));
lv_label_set_text(lbl, expanded);
// Register binding if template contains {N}
if (strchr(text->valuestring, '{') && s_binding_count < DS_MAX_BINDINGS) {
ds_binding_t *b = &s_bindings[s_binding_count++];
b->label = lbl;
strncpy(b->template, text->valuestring, sizeof(b->template) - 1);
b->used = true;
}
}
cJSON *size = cJSON_GetObjectItem(node, "size");
if (size) {
lv_obj_set_style_text_font(lbl,
size->valueint >= 24 ? &lv_font_montserrat_24 :
size->valueint >= 18 ? &lv_font_montserrat_18 :
size->valueint >= 14 ? &lv_font_montserrat_14 :
&lv_font_montserrat_12, 0);
}
return lbl;
}
static lv_obj_t *build_button(cJSON *node, lv_obj_t *parent) {
lv_obj_t *btn = lv_btn_create(parent);
lv_obj_t *lbl = lv_label_create(btn);
cJSON *text = cJSON_GetObjectItem(node, "text");
if (text && text->valuestring) {
char expanded[256];
expand_template(text->valuestring, expanded, sizeof(expanded));
lv_label_set_text(lbl, expanded);
// Register binding for button text too
if (strchr(text->valuestring, '{') && s_binding_count < DS_MAX_BINDINGS) {
ds_binding_t *b = &s_bindings[s_binding_count++];
b->label = lbl;
strncpy(b->template, text->valuestring, sizeof(b->template) - 1);
b->used = true;
}
}
cJSON *on = cJSON_GetObjectItem(node, "on");
if (on) {
cJSON *click = cJSON_GetObjectItem(on, "click");
if (click) {
btn_user_data_t *ud = malloc(sizeof(btn_user_data_t));
ud->action = click; // keep reference (IR JSON stays in memory)
cJSON *id_obj = cJSON_GetObjectItem(node, "id");
ud->node_id = id_obj ? id_obj->valueint : 0;
lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, ud);
}
}
return btn;
}
static lv_obj_t *build_slider(cJSON *node, lv_obj_t *parent) {
lv_obj_t *slider = lv_slider_create(parent);
lv_obj_set_width(slider, LV_PCT(80));
cJSON *min = cJSON_GetObjectItem(node, "min");
cJSON *max = cJSON_GetObjectItem(node, "max");
if (min) lv_slider_set_range(slider, min->valueint, max ? max->valueint : 100);
cJSON *bind = cJSON_GetObjectItem(node, "bind");
if (bind) {
int sid = bind->valueint;
lv_slider_set_value(slider, s_signals[sid].i, LV_ANIM_OFF);
// TODO: add slider change event → ds_signal_update
}
return slider;
}
static lv_obj_t *build_panel(cJSON *node, lv_obj_t *parent) {
lv_obj_t *pnl = lv_obj_create(parent);
lv_obj_set_size(pnl, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(pnl, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(pnl, 12, 0);
lv_obj_set_style_radius(pnl, 8, 0);
cJSON *title = cJSON_GetObjectItem(node, "text");
if (title && title->valuestring) {
lv_obj_t *t = lv_label_create(pnl);
lv_label_set_text(t, title->valuestring);
lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0);
}
cJSON *children = cJSON_GetObjectItem(node, "c");
if (children && cJSON_IsArray(children)) {
cJSON *child;
cJSON_ArrayForEach(child, children) {
build_node(child, pnl);
}
}
return pnl;
}
// ─── Node Dispatcher ───
static lv_obj_t *build_node(cJSON *node, lv_obj_t *parent) {
if (!node) return NULL;
cJSON *type = cJSON_GetObjectItem(node, "t");
if (!type || !type->valuestring) return NULL;
const char *t = type->valuestring;
if (strcmp(t, "col") == 0) return build_container(node, parent, false);
if (strcmp(t, "row") == 0) return build_container(node, parent, true);
if (strcmp(t, "lbl") == 0) return build_label(node, parent);
if (strcmp(t, "btn") == 0) return build_button(node, parent);
if (strcmp(t, "sld") == 0) return build_slider(node, parent);
if (strcmp(t, "pnl") == 0) return build_panel(node, parent);
// stk, inp, sw, bar, img — add as needed
ESP_LOGW(TAG, "Unknown node type: %s", t);
return NULL;
}
// ─── Template Expansion ───
// Replace {N} with signal values: "{2}: {0}°F" → "Kitchen: 72°F"
static void expand_template(const char *tpl, char *out, size_t out_len) {
size_t pos = 0;
while (*tpl && pos < out_len - 1) {
if (*tpl == '{') {
tpl++;
int sig_id = 0;
while (*tpl >= '0' && *tpl <= '9') {
sig_id = sig_id * 10 + (*tpl - '0');
tpl++;
}
if (*tpl == '}') tpl++;
if (sig_id < DS_MAX_SIGNALS && s_signals[sig_id].used) {
int written = snprintf(out + pos, out_len - pos, "%d",
(int)s_signals[sig_id].i);
if (written > 0) pos += written;
}
} else {
out[pos++] = *tpl++;
}
}
out[pos] = '\0';
}
// ─── Action Executor ───
static void execute_action(cJSON *action) {
if (!action) return;
// Handle arrays of actions
if (cJSON_IsArray(action)) {
cJSON *item;
cJSON_ArrayForEach(item, action) {
execute_action(item);
}
return;
}
cJSON *op_json = cJSON_GetObjectItem(action, "op");
cJSON *s_json = cJSON_GetObjectItem(action, "s");
if (!op_json || !s_json) return;
uint8_t op = parse_op(op_json->valuestring);
uint16_t sid = s_json->valueint;
if (sid >= DS_MAX_SIGNALS) return;
int32_t val = s_signals[sid].i;
cJSON *v_json = cJSON_GetObjectItem(action, "v");
int32_t v = v_json ? v_json->valueint : 0;
switch (op) {
case OP_INC: val++; break;
case OP_DEC: val--; break;
case OP_ADD: val += v; break;
case OP_SUB: val -= v; break;
case OP_SET: val = v; break;
case OP_TOGGLE: val = val ? 0 : 1; break;
}
ds_signal_update(sid, val);
}
// ─── Refresh Bindings ───
// Called after any signal change to update bound labels
static void refresh_bindings(void) {
for (int i = 0; i < s_binding_count; i++) {
if (!s_bindings[i].used) continue;
char expanded[256];
expand_template(s_bindings[i].template, expanded, sizeof(expanded));
lv_label_set_text(s_bindings[i].label, expanded);
}
}
// ─── Public API ───
esp_err_t ds_runtime_init(void *parent, ds_action_cb_t action_cb) {
s_parent = (lv_obj_t *)parent;
s_action_cb = action_cb;
s_root = NULL;
s_signal_count = 0;
s_binding_count = 0;
s_timer_count = 0;
memset(s_signals, 0, sizeof(s_signals));
memset(s_bindings, 0, sizeof(s_bindings));
memset(s_timers, 0, sizeof(s_timers));
ESP_LOGI(TAG, "Runtime initialized");
return ESP_OK;
}
esp_err_t ds_ui_build(const char *ir_json, size_t length) {
// Destroy previous UI
ds_ui_destroy();
cJSON *ir = cJSON_ParseWithLength(ir_json, length);
if (!ir) {
ESP_LOGE(TAG, "Failed to parse IR JSON");
return ESP_ERR_INVALID_ARG;
}
// Load signals
cJSON *signals = cJSON_GetObjectItem(ir, "signals");
if (signals && cJSON_IsArray(signals)) {
cJSON *sig;
cJSON_ArrayForEach(sig, signals) {
cJSON *id = cJSON_GetObjectItem(sig, "id");
cJSON *v = cJSON_GetObjectItem(sig, "v");
if (id && id->valueint < DS_MAX_SIGNALS) {
int sid = id->valueint;
s_signals[sid].i = v ? v->valueint : 0;
s_signals[sid].type = DS_SIG_INT;
s_signals[sid].used = true;
s_signal_count++;
}
}
}
// Build UI tree
cJSON *root = cJSON_GetObjectItem(ir, "root");
if (root) {
s_root = build_node(root, s_parent);
}
// Load timers
cJSON *timers = cJSON_GetObjectItem(ir, "timers");
if (timers && cJSON_IsArray(timers)) {
cJSON *t;
cJSON_ArrayForEach(t, timers) {
if (s_timer_count >= DS_MAX_TIMERS) break;
cJSON *ms = cJSON_GetObjectItem(t, "ms");
cJSON *action = cJSON_GetObjectItem(t, "action");
if (ms && action) {
ds_timer_t *timer = &s_timers[s_timer_count];
timer->ms = ms->valueint;
cJSON *op = cJSON_GetObjectItem(action, "op");
cJSON *s = cJSON_GetObjectItem(action, "s");
cJSON *v = cJSON_GetObjectItem(action, "v");
timer->action_op = op ? parse_op(op->valuestring) : 0;
timer->action_sig = s ? s->valueint : 0;
timer->action_val = v ? v->valueint : 0;
timer->timer = lv_timer_create(timer_cb, timer->ms, timer);
s_timer_count++;
ESP_LOGI(TAG, "Timer: every %dms → op %d on s%d",
(int)timer->ms, timer->action_op, timer->action_sig);
}
}
}
ESP_LOGI(TAG, "UI built: %d signals, %d bindings, %d timers",
s_signal_count, s_binding_count, s_timer_count);
// Keep IR JSON alive for button action references
// (cJSON_Delete would invalidate action pointers)
// TODO: deep-copy actions to avoid this leak
return ESP_OK;
}
void ds_ui_destroy(void) {
// Stop timers
for (int i = 0; i < s_timer_count; i++) {
if (s_timers[i].timer) {
lv_timer_delete((lv_timer_t *)s_timers[i].timer);
s_timers[i].timer = NULL;
}
}
s_timer_count = 0;
// Clear bindings
s_binding_count = 0;
memset(s_bindings, 0, sizeof(s_bindings));
// Destroy LVGL tree
if (s_root) {
lv_obj_del(s_root);
s_root = NULL;
}
ESP_LOGI(TAG, "UI destroyed");
}
void ds_signal_update(uint16_t signal_id, int32_t value) {
if (signal_id >= DS_MAX_SIGNALS) return;
s_signals[signal_id].i = value;
s_signals[signal_id].used = true;
// Refresh all bound labels
refresh_bindings();
}
uint16_t ds_signal_count(void) {
return s_signal_count;
}
int32_t ds_signal_get(uint16_t signal_id) {
if (signal_id >= DS_MAX_SIGNALS) return 0;
return s_signals[signal_id].i;
}

View file

@ -0,0 +1,90 @@
/**
* DreamStack Panel IR Runtime LVGL Widget Builder
*
* Parses Panel IR JSON and creates LVGL widgets.
* Handles signal binding, text template expansion,
* event dispatch, and timer execution.
*
* This is the C equivalent of the browser-based panel previewer.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include "esp_err.h"
// ─── Limits ───
#define DS_MAX_SIGNALS 64
#define DS_MAX_NODES 128
#define DS_MAX_TIMERS 8
#define DS_MAX_BINDINGS 64
// ─── Signal types ───
typedef enum {
DS_SIG_INT = 0,
DS_SIG_BOOL,
DS_SIG_STRING,
} ds_sig_type_t;
// ─── Signal value ───
typedef struct {
int32_t i; // integer value (also used for bool: 0/1)
char s[32]; // string value (short strings only)
ds_sig_type_t type;
bool used;
} ds_signal_t;
// ─── Timer entry ───
typedef struct {
uint32_t ms; // interval in milliseconds
uint8_t action_op; // action opcode
uint16_t action_sig; // target signal
int32_t action_val; // value for set/add/sub
void *timer; // LVGL timer handle (lv_timer_t *)
} ds_timer_t;
// ─── Action callback (for forwarding to ESP-NOW) ───
typedef void (*ds_action_cb_t)(uint8_t node_id, uint8_t action_type);
/**
* Initialize the Panel IR runtime.
* Must be called after LVGL is initialized.
*
* @param parent LVGL parent object (usually lv_scr_act())
* @param action_cb Callback for widget actions (forwarded to ESP-NOW)
*/
esp_err_t ds_runtime_init(void *parent, ds_action_cb_t action_cb);
/**
* Build the UI from Panel IR JSON.
* Parses the JSON, creates LVGL widgets, binds signals.
* Destroys any previously built UI first.
*
* @param ir_json Panel IR JSON string
* @param length Length of the JSON string
*/
esp_err_t ds_ui_build(const char *ir_json, size_t length);
/**
* Destroy the current UI tree.
* Removes all LVGL widgets and clears signal bindings.
*/
void ds_ui_destroy(void);
/**
* Update a signal value and refresh bound widgets.
*
* @param signal_id Signal ID (0-based)
* @param value New integer value
*/
void ds_signal_update(uint16_t signal_id, int32_t value);
/**
* Get current signal count.
*/
uint16_t ds_signal_count(void);
/**
* Get signal value by ID.
*/
int32_t ds_signal_get(uint16_t signal_id);

View file

@ -1,15 +1,12 @@
/** /**
* DreamStack Thin Client Waveshare ESP32-P4 10.1" Panel * DreamStack Thin Client Waveshare ESP32-P4 10.1" Panel
* *
* Firmware that turns the panel into a dumb pixel display * Dual transport firmware:
* with touch input. All rendering happens on the source device. * 1. ESP-NOW + Panel IR (primary) sub-1ms signal delivery, LVGL native rendering
* 2. WebSocket + pixel streaming (fallback) for non-DreamStack content
* *
* Flow: WiFi WebSocket receive delta frames blit to display * Build with -DDS_USE_ESPNOW=1 for ESP-NOW mode (default)
* Touch encode event send over WebSocket * Build with -DDS_USE_ESPNOW=0 for WebSocket-only pixel mode
*
* Dependencies (via ESP Component Registry):
* - waveshare/esp_lcd_jd9365_10_1 (10.1" MIPI DSI display driver)
* - espressif/esp_websocket_client (WebSocket client)
*/ */
#include <stdio.h> #include <stdio.h>
@ -21,26 +18,39 @@
#include "esp_event.h" #include "esp_event.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_ops.h"
#include "esp_websocket_client.h"
#include "ds_codec.h" #include "ds_codec.h"
#include "ds_protocol.h" #include "ds_protocol.h"
// ─── Transport selection ───
#ifndef DS_USE_ESPNOW
#define DS_USE_ESPNOW 1
#endif
#if DS_USE_ESPNOW
#include "ds_espnow.h"
#include "ds_runtime.h"
#endif
static const char *TAG = "ds-panel"; static const char *TAG = "ds-panel";
// ─── Configuration (set via menuconfig or hardcode for POC) ─── // ─── Configuration ───
#define PANEL_WIDTH 800 #define PANEL_WIDTH 800
#define PANEL_HEIGHT 1280 #define PANEL_HEIGHT 1280
#define PIXEL_BYTES 2 // RGB565 #define PIXEL_BYTES 2 // RGB565
#define FB_SIZE (PANEL_WIDTH * PANEL_HEIGHT * PIXEL_BYTES) // ~2MB #define FB_SIZE (PANEL_WIDTH * PANEL_HEIGHT * PIXEL_BYTES)
#define WIFI_SSID CONFIG_WIFI_SSID #define WIFI_SSID CONFIG_WIFI_SSID
#define WIFI_PASS CONFIG_WIFI_PASS #define WIFI_PASS CONFIG_WIFI_PASS
#define RELAY_URL CONFIG_RELAY_URL // e.g. "ws://192.168.1.100:9100/stream/home"
// ─── Framebuffers (in PSRAM) ─── #if !DS_USE_ESPNOW
static uint8_t *framebuffer; // Current display state #define RELAY_URL CONFIG_RELAY_URL
static uint8_t *scratch_buf; // Temp buffer for delta decode #include "esp_websocket_client.h"
#endif
// ─── Framebuffers (in PSRAM, for pixel mode) ───
static uint8_t *framebuffer;
static uint8_t *scratch_buf;
// ─── Display handle ─── // ─── Display handle ───
static esp_lcd_panel_handle_t panel_handle = NULL; static esp_lcd_panel_handle_t panel_handle = NULL;
@ -48,7 +58,63 @@ static esp_lcd_panel_handle_t panel_handle = NULL;
// ─── Touch state ─── // ─── Touch state ───
static uint16_t input_seq = 0; static uint16_t input_seq = 0;
// ─── WebSocket event handler ─── #if DS_USE_ESPNOW
// ═══════════════════════════════════════════════════════
// ESP-NOW + Panel IR Mode
// ═══════════════════════════════════════════════════════
static void on_signal(uint16_t signal_id, int32_t value) {
// Feed signal updates to the Panel IR runtime
ds_signal_update(signal_id, value);
ESP_LOGD(TAG, "Signal %d = %d", signal_id, (int)value);
}
static void on_ir_push(const char *ir_json, size_t length) {
// Build LVGL UI from Panel IR JSON
ESP_LOGI(TAG, "IR push received (%zu bytes), building UI...", length);
esp_err_t ret = ds_ui_build(ir_json, length);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "UI built: %d signals", ds_signal_count());
} else {
ESP_LOGE(TAG, "Failed to build UI from IR");
}
}
static void on_action(uint8_t node_id, uint8_t action_type) {
// Forward widget actions to hub via ESP-NOW
ds_espnow_send_action(node_id, action_type);
}
static void espnow_init_and_run(void) {
// Initialize Panel IR runtime (LVGL must be ready)
// TODO: replace NULL with lv_scr_act() once LVGL is initialized
ds_runtime_init(NULL, on_action);
// Initialize ESP-NOW transport
ds_espnow_config_t config = {
.hub_mac = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // broadcast
.channel = DS_ESPNOW_CHANNEL,
.on_signal = on_signal,
.on_ir_push = on_ir_push,
};
esp_err_t ret = ds_espnow_init(&config);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "ESP-NOW init failed");
return;
}
// Send periodic pings so hub discovers us
while (1) {
ds_espnow_send_ping();
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
#else
// ═══════════════════════════════════════════════════════
// WebSocket Pixel Streaming Mode (Legacy)
// ═══════════════════════════════════════════════════════
static void ws_event_handler(void *arg, esp_event_base_t base, static void ws_event_handler(void *arg, esp_event_base_t base,
int32_t event_id, void *event_data) { int32_t event_id, void *event_data) {
esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data; esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data;
@ -69,7 +135,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base,
switch (hdr.frame_type) { switch (hdr.frame_type) {
case DS_FRAME_PIXELS: case DS_FRAME_PIXELS:
// Full keyframe — copy directly to framebuffer
if (payload_len == FB_SIZE) { if (payload_len == FB_SIZE) {
memcpy(framebuffer, payload, FB_SIZE); memcpy(framebuffer, payload, FB_SIZE);
esp_lcd_panel_draw_bitmap(panel_handle, esp_lcd_panel_draw_bitmap(panel_handle,
@ -79,7 +144,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base,
break; break;
case DS_FRAME_DELTA: case DS_FRAME_DELTA:
// Delta frame — RLE decode + XOR apply
if (ds_apply_delta_rle(framebuffer, FB_SIZE, if (ds_apply_delta_rle(framebuffer, FB_SIZE,
payload, payload_len, scratch_buf) == 0) { payload, payload_len, scratch_buf) == 0) {
esp_lcd_panel_draw_bitmap(panel_handle, esp_lcd_panel_draw_bitmap(panel_handle,
@ -90,7 +154,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base,
break; break;
case DS_FRAME_PING: case DS_FRAME_PING:
// Respond with pong (same message back)
break; break;
case DS_FRAME_END: case DS_FRAME_END:
@ -109,7 +172,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base,
} }
} }
// ─── Send touch event over WebSocket ───
static void send_touch_event(esp_websocket_client_handle_t ws, static void send_touch_event(esp_websocket_client_handle_t ws,
uint8_t id, uint16_t x, uint16_t y, uint8_t phase) { uint8_t id, uint16_t x, uint16_t y, uint8_t phase) {
uint8_t buf[DS_HEADER_SIZE + sizeof(ds_touch_event_t)]; uint8_t buf[DS_HEADER_SIZE + sizeof(ds_touch_event_t)];
@ -120,52 +182,27 @@ static void send_touch_event(esp_websocket_client_handle_t ws,
esp_websocket_client_send_bin(ws, (const char *)buf, len, portMAX_DELAY); esp_websocket_client_send_bin(ws, (const char *)buf, len, portMAX_DELAY);
} }
// ─── Touch polling task ─── #endif // DS_USE_ESPNOW
//
// TODO: Replace with actual GT9271 I2C touch driver.
// The Waveshare BSP should provide touch reading functions.
// This is a placeholder showing the integration pattern.
//
static void touch_task(void *arg) {
esp_websocket_client_handle_t ws = (esp_websocket_client_handle_t)arg;
// ─── Touch polling task ───
static void touch_task(void *arg) {
while (1) { while (1) {
// TODO: Read from GT9271 touch controller via I2C // TODO: Read from GT9271 touch controller via I2C
// Example (pseudocode):
//
// gt9271_touch_data_t td; // gt9271_touch_data_t td;
// if (gt9271_read(&td) == ESP_OK && td.num_points > 0) { // if (gt9271_read(&td) == ESP_OK && td.num_points > 0) {
// for (int i = 0; i < td.num_points; i++) { // #if DS_USE_ESPNOW
// send_touch_event(ws, td.points[i].id, // ds_espnow_send_touch(0, 0, td.points[0].x, td.points[0].y);
// td.points[i].x, td.points[i].y, // #else
// td.points[i].phase); // send_touch_event(ws, ...);
// } // #endif
// } // }
vTaskDelay(pdMS_TO_TICKS(10)); // 100Hz touch polling vTaskDelay(pdMS_TO_TICKS(10)); // 100Hz touch polling
} }
} }
// ─── Display initialization ─── // ─── Display initialization ───
//
// TODO: Initialize MIPI DSI display using Waveshare component.
// Add `waveshare/esp_lcd_jd9365_10_1` to idf_component.yml
//
static esp_err_t display_init(void) { static esp_err_t display_init(void) {
// TODO: Configure MIPI DSI bus and JD9365 panel driver // TODO: Initialize MIPI DSI display using Waveshare component
// Example (pseudocode):
//
// esp_lcd_dsi_bus_config_t bus_cfg = { ... };
// esp_lcd_new_dsi_bus(&bus_cfg, &dsi_bus);
//
// esp_lcd_panel_dev_config_t panel_cfg = {
// .reset_gpio_num = ...,
// .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
// .bits_per_pixel = 16, // RGB565
// };
// esp_lcd_new_panel_jd9365_10_1(dsi_bus, &panel_cfg, &panel_handle);
// esp_lcd_panel_init(panel_handle);
ESP_LOGI(TAG, "Display initialized (%dx%d RGB565)", PANEL_WIDTH, PANEL_HEIGHT); ESP_LOGI(TAG, "Display initialized (%dx%d RGB565)", PANEL_WIDTH, PANEL_HEIGHT);
return ESP_OK; return ESP_OK;
} }
@ -188,48 +225,57 @@ static void wifi_init(void) {
esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg);
esp_wifi_start(); esp_wifi_start();
esp_wifi_connect();
#if DS_USE_ESPNOW
// For ESP-NOW: don't need to connect to AP, just start WiFi
ESP_LOGI(TAG, "WiFi started (ESP-NOW mode, no AP connection needed)");
#else
esp_wifi_connect();
ESP_LOGI(TAG, "WiFi connecting to %s...", WIFI_SSID); ESP_LOGI(TAG, "WiFi connecting to %s...", WIFI_SSID);
#endif
} }
// ─── Main ─── // ─── Main ───
void app_main(void) { void app_main(void) {
ESP_LOGI(TAG, "DreamStack Thin Client v0.1"); #if DS_USE_ESPNOW
ESP_LOGI(TAG, "Panel: %dx%d @ %d bpp = %d bytes", ESP_LOGI(TAG, "DreamStack Panel v0.2 (ESP-NOW + Panel IR)");
#else
ESP_LOGI(TAG, "DreamStack Panel v0.2 (WebSocket + Pixel)");
#endif
ESP_LOGI(TAG, "Display: %dx%d @ %d bpp = %d bytes",
PANEL_WIDTH, PANEL_HEIGHT, PIXEL_BYTES * 8, FB_SIZE); PANEL_WIDTH, PANEL_HEIGHT, PIXEL_BYTES * 8, FB_SIZE);
// Initialize NVS (required for WiFi)
nvs_flash_init(); nvs_flash_init();
// Allocate framebuffers in PSRAM // Allocate framebuffers in PSRAM (needed for pixel mode, optional for IR mode)
framebuffer = heap_caps_calloc(1, FB_SIZE, MALLOC_CAP_SPIRAM); framebuffer = heap_caps_calloc(1, FB_SIZE, MALLOC_CAP_SPIRAM);
scratch_buf = heap_caps_calloc(1, FB_SIZE, MALLOC_CAP_SPIRAM); scratch_buf = heap_caps_calloc(1, FB_SIZE, MALLOC_CAP_SPIRAM);
if (!framebuffer || !scratch_buf) { if (!framebuffer || !scratch_buf) {
ESP_LOGE(TAG, "Failed to allocate framebuffers in PSRAM (%d bytes each)", FB_SIZE); ESP_LOGE(TAG, "Failed to allocate framebuffers in PSRAM (%d bytes each)", FB_SIZE);
return; return;
} }
ESP_LOGI(TAG, "Framebuffers allocated in PSRAM (%d MB each)", FB_SIZE / (1024 * 1024));
// Initialize display
display_init(); display_init();
// Initialize WiFi
wifi_init(); wifi_init();
vTaskDelay(pdMS_TO_TICKS(3000)); // Wait for WiFi connection vTaskDelay(pdMS_TO_TICKS(2000));
// Connect WebSocket to relay #if DS_USE_ESPNOW
// ESP-NOW mode: init transport + runtime, wait for IR push
espnow_init_and_run();
#else
// WebSocket mode: connect to relay, receive pixel frames
esp_websocket_client_config_t ws_cfg = { esp_websocket_client_config_t ws_cfg = {
.uri = RELAY_URL, .uri = RELAY_URL,
.buffer_size = 64 * 1024, // 64KB receive buffer .buffer_size = 64 * 1024,
}; };
esp_websocket_client_handle_t ws = esp_websocket_client_init(&ws_cfg); esp_websocket_client_handle_t ws = esp_websocket_client_init(&ws_cfg);
esp_websocket_register_events(ws, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL); esp_websocket_register_events(ws, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL);
esp_websocket_client_start(ws); esp_websocket_client_start(ws);
ESP_LOGI(TAG, "WebSocket connecting to %s...", RELAY_URL); ESP_LOGI(TAG, "WebSocket connecting to %s...", RELAY_URL);
// Start touch polling task
xTaskCreate(touch_task, "touch", 4096, ws, 5, NULL); xTaskCreate(touch_task, "touch", 4096, ws, 5, NULL);
#endif
ESP_LOGI(TAG, "Thin client running. Waiting for frames..."); ESP_LOGI(TAG, "Panel running. Waiting for frames...");
} }

449
docs/explorations.md Normal file
View file

@ -0,0 +1,449 @@
# DreamStack Hardware Explorations
> Research notes on form factors, display technologies, and touch input methods for DreamStack-powered surfaces.
---
## 1. USB Dongle (Chromecast-like)
The DreamStack relay protocol + delta codec already does 90% of the work. A dongle receives the bitstream and outputs to HDMI.
### Path A: ESP32-S3 HDMI Dongle (~$15 DIY)
| Component | Part | Cost |
|-----------|------|------|
| SoC | ESP32-S3-WROOM-1 (N16R8) | ~$4 |
| HDMI output | CH7035B or ADV7513 HDMI encoder IC | ~$3 |
| USB-C power | Standard power-only connector | ~$0.50 |
| PCB + passives | Custom PCB (JLCPCB) | ~$5 for 5 boards |
| HDMI connector | Type-A male or mini-HDMI | ~$1 |
- ESP32-S3 LCD parallel interface → HDMI encoder IC → HDMI out
- WiFi connects to DreamStack relay, receives delta-compressed frames
- Resolution limit: ~480×320 smooth, 800×600 at lower FPS
- Input via BLE HID remote or HDMI CEC (pin 13)
### Path B: Linux Stick / Allwinner (~$25-40)
MangoPi MQ-Pro / Radxa Zero form factor:
| Component | Part | Cost |
|-----------|------|------|
| SoC | Allwinner H616/H618 (HDMI built-in) | ~$15 module |
| RAM | 512MB DDR3 onboard | included |
| WiFi | RTL8723CS | ~$3 |
| Storage | 8GB eMMC or SD | ~$3 |
- Runs minimal Linux (Buildroot), headless browser or C receiver writing to `/dev/fb0`
- Native HDMI — no encoder IC needed
- Full DreamStack JS runtime in headless Chromium/WPE-WebKit
- CEC for remote control
### Path C: Pi Zero 2 W (~$15, recommended MVP)
Best for proving the concept immediately — $15, mini-HDMI out, WiFi, runs DreamStack natively.
```
Laptop WiFi/LAN Pi Zero 2 W (in 3D-printed HDMI case)
────── ───────── ─────────────────────────────────────
dreamstack dev app.ds headless browser / ds_runtime
→ relay-bridge ──────── WebSocket ───────→ → HDMI out to TV
←── CEC/BLE ←──── remote control
```
### Off-the-shelf stick computers
| Device | Price | HDMI | WiFi | Notes |
|--------|-------|------|------|-------|
| Raspberry Pi Zero 2 W | $15 | Mini-HDMI ✅ | ✅ | Best form factor |
| MangoPi MQ-Pro (RISC-V) | $20 | HDMI ✅ | ✅ | Stick form factor |
| Radxa Zero | $25 | Micro-HDMI ✅ | ✅ | Amlogic S905Y2 |
| T-Dongle S3 (LilyGO) | $12 | No (LCD only) | ✅ | ESP32-S3, tiny LCD |
---
## 2. Projected Touch Wall
### Architecture
```
SOURCE (laptop/Pi) RELAY (:9100) WALL
─────────────── ───────────── ────
DreamStack app WebSocket hub UST Projector
800×1280 canvas + touch overlay
pixels → XOR delta → RLE ────→ relay ─────────────────→ decode → project
←── touch {x,y,phase} ←── touch sensor
```
### Ultra-Short-Throw Projectors
| Product | Price (new) | Price (used) | Notes |
|---------|-------------|-------------|-------|
| Xiaomi Laser Cinema 2 | ~$1,200 | ~$400 | Good value |
| BenQ V7050i | ~$2,500 | ~$800 | 4K HDR |
| JMGO U2 | ~$1,000 | ~$300 | Budget friendly |
| Epson LS500 | ~$2,000 | ~$600 | Bright |
Wall prep: screen paint (Silver Ticket / Rust-Oleum, ~$30).
---
## 3. Touch Input Technologies
Ranked from fastest to slowest latency:
### 3a. Piezoelectric Sensors (<1ms, ~$7)
Stick 3-4 piezo discs on the **back** of the wall. Finger taps create vibrations; time-difference-of-arrival (TDOA) triangulates X,Y.
| Part | Cost |
|------|------|
| 4× piezo disc (35mm) | ~$2 |
| ESP32-S3 (built-in ADC, 40kHz+ sampling) | ~$5 |
```
Wall (drywall, glass, wood, whiteboard)
┌───────────────────────────────────────┐
│ P1 ● ● P2 │
│ 👆 TAP │
│ P3 ● ● P4 │
└───────────────────────────────────────┘
└────── ESP32 (TDOA → x,y) ─────┘
```
**Pros:** Near-instant, invisible, dirt cheap, works through paint
**Cons:** Only detects **taps** (not drag/hover), needs hard surface
---
### 3b. Capacitive Wire/Paint Grid (1-3ms, ~$21 DIY)
Grid of conductors (copper tape or conductive paint) behind the wall. Measures capacitance change when a finger approaches.
| Part | Cost |
|------|------|
| Copper tape grid (30 channels) or graphite paint | ~$10 |
| MPR121 capacitive controller ×3 | ~$6 |
| ESP32 | ~$5 |
**Supports:** Touch ✅ Drag ✅ Multi-touch ✅ Hover (~1-2cm) ✅
**Resolution:** Depends on grid pitch — 3cm pitch ≈ 30×40 nodes over 100"
#### How Mutual Capacitance Works
Two layers of conductors (rows + columns) cross each other, separated by a thin insulator. Each intersection forms a capacitor. A finger near any intersection absorbs electric field, reducing measured capacitance.
The controller scans one row at a time (AC drive), reads all columns simultaneously. Full scan of 30×40 grid: ~0.5-1ms. Continuous scanning gives automatic drag/swipe detection. Sub-pixel interpolation from adjacent node readings gives ~1mm accuracy from 5mm pitch.
#### Recommended ICs
| IC | Grid Size | Touch Points | Price |
|----|-----------|-------------|-------|
| MTCH6303 (Microchip) | 15×49 | 10 | ~$5 |
| IQS7211A (Azoteq) | 15×22 | 5 | ~$3 |
| GT911 (Goodix) | 26×14 | 5 | ~$2 |
| FT5x06 (FocalTech) | 24×14 | 5 | ~$2 |
---
### 3c. FTIR — Frustrated Total Internal Reflection (3-8ms, ~$110-250)
Acrylic sheet on wall with IR LEDs on edges (total internal reflection). Finger touch "frustrates" the reflection → bright spots detected by IR camera.
| Part | Cost |
|------|------|
| 4mm acrylic sheet (100") | ~$80-150 |
| IR LED strip (850nm) on edges | ~$10 |
| IR camera (120fps, no IR filter) | ~$15 |
| ESP32-S3 or Pi | ~$5-75 |
**Pros:** Multi-touch, precise, pressure-sensitive (brighter blob = more pressure)
**Cons:** Needs smooth flat surface (acrylic)
---
### 3d. IR Touch Frame (8-15ms, ~$250-500)
Aluminum frame with IR LEDs + sensors on 4 edges. Finger breaks IR beams → X,Y.
| Size | Price | Touch Points |
|------|-------|-------------|
| 65" | ~$250 | 6-10 pt |
| 82" | ~$350 | 10-20 pt |
| 100" | ~$500 | 10-20 pt |
| 120"+ | ~$800+ | 20+ pt |
Premium: Neonode zForce (~6-8ms, 200Hz). Budget: generic Chinese frames (~15-30ms, 100Hz).
**Pros:** USB HID plug-and-play, works on any surface
**Cons:** Physical border/bezel on wall
---
### 3e. Depth Camera (15-30ms, ~$80-450)
| Camera | FPS | Latency | Range | Price |
|--------|-----|---------|-------|-------|
| **Intel RealSense D405** | 90fps | ~11ms | 7cm-50cm | ~$80 |
| RealSense D435i | 90fps | ~11ms | 10cm-10m | ~$200 |
| OAK-D SR (Short Range) | 60fps | ~12ms | 2cm-100cm | ~$150 |
| OAK-D Pro | 30fps depth | ~15ms | 20cm-15m | ~$200 |
| Stereolabs ZED Mini | 100fps | ~10ms | 10cm-12m | ~$300 |
| Stereolabs ZED X Mini | 120fps | ~8ms | 10cm-15m | ~$450 |
| Orbbec Gemini 2 | 60fps | ~16ms | 15cm-10m | ~$130 |
**RealSense D405** is ideal for wall touch — 90fps hardware stereo depth, 7cm minimum distance, global shutter. No ML needed for touch detection: just threshold the depth map (`depth < 5mm → TOUCH`, `< 150mm → HOVER`).
Layer MediaPipe on top (parallel) for gesture classification.
---
### 3f. Hybrid: Best of All Worlds
| Input | Method | Latency |
|-------|--------|---------|
| Tap detection + pressure | Piezo (4 corners) | <1ms |
| Touch + drag + hover | Capacitive grid | 1-3ms |
| Hand gestures (air) | RealSense D405 | ~15ms |
---
## 4. Gesture / Hand Tracking
### DIY Approaches (Ultraleap alternative)
#### Stereo IR Camera + MediaPipe (~$30-50)
Two OV2710 IR USB cameras (stereo pair, ~$15 each) + 850nm IR LED strip (~$5). MediaPipe Hands on Pi 5 or Jetson: 21 landmarks per hand, 30-120fps. Stereo triangulation gives Z. **Latency: ~20-30ms.**
#### Single Depth Camera (~$80-150)
Use RealSense D405 or OAK-D SR (see above). Hardware depth gives Z-distance from wall.
#### ESP32-S3 + IR Matrix (~$20, lowest latency)
IR LEDs flood the area in front of the wall. 2-3 IR cameras do blob detection at 120fps on ESP32-S3. Z estimated from blob size. No ML needed. **Latency: 5-10ms.**
---
## 5. Conductive Paint Recipes
For capacitive grid electrodes painted directly on walls.
### Graphite Paint (easiest, ~$5)
| Ingredient | Amount | Source |
|-----------|--------|--------|
| Graphite powder (<45μm) | 3 tbsp | Art supply, Amazon (~$8/lb) |
| PVA glue (white school glue) | 2 tbsp | Any store |
| Water | 1 tbsp | Tap |
~60% graphite, 30% glue, 10% water by volume. **Resistance: ~500-2000 Ω/sq.** Good enough for capacitive sensing.
### Carbon Black + Acrylic (~$15)
20-25% carbon black powder (conductive grade) in 75-80% acrylic medium. **Resistance: ~200-800 Ω/sq.** Better adhesion. Wear mask + gloves.
### Nickel Paint (~$20)
MG Chemicals 841, premade. **Resistance: ~5-50 Ω/sq.** Mid-range.
### Silver Paint (~$30-50)
Premade: Bare Conductive (~$25/50ml), MG Chemicals 842.
DIY: 70-80% silver flake powder (<10μm), 15-20% acrylic medium, 5-10% butyl acetate.
**Resistance: ~0.5-5 Ω/sq.** Near-wire conductivity.
### For capacitive sensing: graphite is sufficient
Capacitive touch doesn't need low resistance — just enough conductivity to couple with a finger. Paint lines with tape masking at 3-5cm spacing.
---
## 6. Pixel Paint — Paint-On Displays
### Electroluminescent (EL) Paint Display
Real and buildable. A stack of painted layers that glow when AC voltage is applied.
```
Layer stack (painted in order):
5. Clear topcoat
4. Transparent conductor (PEDOT:PSS) ← rows
3. Phosphor layer (ZnS:Cu in acrylic) ← glows
2. Dielectric (BaTiO₃ in acrylic) ← insulator
1. Base conductor (silver/carbon paint) ← columns
─── Wall surface ───
```
Row/column intersection = one pixel. AC across a specific row+column → only that intersection glows (passive matrix).
| Layer | Material | Cost/m² |
|-------|----------|---------|
| Base conductor (columns) | Silver paint, painted in strips | ~$50 |
| Dielectric | Barium titanate (BaTiO₃) in acrylic | ~$30 |
| Phosphor | ZnS:Cu powder in acrylic | ~$20 |
| Top conductor (rows) | PEDOT:PSS | ~$40 |
| Driver electronics | HV507 shift registers + ESP32 | ~$30 |
| **Total** | | **~$170/m²** |
#### Resolution at different pitches
| Pixel Pitch | Pixels (100" wall) | Comparable To |
|------------|---------------------|---------------|
| 20mm | 110×65 = 7,150 | LED sign |
| 10mm | 220×130 = 28,600 | Scoreboard |
| 5mm | 440×260 = 114,400 | ~400×260 display ✅ |
| 2mm | 1100×650 = 715,000 | Near SD |
At 5mm pitch: 440×260 — enough for DreamStack UIs, dashboards, snake game.
#### Color
- ZnS:Cu → green (brightest)
- ZnS:Cu,Mn → amber/orange
- ZnS:Cu,Al → blue-green
- Full RGB requires 3 sub-pixels per pixel (3× driver count)
- Monochrome green is practical and looks great
#### Built-in touch (free!)
The row/column electrodes double as capacitive sensing electrodes via time-multiplexing:
1. **Sense phase** (1ms): measure capacitance = touch position
2. **Drive phase** (15ms): apply AC = illuminate pixels
Same paint layers, no extra hardware.
#### Driver IC
HV507 — 64-channel high-voltage shift register. Drives 100V+ outputs from 3.3V SPI. Chain several for full display.
### Other Display Paint Technologies (Future)
| Technology | Status | Color | Speed |
|-----------|--------|-------|-------|
| Electrochromic (PEDOT:PSS, WO₃) | Real | Grayscale | 1-30s (too slow for video) |
| Thermochromic + resistive grid | Hackable | Limited | 1-5s |
| Perovskite spray-on LEDs | Lab only | Full color | ~ms |
| QD-LED inkjet | Lab only | Full color | ~ms |
Perovskite / QD-LED spray-on is the future (~2028-2030) but not available today.
---
## 7. Off-the-Shelf Solutions
### Capacitive Touch Overlays (stick-on film)
| Product | Max Size | Touch Points | Latency | Price |
|---------|----------|-------------|---------|-------|
| **Displax Skin Ultra** | 105" | 40 | ~6ms | ~$800-1500 |
| Visual Planet TouchFoil | 100"+ | 40 | ~8ms | ~$600-1200 |
| PQ Labs iTouch Plus | 150"+ | 32 | ~8ms | ~$400-900 |
| AliExpress "PCAP touch foil" | 100"+ | 10 | ~10-15ms | ~$200-400 |
Displax Skin Ultra: transparent polymer film with nano-wire grid, adhesive-backed, works through 6mm of material, USB HID, detects hover at ~2cm. Stick on wall, plug USB, done.
### All-in-One Interactive Projectors
| Product | Size | Touch | Latency | Price (new) |
|---------|------|-------|---------|-------------|
| **Epson BrightLink 770Fi** | 100" | 10pt + pen | ~10ms | ~$2,500 |
| Epson BrightLink 735Fi | 100" | 10pt + pen | ~10ms | ~$2,000 |
| BenQ LW890UST | 100" | 10pt | ~12ms | ~$1,800 |
| Boxlight Mimio MiXX | 100" | 20pt | ~8ms | ~$2,200 |
**Used education projectors** (schools constantly upgrade):
| Used Option | Price |
|-------------|-------|
| Epson BrightLink 695Wi/696Ui | $300-600 |
| BenQ MW855UST+ with PointWrite | $400-700 |
| Promethean UST + ActivBoard | $300-500 |
### Interactive Flat Panels (giant touchscreen monitors)
| Product | Size | Price (new) | Price (used) |
|---------|------|-------------|-------------|
| **SMART Board MX** | 65-86" | $3,000-6,000 | $500-1,500 |
| Promethean ActivPanel | 65-86" | $3,000-5,000 | $600-1,200 |
| ViewSonic ViewBoard | 65-98" | $2,000-8,000 | $500-1,500 |
| Samsung Flip | 55-85" | $2,000-4,000 | $800-2,000 |
| Microsoft Surface Hub 2S | 50-85" | $5,000-12,000 | $1,500-3,000 |
---
## 8. Recommended Builds
### Budget: $675
| Component | Source | Price |
|-----------|--------|-------|
| Used SMART Board 65" | eBay | ~$600 |
| Pi 5 | Official | ~$75 |
Plug HDMI + USB, run DreamStack, done.
### Mid-Range: $700
| Component | Price |
|-----------|-------|
| UST projector (used) | ~$300 |
| PCAP touch foil 100" (AliExpress) | ~$300 |
| Pi 5 | ~$75 |
| Screen paint | ~$30 |
### Premium: $1,050
| Component | Price |
|-----------|-------|
| UST projector (used) | ~$400 |
| 100" IR touch frame | ~$350 |
| RealSense D405 (gestures + hover) | ~$80 |
| Pi 5 | ~$75 |
| Piezo sensors (4 corners, tap confirm) | ~$7 |
| Screen paint | ~$30 |
Touch at 8-15ms + hover/gestures at 15ms + tap confirmation at <1ms.
### DIY Maximum: ~$200 + wall paint
| Component | Price |
|-----------|-------|
| Conductive graphite paint (capacitive grid) | ~$10 |
| MPR121/MTCH6303 cap-touch IC | ~$5 |
| ESP32-S3 | ~$5 |
| UST projector (used) | ~$300 |
Paint your own touch grid on the wall, 1-3ms latency, no frame needed.
---
## 9. DreamStack Integration
All touch methods feed into the existing relay protocol:
```
Touch sensor (any method above)
→ ESP32 or Pi reads touch events
→ Encodes as DreamStack protocol:
0x01 Pointer move (x, y)
0x02 Pointer down (x, y, buttons)
0x03 Pointer up
0x10 KeyDown (keyCode)
0x20 Hover (x, y, z_distance) ← new
0x21 Swipe (direction, velocity) ← new
0x22 Pinch/Grab (state) ← new
→ WebSocket → DreamStack relay
→ App receives as signal updates
```
DreamStack syntax for handling:
```
on hover(ev) -> opacity = lerp(0.5, 1.0, ev.z)
on swipe(ev) -> navigate(if ev.dir == "left" then "/next" else "/prev")
on grab(ev) -> scale = if ev.closed then 0.9 else 1.0
```

View file

@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* DreamStack Screencast CDP Capture Agent
*
* Streams any web page to DreamStack panels via Chrome DevTools Protocol.
* Zero changes to the target app just point at any URL.
*
* Usage:
* node capture.js [url] [--headless] [--fps=N] [--quality=N]
*
* Examples:
* node capture.js http://localhost:3000
* node capture.js https://react.dev --headless --fps=30
*/
const CDP = require('chrome-remote-interface');
const { WebSocketServer } = require('ws');
const http = require('http');
const { spawn } = require('child_process');
// ─── Config ───
const TARGET_URL = process.argv[2] || 'http://localhost:3000';
const WIDTH = 800;
const HEIGHT = 1280;
const WS_PORT = 9300;
const MONITOR_PORT = 9301;
const CDP_PORT = 9222;
const QUALITY = parseInt((process.argv.find(a => a.startsWith('--quality=')) || '').split('=')[1] || '75');
const MAX_FPS = parseInt((process.argv.find(a => a.startsWith('--fps=')) || '').split('=')[1] || '30');
const HEADLESS = process.argv.includes('--headless');
const clients = new Set();
let frameCount = 0;
let bytesSent = 0;
const t0 = Date.now();
// ─── 1. Launch Chrome ───
function launchChrome() {
return new Promise((resolve, reject) => {
const args = [
`--remote-debugging-port=${CDP_PORT}`,
`--window-size=${WIDTH},${HEIGHT}`,
'--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--disable-extensions', '--disable-translate', '--disable-sync',
'--disable-background-networking', '--disable-default-apps',
'--mute-audio', '--no-sandbox',
];
if (HEADLESS) args.push('--headless=new');
args.push('about:blank');
const proc = spawn('google-chrome', args, { stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => {
if (d.toString().includes('DevTools listening')) resolve(proc);
});
proc.on('error', reject);
proc.on('exit', code => { console.log(`[Chrome] exit ${code}`); process.exit(0); });
// Fallback timeout
setTimeout(() => resolve(proc), 4000);
});
}
// ─── 2. WebSocket server for panels/monitor ───
function startWS() {
const wss = new WebSocketServer({ host: '0.0.0.0', port: WS_PORT });
wss.on('connection', (ws, req) => {
clients.add(ws);
console.log(`[WS] +1 panel (${clients.size}) from ${req.socket.remoteAddress}`);
ws.on('close', () => { clients.delete(ws); console.log(`[WS] -1 panel (${clients.size})`); });
ws.on('message', data => { ws._inputHandler?.(data); });
});
console.log(`[WS] Panels: ws://0.0.0.0:${WS_PORT}`);
return wss;
}
// ─── 3. Monitor page ───
function startMonitor() {
const html = `<!DOCTYPE html><html><head>
<title>DreamStack Screencast</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:system-ui;color:#999}
canvas{border:1px solid #333;border-radius:8px;max-height:85vh;cursor:crosshair}
#hud{margin-top:10px;font:13px/1.5 monospace;text-align:center}
h3{margin-bottom:6px;color:#555;font-size:14px}
</style></head><body>
<h3>DreamStack Screencast Monitor</h3>
<canvas id="c" width="${WIDTH}" height="${HEIGHT}"></canvas>
<div id="hud">Connecting</div>
<script>
const c=document.getElementById('c'),ctx=c.getContext('2d'),hud=document.getElementById('hud');
c.style.width=Math.min(${WIDTH},innerWidth*.45)+'px';c.style.height='auto';
let fr=0,by=0,t=Date.now();
const ws=new WebSocket('ws://'+location.hostname+':${WS_PORT}');
ws.binaryType='arraybuffer';
ws.onopen=()=>{hud.textContent='Connected — waiting for frames…'};
ws.onmessage=e=>{
const buf=new Uint8Array(e.data);
if(buf[0]!==0x50)return;
const jpeg=buf.slice(9);fr++;by+=jpeg.length;
const blob=new Blob([jpeg],{type:'image/jpeg'});
const url=URL.createObjectURL(blob);
const img=new Image();
img.onload=()=>{ctx.drawImage(img,0,0);URL.revokeObjectURL(url)};
img.src=url;
const now=Date.now();
if(now-t>1000){
hud.textContent='FPS: '+(fr/((now-t)/1000)).toFixed(1)+' | '+(by/1024/((now-t)/1000)).toFixed(0)+' KB/s | Frame: '+(jpeg.length/1024).toFixed(1)+'KB';
fr=0;by=0;t=now;
}
};
c.addEventListener('click',e=>{
const r=c.getBoundingClientRect(),sx=${WIDTH}/r.width,sy=${HEIGHT}/r.height;
const x=Math.round((e.clientX-r.left)*sx),y=Math.round((e.clientY-r.top)*sy);
const b=new Uint8Array(7);const dv=new DataView(b.buffer);
b[0]=0x60;b[1]=0;dv.setUint16(2,x,true);dv.setUint16(4,y,true);b[6]=0;
ws.send(b);
setTimeout(()=>{const e2=new Uint8Array(7);const d2=new DataView(e2.buffer);e2[0]=0x60;e2[1]=2;d2.setUint16(2,x,true);d2.setUint16(4,y,true);e2[6]=0;ws.send(e2)},50);
hud.textContent+=' | Click: ('+x+','+y+')';
});
ws.onclose=()=>{hud.textContent='Disconnected — reload to retry'};
</script></body></html>`;
http.createServer((_, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); })
.listen(MONITOR_PORT, '0.0.0.0', () => console.log(`[Monitor] http://0.0.0.0:${MONITOR_PORT}`));
}
// ─── 4. CDP screencast loop ───
async function startScreencast() {
let client;
// Retry CDP connect (Chrome may still be starting)
for (let i = 0; i < 10; i++) {
try {
client = await CDP({ port: CDP_PORT });
break;
} catch {
await new Promise(r => setTimeout(r, 1000));
}
}
if (!client) throw new Error('Cannot connect to Chrome CDP');
const { Page, Input, Emulation } = client;
await Page.enable();
await Emulation.setDeviceMetricsOverride({
width: WIDTH, height: HEIGHT, deviceScaleFactor: 1, mobile: true,
});
await Emulation.setTouchEmulationEnabled({ enabled: true });
await Page.navigate({ url: TARGET_URL });
await new Promise(r => setTimeout(r, 2000)); // let page load
// Wire up input forwarding from panels
for (const ws of clients) {
ws._inputHandler = (data) => handleInput(Buffer.from(data), Input, Page);
}
// Also for future connections
const origAdd = clients.add.bind(clients);
clients.add = function (ws) {
origAdd(ws);
ws._inputHandler = (data) => handleInput(Buffer.from(data), Input, Page);
};
// Start screencast
await Page.startScreencast({
format: 'jpeg', quality: QUALITY,
maxWidth: WIDTH, maxHeight: HEIGHT,
everyNthFrame: Math.max(1, Math.round(60 / MAX_FPS)),
});
// Listen for frames via the event API
client.on('event', (message) => {
if (message.method !== 'Page.screencastFrame') return;
const { sessionId, data, metadata } = message.params;
// ACK immediately (fire-and-forget)
Page.screencastFrameAck({ sessionId }).catch(() => { });
frameCount++;
const jpegBuf = Buffer.from(data, 'base64');
bytesSent += jpegBuf.length;
// Build frame: [0x50][ts:u32LE][w:u16LE][h:u16LE][jpeg...]
const hdr = Buffer.alloc(9);
hdr[0] = 0x50;
hdr.writeUInt32LE((Date.now() - t0) >>> 0, 1);
hdr.writeUInt16LE(metadata.deviceWidth || WIDTH, 5);
hdr.writeUInt16LE(metadata.deviceHeight || HEIGHT, 7);
const frame = Buffer.concat([hdr, jpegBuf]);
// Broadcast
for (const ws of clients) {
if (ws.readyState === 1) ws.send(frame);
}
if (frameCount % 60 === 0) {
const elapsed = (Date.now() - t0) / 1000;
console.log(`[Cast] #${frameCount} | ${(jpegBuf.length / 1024).toFixed(1)}KB | avg ${(bytesSent / 1024 / elapsed).toFixed(0)} KB/s | ${clients.size} panels`);
}
});
console.log(`[CDP] Casting ${TARGET_URL}${WIDTH}×${HEIGHT} @ q${QUALITY}`);
}
// ─── Input handler ───
function handleInput(buf, Input, Page) {
if (buf.length < 1) return;
const t = buf[0];
if (t === 0x60 && buf.length >= 7) {
const phase = buf[1];
const x = buf.readUInt16LE(2), y = buf.readUInt16LE(4);
const type = phase === 0 ? 'touchStart' : phase === 1 ? 'touchMove' : 'touchEnd';
Input.dispatchTouchEvent({
type, touchPoints: [{ x, y, id: buf[6] || 0, radiusX: 10, radiusY: 10, force: phase === 2 ? 0 : 1 }]
}).catch(() => { });
}
if (t === 0x61 && buf.length >= 6) {
const x = buf.readUInt16LE(1), y = buf.readUInt16LE(3);
Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }).catch(() => { });
setTimeout(() => Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }).catch(() => { }), 50);
}
if (t === 0x63 && buf.length >= 3) {
const len = buf.readUInt16LE(1);
const url = buf.slice(3, 3 + len).toString();
Page.navigate({ url }).catch(() => { });
console.log(`[Nav] → ${url}`);
}
}
// ─── Main ───
async function main() {
console.log(`\n DreamStack Screencast`);
console.log(` ─────────────────────`);
console.log(` URL: ${TARGET_URL}`);
console.log(` Viewport: ${WIDTH}×${HEIGHT}`);
console.log(` Quality: ${QUALITY}% FPS: ${MAX_FPS}`);
console.log(` Headless: ${HEADLESS}\n`);
const chrome = await launchChrome();
startWS();
startMonitor();
await startScreencast();
console.log(`\n ✓ Streaming! Panels → ws://0.0.0.0:${WS_PORT}`);
console.log(` ✓ Monitor → http://localhost:${MONITOR_PORT}\n`);
process.on('SIGINT', () => {
console.log('\n[Stop]');
chrome.kill();
process.exit(0);
});
}
main().catch(err => { console.error('Fatal:', err); process.exit(1); });

View file

@ -0,0 +1 @@
../chrome-remote-interface/bin/client.js

69
engine/ds-screencast/node_modules/.package-lock.json generated vendored Normal file
View file

@ -0,0 +1,69 @@
{
"name": "ds-screencast",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/chrome-remote-interface": {
"version": "0.34.0",
"resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.34.0.tgz",
"integrity": "sha512-rTTcTZ3zemx8I+nvBii7d8BAF0Ms8LLEroypfvwwZOwSpyNGLE28nStXyCA6VwGp2YSQfmCrQH21F/E+oBFvMw==",
"license": "MIT",
"dependencies": {
"commander": "2.11.x",
"ws": "^7.2.0"
},
"bin": {
"chrome-remote-interface": "bin/client.js"
}
},
"node_modules/chrome-remote-interface/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/commander": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,18 @@
Copyright (c) 2026 Andrea Cardaci <cyrus.and@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,991 @@
# chrome-remote-interface
[![CI status](https://github.com/cyrus-and/chrome-remote-interface/actions/workflows/ci.yml/badge.svg)](https://github.com/cyrus-and/chrome-remote-interface/actions?query=workflow:CI)
[Chrome Debugging Protocol] interface that helps to instrument Chrome (or any
other suitable [implementation](#implementations)) by providing a simple
abstraction of commands and notifications using a straightforward JavaScript
API.
## Sample API usage
The following snippet loads `https://github.com` and dumps every request made:
```js
const CDP = require('chrome-remote-interface');
async function example() {
let client;
try {
// connect to endpoint
client = await CDP();
// extract domains
const {Network, Page} = client;
// setup handlers
Network.requestWillBeSent((params) => {
console.log(params.request.url);
});
// enable events then start!
await Network.enable();
await Page.enable();
await Page.navigate({url: 'https://github.com'});
await Page.loadEventFired();
} catch (err) {
console.error(err);
} finally {
if (client) {
await client.close();
}
}
}
example();
```
Find more examples in the [wiki]. You may also want to take a look at the [FAQ].
[wiki]: https://github.com/cyrus-and/chrome-remote-interface/wiki
[async-await-example]: https://github.com/cyrus-and/chrome-remote-interface/wiki/Async-await-example
[FAQ]: https://github.com/cyrus-and/chrome-remote-interface#faq
## Installation
npm install chrome-remote-interface
Install globally (`-g`) to just use the [bundled client](#bundled-client).
## Implementations
This module should work with every application implementing the
[Chrome Debugging Protocol]. In particular, it has been tested against the
following implementations:
Implementation | Protocol version | [Protocol] | [List] | [New] | [Activate] | [Close] | [Version]
---------------------------|--------------------|------------|--------|-------|------------|---------|-----------
[Chrome][1.1] | [tip-of-tree][1.2] | yes¹ | yes | yes | yes | yes | yes
[Opera][2.1] | [tip-of-tree][2.2] | yes | yes | yes | yes | yes | yes
[Node.js][3.1] ([v6.3.0]+) | [node][3.2] | yes | no | no | no | no | yes
[Safari (iOS)][4.1] | [*partial*][4.2] | no | yes | no | no | no | no
[Edge][5.1] | [*partial*][5.2] | yes | yes | no | no | no | yes
[Firefox (Nightly)][6.1] | [*partial*][6.2] | yes | yes | no | yes | yes | yes
¹ Not available on [Chrome for Android][chrome-mobile-protocol], hence a local version of the protocol must be used.
[chrome-mobile-protocol]: https://bugs.chromium.org/p/chromium/issues/detail?id=824626#c4
[1.1]: #chromechromium
[1.2]: https://chromedevtools.github.io/devtools-protocol/tot/
[2.1]: #opera
[2.2]: https://chromedevtools.github.io/devtools-protocol/tot/
[3.1]: #nodejs
[3.2]: https://chromedevtools.github.io/devtools-protocol/v8/
[4.1]: #safari-ios
[4.2]: http://trac.webkit.org/browser/trunk/Source/JavaScriptCore/inspector/protocol
[5.1]: #edge
[5.2]: https://docs.microsoft.com/en-us/microsoft-edge/devtools-protocol/0.1/domains/
[6.1]: #firefox-nightly
[6.2]: https://firefox-source-docs.mozilla.org/remote/index.html
[v6.3.0]: https://nodejs.org/en/blog/release/v6.3.0/
[Protocol]: #cdpprotocoloptions-callback
[List]: #cdplistoptions-callback
[New]: #cdpnewoptions-callback
[Activate]: #cdpactivateoptions-callback
[Close]: #cdpcloseoptions-callback
[Version]: #cdpversionoptions-callback
The meaning of *target* varies according to the implementation, for example,
each Chrome tab represents a target whereas for Node.js a target is the
currently inspected script.
## Setup
An instance of either Chrome itself or another implementation needs to be
running on a known port in order to use this module (defaults to
`localhost:9222`).
### Chrome/Chromium
#### Desktop
Start Chrome with the `--remote-debugging-port` option, for example:
google-chrome --remote-debugging-port=9222
##### Headless
Since version 59, additionally use the `--headless` option, for example:
google-chrome --headless --remote-debugging-port=9222
#### Android
Plug the device and make sure to authorize the connection from the device itself. Then
enable the port forwarding, for example:
adb -d forward tcp:9222 localabstract:chrome_devtools_remote
After that you should be able to use `http://127.0.0.1:9222` as usual, but note that in
Android, Chrome does not have its own protocol available, so a local version must be used.
See [here](#chrome-debugging-protocol-versions) for more information.
##### WebView
In order to be inspectable, a WebView must
be [configured for debugging][webview] and the corresponding process ID must be
known. There are several ways to obtain it, for example:
adb shell grep -a webview_devtools_remote /proc/net/unix
Finally, port forwarding can be enabled as follows:
adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>
[webview]: https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews#configure_webviews_for_debugging
### Opera
Start Opera with the `--remote-debugging-port` option, for example:
opera --remote-debugging-port=9222
### Node.js
Start Node.js with the `--inspect` option, for example:
node --inspect=9222 script.js
### Safari (iOS)
Install and run the [iOS WebKit Debug Proxy][iwdp]. Then use it with the `local`
option set to `true` to use the local version of the protocol or pass a custom
descriptor upon connection (`protocol` option).
[iwdp]: https://github.com/google/ios-webkit-debug-proxy
### Edge
Start Edge with the `--devtools-server-port` option, for example:
MicrosoftEdge.exe --devtools-server-port 9222 about:blank
Please find more information [here][edge-devtools].
[edge-devtools]: https://docs.microsoft.com/en-us/microsoft-edge/devtools-protocol/
### Firefox (Nightly)
Start Firefox with the `--remote-debugging-port` option, for example:
firefox --remote-debugging-port 9222
Bear in mind that this is an experimental feature of Firefox.
## Bundled client
This module comes with a bundled client application that can be used to
interactively control a remote instance.
### Target management
The bundled client exposes subcommands to interact with the HTTP frontend
(e.g., [List](#cdplistoptions-callback), [New](#cdpnewoptions-callback), etc.),
run with `--help` to display the list of available options.
Here are some examples:
```js
$ chrome-remote-interface new 'http://example.com'
{
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/b049bb56-de7d-424c-a331-6ae44cf7ae01",
"id": "b049bb56-de7d-424c-a331-6ae44cf7ae01",
"thumbnailUrl": "/thumb/b049bb56-de7d-424c-a331-6ae44cf7ae01",
"title": "",
"type": "page",
"url": "http://example.com/",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/b049bb56-de7d-424c-a331-6ae44cf7ae01"
}
$ chrome-remote-interface close 'b049bb56-de7d-424c-a331-6ae44cf7ae01'
```
### Inspection
Using the `inspect` subcommand it is possible to perform [command execution](#clientdomainmethodparams-callback)
and [event binding](#clientdomaineventcallback) in a REPL fashion that provides completion.
Here is a sample session:
```js
$ chrome-remote-interface inspect
>>> Runtime.evaluate({expression: 'window.location.toString()'})
{ result: { type: 'string', value: 'about:blank' } }
>>> Page.enable()
{}
>>> Page.loadEventFired(console.log)
[Function]
>>> Page.navigate({url: 'https://github.com'})
{ frameId: 'E1657E22F06E6E0BE13DFA8130C20298',
loaderId: '439236ADE39978F98C20E8939A32D3A5' }
>>> { timestamp: 7454.721299 } // from Page.loadEventFired
>>> Runtime.evaluate({expression: 'window.location.toString()'})
{ result: { type: 'string', value: 'https://github.com/' } }
```
Additionally there are some custom commands available:
```js
>>> .help
[...]
.reset Remove all the registered event handlers
.target Display the current target
```
## Embedded documentation
In both the REPL and the regular API every object of the protocol is *decorated*
with the meta information found within the descriptor. In addition The
`category` field is added, which determines if the member is a `command`, an
`event` or a `type`.
For example to learn how to call `Page.navigate`:
```js
>>> Page.navigate
{ [Function]
category: 'command',
parameters: { url: { type: 'string', description: 'URL to navigate the page to.' } },
returns:
[ { name: 'frameId',
'$ref': 'FrameId',
hidden: true,
description: 'Frame id that will be navigated.' } ],
description: 'Navigates current page to the given URL.',
handlers: [ 'browser', 'renderer' ] }
```
To learn about the parameters returned by the `Network.requestWillBeSent` event:
```js
>>> Network.requestWillBeSent
{ [Function]
category: 'event',
description: 'Fired when page is about to send HTTP request.',
parameters:
{ requestId: { '$ref': 'RequestId', description: 'Request identifier.' },
frameId:
{ '$ref': 'Page.FrameId',
description: 'Frame identifier.',
hidden: true },
loaderId: { '$ref': 'LoaderId', description: 'Loader identifier.' },
documentURL:
{ type: 'string',
description: 'URL of the document this request is loaded for.' },
request: { '$ref': 'Request', description: 'Request data.' },
timestamp: { '$ref': 'Timestamp', description: 'Timestamp.' },
wallTime:
{ '$ref': 'Timestamp',
hidden: true,
description: 'UTC Timestamp.' },
initiator: { '$ref': 'Initiator', description: 'Request initiator.' },
redirectResponse:
{ optional: true,
'$ref': 'Response',
description: 'Redirect response data.' },
type:
{ '$ref': 'Page.ResourceType',
optional: true,
hidden: true,
description: 'Type of this resource.' } } }
```
To inspect the `Network.Request` (note that unlike commands and events, types
are named in upper camel case) type:
```js
>>> Network.Request
{ category: 'type',
id: 'Request',
type: 'object',
description: 'HTTP request data.',
properties:
{ url: { type: 'string', description: 'Request URL.' },
method: { type: 'string', description: 'HTTP request method.' },
headers: { '$ref': 'Headers', description: 'HTTP request headers.' },
postData:
{ type: 'string',
optional: true,
description: 'HTTP POST request data.' },
mixedContentType:
{ optional: true,
type: 'string',
enum: [Object],
description: 'The mixed content status of the request, as defined in http://www.w3.org/TR/mixed-content/' },
initialPriority:
{ '$ref': 'ResourcePriority',
description: 'Priority of the resource request at the time request is sent.' } } }
```
## Chrome Debugging Protocol versions
By default `chrome-remote-interface` *asks* the remote instance to provide its
own protocol.
This behavior can be changed by setting the `local` option to `true`
upon [connection](#cdpoptions-callback), in which case the [local version] of
the protocol descriptor is used. This file is manually updated from time to time
using `scripts/update-protocol.sh` and pushed to this repository.
To further override the above behavior there are basically two options:
- pass a custom protocol descriptor upon [connection](#cdpoptions-callback)
(`protocol` option);
- use the *raw* version of the [commands](#clientsendmethod-params-callback)
and [events](#event-domainmethod) interface to use bleeding-edge features that
do not appear in the [local version] of the protocol descriptor;
[local version]: lib/protocol.json
## Browser usage
This module is able to run within a web context, with obvious limitations
though, namely external HTTP requests
([List](#cdplistoptions-callback), [New](#cdpnewoptions-callback), etc.) cannot
be performed directly, for this reason the user must provide a global
`criRequest` in order to use them:
```js
function criRequest(options, callback) {}
```
`options` is the same object used by the Node.js `http` module and `callback` is
a function taking two arguments: `err` (JavaScript `Error` object or `null`) and
`data` (string result).
### Using [webpack](https://webpack.github.io/)
It just works, simply require this module:
```js
const CDP = require('chrome-remote-interface');
```
### Using *vanilla* JavaScript
To generate a JavaScript file that can be used with a `<script>` element:
1. run `npm install` from the root directory;
2. manually run webpack with:
TARGET=var npm run webpack
3. use as:
```html
<script>
function criRequest(options, callback) { /*...*/ }
</script>
<script src="chrome-remote-interface.js"></script>
```
## TypeScript Support
[TypeScript][] definitions are kindly provided by [Khairul Azhar Kasmiran][] and [Seth Westphal][], and can be installed from [DefinitelyTyped][]:
```
npm install --save-dev @types/chrome-remote-interface
```
Note that the TypeScript definitions are automatically generated from the npm package `devtools-protocol@0.0.927104`. For other versions of devtools-protocol:
1. Install patch-package using [the instructions given](https://github.com/ds300/patch-package#set-up).
2. Copy the contents of the corresponding https://github.com/ChromeDevTools/devtools-protocol/tree/master/types folder (according to commit) into `node_modules/devtools-protocol/types`.
3. Run `npx patch-package devtools-protocol` so that the changes persist across an `npm install`.
[TypeScript]: https://www.typescriptlang.org/
[Khairul Azhar Kasmiran]: https://github.com/kazarmy
[Seth Westphal]: https://github.com/westy92
[DefinitelyTyped]: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/chrome-remote-interface
## API
The API consists of three parts:
- *DevTools* methods (for those [implementations](#implementations) that support
them, e.g., [List](#cdplistoptions-callback), [New](#cdpnewoptions-callback),
etc.);
- [connection](#cdpoptions-callback) establishment;
- the actual [protocol interaction](#class-cdp).
### CDP([options], [callback])
Connects to a remote instance using the [Chrome Debugging Protocol].
`options` is an object with the following optional properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function;
- `target`: determines which target this client should attach to. The behavior
changes according to the type:
- a `function` that takes the array returned by the `List` method and returns
a target or its numeric index relative to the array;
- a target `object` like those returned by the `New` and `List` methods;
- a `string` representing the raw WebSocket URL, in this case `host` and
`port` are not used to fetch the target list, yet they are used to complete
the URL if relative;
- a `string` representing the target id.
Defaults to a function which returns the first available target according to
the implementation (note that at most one connection can be established to the
same target);
- `protocol`: [Chrome Debugging Protocol] descriptor object. Defaults to use the
protocol chosen according to the `local` option;
- `local`: a boolean indicating whether the protocol must be fetched *remotely*
or if the local version must be used. It has no effect if the `protocol`
option is set. Defaults to `false`.
These options are also valid properties of all the instances of the `CDP`
class. In addition to that, the `webSocketUrl` field contains the currently used
WebSocket URL.
`callback` is a listener automatically added to the `connect` event of the
returned `EventEmitter`. When `callback` is omitted a `Promise` object is
returned which becomes fulfilled if the `connect` event is triggered and
rejected if the `error` event is triggered.
The `EventEmitter` supports the following events:
#### Event: 'connect'
```js
function (client) {}
```
Emitted when the connection to the WebSocket is established.
`client` is an instance of the `CDP` class.
#### Event: 'error'
```js
function (err) {}
```
Emitted when `http://host:port/json` cannot be reached or if it is not possible
to connect to the WebSocket.
`err` is an instance of `Error`.
### CDP.Protocol([options], [callback])
Fetch the [Chrome Debugging Protocol] descriptor.
`options` is an object with the following optional properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function;
- `local`: a boolean indicating whether the protocol must be fetched *remotely*
or if the local version must be returned. Defaults to `false`.
`callback` is executed when the protocol is fetched, it gets the following
arguments:
- `err`: a `Error` object indicating the success status;
- `protocol`: the [Chrome Debugging Protocol] descriptor.
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.Protocol((err, protocol) => {
if (!err) {
console.log(JSON.stringify(protocol, null, 4));
}
});
```
### CDP.List([options], [callback])
Request the list of the available open targets/tabs of the remote instance.
`options` is an object with the following optional properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function.
`callback` is executed when the list is correctly received, it gets the
following arguments:
- `err`: a `Error` object indicating the success status;
- `targets`: the array returned by `http://host:port/json/list` containing the
target list.
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.List((err, targets) => {
if (!err) {
console.log(targets);
}
});
```
### CDP.New([options], [callback])
Create a new target/tab in the remote instance.
`options` is an object with the following optional properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function;
- `url`: URL to load in the new target/tab. Defaults to `about:blank`.
`callback` is executed when the target is created, it gets the following
arguments:
- `err`: a `Error` object indicating the success status;
- `target`: the object returned by `http://host:port/json/new` containing the
target.
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.New((err, target) => {
if (!err) {
console.log(target);
}
});
```
### CDP.Activate([options], [callback])
Activate an open target/tab of the remote instance.
`options` is an object with the following properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function;
- `id`: Target id. Required, no default.
`callback` is executed when the response to the activation request is
received. It gets the following arguments:
- `err`: a `Error` object indicating the success status;
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.Activate({id: 'CC46FBFA-3BDA-493B-B2E4-2BE6EB0D97EC'}, (err) => {
if (!err) {
console.log('target is activated');
}
});
```
### CDP.Close([options], [callback])
Close an open target/tab of the remote instance.
`options` is an object with the following properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function;
- `id`: Target id. Required, no default.
`callback` is executed when the response to the close request is received. It
gets the following arguments:
- `err`: a `Error` object indicating the success status;
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.Close({id: 'CC46FBFA-3BDA-493B-B2E4-2BE6EB0D97EC'}, (err) => {
if (!err) {
console.log('target is closing');
}
});
```
Note that the callback is fired when the target is *queued* for removal, but the
actual removal will occur asynchronously.
### CDP.Version([options], [callback])
Request version information from the remote instance.
`options` is an object with the following optional properties:
- `host`: HTTP frontend host. Defaults to `localhost`;
- `port`: HTTP frontend port. Defaults to `9222`;
- `secure`: HTTPS/WSS frontend. Defaults to `false`;
- `useHostName`: do not perform a DNS lookup of the host. Defaults to `false`;
- `alterPath`: a `function` taking and returning the path fragment of a URL
before that a request happens. Defaults to the identity function.
`callback` is executed when the version information is correctly received, it
gets the following arguments:
- `err`: a `Error` object indicating the success status;
- `info`: a JSON object returned by `http://host:port/json/version` containing
the version information.
When `callback` is omitted a `Promise` object is returned.
For example:
```js
const CDP = require('chrome-remote-interface');
CDP.Version((err, info) => {
if (!err) {
console.log(info);
}
});
```
### Class: CDP
#### Event: 'event'
```js
function (message) {}
```
Emitted when the remote instance sends any notification through the WebSocket.
`message` is the object received, it has the following properties:
- `method`: a string describing the notification (e.g.,
`'Network.requestWillBeSent'`);
- `params`: an object containing the payload;
- `sessionId`: an optional string representing the session identifier.
Refer to the [Chrome Debugging Protocol] specification for more information.
For example:
```js
client.on('event', (message) => {
if (message.method === 'Network.requestWillBeSent') {
console.log(message.params);
}
});
```
#### Event: '`<domain>`.`<method>`'
```js
function (params, sessionId) {}
```
Emitted when the remote instance sends a notification for `<domain>.<method>`
through the WebSocket.
`params` is an object containing the payload.
`sessionId` is an optional string representing the session identifier.
This is just a utility event which allows to easily listen for specific
notifications (see [`'event'`](#event-event)), for example:
```js
client.on('Network.requestWillBeSent', console.log);
```
Additionally, the equivalent `<domain>.on('<method>', ...)` syntax is available, for example:
```js
client.Network.on('requestWillBeSent', console.log);
```
#### Event: '`<domain>`.`<method>`.`<sessionId>`'
```js
function (params, sessionId) {}
```
Equivalent to the following but only for those events belonging to the given `session`:
```js
client.on('<domain>.<event>', callback);
```
#### Event: 'ready'
```js
function () {}
```
Emitted every time that there are no more pending commands waiting for a
response from the remote instance. The interaction is asynchronous so the only
way to serialize a sequence of commands is to use the callback provided by
the [`send`](#clientsendmethod-params-callback) method. This event acts as a
barrier and it is useful to avoid the *callback hell* in certain simple
situations.
Users are encouraged to extensively check the response of each method and should
prefer the promises API when dealing with complex asynchronous program flows.
For example to load a URL only after having enabled the notifications of both
`Network` and `Page` domains:
```js
client.Network.enable();
client.Page.enable();
client.once('ready', () => {
client.Page.navigate({url: 'https://github.com'});
});
```
In this particular case, not enforcing this kind of serialization may cause that
the remote instance does not properly deliver the desired notifications the
client.
#### Event: 'disconnect'
```js
function () {}
```
Emitted when the instance closes the WebSocket connection.
This may happen for example when the user opens DevTools or when the tab is
closed.
#### client.send(method, [params], [sessionId], [callback])
Issue a command to the remote instance.
`method` is a string describing the command.
`params` is an object containing the payload.
`sessionId` is a string representing the session identifier.
`callback` is executed when the remote instance sends a response to this
command, it gets the following arguments:
- `error`: a boolean value indicating the success status, as reported by the
remote instance;
- `response`: an object containing either the response (`result` field, if
`error === false`) or the indication of the error (`error` field, if `error
=== true`).
When `callback` is omitted a `Promise` object is returned instead, with the
fulfilled/rejected states implemented according to the `error` parameter. The
`Error` object con be an instance of [`ProtocolError`](#cdpprotocolerror) for
protocol invocation errors. Alternatively, in case of low-level WebSocket
errors, the `error` parameter contains the originating `Error` object.
Note that the field `id` mentioned in the [Chrome Debugging Protocol]
specification is managed internally and it is not exposed to the user.
For example:
```js
client.send('Page.navigate', {url: 'https://github.com'}, console.log);
```
#### client.`<domain>`.`<method>`([params], [sessionId], [callback])
Just a shorthand for:
```js
client.send('<domain>.<method>', params, sessionId, callback);
```
For example:
```js
client.Page.navigate({url: 'https://github.com'}, console.log);
```
#### client.`<domain>`.`<event>`([sessionId], [callback])
Just a shorthand for:
```js
client.on('<domain>.<event>[.<sessionId>]', callback);
```
When `callback` is omitted the event is registered only once and a `Promise`
object is returned. Notice though that in this case the optional `sessionId` usually passed to `callback` is not returned.
When `callback` is provided, it returns a function that can be used to
unsubscribe `callback` from the event, it can be useful when anonymous functions
are used as callbacks.
For example:
```js
const unsubscribe = client.Network.requestWillBeSent((params, sessionId) => {
console.log(params.request.url);
});
unsubscribe();
```
#### client.close([callback])
Close the connection to the remote instance.
`callback` is executed when the WebSocket is successfully closed.
When `callback` is omitted a `Promise` object is returned.
#### client['`<domain>`.`<name>`']
Just a shorthand for:
```js
client.<domain>.<name>
```
Where `<name>` can be a command, an event, or a type.
### CDP.ProtocolError
Error returned by the [`send`](#clientsendmethod-params-callback) method in case Chrome experienced issues in the protocol invocation. It exposes the following fields:
- `request`: the raw request object containing the `method`, `params`, and `sessionId` fields;
- `response`: the raw response from Chrome, usually containing the `code`, `message`, and `data` fields.
## FAQ
### Invoking `Domain.methodOrEvent` I obtain `Domain.methodOrEvent is not a function`
This means that you are trying to use a method or an event that are not present
in the protocol descriptor that you are using.
If the protocol is fetched from Chrome directly, then it means that this version
of Chrome does not support that feature. The solution is to update it.
If you are using a local or custom version of the protocol, then it means that
the version is obsolete. The solution is to provide an up-to-date one, or if you
are using the protocol embedded in chrome-remote-interface, make sure to be
running the latest version of this module. In case the embedded protocol is
obsolete, please [file an issue](https://github.com/cyrus-and/chrome-remote-interface/issues/new).
See [here](#chrome-debugging-protocol-versions) for more information.
### Invoking `Domain.method` I obtain `Domain.method wasn't found`
This means that you are providing a custom or local protocol descriptor
(`CDP({protocol: customProtocol})`) which declares `Domain.method` while the
Chrome version that you are using does not support it.
To inspect the currently available protocol descriptor use:
```
$ chrome-remote-interface inspect
```
See [here](#chrome-debugging-protocol-versions) for more information.
### Why my program stalls or behave unexpectedly if I run Chrome in a Docker container?
This happens because the size of `/dev/shm` is set to 64MB by default in Docker
and may not be enough for Chrome to navigate certain web pages.
You can change this value by running your container with, say,
`--shm-size=256m`.
### Using `Runtime.evaluate` with `awaitPromise: true` I sometimes obtain `Error: Promise was collected`
This is thrown by `Runtime.evaluate` when the browser-side promise gets
*collected* by the Chrome's garbage collector, this happens when the whole
JavaScript execution environment is invalidated, e.g., a when page is navigated
or reloaded while a promise is still waiting to be resolved.
Here is an example:
```
$ chrome-remote-interface inspect
>>> Runtime.evaluate({expression: `new Promise(() => {})`, awaitPromise: true})
>>> Page.reload() // then wait several seconds
{ result: {} }
{ error: { code: -32000, message: 'Promise was collected' } }
```
To fix this, just make sure there are no pending promises before closing,
reloading, etc. a page.
### How does this compare to Puppeteer?
[Puppeteer] is an additional high-level API built upon the [Chrome Debugging
Protocol] which, among the other things, may start and use a bundled version of
Chromium instead of the one installed on your system. Use it if its API meets
your needs as it would probably be easier to work with.
chrome-remote-interface instead is just a general purpose 1:1 Node.js binding
for the [Chrome Debugging Protocol]. Use it if you need all the power of the raw
protocol, e.g., to implement your own high-level API.
See [#240] for a more thorough discussion.
[Puppeteer]: https://github.com/GoogleChrome/puppeteer
[#240]: https://github.com/cyrus-and/chrome-remote-interface/issues/240
## Contributors
- [Andrey Sidorov](https://github.com/sidorares)
- [Greg Cochard](https://github.com/gcochard)
## Resources
- [Chrome Debugging Protocol]
- [Chrome Debugging Protocol Google group](https://groups.google.com/forum/#!forum/chrome-debugging-protocol)
- [devtools-protocol official repo](https://github.com/ChromeDevTools/devtools-protocol)
- [Showcase Chrome Debugging Protocol Clients](https://developer.chrome.com/devtools/docs/debugging-clients)
- [Awesome chrome-devtools](https://github.com/ChromeDevTools/awesome-chrome-devtools)
[Chrome Debugging Protocol]: https://chromedevtools.github.io/devtools-protocol/

View file

@ -0,0 +1,311 @@
#!/usr/bin/env node
'use strict';
const repl = require('repl');
const util = require('util');
const fs = require('fs');
const path = require('path');
const program = require('commander');
const CDP = require('../');
const packageInfo = require('../package.json');
function display(object) {
return util.inspect(object, {
colors: process.stdout.isTTY,
depth: null
});
}
function toJSON(object) {
return JSON.stringify(object, null, 4);
}
///
function inspect(target, args, options) {
options.local = args.local;
// otherwise the active target
if (target) {
if (args.webSocket) {
// by WebSocket URL
options.target = target;
} else {
// by target id
options.target = (targets) => {
return targets.findIndex((_target) => {
return _target.id === target;
});
};
}
}
if (args.protocol) {
options.protocol = JSON.parse(fs.readFileSync(args.protocol));
}
CDP(options, (client) => {
const cdpRepl = repl.start({
prompt: process.stdin.isTTY ? '\x1b[32m>>>\x1b[0m ' : '',
ignoreUndefined: true,
writer: display
});
// XXX always await promises on the REPL
const defaultEval = cdpRepl.eval;
cdpRepl.eval = (cmd, context, filename, callback) => {
defaultEval(cmd, context, filename, async (err, result) => {
if (err) {
// propagate errors from the eval
callback(err);
} else {
// awaits the promise and either return result or error
try {
callback(null, await Promise.resolve(result));
} catch (err) {
callback(err);
}
}
});
};
const homePath = process.env.HOME || process.env.USERPROFILE;
const historyFile = path.join(homePath, '.cri_history');
const historySize = 10000;
function loadHistory() {
// only if run from a terminal
if (!process.stdin.isTTY) {
return;
}
// attempt to open the history file
let fd;
try {
fd = fs.openSync(historyFile, 'r');
} catch (err) {
return; // no history file present
}
// populate the REPL history
fs.readFileSync(fd, 'utf8')
.split('\n')
.filter((entry) => {
return entry.trim();
})
.reverse() // to be compatible with repl.history files
.forEach((entry) => {
cdpRepl.history.push(entry);
});
}
function saveHistory() {
// only if run from a terminal
if (!process.stdin.isTTY) {
return;
}
// only store the last chunk
const entries = cdpRepl.history.slice(0, historySize).reverse().join('\n');
fs.writeFileSync(historyFile, entries + '\n');
}
// utility custom command
cdpRepl.defineCommand('target', {
help: 'Display the current target',
action: () => {
console.log(client.webSocketUrl);
cdpRepl.displayPrompt();
}
});
// utility to purge all the event handlers
cdpRepl.defineCommand('reset', {
help: 'Remove all the registered event handlers',
action: () => {
client.removeAllListeners();
cdpRepl.displayPrompt();
}
});
// enable history
loadHistory();
// disconnect on exit
cdpRepl.on('exit', () => {
if (process.stdin.isTTY) {
console.log();
}
client.close();
saveHistory();
});
// exit on disconnection
client.on('disconnect', () => {
console.error('Disconnected.');
saveHistory();
process.exit(1);
});
// add protocol API
for (const domainObject of client.protocol.domains) {
// walk the domain names
const domainName = domainObject.domain;
cdpRepl.context[domainName] = {};
// walk the items in the domain
for (const itemName in client[domainName]) {
// add CDP object to the REPL context
const cdpObject = client[domainName][itemName];
cdpRepl.context[domainName][itemName] = cdpObject;
}
}
}).on('error', (err) => {
console.error('Cannot connect to remote endpoint:', err.toString());
});
}
function list(options) {
CDP.List(options, (err, targets) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
console.log(toJSON(targets));
});
}
function _new(url, options) {
options.url = url;
CDP.New(options, (err, target) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
console.log(toJSON(target));
});
}
function activate(args, options) {
options.id = args;
CDP.Activate(options, (err) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
});
}
function close(args, options) {
options.id = args;
CDP.Close(options, (err) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
});
}
function version(options) {
CDP.Version(options, (err, info) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
console.log(toJSON(info));
});
}
function protocol(args, options) {
options.local = args.local;
CDP.Protocol(options, (err, protocol) => {
if (err) {
console.error(err.toString());
process.exit(1);
}
console.log(toJSON(protocol));
});
}
///
let action;
program
.option('-v, --v', 'Show this module version')
.option('-t, --host <host>', 'HTTP frontend host')
.option('-p, --port <port>', 'HTTP frontend port')
.option('-s, --secure', 'HTTPS/WSS frontend')
.option('-n, --use-host-name', 'Do not perform a DNS lookup of the host');
program
.command('inspect [<target>]')
.description('inspect a target (defaults to the first available target)')
.option('-w, --web-socket', 'interpret <target> as a WebSocket URL instead of a target id')
.option('-j, --protocol <file.json>', 'Chrome Debugging Protocol descriptor (overrides `--local`)')
.option('-l, --local', 'Use the local protocol descriptor')
.action((target, args) => {
action = inspect.bind(null, target, args);
});
program
.command('list')
.description('list all the available targets/tabs')
.action(() => {
action = list;
});
program
.command('new [<url>]')
.description('create a new target/tab')
.action((url) => {
action = _new.bind(null, url);
});
program
.command('activate <id>')
.description('activate a target/tab by id')
.action((id) => {
action = activate.bind(null, id);
});
program
.command('close <id>')
.description('close a target/tab by id')
.action((id) => {
action = close.bind(null, id);
});
program
.command('version')
.description('show the browser version')
.action(() => {
action = version;
});
program
.command('protocol')
.description('show the currently available protocol descriptor')
.option('-l, --local', 'Return the local protocol descriptor')
.action((args) => {
action = protocol.bind(null, args);
});
program.parse(process.argv);
// common options
const options = {
host: program.host,
port: program.port,
secure: program.secure,
useHostName: program.useHostName
};
if (action) {
action(options);
} else {
if (program.v) {
console.log(packageInfo.version);
} else {
program.outputHelp();
process.exit(1);
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,46 @@
'use strict';
const EventEmitter = require('events');
const dns = require('dns');
const devtools = require('./lib/devtools.js');
const errors = require('./lib/errors.js');
const Chrome = require('./lib/chrome.js');
// XXX reset the default that has been changed in
// (https://github.com/nodejs/node/pull/39987) to prefer IPv4. since
// implementations alway bind on 127.0.0.1 this solution should be fairly safe
// (see #467)
if (dns.setDefaultResultOrder) {
dns.setDefaultResultOrder('ipv4first');
}
function CDP(options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
const notifier = new EventEmitter();
if (typeof callback === 'function') {
// allow to register the error callback later
process.nextTick(() => {
new Chrome(options, notifier);
});
return notifier.once('connect', callback);
} else {
return new Promise((fulfill, reject) => {
notifier.once('connect', fulfill);
notifier.once('error', reject);
new Chrome(options, notifier);
});
}
}
module.exports = CDP;
module.exports.Protocol = devtools.Protocol;
module.exports.List = devtools.List;
module.exports.New = devtools.New;
module.exports.Activate = devtools.Activate;
module.exports.Close = devtools.Close;
module.exports.Version = devtools.Version;
module.exports.ProtocolError = errors.ProtocolError;

View file

@ -0,0 +1,92 @@
'use strict';
function arrayToObject(parameters) {
const keyValue = {};
parameters.forEach((parameter) =>{
const name = parameter.name;
delete parameter.name;
keyValue[name] = parameter;
});
return keyValue;
}
function decorate(to, category, object) {
to.category = category;
Object.keys(object).forEach((field) => {
// skip the 'name' field as it is part of the function prototype
if (field === 'name') {
return;
}
// commands and events have parameters whereas types have properties
if (category === 'type' && field === 'properties' ||
field === 'parameters') {
to[field] = arrayToObject(object[field]);
} else {
to[field] = object[field];
}
});
}
function addCommand(chrome, domainName, command) {
const commandName = `${domainName}.${command.name}`;
const handler = (params, sessionId, callback) => {
return chrome.send(commandName, params, sessionId, callback);
};
decorate(handler, 'command', command);
chrome[commandName] = chrome[domainName][command.name] = handler;
}
function addEvent(chrome, domainName, event) {
const eventName = `${domainName}.${event.name}`;
const handler = (sessionId, handler) => {
if (typeof sessionId === 'function') {
handler = sessionId;
sessionId = undefined;
}
const rawEventName = sessionId ? `${eventName}.${sessionId}` : eventName;
if (typeof handler === 'function') {
chrome.on(rawEventName, handler);
return () => chrome.removeListener(rawEventName, handler);
} else {
return new Promise((fulfill, reject) => {
chrome.once(rawEventName, fulfill);
});
}
};
decorate(handler, 'event', event);
chrome[eventName] = chrome[domainName][event.name] = handler;
}
function addType(chrome, domainName, type) {
const typeName = `${domainName}.${type.id}`;
const help = {};
decorate(help, 'type', type);
chrome[typeName] = chrome[domainName][type.id] = help;
}
function prepare(object, protocol) {
// assign the protocol and generate the shorthands
object.protocol = protocol;
protocol.domains.forEach((domain) => {
const domainName = domain.domain;
object[domainName] = {};
// add commands
(domain.commands || []).forEach((command) => {
addCommand(object, domainName, command);
});
// add events
(domain.events || []).forEach((event) => {
addEvent(object, domainName, event);
});
// add types
(domain.types || []).forEach((type) => {
addType(object, domainName, type);
});
// add utility listener for each domain
object[domainName].on = (eventName, handler) => {
return object[domainName][eventName](handler);
};
});
}
module.exports.prepare = prepare;

View file

@ -0,0 +1,302 @@
'use strict';
const EventEmitter = require('events');
const util = require('util');
const formatUrl = require('url').format;
const parseUrl = require('url').parse;
const WebSocket = require('ws');
const api = require('./api.js');
const defaults = require('./defaults.js');
const devtools = require('./devtools.js');
const errors = require('./errors.js');
class Chrome extends EventEmitter {
constructor(options, notifier) {
super();
// options
const defaultTarget = (targets) => {
// prefer type = 'page' inspectable targets as they represents
// browser tabs (fall back to the first inspectable target
// otherwise)
let backup;
let target = targets.find((target) => {
if (target.webSocketDebuggerUrl) {
backup = backup || target;
return target.type === 'page';
} else {
return false;
}
});
target = target || backup;
if (target) {
return target;
} else {
throw new Error('No inspectable targets');
}
};
options = options || {};
this.host = options.host || defaults.HOST;
this.port = options.port || defaults.PORT;
this.secure = !!(options.secure);
this.useHostName = !!(options.useHostName);
this.alterPath = options.alterPath || ((path) => path);
this.protocol = options.protocol;
this.local = !!(options.local);
this.target = options.target || defaultTarget;
// locals
this._notifier = notifier;
this._callbacks = {};
this._nextCommandId = 1;
// properties
this.webSocketUrl = undefined;
// operations
this._start();
}
// avoid misinterpreting protocol's members as custom util.inspect functions
inspect(depth, options) {
options.customInspect = false;
return util.inspect(this, options);
}
send(method, params, sessionId, callback) {
// handle optional arguments
const optionals = Array.from(arguments).slice(1);
params = optionals.find(x => typeof x === 'object');
sessionId = optionals.find(x => typeof x === 'string');
callback = optionals.find(x => typeof x === 'function');
// return a promise when a callback is not provided
if (typeof callback === 'function') {
this._enqueueCommand(method, params, sessionId, callback);
return undefined;
} else {
return new Promise((fulfill, reject) => {
this._enqueueCommand(method, params, sessionId, (error, response) => {
if (error) {
const request = {method, params, sessionId};
reject(
error instanceof Error
? error // low-level WebSocket error
: new errors.ProtocolError(request, response)
);
} else {
fulfill(response);
}
});
});
}
}
close(callback) {
const closeWebSocket = (callback) => {
// don't close if it's already closed
if (this._ws.readyState === 3) {
callback();
} else {
// don't notify on user-initiated shutdown ('disconnect' event)
this._ws.removeAllListeners('close');
this._ws.once('close', () => {
this._ws.removeAllListeners();
this._handleConnectionClose();
callback();
});
this._ws.close();
}
};
if (typeof callback === 'function') {
closeWebSocket(callback);
return undefined;
} else {
return new Promise((fulfill, reject) => {
closeWebSocket(fulfill);
});
}
}
// initiate the connection process
async _start() {
const options = {
host: this.host,
port: this.port,
secure: this.secure,
useHostName: this.useHostName,
alterPath: this.alterPath
};
try {
// fetch the WebSocket debugger URL
const url = await this._fetchDebuggerURL(options);
// allow the user to alter the URL
const urlObject = parseUrl(url);
urlObject.pathname = options.alterPath(urlObject.pathname);
this.webSocketUrl = formatUrl(urlObject);
// update the connection parameters using the debugging URL
options.host = urlObject.hostname;
options.port = urlObject.port || options.port;
// fetch the protocol and prepare the API
const protocol = await this._fetchProtocol(options);
api.prepare(this, protocol);
// finally connect to the WebSocket
await this._connectToWebSocket();
// since the handler is executed synchronously, the emit() must be
// performed in the next tick so that uncaught errors in the client code
// are not intercepted by the Promise mechanism and therefore reported
// via the 'error' event
process.nextTick(() => {
this._notifier.emit('connect', this);
});
} catch (err) {
this._notifier.emit('error', err);
}
}
// fetch the WebSocket URL according to 'target'
async _fetchDebuggerURL(options) {
const userTarget = this.target;
switch (typeof userTarget) {
case 'string': {
let idOrUrl = userTarget;
// use default host and port if omitted (and a relative URL is specified)
if (idOrUrl.startsWith('/')) {
idOrUrl = `ws://${this.host}:${this.port}${idOrUrl}`;
}
// a WebSocket URL is specified by the user (e.g., node-inspector)
if (idOrUrl.match(/^wss?:/i)) {
return idOrUrl; // done!
}
// a target id is specified by the user
else {
const targets = await devtools.List(options);
const object = targets.find((target) => target.id === idOrUrl);
return object.webSocketDebuggerUrl;
}
}
case 'object': {
const object = userTarget;
return object.webSocketDebuggerUrl;
}
case 'function': {
const func = userTarget;
const targets = await devtools.List(options);
const result = func(targets);
const object = typeof result === 'number' ? targets[result] : result;
return object.webSocketDebuggerUrl;
}
default:
throw new Error(`Invalid target argument "${this.target}"`);
}
}
// fetch the protocol according to 'protocol' and 'local'
async _fetchProtocol(options) {
// if a protocol has been provided then use it
if (this.protocol) {
return this.protocol;
}
// otherwise user either the local or the remote version
else {
options.local = this.local;
return await devtools.Protocol(options);
}
}
// establish the WebSocket connection and start processing user commands
_connectToWebSocket() {
return new Promise((fulfill, reject) => {
// create the WebSocket
try {
if (this.secure) {
this.webSocketUrl = this.webSocketUrl.replace(/^ws:/i, 'wss:');
}
this._ws = new WebSocket(this.webSocketUrl, [], {
maxPayload: 256 * 1024 * 1024,
perMessageDeflate: false,
followRedirects: true,
});
} catch (err) {
// handles bad URLs
reject(err);
return;
}
// set up event handlers
this._ws.on('open', () => {
fulfill();
});
this._ws.on('message', (data) => {
const message = JSON.parse(data);
this._handleMessage(message);
});
this._ws.on('close', (code) => {
this._handleConnectionClose();
this.emit('disconnect');
});
this._ws.on('error', (err) => {
reject(err);
});
});
}
_handleConnectionClose() {
// make sure to complete all the unresolved callbacks
const err = new Error('WebSocket connection closed');
for (const callback of Object.values(this._callbacks)) {
callback(err);
}
this._callbacks = {};
}
// handle the messages read from the WebSocket
_handleMessage(message) {
// command response
if (message.id) {
const callback = this._callbacks[message.id];
if (!callback) {
return;
}
// interpret the lack of both 'error' and 'result' as success
// (this may happen with node-inspector)
if (message.error) {
callback(true, message.error);
} else {
callback(false, message.result || {});
}
// unregister command response callback
delete this._callbacks[message.id];
// notify when there are no more pending commands
if (Object.keys(this._callbacks).length === 0) {
this.emit('ready');
}
}
// event
else if (message.method) {
const {method, params, sessionId} = message;
this.emit('event', message);
this.emit(method, params, sessionId);
this.emit(`${method}.${sessionId}`, params, sessionId);
}
}
// send a command to the remote endpoint and register a callback for the reply
_enqueueCommand(method, params, sessionId, callback) {
const id = this._nextCommandId++;
const message = {
id,
method,
sessionId,
params: params || {}
};
this._ws.send(JSON.stringify(message), (err) => {
if (err) {
// handle low-level WebSocket errors
if (typeof callback === 'function') {
callback(err);
}
} else {
this._callbacks[id] = callback;
}
});
}
}
module.exports = Chrome;

View file

@ -0,0 +1,4 @@
'use strict';
module.exports.HOST = 'localhost';
module.exports.PORT = 9222;

View file

@ -0,0 +1,127 @@
'use strict';
const http = require('http');
const https = require('https');
const defaults = require('./defaults.js');
const externalRequest = require('./external-request.js');
// options.path must be specified; callback(err, data)
function devToolsInterface(path, options, callback) {
const transport = options.secure ? https : http;
const requestOptions = {
method: options.method,
host: options.host || defaults.HOST,
port: options.port || defaults.PORT,
useHostName: options.useHostName,
path: (options.alterPath ? options.alterPath(path) : path)
};
externalRequest(transport, requestOptions, callback);
}
// wrapper that allows to return a promise if the callback is omitted, it works
// for DevTools methods
function promisesWrapper(func) {
return (options, callback) => {
// options is an optional argument
if (typeof options === 'function') {
callback = options;
options = undefined;
}
options = options || {};
// just call the function otherwise wrap a promise around its execution
if (typeof callback === 'function') {
func(options, callback);
return undefined;
} else {
return new Promise((fulfill, reject) => {
func(options, (err, result) => {
if (err) {
reject(err);
} else {
fulfill(result);
}
});
});
}
};
}
function Protocol(options, callback) {
// if the local protocol is requested
if (options.local) {
const localDescriptor = require('./protocol.json');
callback(null, localDescriptor);
return;
}
// try to fetch the protocol remotely
devToolsInterface('/json/protocol', options, (err, descriptor) => {
if (err) {
callback(err);
} else {
callback(null, JSON.parse(descriptor));
}
});
}
function List(options, callback) {
devToolsInterface('/json/list', options, (err, tabs) => {
if (err) {
callback(err);
} else {
callback(null, JSON.parse(tabs));
}
});
}
function New(options, callback) {
let path = '/json/new';
if (Object.prototype.hasOwnProperty.call(options, 'url')) {
path += `?${options.url}`;
}
options.method = options.method || 'PUT'; // see #497
devToolsInterface(path, options, (err, tab) => {
if (err) {
callback(err);
} else {
callback(null, JSON.parse(tab));
}
});
}
function Activate(options, callback) {
devToolsInterface('/json/activate/' + options.id, options, (err) => {
if (err) {
callback(err);
} else {
callback(null);
}
});
}
function Close(options, callback) {
devToolsInterface('/json/close/' + options.id, options, (err) => {
if (err) {
callback(err);
} else {
callback(null);
}
});
}
function Version(options, callback) {
devToolsInterface('/json/version', options, (err, versionInfo) => {
if (err) {
callback(err);
} else {
callback(null, JSON.parse(versionInfo));
}
});
}
module.exports.Protocol = promisesWrapper(Protocol);
module.exports.List = promisesWrapper(List);
module.exports.New = promisesWrapper(New);
module.exports.Activate = promisesWrapper(Activate);
module.exports.Close = promisesWrapper(Close);
module.exports.Version = promisesWrapper(Version);

View file

@ -0,0 +1,16 @@
'use strict';
class ProtocolError extends Error {
constructor(request, response) {
let {message} = response;
if (response.data) {
message += ` (${response.data})`;
}
super(message);
// attach the original response as well
this.request = request;
this.response = response;
}
}
module.exports.ProtocolError = ProtocolError;

View file

@ -0,0 +1,44 @@
'use strict';
const dns = require('dns');
const util = require('util');
const REQUEST_TIMEOUT = 10000;
// callback(err, data)
async function externalRequest(transport, options, callback) {
// perform the DNS lookup manually so that the HTTP host header generated by
// http.get will contain the IP address, this is needed because since Chrome
// 66 the host header cannot contain an host name different than localhost
// (see https://github.com/cyrus-and/chrome-remote-interface/issues/340)
if (!options.useHostName) {
try {
const {address} = await util.promisify(dns.lookup)(options.host);
options.host = address;
} catch (err) {
callback(err);
return;
}
}
// perform the actual request
const request = transport.request(options, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode === 200) {
callback(null, data);
} else {
callback(new Error(data));
}
});
});
request.setTimeout(REQUEST_TIMEOUT, () => {
request.abort();
});
request.on('error', callback);
request.end();
}
module.exports = externalRequest;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
'use strict';
const EventEmitter = require('events');
// wrapper around the Node.js ws module
// for use in browsers
class WebSocketWrapper extends EventEmitter {
constructor(url) {
super();
this._ws = new WebSocket(url); // eslint-disable-line no-undef
this._ws.onopen = () => {
this.emit('open');
};
this._ws.onclose = () => {
this.emit('close');
};
this._ws.onmessage = (event) => {
this.emit('message', event.data);
};
this._ws.onerror = () => {
this.emit('error', new Error('WebSocket error'));
};
}
close() {
this._ws.close();
}
send(data, callback) {
try {
this._ws.send(data);
callback();
} catch (err) {
callback(err);
}
}
}
module.exports = WebSocketWrapper;

View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,495 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a back end with the role of a client in the WebSocket
communication. Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [echo.websocket.org demo](#echowebsocketorg-demo)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
There are 2 optional modules that can be installed along side with the ws
module. These modules are binary addons which improve certain operations.
Prebuilt binaries are available for the most popular platforms so you don't
necessarily need to have a C++ compiler installed on your machine.
- `npm install --save-optional bufferutil`: Allows to efficiently perform
operations such as masking and unmasking the data payload of the WebSocket
frames.
- `npm install --save-optional utf-8-validate`: Allows to efficiently check if a
message contains valid UTF-8.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
const WebSocket = require('ws');
const wss = new WebSocket.Server({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client set the
`perMessageDeflate` option to `false`.
```js
const WebSocket = require('ws');
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
const WebSocket = require('ws');
const ws = new WebSocket('ws://www.host.com/path');
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function incoming(data) {
console.log(data);
});
```
### Sending binary data
```js
const WebSocket = require('ws');
const ws = new WebSocket('ws://www.host.com/path');
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(message) {
console.log('received: %s', message);
});
ws.send('something');
});
```
### External HTTP/S server
```js
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');
const server = https.createServer({
cert: fs.readFileSync('/path/to/cert.pem'),
key: fs.readFileSync('/path/to/key.pem')
});
const wss = new WebSocket.Server({ server });
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(message) {
console.log('received: %s', message);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
const http = require('http');
const WebSocket = require('ws');
const url = require('url');
const server = http.createServer();
const wss1 = new WebSocket.Server({ noServer: true });
const wss2 = new WebSocket.Server({ noServer: true });
wss1.on('connection', function connection(ws) {
// ...
});
wss2.on('connection', function connection(ws) {
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const pathname = url.parse(request.url).pathname;
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer();
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('message', function message(msg) {
console.log(`Received message ${msg} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, (err, client) => {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(data) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(data) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
});
});
```
### echo.websocket.org demo
```js
const WebSocket = require('ws');
const ws = new WebSocket('wss://echo.websocket.org/', {
origin: 'https://websocket.org'
});
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function incoming(data) {
console.log(`Roundtrip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
const WebSocket = require('ws');
const ws = new WebSocket('wss://echo.websocket.org/', {
origin: 'https://websocket.org'
});
const duplex = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' });
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
});
```
### How to detect and close broken connections?
Sometimes the link between the server and the client can be interrupted in a way
that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
const WebSocket = require('ws');
function noop() {}
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping(noop);
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
const WebSocket = require('ws');
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://echo.websocket.org/');
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[ws-server-options]:
https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback

View file

@ -0,0 +1,8 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};

View file

@ -0,0 +1,10 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
module.exports = WebSocket;

View file

@ -0,0 +1,129 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) return target.slice(0, offset);
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
// Required until https://github.com/nodejs/node/issues/9006 is resolved.
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.byteLength === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = Buffer.from(data);
} else if (ArrayBuffer.isView(data)) {
buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
try {
const bufferUtil = require('bufferutil');
const bu = bufferUtil.BufferUtil || bufferUtil;
module.exports = {
concat,
mask(source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bu.mask(source, mask, output, offset, length);
},
toArrayBuffer,
toBuffer,
unmask(buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bu.unmask(buffer, mask);
}
};
} catch (e) /* istanbul ignore next */ {
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
}

View file

@ -0,0 +1,10 @@
'use strict';
module.exports = {
BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'],
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
EMPTY_BUFFER: Buffer.alloc(0),
NOOP: () => {}
};

View file

@ -0,0 +1,184 @@
'use strict';
/**
* Class representing an event.
*
* @private
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @param {Object} target A reference to the target to which the event was
* dispatched
*/
constructor(type, target) {
this.target = target;
this.type = type;
}
}
/**
* Class representing a message event.
*
* @extends Event
* @private
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {(String|Buffer|ArrayBuffer|Buffer[])} data The received data
* @param {WebSocket} target A reference to the target to which the event was
* dispatched
*/
constructor(data, target) {
super('message', target);
this.data = data;
}
}
/**
* Class representing a close event.
*
* @extends Event
* @private
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {Number} code The status code explaining why the connection is being
* closed
* @param {String} reason A human-readable string explaining why the
* connection is closing
* @param {WebSocket} target A reference to the target to which the event was
* dispatched
*/
constructor(code, reason, target) {
super('close', target);
this.wasClean = target._closeFrameReceived && target._closeFrameSent;
this.reason = reason;
this.code = code;
}
}
/**
* Class representing an open event.
*
* @extends Event
* @private
*/
class OpenEvent extends Event {
/**
* Create a new `OpenEvent`.
*
* @param {WebSocket} target A reference to the target to which the event was
* dispatched
*/
constructor(target) {
super('open', target);
}
}
/**
* Class representing an error event.
*
* @extends Event
* @private
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {Object} error The error that generated this event
* @param {WebSocket} target A reference to the target to which the event was
* dispatched
*/
constructor(error, target) {
super('error', target);
this.message = error.message;
this.error = error;
}
}
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {Function} listener The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean`` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, listener, options) {
if (typeof listener !== 'function') return;
function onMessage(data) {
listener.call(this, new MessageEvent(data, this));
}
function onClose(code, message) {
listener.call(this, new CloseEvent(code, message, this));
}
function onError(error) {
listener.call(this, new ErrorEvent(error, this));
}
function onOpen() {
listener.call(this, new OpenEvent(this));
}
const method = options && options.once ? 'once' : 'on';
if (type === 'message') {
onMessage._listener = listener;
this[method](type, onMessage);
} else if (type === 'close') {
onClose._listener = listener;
this[method](type, onClose);
} else if (type === 'error') {
onError._listener = listener;
this[method](type, onError);
} else if (type === 'open') {
onOpen._listener = listener;
this[method](type, onOpen);
} else {
this[method](type, listener);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {Function} listener The listener to remove
* @public
*/
removeEventListener(type, listener) {
const listeners = this.listeners(type);
for (let i = 0; i < listeners.length; i++) {
if (listeners[i] === listener || listeners[i]._listener === listener) {
this.removeListener(type, listeners[i]);
}
}
}
};
module.exports = EventTarget;

View file

@ -0,0 +1,223 @@
'use strict';
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
if (header === undefined || header === '') return offers;
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
const code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 /* ' ' */ || code === 0x09 /* '\t' */) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };

View file

@ -0,0 +1,55 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;

View file

@ -0,0 +1,518 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode, NOOP } = require('./constants');
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._isServer = !!isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {Buffer} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {Buffer} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
//
// An `'error'` event is emitted, only on Node.js < 10.0.0, if the
// `zlib.DeflateRaw` instance is closed while data is being processed.
// This can happen if `PerMessageDeflate#cleanup()` is called at the wrong
// time due to an abnormal WebSocket closure.
//
this._deflate.on('error', NOOP);
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) data = data.slice(0, data.length - 4);
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
err[kStatusCode] = 1007;
this[kCallback](err);
}

View file

@ -0,0 +1,607 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {String} [binaryType=nodebuffer] The type for binary data
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Boolean} [isServer=false] Specifies whether to operate in client or
* server mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(binaryType, extensions, isServer, maxPayload) {
super();
this._binaryType = binaryType || BINARY_TYPES[0];
this[kWebSocket] = undefined;
this._extensions = extensions || {};
this._isServer = !!isServer;
this._maxPayload = maxPayload | 0;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._state = GET_INFO;
this._loop = false;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = buf.slice(n);
return buf.slice(0, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = buf.slice(n);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
let err;
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
err = this.getInfo();
break;
case GET_PAYLOAD_LENGTH_16:
err = this.getPayloadLength16();
break;
case GET_PAYLOAD_LENGTH_64:
err = this.getPayloadLength64();
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
err = this.getData(cb);
break;
default:
// `INFLATING`
this._loop = false;
return;
}
} while (this._loop);
cb(err);
}
/**
* Reads the first two bytes of a frame.
*
* @return {(RangeError|undefined)} A possible error
* @private
*/
getInfo() {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
this._loop = false;
return error(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
this._loop = false;
return error(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
this._loop = false;
return error(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
}
if (!this._fragmented) {
this._loop = false;
return error(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
this._loop = false;
return error(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
this._loop = false;
return error(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
}
if (compressed) {
this._loop = false;
return error(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
}
if (this._payloadLength > 0x7d) {
this._loop = false;
return error(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
}
} else {
this._loop = false;
return error(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
this._loop = false;
return error(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
}
} else if (this._masked) {
this._loop = false;
return error(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else return this.haveLength();
}
/**
* Gets extended payload length (7+16).
*
* @return {(RangeError|undefined)} A possible error
* @private
*/
getPayloadLength16() {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
return this.haveLength();
}
/**
* Gets extended payload length (7+64).
*
* @return {(RangeError|undefined)} A possible error
* @private
*/
getPayloadLength64() {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
this._loop = false;
return error(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
return this.haveLength();
}
/**
* Payload length has been read.
*
* @return {(RangeError|undefined)} A possible error
* @private
*/
haveLength() {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
this._loop = false;
return error(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (this._masked) unmask(data, this._mask);
}
if (this._opcode > 0x07) return this.controlMessage(data);
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its lenght is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
return this.dataMessage();
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
return cb(
error(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
)
);
}
this._fragments.push(buf);
}
const er = this.dataMessage();
if (er) return cb(er);
this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @return {(Error|undefined)} A possible error
* @private
*/
dataMessage() {
if (this._fin) {
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else {
data = fragments;
}
this.emit('message', data);
} else {
const buf = concat(fragments, messageLength);
if (!isValidUTF8(buf)) {
this._loop = false;
return error(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
}
this.emit('message', buf.toString());
}
}
this._state = GET_INFO;
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data) {
if (this._opcode === 0x08) {
this._loop = false;
if (data.length === 0) {
this.emit('conclude', 1005, '');
this.end();
} else if (data.length === 1) {
return error(
RangeError,
'invalid payload length 1',
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
return error(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
}
const buf = data.slice(2);
if (!isValidUTF8(buf)) {
return error(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
}
this.emit('conclude', code, buf.toString());
this.end();
}
} else if (this._opcode === 0x09) {
this.emit('ping', data);
} else {
this.emit('pong', data);
}
this._state = GET_INFO;
}
}
module.exports = Receiver;
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
function error(ErrorCtor, message, prefix, statusCode, errorCode) {
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, error);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}

View file

@ -0,0 +1,409 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls$" }] */
'use strict';
const net = require('net');
const tls = require('tls');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER } = require('./constants');
const { isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const mask = Buffer.alloc(4);
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {(net.Socket|tls.Socket)} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
*/
constructor(socket, extensions) {
this._extensions = extensions || {};
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._deflating = false;
this._queue = [];
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {Buffer} data The data to frame
* @param {Object} options Options object
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {Buffer[]} The framed data as a list of `Buffer` instances
* @public
*/
static frame(data, options) {
const merge = options.mask && options.readOnly;
let offset = options.mask ? 6 : 2;
let payloadLength = data.length;
if (data.length >= 65536) {
offset += 8;
payloadLength = 127;
} else if (data.length > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? data.length + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(data.length, 2);
} else if (payloadLength === 127) {
target.writeUInt32BE(0, 2);
target.writeUInt32BE(data.length, 6);
}
if (!options.mask) return [target, data];
randomFillSync(mask, 0, 4);
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (merge) {
applyMask(data, mask, target, offset, data.length);
return [target];
}
applyMask(data, mask, data, 0, data.length);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {String} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || data === '') {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
buf.write(data, 2);
}
if (this._deflating) {
this.enqueue([this.doClose, buf, mask, cb]);
} else {
this.doClose(buf, mask, cb);
}
}
/**
* Frames and sends a close message.
*
* @param {Buffer} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @private
*/
doClose(data, mask, cb) {
this.sendFrame(
Sender.frame(data, {
fin: true,
rsv1: false,
opcode: 0x08,
mask,
readOnly: false
}),
cb
);
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
const buf = toBuffer(data);
if (buf.length > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
if (this._deflating) {
this.enqueue([this.doPing, buf, mask, toBuffer.readOnly, cb]);
} else {
this.doPing(buf, mask, toBuffer.readOnly, cb);
}
}
/**
* Frames and sends a ping message.
*
* @param {Buffer} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Boolean} [readOnly=false] Specifies whether `data` can be modified
* @param {Function} [cb] Callback
* @private
*/
doPing(data, mask, readOnly, cb) {
this.sendFrame(
Sender.frame(data, {
fin: true,
rsv1: false,
opcode: 0x09,
mask,
readOnly
}),
cb
);
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
const buf = toBuffer(data);
if (buf.length > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
if (this._deflating) {
this.enqueue([this.doPong, buf, mask, toBuffer.readOnly, cb]);
} else {
this.doPong(buf, mask, toBuffer.readOnly, cb);
}
}
/**
* Frames and sends a pong message.
*
* @param {Buffer} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Boolean} [readOnly=false] Specifies whether `data` can be modified
* @param {Function} [cb] Callback
* @private
*/
doPong(data, mask, readOnly, cb) {
this.sendFrame(
Sender.frame(data, {
fin: true,
rsv1: false,
opcode: 0x0a,
mask,
readOnly
}),
cb
);
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const buf = toBuffer(data);
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
if (this._firstFragment) {
this._firstFragment = false;
if (rsv1 && perMessageDeflate) {
rsv1 = buf.length >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
if (perMessageDeflate) {
const opts = {
fin: options.fin,
rsv1,
opcode,
mask: options.mask,
readOnly: toBuffer.readOnly
};
if (this._deflating) {
this.enqueue([this.dispatch, buf, this._compress, opts, cb]);
} else {
this.dispatch(buf, this._compress, opts, cb);
}
} else {
this.sendFrame(
Sender.frame(buf, {
fin: options.fin,
rsv1: false,
opcode,
mask: options.mask,
readOnly: toBuffer.readOnly
}),
cb
);
}
}
/**
* Dispatches a data message.
*
* @param {Buffer} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += data.length;
this._deflating = true;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
if (typeof cb === 'function') cb(err);
for (let i = 0; i < this._queue.length; i++) {
const callback = this._queue[i][4];
if (typeof callback === 'function') callback(err);
}
return;
}
this._bufferedBytes -= data.length;
this._deflating = false;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (!this._deflating && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[1].length;
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[1].length;
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {Buffer[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;

View file

@ -0,0 +1,180 @@
'use strict';
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let resumeOnReceiverDrain = true;
let terminateOnDestroy = true;
function receiverOnDrain() {
if (resumeOnReceiverDrain) ws._socket.resume();
}
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
ws._receiver.removeAllListeners('drain');
ws._receiver.on('drain', receiverOnDrain);
});
} else {
ws._receiver.removeAllListeners('drain');
ws._receiver.on('drain', receiverOnDrain);
}
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg) {
if (!duplex.push(msg)) {
resumeOnReceiverDrain = false;
ws._socket.pause();
}
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (
(ws.readyState === ws.OPEN || ws.readyState === ws.CLOSING) &&
!resumeOnReceiverDrain
) {
resumeOnReceiverDrain = true;
if (!ws._receiver._writableState.needDrain) ws._socket.resume();
}
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;

View file

@ -0,0 +1,104 @@
'use strict';
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
try {
let isValidUTF8 = require('utf-8-validate');
/* istanbul ignore if */
if (typeof isValidUTF8 === 'object') {
isValidUTF8 = isValidUTF8.Validation.isValidUTF8; // utf-8-validate@<3.0.0
}
module.exports = {
isValidStatusCode,
isValidUTF8(buf) {
return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf);
}
};
} catch (e) /* istanbul ignore next */ {
module.exports = {
isValidStatusCode,
isValidUTF8: _isValidUTF8
};
}

View file

@ -0,0 +1,449 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const { createHash } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const WebSocket = require('./websocket');
const { format, parse } = require('./extension');
const { GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
maxPayload: 100 * 1024 * 1024,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) this.clients = new Set();
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Close the server.
*
* @param {Function} [cb] Callback
* @public
*/
close(cb) {
if (cb) this.once('close', cb);
if (this._state === CLOSED) {
process.nextTick(emitClose, this);
return;
}
if (this._state === CLOSING) return;
this._state = CLOSING;
//
// Terminate all associated clients.
//
if (this.clients) {
for (const client of this.clients) client.terminate();
}
const server = this._server;
if (server) {
this._removeListeners();
this._removeListeners = this._server = null;
//
// Close the http server if it was internally created.
//
if (this.options.port != null) {
server.close(emitClose.bind(undefined, this));
return;
}
}
process.nextTick(emitClose, this);
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {(net.Socket|tls.Socket)} socket The network socket between the
* server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key =
req.headers['sec-websocket-key'] !== undefined
? req.headers['sec-websocket-key'].trim()
: false;
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
const extensions = {};
if (
req.method !== 'GET' ||
upgrade === undefined ||
upgrade.toLowerCase() !== 'websocket' ||
!key ||
!keyRegex.test(key) ||
(version !== 8 && version !== 13) ||
!this.shouldHandle(req)
) {
return abortHandshake(socket, 400);
}
if (this.options.perMessageDeflate) {
const perMessageDeflate = new PerMessageDeflate(
this.options.perMessageDeflate,
true,
this.options.maxPayload
);
try {
const offers = parse(req.headers['sec-websocket-extensions']);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
return abortHandshake(socket, 400);
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(key, extensions, req, socket, head, cb);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(key, extensions, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Object} extensions The accepted extensions
* @param {http.IncomingMessage} req The request object
* @param {(net.Socket|tls.Socket)} socket The network socket between the
* server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(key, extensions, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new WebSocket(null);
let protocol = req.headers['sec-websocket-protocol'];
if (protocol) {
protocol = protocol.split(',').map(trim);
//
// Optionally call external protocol selection handler.
//
if (this.options.handleProtocols) {
protocol = this.options.handleProtocols(protocol, req);
} else {
protocol = protocol[0];
}
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, this.options.maxPayload);
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => this.clients.delete(ws));
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle premature socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
if (socket.writable) {
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.write(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
socket.removeListener('error', socketOnError);
socket.destroy();
}
/**
* Remove whitespace characters from both ends of a string.
*
* @param {String} str The string
* @return {String} A new string representing `str` stripped of whitespace
* characters from both its beginning and end
* @private
*/
function trim(str) {
return str.trim();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,56 @@
{
"name": "ws",
"version": "7.5.10",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
"Push",
"RFC-6455",
"WebSocket",
"WebSockets",
"real-time"
],
"homepage": "https://github.com/websockets/ws",
"bugs": "https://github.com/websockets/ws/issues",
"repository": "websockets/ws",
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"browser": "browser.js",
"engines": {
"node": ">=8.3.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
},
"devDependencies": {
"benchmark": "^2.1.4",
"bufferutil": "^4.0.1",
"eslint": "^7.2.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^7.0.0",
"nyc": "^15.0.0",
"prettier": "^2.0.5",
"utf-8-validate": "^5.0.2"
}
}

View file

@ -0,0 +1,64 @@
{
"name": "chrome-remote-interface",
"author": "Andrea Cardaci <cyrus.and@gmail.com>",
"license": "MIT",
"contributors": [
"Andrey Sidorov <sidoares@yandex.ru>",
"Greg Cochard <greg@gregcochard.com>"
],
"description": "Chrome Debugging Protocol interface",
"keywords": [
"chrome",
"debug",
"protocol",
"remote",
"interface"
],
"homepage": "https://github.com/cyrus-and/chrome-remote-interface",
"version": "0.34.0",
"repository": {
"type": "git",
"url": "git://github.com/cyrus-and/chrome-remote-interface.git"
},
"bugs": {
"url": "http://github.com/cyrus-and/chrome-remote-interface/issues"
},
"engine-strict": {
"node": ">=8"
},
"dependencies": {
"commander": "2.11.x",
"ws": "^7.2.0"
},
"files": [
"lib",
"bin",
"index.js",
"chrome-remote-interface.js",
"webpack.config.js"
],
"bin": {
"chrome-remote-interface": "bin/client.js"
},
"main": "index.js",
"browser": "chrome-remote-interface.js",
"devDependencies": {
"babel-core": "^6.26.3",
"babel-loader": "8.x.x",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^0.0.0",
"eslint": "^8.8.0",
"json-loader": "^0.5.4",
"mocha": "^11.1.0",
"process": "^0.11.10",
"url": "^0.11.0",
"util": "^0.12.4",
"webpack": "^5.39.0",
"webpack-cli": "^4.7.2"
},
"scripts": {
"test": "./scripts/run-tests.sh",
"webpack": "webpack",
"prepare": "webpack"
}
}

View file

@ -0,0 +1,48 @@
'use strict';
const TerserPlugin = require('terser-webpack-plugin');
const webpack = require('webpack');
function criWrapper(_, options, callback) {
window.criRequest(options, callback); // eslint-disable-line no-undef
}
module.exports = {
mode: 'production',
resolve: {
fallback: {
'util': require.resolve('util/'),
'url': require.resolve('url/'),
'http': false,
'https': false,
'dns': false
},
alias: {
'ws': './websocket-wrapper.js'
}
},
externals: [
{
'./external-request.js': `var (${criWrapper})`
}
],
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
}),
],
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
})
],
},
entry: ['babel-polyfill', './index.js'],
output: {
path: __dirname,
filename: 'chrome-remote-interface.js',
libraryTarget: process.env.TARGET || 'commonjs2',
library: 'CDP'
}
};

298
engine/ds-screencast/node_modules/commander/History.md generated vendored Normal file
View file

@ -0,0 +1,298 @@
2.11.0 / 2017-07-03
==================
* Fix help section order and padding (#652)
* feature: support for signals to subcommands (#632)
* Fixed #37, --help should not display first (#447)
* Fix translation errors. (#570)
* Add package-lock.json
* Remove engines
* Upgrade package version
* Prefix events to prevent conflicts between commands and options (#494)
* Removing dependency on graceful-readlink
* Support setting name in #name function and make it chainable
* Add .vscode directory to .gitignore (Visual Studio Code metadata)
* Updated link to ruby commander in readme files
2.10.0 / 2017-06-19
==================
* Update .travis.yml. drop support for older node.js versions.
* Fix require arguments in README.md
* On SemVer you do not start from 0.0.1
* Add missing semi colon in readme
* Add save param to npm install
* node v6 travis test
* Update Readme_zh-CN.md
* Allow literal '--' to be passed-through as an argument
* Test subcommand alias help
* link build badge to master branch
* Support the alias of Git style sub-command
* added keyword commander for better search result on npm
* Fix Sub-Subcommands
* test node.js stable
* Fixes TypeError when a command has an option called `--description`
* Update README.md to make it beginner friendly and elaborate on the difference between angled and square brackets.
* Add chinese Readme file
2.9.0 / 2015-10-13
==================
* Add option `isDefault` to set default subcommand #415 @Qix-
* Add callback to allow filtering or post-processing of help text #434 @djulien
* Fix `undefined` text in help information close #414 #416 @zhiyelee
2.8.1 / 2015-04-22
==================
* Back out `support multiline description` Close #396 #397
2.8.0 / 2015-04-07
==================
* Add `process.execArg` support, execution args like `--harmony` will be passed to sub-commands #387 @DigitalIO @zhiyelee
* Fix bug in Git-style sub-commands #372 @zhiyelee
* Allow commands to be hidden from help #383 @tonylukasavage
* When git-style sub-commands are in use, yet none are called, display help #382 @claylo
* Add ability to specify arguments syntax for top-level command #258 @rrthomas
* Support multiline descriptions #208 @zxqfox
2.7.1 / 2015-03-11
==================
* Revert #347 (fix collisions when option and first arg have same name) which causes a bug in #367.
2.7.0 / 2015-03-09
==================
* Fix git-style bug when installed globally. Close #335 #349 @zhiyelee
* Fix collisions when option and first arg have same name. Close #346 #347 @tonylukasavage
* Add support for camelCase on `opts()`. Close #353 @nkzawa
* Add node.js 0.12 and io.js to travis.yml
* Allow RegEx options. #337 @palanik
* Fixes exit code when sub-command failing. Close #260 #332 @pirelenito
* git-style `bin` files in $PATH make sense. Close #196 #327 @zhiyelee
2.6.0 / 2014-12-30
==================
* added `Command#allowUnknownOption` method. Close #138 #318 @doozr @zhiyelee
* Add application description to the help msg. Close #112 @dalssoft
2.5.1 / 2014-12-15
==================
* fixed two bugs incurred by variadic arguments. Close #291 @Quentin01 #302 @zhiyelee
2.5.0 / 2014-10-24
==================
* add support for variadic arguments. Closes #277 @whitlockjc
2.4.0 / 2014-10-17
==================
* fixed a bug on executing the coercion function of subcommands option. Closes #270
* added `Command.prototype.name` to retrieve command name. Closes #264 #266 @tonylukasavage
* added `Command.prototype.opts` to retrieve all the options as a simple object of key-value pairs. Closes #262 @tonylukasavage
* fixed a bug on subcommand name. Closes #248 @jonathandelgado
* fixed function normalize doesnt honor option terminator. Closes #216 @abbr
2.3.0 / 2014-07-16
==================
* add command alias'. Closes PR #210
* fix: Typos. Closes #99
* fix: Unused fs module. Closes #217
2.2.0 / 2014-03-29
==================
* add passing of previous option value
* fix: support subcommands on windows. Closes #142
* Now the defaultValue passed as the second argument of the coercion function.
2.1.0 / 2013-11-21
==================
* add: allow cflag style option params, unit test, fixes #174
2.0.0 / 2013-07-18
==================
* remove input methods (.prompt, .confirm, etc)
1.3.2 / 2013-07-18
==================
* add support for sub-commands to co-exist with the original command
1.3.1 / 2013-07-18
==================
* add quick .runningCommand hack so you can opt-out of other logic when running a sub command
1.3.0 / 2013-07-09
==================
* add EACCES error handling
* fix sub-command --help
1.2.0 / 2013-06-13
==================
* allow "-" hyphen as an option argument
* support for RegExp coercion
1.1.1 / 2012-11-20
==================
* add more sub-command padding
* fix .usage() when args are present. Closes #106
1.1.0 / 2012-11-16
==================
* add git-style executable subcommand support. Closes #94
1.0.5 / 2012-10-09
==================
* fix `--name` clobbering. Closes #92
* fix examples/help. Closes #89
1.0.4 / 2012-09-03
==================
* add `outputHelp()` method.
1.0.3 / 2012-08-30
==================
* remove invalid .version() defaulting
1.0.2 / 2012-08-24
==================
* add `--foo=bar` support [arv]
* fix password on node 0.8.8. Make backward compatible with 0.6 [focusaurus]
1.0.1 / 2012-08-03
==================
* fix issue #56
* fix tty.setRawMode(mode) was moved to tty.ReadStream#setRawMode() (i.e. process.stdin.setRawMode())
1.0.0 / 2012-07-05
==================
* add support for optional option descriptions
* add defaulting of `.version()` to package.json's version
0.6.1 / 2012-06-01
==================
* Added: append (yes or no) on confirmation
* Added: allow node.js v0.7.x
0.6.0 / 2012-04-10
==================
* Added `.prompt(obj, callback)` support. Closes #49
* Added default support to .choose(). Closes #41
* Fixed the choice example
0.5.1 / 2011-12-20
==================
* Fixed `password()` for recent nodes. Closes #36
0.5.0 / 2011-12-04
==================
* Added sub-command option support [itay]
0.4.3 / 2011-12-04
==================
* Fixed custom help ordering. Closes #32
0.4.2 / 2011-11-24
==================
* Added travis support
* Fixed: line-buffered input automatically trimmed. Closes #31
0.4.1 / 2011-11-18
==================
* Removed listening for "close" on --help
0.4.0 / 2011-11-15
==================
* Added support for `--`. Closes #24
0.3.3 / 2011-11-14
==================
* Fixed: wait for close event when writing help info [Jerry Hamlet]
0.3.2 / 2011-11-01
==================
* Fixed long flag definitions with values [felixge]
0.3.1 / 2011-10-31
==================
* Changed `--version` short flag to `-V` from `-v`
* Changed `.version()` so it's configurable [felixge]
0.3.0 / 2011-10-31
==================
* Added support for long flags only. Closes #18
0.2.1 / 2011-10-24
==================
* "node": ">= 0.4.x < 0.7.0". Closes #20
0.2.0 / 2011-09-26
==================
* Allow for defaults that are not just boolean. Default peassignment only occurs for --no-*, optional, and required arguments. [Jim Isaacs]
0.1.0 / 2011-08-24
==================
* Added support for custom `--help` output
0.0.5 / 2011-08-18
==================
* Changed: when the user enters nothing prompt for password again
* Fixed issue with passwords beginning with numbers [NuckChorris]
0.0.4 / 2011-08-15
==================
* Fixed `Commander#args`
0.0.3 / 2011-08-15
==================
* Added default option value support
0.0.2 / 2011-08-15
==================
* Added mask support to `Command#password(str[, mask], fn)`
* Added `Command#password(str, fn)`
0.0.1 / 2010-01-03
==================
* Initial release

22
engine/ds-screencast/node_modules/commander/LICENSE generated vendored Normal file
View file

@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) 2011 TJ Holowaychuk <tj@vision-media.ca>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

351
engine/ds-screencast/node_modules/commander/Readme.md generated vendored Normal file
View file

@ -0,0 +1,351 @@
# Commander.js
[![Build Status](https://api.travis-ci.org/tj/commander.js.svg?branch=master)](http://travis-ci.org/tj/commander.js)
[![NPM Version](http://img.shields.io/npm/v/commander.svg?style=flat)](https://www.npmjs.org/package/commander)
[![NPM Downloads](https://img.shields.io/npm/dm/commander.svg?style=flat)](https://www.npmjs.org/package/commander)
[![Join the chat at https://gitter.im/tj/commander.js](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/tj/commander.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
The complete solution for [node.js](http://nodejs.org) command-line interfaces, inspired by Ruby's [commander](https://github.com/commander-rb/commander).
[API documentation](http://tj.github.com/commander.js/)
## Installation
$ npm install commander --save
## Option parsing
Options with commander are defined with the `.option()` method, also serving as documentation for the options. The example below parses args and options from `process.argv`, leaving remaining args as the `program.args` array which were not consumed by options.
```js
#!/usr/bin/env node
/**
* Module dependencies.
*/
var program = require('commander');
program
.version('0.1.0')
.option('-p, --peppers', 'Add peppers')
.option('-P, --pineapple', 'Add pineapple')
.option('-b, --bbq-sauce', 'Add bbq sauce')
.option('-c, --cheese [type]', 'Add the specified type of cheese [marble]', 'marble')
.parse(process.argv);
console.log('you ordered a pizza with:');
if (program.peppers) console.log(' - peppers');
if (program.pineapple) console.log(' - pineapple');
if (program.bbqSauce) console.log(' - bbq');
console.log(' - %s cheese', program.cheese);
```
Short flags may be passed as a single arg, for example `-abc` is equivalent to `-a -b -c`. Multi-word options such as "--template-engine" are camel-cased, becoming `program.templateEngine` etc.
## Coercion
```js
function range(val) {
return val.split('..').map(Number);
}
function list(val) {
return val.split(',');
}
function collect(val, memo) {
memo.push(val);
return memo;
}
function increaseVerbosity(v, total) {
return total + 1;
}
program
.version('0.1.0')
.usage('[options] <file ...>')
.option('-i, --integer <n>', 'An integer argument', parseInt)
.option('-f, --float <n>', 'A float argument', parseFloat)
.option('-r, --range <a>..<b>', 'A range', range)
.option('-l, --list <items>', 'A list', list)
.option('-o, --optional [value]', 'An optional value')
.option('-c, --collect [value]', 'A repeatable value', collect, [])
.option('-v, --verbose', 'A value that can be increased', increaseVerbosity, 0)
.parse(process.argv);
console.log(' int: %j', program.integer);
console.log(' float: %j', program.float);
console.log(' optional: %j', program.optional);
program.range = program.range || [];
console.log(' range: %j..%j', program.range[0], program.range[1]);
console.log(' list: %j', program.list);
console.log(' collect: %j', program.collect);
console.log(' verbosity: %j', program.verbose);
console.log(' args: %j', program.args);
```
## Regular Expression
```js
program
.version('0.1.0')
.option('-s --size <size>', 'Pizza size', /^(large|medium|small)$/i, 'medium')
.option('-d --drink [drink]', 'Drink', /^(coke|pepsi|izze)$/i)
.parse(process.argv);
console.log(' size: %j', program.size);
console.log(' drink: %j', program.drink);
```
## Variadic arguments
The last argument of a command can be variadic, and only the last argument. To make an argument variadic you have to
append `...` to the argument name. Here is an example:
```js
#!/usr/bin/env node
/**
* Module dependencies.
*/
var program = require('commander');
program
.version('0.1.0')
.command('rmdir <dir> [otherDirs...]')
.action(function (dir, otherDirs) {
console.log('rmdir %s', dir);
if (otherDirs) {
otherDirs.forEach(function (oDir) {
console.log('rmdir %s', oDir);
});
}
});
program.parse(process.argv);
```
An `Array` is used for the value of a variadic argument. This applies to `program.args` as well as the argument passed
to your action as demonstrated above.
## Specify the argument syntax
```js
#!/usr/bin/env node
var program = require('commander');
program
.version('0.1.0')
.arguments('<cmd> [env]')
.action(function (cmd, env) {
cmdValue = cmd;
envValue = env;
});
program.parse(process.argv);
if (typeof cmdValue === 'undefined') {
console.error('no command given!');
process.exit(1);
}
console.log('command:', cmdValue);
console.log('environment:', envValue || "no environment given");
```
Angled brackets (e.g. `<cmd>`) indicate required input. Square brackets (e.g. `[env]`) indicate optional input.
## Git-style sub-commands
```js
// file: ./examples/pm
var program = require('commander');
program
.version('0.1.0')
.command('install [name]', 'install one or more packages')
.command('search [query]', 'search with optional query')
.command('list', 'list packages installed', {isDefault: true})
.parse(process.argv);
```
When `.command()` is invoked with a description argument, no `.action(callback)` should be called to handle sub-commands, otherwise there will be an error. This tells commander that you're going to use separate executables for sub-commands, much like `git(1)` and other popular tools.
The commander will try to search the executables in the directory of the entry script (like `./examples/pm`) with the name `program-command`, like `pm-install`, `pm-search`.
Options can be passed with the call to `.command()`. Specifying `true` for `opts.noHelp` will remove the option from the generated help output. Specifying `true` for `opts.isDefault` will run the subcommand if no other subcommand is specified.
If the program is designed to be installed globally, make sure the executables have proper modes, like `755`.
### `--harmony`
You can enable `--harmony` option in two ways:
* Use `#! /usr/bin/env node --harmony` in the sub-commands scripts. Note some os version dont support this pattern.
* Use the `--harmony` option when call the command, like `node --harmony examples/pm publish`. The `--harmony` option will be preserved when spawning sub-command process.
## Automated --help
The help information is auto-generated based on the information commander already knows about your program, so the following `--help` info is for free:
```
$ ./examples/pizza --help
Usage: pizza [options]
An application for pizzas ordering
Options:
-h, --help output usage information
-V, --version output the version number
-p, --peppers Add peppers
-P, --pineapple Add pineapple
-b, --bbq Add bbq sauce
-c, --cheese <type> Add the specified type of cheese [marble]
-C, --no-cheese You do not want any cheese
```
## Custom help
You can display arbitrary `-h, --help` information
by listening for "--help". Commander will automatically
exit once you are done so that the remainder of your program
does not execute causing undesired behaviours, for example
in the following executable "stuff" will not output when
`--help` is used.
```js
#!/usr/bin/env node
/**
* Module dependencies.
*/
var program = require('commander');
program
.version('0.1.0')
.option('-f, --foo', 'enable some foo')
.option('-b, --bar', 'enable some bar')
.option('-B, --baz', 'enable some baz');
// must be before .parse() since
// node's emit() is immediate
program.on('--help', function(){
console.log(' Examples:');
console.log('');
console.log(' $ custom-help --help');
console.log(' $ custom-help -h');
console.log('');
});
program.parse(process.argv);
console.log('stuff');
```
Yields the following help output when `node script-name.js -h` or `node script-name.js --help` are run:
```
Usage: custom-help [options]
Options:
-h, --help output usage information
-V, --version output the version number
-f, --foo enable some foo
-b, --bar enable some bar
-B, --baz enable some baz
Examples:
$ custom-help --help
$ custom-help -h
```
## .outputHelp(cb)
Output help information without exiting.
Optional callback cb allows post-processing of help text before it is displayed.
If you want to display help by default (e.g. if no command was provided), you can use something like:
```js
var program = require('commander');
var colors = require('colors');
program
.version('0.1.0')
.command('getstream [url]', 'get stream URL')
.parse(process.argv);
if (!process.argv.slice(2).length) {
program.outputHelp(make_red);
}
function make_red(txt) {
return colors.red(txt); //display the help text in red on the console
}
```
## .help(cb)
Output help information and exit immediately.
Optional callback cb allows post-processing of help text before it is displayed.
## Examples
```js
var program = require('commander');
program
.version('0.1.0')
.option('-C, --chdir <path>', 'change the working directory')
.option('-c, --config <path>', 'set config path. defaults to ./deploy.conf')
.option('-T, --no-tests', 'ignore test hook');
program
.command('setup [env]')
.description('run setup commands for all envs')
.option("-s, --setup_mode [mode]", "Which setup mode to use")
.action(function(env, options){
var mode = options.setup_mode || "normal";
env = env || 'all';
console.log('setup for %s env(s) with %s mode', env, mode);
});
program
.command('exec <cmd>')
.alias('ex')
.description('execute the given remote cmd')
.option("-e, --exec_mode <mode>", "Which exec mode to use")
.action(function(cmd, options){
console.log('exec "%s" using %s mode', cmd, options.exec_mode);
}).on('--help', function() {
console.log(' Examples:');
console.log();
console.log(' $ deploy exec sequential');
console.log(' $ deploy exec async');
console.log();
});
program
.command('*')
.action(function(env){
console.log('deploying "%s"', env);
});
program.parse(process.argv);
```
More Demos can be found in the [examples](https://github.com/tj/commander.js/tree/master/examples) directory.
## License
MIT

1137
engine/ds-screencast/node_modules/commander/index.js generated vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
{
"name": "commander",
"version": "2.11.0",
"description": "the complete solution for node.js command-line programs",
"keywords": [
"commander",
"command",
"option",
"parser"
],
"author": "TJ Holowaychuk <tj@vision-media.ca>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tj/commander.js.git"
},
"devDependencies": {
"should": "^11.2.1",
"sinon": "^2.3.5"
},
"scripts": {
"test": "make test"
},
"main": "index",
"files": [
"index.js"
],
"dependencies": {}
}

20
engine/ds-screencast/node_modules/ws/LICENSE generated vendored Normal file
View file

@ -0,0 +1,20 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

548
engine/ds-screencast/node_modules/ws/README.md generated vendored Normal file
View file

@ -0,0 +1,548 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a backend with the role of a client in the WebSocket communication.
Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [Round-trip time](#round-trip-time)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
[bufferutil][] is an optional module that can be installed alongside the ws
module:
```
npm install --save-optional bufferutil
```
This is a binary addon that improves the performance of certain operations such
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
binaries are available for the most popular platforms, so you don't necessarily
need to have a C++ compiler installed on your machine.
To force ws to not use bufferutil, use the
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
can be useful to enhance security in systems where a user can put a package in
the package search path of an application of another user, due to how the
Node.js resolver algorithm works.
#### Legacy opt-in for performance
If you are running on an old version of Node.js (prior to v18.14.0), ws also
supports the [utf-8-validate][] module:
```
npm install --save-optional utf-8-validate
```
This contains a binary polyfill for [`buffer.isUtf8()`][].
To force ws not to use utf-8-validate, use the
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client, set the
`perMessageDeflate` option to `false`.
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function message(data) {
console.log('received: %s', data);
});
```
### Sending binary data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
```
### External HTTP/S server
```js
import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/path/to/cert.pem'),
key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'wss://base.url');
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
function onSocketError(err) {
console.error(err);
}
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log(`Received message ${data} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
socket.on('error', onSocketError);
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
socket.removeListener('error', onSocketError);
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
### Round-trip time
```js
import WebSocket from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
ws.on('error', console.error);
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function message(data) {
console.log(`Round-trip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
import WebSocket, { createWebSocketStream } from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
duplex.on('error', console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
ws.on('error', console.error);
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
ws.on('error', console.error);
});
```
### How to detect and close broken connections?
Sometimes, the link between the server and the client can be interrupted in a
way that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases, ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
import { WebSocketServer } from 'ws';
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('error', console.error);
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above, your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
import WebSocket from 'ws';
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://websocket-echo.com/');
client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
[bufferutil]: https://github.com/websockets/bufferutil
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[utf-8-validate]: https://github.com/websockets/utf-8-validate
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback

8
engine/ds-screencast/node_modules/ws/browser.js generated vendored Normal file
View file

@ -0,0 +1,8 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};

13
engine/ds-screencast/node_modules/ws/index.js generated vendored Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket;

131
engine/ds-screencast/node_modules/ws/lib/buffer-util.js generated vendored Normal file
View file

@ -0,0 +1,131 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}

19
engine/ds-screencast/node_modules/ws/lib/constants.js generated vendored Normal file
View file

@ -0,0 +1,19 @@
'use strict';
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
const hasBlob = typeof Blob !== 'undefined';
if (hasBlob) BINARY_TYPES.push('blob');
module.exports = {
BINARY_TYPES,
CLOSE_TIMEOUT: 30000,
EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob,
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
kListener: Symbol('kListener'),
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
NOOP: () => {}
};

View file

@ -0,0 +1,292 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}

203
engine/ds-screencast/node_modules/ws/lib/extension.js generated vendored Normal file
View file

@ -0,0 +1,203 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };

55
engine/ds-screencast/node_modules/ws/lib/limiter.js generated vendored Normal file
View file

@ -0,0 +1,55 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;

View file

@ -0,0 +1,528 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed if context takeover is disabled
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._isServer = !!isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) {
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
}
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007;
this[kCallback](err);
}

706
engine/ds-screencast/node_modules/ws/lib/receiver.js generated vendored Normal file
View file

@ -0,0 +1,706 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const FastBuffer = Buffer[Symbol.species];
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
const DEFER_EVENT = 6;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {Object} [options] Options object
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {String} [options.binaryType=nodebuffer] The type for binary data
* @param {Object} [options.extensions] An object containing the negotiated
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
*/
constructor(options = {}) {
super();
this._allowSynchronousEvents =
options.allowSynchronousEvents !== undefined
? options.allowSynchronousEvents
: true;
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._errored = false;
this._loop = false;
this._state = GET_INFO;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
return new FastBuffer(buf.buffer, buf.byteOffset, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
this.getInfo(cb);
break;
case GET_PAYLOAD_LENGTH_16:
this.getPayloadLength16(cb);
break;
case GET_PAYLOAD_LENGTH_64:
this.getPayloadLength64(cb);
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
this.getData(cb);
break;
case INFLATING:
case DEFER_EVENT:
this._loop = false;
return;
}
} while (this._loop);
if (!this._errored) cb();
}
/**
* Reads the first two bytes of a frame.
*
* @param {Function} cb Callback
* @private
*/
getInfo(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
const error = this.createError(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
cb(error);
return;
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (!this._fragmented) {
const error = this.createError(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
const error = this.createError(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
cb(error);
return;
}
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (
this._payloadLength > 0x7d ||
(this._opcode === 0x08 && this._payloadLength === 1)
) {
const error = this.createError(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
cb(error);
return;
}
} else {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
const error = this.createError(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
cb(error);
return;
}
} else if (this._masked) {
const error = this.createError(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
cb(error);
return;
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else this.haveLength(cb);
}
/**
* Gets extended payload length (7+16).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength16(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
this.haveLength(cb);
}
/**
* Gets extended payload length (7+64).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength64(cb) {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
const error = this.createError(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
cb(error);
return;
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
this.haveLength(cb);
}
/**
* Payload length has been read.
*
* @param {Function} cb Callback
* @private
*/
haveLength(cb) {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (
this._masked &&
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
) {
unmask(data, this._mask);
}
}
if (this._opcode > 0x07) {
this.controlMessage(data, cb);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its length is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
this.dataMessage(cb);
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
this._fragments.push(buf);
}
this.dataMessage(cb);
if (this._state === GET_INFO) this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @param {Function} cb Callback
* @private
*/
dataMessage(cb) {
if (!this._fin) {
this._state = GET_INFO;
return;
}
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else if (this._binaryType === 'blob') {
data = new Blob(fragments);
} else {
data = fragments;
}
if (this._allowSynchronousEvents) {
this.emit('message', data, true);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', data, true);
this._state = GET_INFO;
this.startLoop(cb);
});
}
} else {
const buf = concat(fragments, messageLength);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
if (this._state === INFLATING || this._allowSynchronousEvents) {
this.emit('message', buf, false);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', buf, false);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data, cb) {
if (this._opcode === 0x08) {
if (data.length === 0) {
this._loop = false;
this.emit('conclude', 1005, EMPTY_BUFFER);
this.end();
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
const error = this.createError(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
cb(error);
return;
}
const buf = new FastBuffer(
data.buffer,
data.byteOffset + 2,
data.length - 2
);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
this._loop = false;
this.emit('conclude', code, buf);
this.end();
}
this._state = GET_INFO;
return;
}
if (this._allowSynchronousEvents) {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
this._loop = false;
this._errored = true;
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, this.createError);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}
}
module.exports = Receiver;

602
engine/ds-screencast/node_modules/ws/lib/sender.js generated vendored Normal file
View file

@ -0,0 +1,602 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
'use strict';
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
const { isBlob, isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const kByteLength = Symbol('kByteLength');
const maskBuffer = Buffer.alloc(4);
const RANDOM_POOL_SIZE = 8 * 1024;
let randomPool;
let randomPoolPointer = RANDOM_POOL_SIZE;
const DEFAULT = 0;
const DEFLATING = 1;
const GET_BLOB_DATA = 2;
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {Duplex} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Function} [generateMask] The function used to generate the masking
* key
*/
constructor(socket, extensions, generateMask) {
this._extensions = extensions || {};
if (generateMask) {
this._generateMask = generateMask;
this._maskBuffer = Buffer.alloc(4);
}
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._queue = [];
this._state = DEFAULT;
this.onerror = NOOP;
this[kWebSocket] = undefined;
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {(Buffer|String)} data The data to frame
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {(Buffer|String)[]} The framed data
* @public
*/
static frame(data, options) {
let mask;
let merge = false;
let offset = 2;
let skipMasking = false;
if (options.mask) {
mask = options.maskBuffer || maskBuffer;
if (options.generateMask) {
options.generateMask(mask);
} else {
if (randomPoolPointer === RANDOM_POOL_SIZE) {
/* istanbul ignore else */
if (randomPool === undefined) {
//
// This is lazily initialized because server-sent frames must not
// be masked so it may never be used.
//
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
}
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
randomPoolPointer = 0;
}
mask[0] = randomPool[randomPoolPointer++];
mask[1] = randomPool[randomPoolPointer++];
mask[2] = randomPool[randomPoolPointer++];
mask[3] = randomPool[randomPoolPointer++];
}
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
offset = 6;
}
let dataLength;
if (typeof data === 'string') {
if (
(!options.mask || skipMasking) &&
options[kByteLength] !== undefined
) {
dataLength = options[kByteLength];
} else {
data = Buffer.from(data);
dataLength = data.length;
}
} else {
dataLength = data.length;
merge = options.mask && options.readOnly && !skipMasking;
}
let payloadLength = dataLength;
if (dataLength >= 65536) {
offset += 8;
payloadLength = 127;
} else if (dataLength > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(dataLength, 2);
} else if (payloadLength === 127) {
target[2] = target[3] = 0;
target.writeUIntBE(dataLength, 4, 6);
}
if (!options.mask) return [target, data];
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (skipMasking) return [target, data];
if (merge) {
applyMask(data, mask, target, offset, dataLength);
return [target];
}
applyMask(data, mask, data, 0, dataLength);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {(String|Buffer)} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || !data.length) {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
if (typeof data === 'string') {
buf.write(data, 2);
} else {
buf.set(data, 2);
}
}
const options = {
[kByteLength]: buf.length,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x08,
readOnly: false,
rsv1: false
};
if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, buf, false, options, cb]);
} else {
this.sendFrame(Sender.frame(buf, options), cb);
}
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x09,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x0a,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (this._firstFragment) {
this._firstFragment = false;
if (
rsv1 &&
perMessageDeflate &&
perMessageDeflate.params[
perMessageDeflate._isServer
? 'server_no_context_takeover'
: 'client_no_context_takeover'
]
) {
rsv1 = byteLength >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
const opts = {
[kByteLength]: byteLength,
fin: options.fin,
generateMask: this._generateMask,
mask: options.mask,
maskBuffer: this._maskBuffer,
opcode,
readOnly,
rsv1
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
} else {
this.getBlobData(data, this._compress, opts, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
} else {
this.dispatch(data, this._compress, opts, cb);
}
}
/**
* Gets the contents of a blob as binary data.
*
* @param {Blob} blob The blob
* @param {Boolean} [compress=false] Specifies whether or not to compress
* the data
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
getBlobData(blob, compress, options, cb) {
this._bufferedBytes += options[kByteLength];
this._state = GET_BLOB_DATA;
blob
.arrayBuffer()
.then((arrayBuffer) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while the blob was being read'
);
//
// `callCallbacks` is called in the next tick to ensure that errors
// that might be thrown in the callbacks behave like errors thrown
// outside the promise chain.
//
process.nextTick(callCallbacks, this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
const data = toBuffer(arrayBuffer);
if (!compress) {
this._state = DEFAULT;
this.sendFrame(Sender.frame(data, options), cb);
this.dequeue();
} else {
this.dispatch(data, compress, options, cb);
}
})
.catch((err) => {
//
// `onError` is called in the next tick for the same reason that
// `callCallbacks` above is.
//
process.nextTick(onError, this, err, cb);
});
}
/**
* Dispatches a message.
*
* @param {(Buffer|String)} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += options[kByteLength];
this._state = DEFLATING;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
callCallbacks(this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
this._state = DEFAULT;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (this._state === DEFAULT && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[3][kByteLength];
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[3][kByteLength];
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;
/**
* Calls queued callbacks with an error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error to call the callbacks with
* @param {Function} [cb] The first callback
* @private
*/
function callCallbacks(sender, err, cb) {
if (typeof cb === 'function') cb(err);
for (let i = 0; i < sender._queue.length; i++) {
const params = sender._queue[i];
const callback = params[params.length - 1];
if (typeof callback === 'function') callback(err);
}
}
/**
* Handles a `Sender` error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error
* @param {Function} [cb] The first pending callback
* @private
*/
function onError(sender, err, cb) {
callCallbacks(sender, err, cb);
sender.onerror(err);
}

161
engine/ds-screencast/node_modules/ws/lib/stream.js generated vendored Normal file
View file

@ -0,0 +1,161 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let terminateOnDestroy = true;
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg, isBinary) {
const data =
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
if (!duplex.push(data)) ws.pause();
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (ws.isPaused) ws.resume();
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;

View file

@ -0,0 +1,62 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };

152
engine/ds-screencast/node_modules/ws/lib/validation.js generated vendored Normal file
View file

@ -0,0 +1,152 @@
'use strict';
const { isUtf8 } = require('buffer');
const { hasBlob } = require('./constants');
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
/**
* Determines whether a value is a `Blob`.
*
* @param {*} value The value to be tested
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
* @private
*/
function isBlob(value) {
return (
hasBlob &&
typeof value === 'object' &&
typeof value.arrayBuffer === 'function' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
(value[Symbol.toStringTag] === 'Blob' ||
value[Symbol.toStringTag] === 'File')
);
}
module.exports = {
isBlob,
isValidStatusCode,
isValidUTF8: _isValidUTF8,
tokenChars
};
if (isUtf8) {
module.exports.isValidUTF8 = function (buf) {
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
};
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
try {
const isValidUTF8 = require('utf-8-validate');
module.exports.isValidUTF8 = function (buf) {
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
};
} catch (e) {
// Continue regardless of the error.
}
}

View file

@ -0,0 +1,554 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const { Duplex } = require('stream');
const { createHash } = require('crypto');
const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket');
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
* wait for the closing handshake to finish after `websocket.close()` is
* called
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
* class to use. It must be the `WebSocket` class or class that extends it
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
allowSynchronousEvents: true,
autoPong: true,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
closeTimeout: CLOSE_TIMEOUT,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
WebSocket,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) {
this.clients = new Set();
this._shouldEmitClose = false;
}
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Stop the server from accepting new connections and emit the `'close'` event
* when all existing connections are closed.
*
* @param {Function} [cb] A one-time listener for the `'close'` event
* @public
*/
close(cb) {
if (this._state === CLOSED) {
if (cb) {
this.once('close', () => {
cb(new Error('The server is not running'));
});
}
process.nextTick(emitClose, this);
return;
}
if (cb) this.once('close', cb);
if (this._state === CLOSING) return;
this._state = CLOSING;
if (this.options.noServer || this.options.server) {
if (this._server) {
this._removeListeners();
this._removeListeners = this._server = null;
}
if (this.clients) {
if (!this.clients.size) {
process.nextTick(emitClose, this);
} else {
this._shouldEmitClose = true;
}
} else {
process.nextTick(emitClose, this);
}
} else {
const server = this._server;
this._removeListeners();
this._removeListeners = this._server = null;
//
// The HTTP/S server was created internally. Close it, and rely on its
// `'close'` event.
//
server.close(() => {
emitClose(this);
});
}
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key = req.headers['sec-websocket-key'];
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
if (req.method !== 'GET') {
const message = 'Invalid HTTP method';
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
return;
}
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
const message = 'Invalid Upgrade header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (key === undefined || !keyRegex.test(key)) {
const message = 'Missing or invalid Sec-WebSocket-Key header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (version !== 13 && version !== 8) {
const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
'Sec-WebSocket-Version': '13, 8'
});
return;
}
if (!this.shouldHandle(req)) {
abortHandshake(socket, 400);
return;
}
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
let protocols = new Set();
if (secWebSocketProtocol !== undefined) {
try {
protocols = subprotocol.parse(secWebSocketProtocol);
} catch (err) {
const message = 'Invalid Sec-WebSocket-Protocol header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
const extensions = {};
if (
this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined
) {
const perMessageDeflate = new PerMessageDeflate(
this.options.perMessageDeflate,
true,
this.options.maxPayload
);
try {
const offers = extension.parse(secWebSocketExtensions);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
const message =
'Invalid or unacceptable Sec-WebSocket-Extensions header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(
extensions,
key,
protocols,
req,
socket,
head,
cb
);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {Object} extensions The accepted extensions
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Set} protocols The subprotocols
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new this.options.WebSocket(null, undefined, this.options);
if (protocols.size) {
//
// Optionally call external protocol selection handler.
//
const protocol = this.options.handleProtocols
? this.options.handleProtocols(protocols, req)
: protocols.values().next().value;
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = extension.format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => {
this.clients.delete(ws);
if (this._shouldEmitClose && !this.clients.size) {
process.nextTick(emitClose, this);
}
});
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private
*/
function abortHandshakeOrEmitwsClientError(
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) {
const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req);
} else {
abortHandshake(socket, code, message, headers);
}
}

1393
engine/ds-screencast/node_modules/ws/lib/websocket.js generated vendored Normal file

File diff suppressed because it is too large Load diff

69
engine/ds-screencast/node_modules/ws/package.json generated vendored Normal file
View file

@ -0,0 +1,69 @@
{
"name": "ws",
"version": "8.19.0",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
"Push",
"RFC-6455",
"WebSocket",
"WebSockets",
"real-time"
],
"homepage": "https://github.com/websockets/ws",
"bugs": "https://github.com/websockets/ws/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/websockets/ws.git"
},
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"exports": {
".": {
"browser": "./browser.js",
"import": "./wrapper.mjs",
"require": "./index.js"
},
"./package.json": "./package.json"
},
"browser": "browser.js",
"engines": {
"node": ">=10.0.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js",
"wrapper.mjs"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
},
"devDependencies": {
"benchmark": "^2.1.4",
"bufferutil": "^4.0.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^16.0.0",
"mocha": "^8.4.0",
"nyc": "^15.0.0",
"prettier": "^3.0.0",
"utf-8-validate": "^6.0.0"
}
}

8
engine/ds-screencast/node_modules/ws/wrapper.mjs generated vendored Normal file
View file

@ -0,0 +1,8 @@
import createWebSocketStream from './lib/stream.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
export default WebSocket;

78
engine/ds-screencast/package-lock.json generated Normal file
View file

@ -0,0 +1,78 @@
{
"name": "ds-screencast",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ds-screencast",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"chrome-remote-interface": "^0.34.0",
"ws": "^8.19.0"
}
},
"node_modules/chrome-remote-interface": {
"version": "0.34.0",
"resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.34.0.tgz",
"integrity": "sha512-rTTcTZ3zemx8I+nvBii7d8BAF0Ms8LLEroypfvwwZOwSpyNGLE28nStXyCA6VwGp2YSQfmCrQH21F/E+oBFvMw==",
"license": "MIT",
"dependencies": {
"commander": "2.11.x",
"ws": "^7.2.0"
},
"bin": {
"chrome-remote-interface": "bin/client.js"
}
},
"node_modules/chrome-remote-interface/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/commander": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,16 @@
{
"name": "ds-screencast",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"chrome-remote-interface": "^0.34.0",
"ws": "^8.19.0"
}
}

View file

@ -0,0 +1,221 @@
//! DreamStack Hub — ESP-NOW + UDP Signal Sender
//!
//! Hub-side transport for Panel IR:
//! - UDP: push full IR JSON to panels on port 9200
//! - ESP-NOW (via serial bridge): send binary signal frames (<1ms)
//!
//! # Architecture
//!
//! Since Linux doesn't have native ESP-NOW support, we use an
//! ESP32-C3 USB dongle as a serial bridge:
//!
//! ```text
//! ┌──────────┐ UART/USB ┌──────────┐ ESP-NOW ┌──────────┐
//! │ ds-hub │ ─────────► │ C3 bridge │ ────────► │ Panel │
//! │ (Rust) │ ◄───────── │ (dongle) │ ◄──────── │ (ESP32) │
//! └──────────┘ └──────────┘ └──────────┘
//! ```
//!
//! For testing without hardware, the hub falls back to UDP-only mode,
//! which works with the browser previewer over WebSocket.
use std::net::UdpSocket;
use std::io::{self, Read, Write};
use std::time::Duration;
use std::fs;
use std::path::Path;
/// DreamStack binary frame types (must match ds_espnow.h)
pub mod frame {
pub const DS_NOW_SIG: u8 = 0x20;
pub const DS_NOW_SIG_BATCH: u8 = 0x21;
pub const DS_NOW_TOUCH: u8 = 0x30;
pub const DS_NOW_ACTION: u8 = 0x31;
pub const DS_NOW_PING: u8 = 0xFE;
pub const DS_NOW_PONG: u8 = 0xFD;
pub const DS_UDP_IR_PUSH: u8 = 0x40;
pub const DS_UDP_IR_FRAG: u8 = 0x41;
pub const DS_MAGIC: [u8; 2] = [0xD5, 0x7A];
pub const DS_UDP_PORT: u16 = 9200;
}
/// A signal update ready to send.
#[derive(Debug, Clone)]
pub struct SignalUpdate {
pub id: u16,
pub value: i32,
}
/// Hub transport — sends signals and IR to panels.
pub struct DsHub {
udp: UdpSocket,
panel_addr: String,
seq: u8,
// serial: Option<Box<dyn SerialPort>>, // For ESP32-C3 bridge (future)
}
impl DsHub {
/// Create a new hub targeting a panel at the given address.
///
/// ```rust
/// let hub = DsHub::new("192.168.1.100")?;
/// hub.push_ir(&ir_json)?;
/// hub.send_signal(0, 75)?;
/// ```
pub fn new(panel_ip: &str) -> io::Result<Self> {
let udp = UdpSocket::bind("0.0.0.0:0")?;
udp.set_broadcast(true)?;
Ok(DsHub {
udp,
panel_addr: format!("{}:{}", panel_ip, frame::DS_UDP_PORT),
seq: 0,
// serial: None,
})
}
/// Create a hub that broadcasts to all panels on the network.
pub fn broadcast() -> io::Result<Self> {
let udp = UdpSocket::bind("0.0.0.0:0")?;
udp.set_broadcast(true)?;
Ok(DsHub {
udp,
panel_addr: format!("255.255.255.255:{}", frame::DS_UDP_PORT),
seq: 0,
})
}
/// Push IR JSON to the panel via UDP.
/// Fragments if > 1400 bytes (UDP MTU safety margin).
pub fn push_ir(&mut self, ir_json: &str) -> io::Result<()> {
let data = ir_json.as_bytes();
if data.len() <= 1400 {
// Single packet
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&frame::DS_MAGIC);
buf.push(frame::DS_UDP_IR_PUSH);
buf.push(0); // reserved
buf.extend_from_slice(&(data.len() as u16).to_le_bytes());
buf.extend_from_slice(data);
self.udp.send_to(&buf, &self.panel_addr)?;
} else {
// Fragment
let frag_size = 1400;
let total = (data.len() + frag_size - 1) / frag_size;
let seq = self.next_seq();
for i in 0..total {
let start = i * frag_size;
let end = std::cmp::min(start + frag_size, data.len());
let chunk = &data[start..end];
let mut buf = Vec::with_capacity(6 + chunk.len());
buf.extend_from_slice(&frame::DS_MAGIC);
buf.push(frame::DS_UDP_IR_FRAG);
buf.push(i as u8); // frag_id
buf.push(total as u8); // frag_total
buf.push(seq); // group seq
buf.extend_from_slice(chunk);
self.udp.send_to(&buf, &self.panel_addr)?;
// Small delay between fragments to avoid overwhelming receiver
std::thread::sleep(Duration::from_millis(2));
}
}
Ok(())
}
/// Send a single signal update via UDP (fallback, ~2ms).
/// For sub-1ms, use ESP-NOW via serial bridge.
pub fn send_signal(&mut self, id: u16, value: i32) -> io::Result<()> {
let buf: [u8; 7] = [
frame::DS_NOW_SIG,
(id & 0xFF) as u8,
((id >> 8) & 0xFF) as u8,
(value & 0xFF) as u8,
((value >> 8) & 0xFF) as u8,
((value >> 16) & 0xFF) as u8,
((value >> 24) & 0xFF) as u8,
];
self.udp.send_to(&buf, &self.panel_addr)?;
Ok(())
}
/// Send a batch of signal updates in one UDP packet.
pub fn send_signal_batch(&mut self, signals: &[SignalUpdate]) -> io::Result<()> {
let mut buf = Vec::with_capacity(3 + signals.len() * 6);
buf.push(frame::DS_NOW_SIG_BATCH);
buf.push(signals.len() as u8);
buf.push(self.next_seq());
for sig in signals {
buf.extend_from_slice(&sig.id.to_le_bytes());
buf.extend_from_slice(&sig.value.to_le_bytes());
}
self.udp.send_to(&buf, &self.panel_addr)?;
Ok(())
}
/// Send a heartbeat ping.
pub fn send_ping(&mut self) -> io::Result<()> {
let buf = [frame::DS_NOW_PING, self.next_seq()];
self.udp.send_to(&buf, &self.panel_addr)?;
Ok(())
}
/// Listen for panel responses (pong, action events).
/// Blocks for up to `timeout` duration.
pub fn recv_event(&self, timeout: Duration) -> io::Result<Option<PanelEvent>> {
self.udp.set_read_timeout(Some(timeout))?;
let mut buf = [0u8; 256];
match self.udp.recv_from(&mut buf) {
Ok((len, _addr)) => {
if len < 1 { return Ok(None); }
match buf[0] {
frame::DS_NOW_PONG => Ok(Some(PanelEvent::Pong(buf[1]))),
frame::DS_NOW_ACTION if len >= 4 => Ok(Some(PanelEvent::Action {
node_id: buf[1],
action: buf[2],
})),
frame::DS_NOW_TOUCH if len >= 8 => {
let x = u16::from_le_bytes([buf[4], buf[5]]);
let y = u16::from_le_bytes([buf[6], buf[7]]);
Ok(Some(PanelEvent::Touch {
node_id: buf[1],
event: buf[2],
x,
y,
}))
}
_ => Ok(None),
}
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(e),
}
}
/// Load IR JSON from a file and push to panel.
pub fn push_ir_file(&mut self, path: &Path) -> io::Result<()> {
let json = fs::read_to_string(path)?;
self.push_ir(&json)
}
fn next_seq(&mut self) -> u8 {
let s = self.seq;
self.seq = self.seq.wrapping_add(1);
s
}
}
/// Events received from a panel.
#[derive(Debug)]
pub enum PanelEvent {
Pong(u8),
Action { node_id: u8, action: u8 },
Touch { node_id: u8, event: u8, x: u16, y: u16 },
}

View file

@ -24,3 +24,4 @@
pub mod protocol; pub mod protocol;
pub mod codec; pub mod codec;
pub mod relay; pub mod relay;
pub mod ds_hub;