From 9e2cb29dd9d5e35089a665f822bfd3467b306012 Mon Sep 17 00:00:00 2001 From: enzotar Date: Sat, 7 Mar 2026 02:34:57 -0800 Subject: [PATCH] feat: Implement ds-screencast engine, panel preview, and Waveshare ESP-NOW communication. --- devices/panel-preview/index.html | 129 +- .../node_modules/.package-lock.json | 29 + devices/panel-preview/node_modules/ws/LICENSE | 20 + .../panel-preview/node_modules/ws/README.md | 548 + .../panel-preview/node_modules/ws/browser.js | 8 + .../panel-preview/node_modules/ws/index.js | 13 + .../node_modules/ws/lib/buffer-util.js | 131 + .../node_modules/ws/lib/constants.js | 19 + .../node_modules/ws/lib/event-target.js | 292 + .../node_modules/ws/lib/extension.js | 203 + .../node_modules/ws/lib/limiter.js | 55 + .../node_modules/ws/lib/permessage-deflate.js | 528 + .../node_modules/ws/lib/receiver.js | 706 + .../node_modules/ws/lib/sender.js | 602 + .../node_modules/ws/lib/stream.js | 161 + .../node_modules/ws/lib/subprotocol.js | 62 + .../node_modules/ws/lib/validation.js | 152 + .../node_modules/ws/lib/websocket-server.js | 554 + .../node_modules/ws/lib/websocket.js | 1393 + .../node_modules/ws/package.json | 69 + .../panel-preview/node_modules/ws/wrapper.mjs | 8 + devices/panel-preview/package-lock.json | 37 + devices/panel-preview/package.json | 15 + devices/panel-preview/relay-bridge.js | 139 + devices/panel-preview/test-hub.js | 96 + .../waveshare-p4-panel/main/CMakeLists.txt | 4 +- devices/waveshare-p4-panel/main/ds_espnow.c | 248 + devices/waveshare-p4-panel/main/ds_espnow.h | 143 + devices/waveshare-p4-panel/main/ds_runtime.c | 458 + devices/waveshare-p4-panel/main/ds_runtime.h | 90 + devices/waveshare-p4-panel/main/main.c | 182 +- docs/explorations.md | 449 + engine/ds-screencast/capture.js | 259 + .../node_modules/.bin/chrome-remote-interface | 1 + .../node_modules/.package-lock.json | 69 + .../chrome-remote-interface/LICENSE | 18 + .../chrome-remote-interface/README.md | 991 + .../chrome-remote-interface/bin/client.js | 311 + .../chrome-remote-interface.js | 1 + .../chrome-remote-interface/index.js | 46 + .../chrome-remote-interface/lib/api.js | 92 + .../chrome-remote-interface/lib/chrome.js | 302 + .../chrome-remote-interface/lib/defaults.js | 4 + .../chrome-remote-interface/lib/devtools.js | 127 + .../chrome-remote-interface/lib/errors.js | 16 + .../lib/external-request.js | 44 + .../chrome-remote-interface/lib/protocol.json | 27862 ++++++++++++++++ .../lib/websocket-wrapper.js | 39 + .../node_modules/ws/LICENSE | 21 + .../node_modules/ws/README.md | 495 + .../node_modules/ws/browser.js | 8 + .../node_modules/ws/index.js | 10 + .../node_modules/ws/lib/buffer-util.js | 129 + .../node_modules/ws/lib/constants.js | 10 + .../node_modules/ws/lib/event-target.js | 184 + .../node_modules/ws/lib/extension.js | 223 + .../node_modules/ws/lib/limiter.js | 55 + .../node_modules/ws/lib/permessage-deflate.js | 518 + .../node_modules/ws/lib/receiver.js | 607 + .../node_modules/ws/lib/sender.js | 409 + .../node_modules/ws/lib/stream.js | 180 + .../node_modules/ws/lib/validation.js | 104 + .../node_modules/ws/lib/websocket-server.js | 449 + .../node_modules/ws/lib/websocket.js | 1197 + .../node_modules/ws/package.json | 56 + .../chrome-remote-interface/package.json | 64 + .../chrome-remote-interface/webpack.config.js | 48 + .../node_modules/commander/History.md | 298 + .../node_modules/commander/LICENSE | 22 + .../node_modules/commander/Readme.md | 351 + .../node_modules/commander/index.js | 1137 + .../node_modules/commander/package.json | 29 + engine/ds-screencast/node_modules/ws/LICENSE | 20 + .../ds-screencast/node_modules/ws/README.md | 548 + .../ds-screencast/node_modules/ws/browser.js | 8 + engine/ds-screencast/node_modules/ws/index.js | 13 + .../node_modules/ws/lib/buffer-util.js | 131 + .../node_modules/ws/lib/constants.js | 19 + .../node_modules/ws/lib/event-target.js | 292 + .../node_modules/ws/lib/extension.js | 203 + .../node_modules/ws/lib/limiter.js | 55 + .../node_modules/ws/lib/permessage-deflate.js | 528 + .../node_modules/ws/lib/receiver.js | 706 + .../node_modules/ws/lib/sender.js | 602 + .../node_modules/ws/lib/stream.js | 161 + .../node_modules/ws/lib/subprotocol.js | 62 + .../node_modules/ws/lib/validation.js | 152 + .../node_modules/ws/lib/websocket-server.js | 554 + .../node_modules/ws/lib/websocket.js | 1393 + .../node_modules/ws/package.json | 69 + .../ds-screencast/node_modules/ws/wrapper.mjs | 8 + engine/ds-screencast/package-lock.json | 78 + engine/ds-screencast/package.json | 16 + engine/ds-stream/src/ds_hub.rs | 221 + engine/ds-stream/src/lib.rs | 1 + 95 files changed, 50095 insertions(+), 74 deletions(-) create mode 100644 devices/panel-preview/node_modules/.package-lock.json create mode 100644 devices/panel-preview/node_modules/ws/LICENSE create mode 100644 devices/panel-preview/node_modules/ws/README.md create mode 100644 devices/panel-preview/node_modules/ws/browser.js create mode 100644 devices/panel-preview/node_modules/ws/index.js create mode 100644 devices/panel-preview/node_modules/ws/lib/buffer-util.js create mode 100644 devices/panel-preview/node_modules/ws/lib/constants.js create mode 100644 devices/panel-preview/node_modules/ws/lib/event-target.js create mode 100644 devices/panel-preview/node_modules/ws/lib/extension.js create mode 100644 devices/panel-preview/node_modules/ws/lib/limiter.js create mode 100644 devices/panel-preview/node_modules/ws/lib/permessage-deflate.js create mode 100644 devices/panel-preview/node_modules/ws/lib/receiver.js create mode 100644 devices/panel-preview/node_modules/ws/lib/sender.js create mode 100644 devices/panel-preview/node_modules/ws/lib/stream.js create mode 100644 devices/panel-preview/node_modules/ws/lib/subprotocol.js create mode 100644 devices/panel-preview/node_modules/ws/lib/validation.js create mode 100644 devices/panel-preview/node_modules/ws/lib/websocket-server.js create mode 100644 devices/panel-preview/node_modules/ws/lib/websocket.js create mode 100644 devices/panel-preview/node_modules/ws/package.json create mode 100644 devices/panel-preview/node_modules/ws/wrapper.mjs create mode 100644 devices/panel-preview/package-lock.json create mode 100644 devices/panel-preview/package.json create mode 100644 devices/panel-preview/relay-bridge.js create mode 100644 devices/panel-preview/test-hub.js create mode 100644 devices/waveshare-p4-panel/main/ds_espnow.c create mode 100644 devices/waveshare-p4-panel/main/ds_espnow.h create mode 100644 devices/waveshare-p4-panel/main/ds_runtime.c create mode 100644 devices/waveshare-p4-panel/main/ds_runtime.h create mode 100644 docs/explorations.md create mode 100644 engine/ds-screencast/capture.js create mode 120000 engine/ds-screencast/node_modules/.bin/chrome-remote-interface create mode 100644 engine/ds-screencast/node_modules/.package-lock.json create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/LICENSE create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/README.md create mode 100755 engine/ds-screencast/node_modules/chrome-remote-interface/bin/client.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/chrome-remote-interface.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/index.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/api.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/chrome.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/defaults.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/devtools.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/errors.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/external-request.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/protocol.json create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/lib/websocket-wrapper.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/LICENSE create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/README.md create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/browser.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/index.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/buffer-util.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/constants.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/event-target.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/extension.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/limiter.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/permessage-deflate.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/receiver.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/sender.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/stream.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/validation.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/websocket-server.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/lib/websocket.js create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/node_modules/ws/package.json create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/package.json create mode 100644 engine/ds-screencast/node_modules/chrome-remote-interface/webpack.config.js create mode 100644 engine/ds-screencast/node_modules/commander/History.md create mode 100644 engine/ds-screencast/node_modules/commander/LICENSE create mode 100644 engine/ds-screencast/node_modules/commander/Readme.md create mode 100644 engine/ds-screencast/node_modules/commander/index.js create mode 100644 engine/ds-screencast/node_modules/commander/package.json create mode 100644 engine/ds-screencast/node_modules/ws/LICENSE create mode 100644 engine/ds-screencast/node_modules/ws/README.md create mode 100644 engine/ds-screencast/node_modules/ws/browser.js create mode 100644 engine/ds-screencast/node_modules/ws/index.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/buffer-util.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/constants.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/event-target.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/extension.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/limiter.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/permessage-deflate.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/receiver.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/sender.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/stream.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/subprotocol.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/validation.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/websocket-server.js create mode 100644 engine/ds-screencast/node_modules/ws/lib/websocket.js create mode 100644 engine/ds-screencast/node_modules/ws/package.json create mode 100644 engine/ds-screencast/node_modules/ws/wrapper.mjs create mode 100644 engine/ds-screencast/package-lock.json create mode 100644 engine/ds-screencast/package.json create mode 100644 engine/ds-stream/src/ds_hub.rs diff --git a/devices/panel-preview/index.html b/devices/panel-preview/index.html index b852f91..29b7cd3 100644 --- a/devices/panel-preview/index.html +++ b/devices/panel-preview/index.html @@ -973,10 +973,16 @@ } // ── File loading ──────────────────────────────────── - // Load from URL param const params = new URLSearchParams(location.search); 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) .then(r => r.json()) .then(ir => { buildUI(ir); log(`Loaded from ${fileUrl}`); }) @@ -1003,13 +1009,126 @@ } }); - // Also try loading app.ir.json from same directory - if (!fileUrl) { + // Auto-load app.ir.json (file mode fallback) + if (!fileUrl && !wsUrl) { fetch('app.ir.json') .then(r => r.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; diff --git a/devices/panel-preview/node_modules/.package-lock.json b/devices/panel-preview/node_modules/.package-lock.json new file mode 100644 index 0000000..81e9909 --- /dev/null +++ b/devices/panel-preview/node_modules/.package-lock.json @@ -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 + } + } + } + } +} diff --git a/devices/panel-preview/node_modules/ws/LICENSE b/devices/panel-preview/node_modules/ws/LICENSE new file mode 100644 index 0000000..1da5b96 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2011 Einar Otto Stangvik +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. diff --git a/devices/panel-preview/node_modules/ws/README.md b/devices/panel-preview/node_modules/ws/README.md new file mode 100644 index 0000000..21f10df --- /dev/null +++ b/devices/panel-preview/node_modules/ws/README.md @@ -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 diff --git a/devices/panel-preview/node_modules/ws/browser.js b/devices/panel-preview/node_modules/ws/browser.js new file mode 100644 index 0000000..ca4f628 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/browser.js @@ -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' + ); +}; diff --git a/devices/panel-preview/node_modules/ws/index.js b/devices/panel-preview/node_modules/ws/index.js new file mode 100644 index 0000000..41edb3b --- /dev/null +++ b/devices/panel-preview/node_modules/ws/index.js @@ -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; diff --git a/devices/panel-preview/node_modules/ws/lib/buffer-util.js b/devices/panel-preview/node_modules/ws/lib/buffer-util.js new file mode 100644 index 0000000..f7536e2 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/buffer-util.js @@ -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. + } +} diff --git a/devices/panel-preview/node_modules/ws/lib/constants.js b/devices/panel-preview/node_modules/ws/lib/constants.js new file mode 100644 index 0000000..69b2fe3 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/constants.js @@ -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: () => {} +}; diff --git a/devices/panel-preview/node_modules/ws/lib/event-target.js b/devices/panel-preview/node_modules/ws/lib/event-target.js new file mode 100644 index 0000000..fea4cbc --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/event-target.js @@ -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); + } +} diff --git a/devices/panel-preview/node_modules/ws/lib/extension.js b/devices/panel-preview/node_modules/ws/lib/extension.js new file mode 100644 index 0000000..3d7895c --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/extension.js @@ -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 }; diff --git a/devices/panel-preview/node_modules/ws/lib/limiter.js b/devices/panel-preview/node_modules/ws/lib/limiter.js new file mode 100644 index 0000000..3fd3578 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/limiter.js @@ -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; diff --git a/devices/panel-preview/node_modules/ws/lib/permessage-deflate.js b/devices/panel-preview/node_modules/ws/lib/permessage-deflate.js new file mode 100644 index 0000000..41ff70e --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/permessage-deflate.js @@ -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); +} diff --git a/devices/panel-preview/node_modules/ws/lib/receiver.js b/devices/panel-preview/node_modules/ws/lib/receiver.js new file mode 100644 index 0000000..54d9b4f --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/receiver.js @@ -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; diff --git a/devices/panel-preview/node_modules/ws/lib/sender.js b/devices/panel-preview/node_modules/ws/lib/sender.js new file mode 100644 index 0000000..a8b1da3 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/sender.js @@ -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); +} diff --git a/devices/panel-preview/node_modules/ws/lib/stream.js b/devices/panel-preview/node_modules/ws/lib/stream.js new file mode 100644 index 0000000..4c58c91 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/stream.js @@ -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; diff --git a/devices/panel-preview/node_modules/ws/lib/subprotocol.js b/devices/panel-preview/node_modules/ws/lib/subprotocol.js new file mode 100644 index 0000000..d4381e8 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/subprotocol.js @@ -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 }; diff --git a/devices/panel-preview/node_modules/ws/lib/validation.js b/devices/panel-preview/node_modules/ws/lib/validation.js new file mode 100644 index 0000000..4a2e68d --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/validation.js @@ -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. + } +} diff --git a/devices/panel-preview/node_modules/ws/lib/websocket-server.js b/devices/panel-preview/node_modules/ws/lib/websocket-server.js new file mode 100644 index 0000000..75e04c1 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/websocket-server.js @@ -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 + * pairs. + * + * @param {EventEmitter} server The event emitter + * @param {Object.} 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); + } +} diff --git a/devices/panel-preview/node_modules/ws/lib/websocket.js b/devices/panel-preview/node_modules/ws/lib/websocket.js new file mode 100644 index 0000000..0da2949 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/lib/websocket.js @@ -0,0 +1,1393 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const https = require('https'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { randomBytes, createHash } = require('crypto'); +const { Duplex, Readable } = require('stream'); +const { URL } = require('url'); + +const PerMessageDeflate = require('./permessage-deflate'); +const Receiver = require('./receiver'); +const Sender = require('./sender'); +const { isBlob } = require('./validation'); + +const { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); + +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + +/** + * Class representing a WebSocket. + * + * @extends EventEmitter + */ +class WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + + if (protocols === undefined) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === 'object' && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + + initAsClient(this, address, protocols, options); + } else { + this._autoPong = options.autoPong; + this._closeTimeout = options.closeTimeout; + this._isServer = true; + } + } + + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + + this._binaryType = type; + + // + // Allow to change `binaryType` on the fly. + // + if (this._receiver) this._receiver._binaryType = type; + } + + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + + return this._socket._writableState.length + this._sender._bufferedBytes; + } + + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + const sender = new Sender(socket, this._extensions, options.generateMask); + + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + + receiver.on('conclude', receiverOnConclude); + receiver.on('drain', receiverOnDrain); + receiver.on('error', receiverOnError); + receiver.on('message', receiverOnMessage); + receiver.on('ping', receiverOnPing); + receiver.on('pong', receiverOnPong); + + sender.onerror = senderOnError; + + // + // These methods may not be available if `socket` is just a `Duplex`. + // + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + + if (head.length > 0) socket.unshift(head); + + socket.on('close', socketOnClose); + socket.on('data', socketOnData); + socket.on('end', socketOnEnd); + socket.on('error', socketOnError); + + this._readyState = WebSocket.OPEN; + this.emit('open'); + } + + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + return; + } + + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + + this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + } + + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this.readyState === WebSocket.CLOSING) { + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + + return; + } + + this._readyState = WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + // + // This error is handled by the `'error'` listener on the socket. We only + // want to know if the close frame has been sent here. + // + if (err) return; + + this._closeFrameSent = true; + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } + }); + + setCloseTimer(this); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Resume the socket. + * + * @public + */ + resume() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof options === 'function') { + cb = options; + options = {}; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + const opts = { + binary: typeof data !== 'string', + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this._socket) { + this._readyState = WebSocket.CLOSING; + this._socket.destroy(); + } + } +} + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); +}); + +// +// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. +// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface +// +['open', 'error', 'close', 'message'].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); +}); + +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; + +module.exports = WebSocket; + +/** + * Initialize a WebSocket client. + * + * @param {WebSocket} websocket The client to initialize + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection 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.closeTimeout=30000] Duration in milliseconds to wait + * for the closing handshake to finish after `websocket.close()` is called + * @param {Function} [options.finishRequest] A function which can be used to + * customize the headers of each http request before it is sent + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ +function initAsClient(websocket, address, protocols, options) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} ` + + `(supported versions: ${protocolVersions.join(', ')})` + ); + } + + let parsedUrl; + + if (address instanceof URL) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'ws:'; + } else if (parsedUrl.protocol === 'https:') { + parsedUrl.protocol = 'wss:'; + } + + websocket._url = parsedUrl.href; + + const isSecure = parsedUrl.protocol === 'wss:'; + const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; + let invalidUrlMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { + invalidUrlMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https:", or "ws+unix:"'; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = 'The URL contains a fragment identifier'; + } + + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString('base64'); + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); + let perMessageDeflate; + + opts.createConnection = + opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith('[') + ? parsedUrl.hostname.slice(1, -1) + : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket' + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers['Sec-WebSocket-Extensions'] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if ( + typeof protocol !== 'string' || + !subprotocolRegex.test(protocol) || + protocolSet.has(protocol) + ) { + throw new SyntaxError( + 'An invalid or duplicated subprotocol was specified' + ); + } + + protocolSet.add(protocol); + } + + opts.headers['Sec-WebSocket-Protocol'] = protocols.join(','); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + + if (isIpcUrl) { + const parts = opts.path.split(':'); + + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl + ? opts.socketPath + : parsedUrl.host; + + const headers = options && options.headers; + + // + // Shallow copy the user provided options so that headers can be changed + // without mutating the original object. + // + options = { ...options, headers: {} }; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + options.headers[key.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = isIpcUrl + ? websocket._originalIpc + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalIpc + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; + + if (!isSameHost || (websocket._originalSecure && !isSecure)) { + // + // Match curl 7.77.0 behavior and drop the following headers. These + // headers are also dropped when following a redirect to a subdomain. + // + delete opts.headers.authorization; + delete opts.headers.cookie; + + if (!isSameHost) delete opts.headers.host; + + opts.auth = undefined; + } + } + + // + // Match curl 7.77.0 behavior and make the first `Authorization` header win. + // If the `Authorization` header is set, then there is nothing to do as it + // will take precedence. + // + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = + 'Basic ' + Buffer.from(opts.auth).toString('base64'); + } + + req = websocket._req = request(opts); + + if (websocket._redirects) { + // + // Unlike what is done for the `'upgrade'` event, no early exit is + // triggered here if the user calls `websocket.close()` or + // `websocket.terminate()` from a listener of the `'redirect'` event. This + // is because the user can also call `request.destroy()` with an error + // before calling `websocket.close()` or `websocket.terminate()` and this + // would result in an error being emitted on the `request` object with no + // `'error'` event listeners attached. + // + websocket.emit('redirect', websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + + if (opts.timeout) { + req.on('timeout', () => { + abortHandshake(websocket, req, 'Opening handshake has timed out'); + }); + } + + req.on('error', (err) => { + if (req === null || req[kAborted]) return; + + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + + req.on('response', (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + + if ( + location && + opts.followRedirects && + statusCode >= 300 && + statusCode < 400 + ) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, 'Maximum redirects exceeded'); + return; + } + + req.abort(); + + let addr; + + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit('unexpected-response', req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + + req.on('upgrade', (res, socket, head) => { + websocket.emit('upgrade', res); + + // + // The user may have closed the connection from a listener of the + // `'upgrade'` event. + // + if (websocket.readyState !== WebSocket.CONNECTING) return; + + req = websocket._req = null; + + const upgrade = res.headers.upgrade; + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + if (res.headers['sec-websocket-accept'] !== digest) { + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); + return; + } + + const serverProt = res.headers['sec-websocket-protocol']; + let protError; + + if (serverProt !== undefined) { + if (!protocolSet.size) { + protError = 'Server sent a subprotocol but none was requested'; + } else if (!protocolSet.has(serverProt)) { + protError = 'Server sent an invalid subprotocol'; + } + } else if (protocolSet.size) { + protError = 'Server sent no subprotocol'; + } + + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; + + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; + } + + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } +} + +/** + * Emit the `'error'` and `'close'` events. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + // + // The following assignment is practically useless and is done only for + // consistency. + // + websocket._errorEmitted = true; + websocket.emit('error', err); + websocket.emitClose(); +} + +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect(options) { + options.path = undefined; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + + return tls.connect(options); +} + +/** + * Abort the handshake and emit an error. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy + * @param {String} message The error message + * @private + */ +function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once('error', websocket.emit.bind(websocket, 'error')); + stream.once('close', websocket.emitClose.bind(websocket)); + } +} + +/** + * Handle cases where the `ping()`, `pong()`, or `send()` methods are called + * when the `readyState` attribute is `CLOSING` or `CLOSED`. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {*} [data] The data to send + * @param {Function} [cb] Callback + * @private + */ +function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + + // + // The `_bufferedAmount` property is used only when the peer is a client and + // the opening handshake fails. Under these circumstances, in fact, the + // `setSocket()` method is not called, so the `_socket` and `_sender` + // properties are set to `null`. + // + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} ` + + `(${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } +} + +/** + * The listener of the `Receiver` `'conclude'` event. + * + * @param {Number} code The status code + * @param {Buffer} reason The reason for closing + * @private + */ +function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + + if (websocket._socket[kWebSocket] === undefined) return; + + websocket._socket.removeListener('data', socketOnData); + process.nextTick(resume, websocket._socket); + + if (code === 1005) websocket.close(); + else websocket.close(code, reason); +} + +/** + * The listener of the `Receiver` `'drain'` event. + * + * @private + */ +function receiverOnDrain() { + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); +} + +/** + * The listener of the `Receiver` `'error'` event. + * + * @param {(RangeError|Error)} err The emitted error + * @private + */ +function receiverOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * The listener of the `Receiver` `'finish'` event. + * + * @private + */ +function receiverOnFinish() { + this[kWebSocket].emitClose(); +} + +/** + * The listener of the `Receiver` `'message'` event. + * + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not + * @private + */ +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); +} + +/** + * The listener of the `Receiver` `'ping'` event. + * + * @param {Buffer} data The data included in the ping frame + * @private + */ +function receiverOnPing(data) { + const websocket = this[kWebSocket]; + + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit('ping', data); +} + +/** + * The listener of the `Receiver` `'pong'` event. + * + * @param {Buffer} data The data included in the pong frame + * @private + */ +function receiverOnPong(data) { + this[kWebSocket].emit('pong', data); +} + +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The `Sender` error event handler. + * + * @param {Error} The error + * @private + */ +function senderOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + + // + // `socket.end()` is used instead of `socket.destroy()` to allow the other + // peer to finish sending queued data. There is no need to set a timer here + // because `CLOSING` means that it is already set or not needed. + // + this._socket.end(); + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * Set a timer to destroy the underlying raw socket of a WebSocket. + * + * @param {WebSocket} websocket The WebSocket instance + * @private + */ +function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); +} + +/** + * The listener of the socket `'close'` event. + * + * @private + */ +function socketOnClose() { + const websocket = this[kWebSocket]; + + this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); + this.removeListener('end', socketOnEnd); + + websocket._readyState = WebSocket.CLOSING; + + // + // The close frame might not have been received or the `'end'` event emitted, + // for example, if the socket was destroyed due to an error. Ensure that the + // `receiver` stream is closed after writing any remaining buffered data to + // it. If the readable side of the socket is in flowing mode then there is no + // buffered data as everything has been already written. If instead, the + // socket is paused, any possible buffered data will be read as a single + // chunk. + // + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + this._readableState.length !== 0 + ) { + const chunk = this.read(this._readableState.length); + + websocket._receiver.write(chunk); + } + + websocket._receiver.end(); + + this[kWebSocket] = undefined; + + clearTimeout(websocket._closeTimer); + + if ( + websocket._receiver._writableState.finished || + websocket._receiver._writableState.errorEmitted + ) { + websocket.emitClose(); + } else { + websocket._receiver.on('error', receiverOnFinish); + websocket._receiver.on('finish', receiverOnFinish); + } +} + +/** + * The listener of the socket `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } +} + +/** + * The listener of the socket `'end'` event. + * + * @private + */ +function socketOnEnd() { + const websocket = this[kWebSocket]; + + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); +} + +/** + * The listener of the socket `'error'` event. + * + * @private + */ +function socketOnError() { + const websocket = this[kWebSocket]; + + this.removeListener('error', socketOnError); + this.on('error', NOOP); + + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } +} diff --git a/devices/panel-preview/node_modules/ws/package.json b/devices/panel-preview/node_modules/ws/package.json new file mode 100644 index 0000000..91b8269 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/package.json @@ -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 (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" + } +} diff --git a/devices/panel-preview/node_modules/ws/wrapper.mjs b/devices/panel-preview/node_modules/ws/wrapper.mjs new file mode 100644 index 0000000..7245ad1 --- /dev/null +++ b/devices/panel-preview/node_modules/ws/wrapper.mjs @@ -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; diff --git a/devices/panel-preview/package-lock.json b/devices/panel-preview/package-lock.json new file mode 100644 index 0000000..fba8073 --- /dev/null +++ b/devices/panel-preview/package-lock.json @@ -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 + } + } + } + } +} diff --git a/devices/panel-preview/package.json b/devices/panel-preview/package.json new file mode 100644 index 0000000..90e608c --- /dev/null +++ b/devices/panel-preview/package.json @@ -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" + } +} diff --git a/devices/panel-preview/relay-bridge.js b/devices/panel-preview/relay-bridge.js new file mode 100644 index 0000000..b92b43b --- /dev/null +++ b/devices/panel-preview/relay-bridge.js @@ -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(''); +}); diff --git a/devices/panel-preview/test-hub.js b/devices/panel-preview/test-hub.js new file mode 100644 index 0000000..be10626 --- /dev/null +++ b/devices/panel-preview/test-hub.js @@ -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'); diff --git a/devices/waveshare-p4-panel/main/CMakeLists.txt b/devices/waveshare-p4-panel/main/CMakeLists.txt index d3df4b2..882a771 100644 --- a/devices/waveshare-p4-panel/main/CMakeLists.txt +++ b/devices/waveshare-p4-panel/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "main.c" "ds_codec.c" + SRCS "main.c" "ds_codec.c" "ds_espnow.c" "ds_runtime.c" INCLUDE_DIRS "." REQUIRES esp_wifi @@ -8,4 +8,6 @@ idf_component_register( esp_timer nvs_flash esp_psram + esp_now + lwip ) diff --git a/devices/waveshare-p4-panel/main/ds_espnow.c b/devices/waveshare-p4-panel/main/ds_espnow.c new file mode 100644 index 0000000..3d4efe5 --- /dev/null +++ b/devices/waveshare-p4-panel/main/ds_espnow.c @@ -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 +#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"); +} diff --git a/devices/waveshare-p4-panel/main/ds_espnow.h b/devices/waveshare-p4-panel/main/ds_espnow.h new file mode 100644 index 0000000..79e5cc8 --- /dev/null +++ b/devices/waveshare-p4-panel/main/ds_espnow.h @@ -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 +#include +#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); diff --git a/devices/waveshare-p4-panel/main/ds_runtime.c b/devices/waveshare-p4-panel/main/ds_runtime.c new file mode 100644 index 0000000..b7a98f5 --- /dev/null +++ b/devices/waveshare-p4-panel/main/ds_runtime.c @@ -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 +#include +#include +#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; +} diff --git a/devices/waveshare-p4-panel/main/ds_runtime.h b/devices/waveshare-p4-panel/main/ds_runtime.h new file mode 100644 index 0000000..dc32272 --- /dev/null +++ b/devices/waveshare-p4-panel/main/ds_runtime.h @@ -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 +#include +#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); diff --git a/devices/waveshare-p4-panel/main/main.c b/devices/waveshare-p4-panel/main/main.c index a65b91a..7bd74b7 100644 --- a/devices/waveshare-p4-panel/main/main.c +++ b/devices/waveshare-p4-panel/main/main.c @@ -1,15 +1,12 @@ /** * DreamStack Thin Client — Waveshare ESP32-P4 10.1" Panel * - * Firmware that turns the panel into a dumb pixel display - * with touch input. All rendering happens on the source device. + * Dual transport firmware: + * 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 - * Touch → encode event → send over WebSocket - * - * Dependencies (via ESP Component Registry): - * - waveshare/esp_lcd_jd9365_10_1 (10.1" MIPI DSI display driver) - * - espressif/esp_websocket_client (WebSocket client) + * Build with -DDS_USE_ESPNOW=1 for ESP-NOW mode (default) + * Build with -DDS_USE_ESPNOW=0 for WebSocket-only pixel mode */ #include @@ -21,26 +18,39 @@ #include "esp_event.h" #include "nvs_flash.h" #include "esp_lcd_panel_ops.h" -#include "esp_websocket_client.h" #include "ds_codec.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"; -// ─── Configuration (set via menuconfig or hardcode for POC) ─── +// ─── Configuration ─── #define PANEL_WIDTH 800 #define PANEL_HEIGHT 1280 #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_PASS CONFIG_WIFI_PASS -#define RELAY_URL CONFIG_RELAY_URL // e.g. "ws://192.168.1.100:9100/stream/home" -// ─── Framebuffers (in PSRAM) ─── -static uint8_t *framebuffer; // Current display state -static uint8_t *scratch_buf; // Temp buffer for delta decode +#if !DS_USE_ESPNOW +#define RELAY_URL CONFIG_RELAY_URL +#include "esp_websocket_client.h" +#endif + +// ─── Framebuffers (in PSRAM, for pixel mode) ─── +static uint8_t *framebuffer; +static uint8_t *scratch_buf; // ─── Display handle ─── static esp_lcd_panel_handle_t panel_handle = NULL; @@ -48,7 +58,63 @@ static esp_lcd_panel_handle_t panel_handle = NULL; // ─── Touch state ─── 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, int32_t event_id, void *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) { case DS_FRAME_PIXELS: - // Full keyframe — copy directly to framebuffer if (payload_len == FB_SIZE) { memcpy(framebuffer, payload, FB_SIZE); esp_lcd_panel_draw_bitmap(panel_handle, @@ -79,7 +144,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base, break; case DS_FRAME_DELTA: - // Delta frame — RLE decode + XOR apply if (ds_apply_delta_rle(framebuffer, FB_SIZE, payload, payload_len, scratch_buf) == 0) { esp_lcd_panel_draw_bitmap(panel_handle, @@ -90,7 +154,6 @@ static void ws_event_handler(void *arg, esp_event_base_t base, break; case DS_FRAME_PING: - // Respond with pong (same message back) break; 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, uint8_t id, uint16_t x, uint16_t y, uint8_t phase) { 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); } -// ─── Touch polling task ─── -// -// 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; +#endif // DS_USE_ESPNOW +// ─── Touch polling task ─── +static void touch_task(void *arg) { while (1) { // TODO: Read from GT9271 touch controller via I2C - // Example (pseudocode): - // // gt9271_touch_data_t td; // if (gt9271_read(&td) == ESP_OK && td.num_points > 0) { - // for (int i = 0; i < td.num_points; i++) { - // send_touch_event(ws, td.points[i].id, - // td.points[i].x, td.points[i].y, - // td.points[i].phase); - // } + // #if DS_USE_ESPNOW + // ds_espnow_send_touch(0, 0, td.points[0].x, td.points[0].y); + // #else + // send_touch_event(ws, ...); + // #endif // } - vTaskDelay(pdMS_TO_TICKS(10)); // 100Hz touch polling } } // ─── 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) { - // TODO: Configure MIPI DSI bus and JD9365 panel driver - // 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); - + // TODO: Initialize MIPI DSI display using Waveshare component ESP_LOGI(TAG, "Display initialized (%dx%d RGB565)", PANEL_WIDTH, PANEL_HEIGHT); return ESP_OK; } @@ -188,48 +225,57 @@ static void wifi_init(void) { esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); 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); +#endif } // ─── Main ─── void app_main(void) { - ESP_LOGI(TAG, "DreamStack Thin Client v0.1"); - ESP_LOGI(TAG, "Panel: %dx%d @ %d bpp = %d bytes", +#if DS_USE_ESPNOW + 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); - // Initialize NVS (required for WiFi) 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); scratch_buf = heap_caps_calloc(1, FB_SIZE, MALLOC_CAP_SPIRAM); if (!framebuffer || !scratch_buf) { ESP_LOGE(TAG, "Failed to allocate framebuffers in PSRAM (%d bytes each)", FB_SIZE); return; } - ESP_LOGI(TAG, "Framebuffers allocated in PSRAM (%d MB each)", FB_SIZE / (1024 * 1024)); - // Initialize display display_init(); - - // Initialize WiFi 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 = { .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_register_events(ws, WEBSOCKET_EVENT_ANY, ws_event_handler, NULL); esp_websocket_client_start(ws); ESP_LOGI(TAG, "WebSocket connecting to %s...", RELAY_URL); - // Start touch polling task 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..."); } + diff --git a/docs/explorations.md b/docs/explorations.md new file mode 100644 index 0000000..767ea93 --- /dev/null +++ b/docs/explorations.md @@ -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 +``` diff --git a/engine/ds-screencast/capture.js b/engine/ds-screencast/capture.js new file mode 100644 index 0000000..95a67ea --- /dev/null +++ b/engine/ds-screencast/capture.js @@ -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 = ` +DreamStack Screencast + +

DreamStack Screencast Monitor

+ +
Connecting…
+`; + + 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); }); diff --git a/engine/ds-screencast/node_modules/.bin/chrome-remote-interface b/engine/ds-screencast/node_modules/.bin/chrome-remote-interface new file mode 120000 index 0000000..b6257f3 --- /dev/null +++ b/engine/ds-screencast/node_modules/.bin/chrome-remote-interface @@ -0,0 +1 @@ +../chrome-remote-interface/bin/client.js \ No newline at end of file diff --git a/engine/ds-screencast/node_modules/.package-lock.json b/engine/ds-screencast/node_modules/.package-lock.json new file mode 100644 index 0000000..85d3425 --- /dev/null +++ b/engine/ds-screencast/node_modules/.package-lock.json @@ -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 + } + } + } + } +} diff --git a/engine/ds-screencast/node_modules/chrome-remote-interface/LICENSE b/engine/ds-screencast/node_modules/chrome-remote-interface/LICENSE new file mode 100644 index 0000000..213191f --- /dev/null +++ b/engine/ds-screencast/node_modules/chrome-remote-interface/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) 2026 Andrea Cardaci + +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. diff --git a/engine/ds-screencast/node_modules/chrome-remote-interface/README.md b/engine/ds-screencast/node_modules/chrome-remote-interface/README.md new file mode 100644 index 0000000..d0f82fc --- /dev/null +++ b/engine/ds-screencast/node_modules/chrome-remote-interface/README.md @@ -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_ + +[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 ` + + ``` + +## 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: '``.``' + +```js +function (params, sessionId) {} +``` + +Emitted when the remote instance sends a notification for `.` +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 `.on('', ...)` syntax is available, for example: + +```js +client.Network.on('requestWillBeSent', console.log); +``` + +#### Event: '``.``.``' + +```js +function (params, sessionId) {} +``` + +Equivalent to the following but only for those events belonging to the given `session`: + +```js +client.on('.', 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.``.``([params], [sessionId], [callback]) + +Just a shorthand for: + +```js +client.send('.', params, sessionId, callback); +``` + +For example: + +```js +client.Page.navigate({url: 'https://github.com'}, console.log); +``` + +#### client.``.``([sessionId], [callback]) + +Just a shorthand for: + +```js +client.on('.[.]', 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['``.``'] + +Just a shorthand for: + +```js +client.. +``` + +Where `` 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/ diff --git a/engine/ds-screencast/node_modules/chrome-remote-interface/bin/client.js b/engine/ds-screencast/node_modules/chrome-remote-interface/bin/client.js new file mode 100755 index 0000000..80b2d4d --- /dev/null +++ b/engine/ds-screencast/node_modules/chrome-remote-interface/bin/client.js @@ -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 ', 'HTTP frontend host') + .option('-p, --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 []') + .description('inspect a target (defaults to the first available target)') + .option('-w, --web-socket', 'interpret as a WebSocket URL instead of a target id') + .option('-j, --protocol ', '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 []') + .description('create a new target/tab') + .action((url) => { + action = _new.bind(null, url); + }); + +program + .command('activate ') + .description('activate a target/tab by id') + .action((id) => { + action = activate.bind(null, id); + }); + +program + .command('close ') + .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); + } +} diff --git a/engine/ds-screencast/node_modules/chrome-remote-interface/chrome-remote-interface.js b/engine/ds-screencast/node_modules/chrome-remote-interface/chrome-remote-interface.js new file mode 100644 index 0000000..82e4a96 --- /dev/null +++ b/engine/ds-screencast/node_modules/chrome-remote-interface/chrome-remote-interface.js @@ -0,0 +1 @@ +(()=>{var e={5237(e,t,r){"use strict";var n=r(5606);const i=r(7007),o=r(7708),a=r(5507),s=r(5746),p=r(3683);o.setDefaultResultOrder&&o.setDefaultResultOrder("ipv4first"),e.exports=function(e,t){"function"==typeof e&&(t=e,e=void 0);const r=new i;return"function"==typeof t?(n.nextTick(()=>{new p(e,r)}),r.once("connect",t)):new Promise((t,n)=>{r.once("connect",t),r.once("error",n),new p(e,r)})},e.exports.Protocol=a.Protocol,e.exports.List=a.List,e.exports.New=a.New,e.exports.Activate=a.Activate,e.exports.Close=a.Close,e.exports.Version=a.Version,e.exports.ProtocolError=s.ProtocolError},3613(e){"use strict";function t(e,t,r){e.category=t,Object.keys(r).forEach(n=>{"name"!==n&&(e[n]="type"===t&&"properties"===n||"parameters"===n?function(e){const t={};return e.forEach(e=>{const r=e.name;delete e.name,t[r]=e}),t}(r[n]):r[n])})}e.exports.prepare=function(e,r){e.protocol=r,r.domains.forEach(r=>{const n=r.domain;e[n]={},(r.commands||[]).forEach(r=>{!function(e,r,n){const i=`${r}.${n.name}`,o=(t,r,n)=>e.send(i,t,r,n);t(o,"command",n),e[i]=e[r][n.name]=o}(e,n,r)}),(r.events||[]).forEach(r=>{!function(e,r,n){const i=`${r}.${n.name}`,o=(t,r)=>{"function"==typeof t&&(r=t,t=void 0);const n=t?`${i}.${t}`:i;return"function"==typeof r?(e.on(n,r),()=>e.removeListener(n,r)):new Promise((t,r)=>{e.once(n,t)})};t(o,"event",n),e[i]=e[r][n.name]=o}(e,n,r)}),(r.types||[]).forEach(r=>{!function(e,r,n){const i=`${r}.${n.id}`,o={};t(o,"type",n),e[i]=e[r][n.id]=o}(e,n,r)}),e[n].on=(t,r)=>e[n][t](r)})}},3683(e,t,r){"use strict";var n=r(5606);const i=r(7007),o=r(537),a=r(8835).GP,s=r(8835).qg,p=r(8484),d=r(3613),c=r(3447),l=r(5507),u=r(5746);e.exports=class extends i{constructor(e,t){super();e=e||{},this.host=e.host||c.HOST,this.port=e.port||c.PORT,this.secure=!!e.secure,this.useHostName=!!e.useHostName,this.alterPath=e.alterPath||(e=>e),this.protocol=e.protocol,this.local=!!e.local,this.target=e.target||(e=>{let t,r=e.find(e=>!!e.webSocketDebuggerUrl&&(t=t||e,"page"===e.type));if(r=r||t,r)return r;throw new Error("No inspectable targets")}),this._notifier=t,this._callbacks={},this._nextCommandId=1,this.webSocketUrl=void 0,this._start()}inspect(e,t){return t.customInspect=!1,o.inspect(this,t)}send(e,t,r,n){const i=Array.from(arguments).slice(1);return t=i.find(e=>"object"==typeof e),r=i.find(e=>"string"==typeof e),"function"==typeof(n=i.find(e=>"function"==typeof e))?void this._enqueueCommand(e,t,r,n):new Promise((n,i)=>{this._enqueueCommand(e,t,r,(o,a)=>{if(o){const n={method:e,params:t,sessionId:r};i(o instanceof Error?o:new u.ProtocolError(n,a))}else n(a)})})}close(e){const t=e=>{3===this._ws.readyState?e():(this._ws.removeAllListeners("close"),this._ws.once("close",()=>{this._ws.removeAllListeners(),this._handleConnectionClose(),e()}),this._ws.close())};return"function"==typeof e?void t(e):new Promise((e,r)=>{t(e)})}async _start(){const e={host:this.host,port:this.port,secure:this.secure,useHostName:this.useHostName,alterPath:this.alterPath};try{const t=await this._fetchDebuggerURL(e),r=s(t);r.pathname=e.alterPath(r.pathname),this.webSocketUrl=a(r),e.host=r.hostname,e.port=r.port||e.port;const i=await this._fetchProtocol(e);d.prepare(this,i),await this._connectToWebSocket(),n.nextTick(()=>{this._notifier.emit("connect",this)})}catch(e){this._notifier.emit("error",e)}}async _fetchDebuggerURL(e){const t=this.target;switch(typeof t){case"string":{let r=t;if(r.startsWith("/")&&(r=`ws://${this.host}:${this.port}${r}`),r.match(/^wss?:/i))return r;return(await l.List(e)).find(e=>e.id===r).webSocketDebuggerUrl}case"object":return t.webSocketDebuggerUrl;case"function":{const r=t,n=await l.List(e),i=r(n);return("number"==typeof i?n[i]:i).webSocketDebuggerUrl}default:throw new Error(`Invalid target argument "${this.target}"`)}}async _fetchProtocol(e){return this.protocol?this.protocol:(e.local=this.local,await l.Protocol(e))}_connectToWebSocket(){return new Promise((e,t)=>{try{this.secure&&(this.webSocketUrl=this.webSocketUrl.replace(/^ws:/i,"wss:")),this._ws=new p(this.webSocketUrl,[],{maxPayload:268435456,perMessageDeflate:!1,followRedirects:!0})}catch(e){return void t(e)}this._ws.on("open",()=>{e()}),this._ws.on("message",e=>{const t=JSON.parse(e);this._handleMessage(t)}),this._ws.on("close",e=>{this._handleConnectionClose(),this.emit("disconnect")}),this._ws.on("error",e=>{t(e)})})}_handleConnectionClose(){const e=new Error("WebSocket connection closed");for(const t of Object.values(this._callbacks))t(e);this._callbacks={}}_handleMessage(e){if(e.id){const t=this._callbacks[e.id];if(!t)return;e.error?t(!0,e.error):t(!1,e.result||{}),delete this._callbacks[e.id],0===Object.keys(this._callbacks).length&&this.emit("ready")}else if(e.method){const{method:t,params:r,sessionId:n}=e;this.emit("event",e),this.emit(t,r,n),this.emit(`${t}.${n}`,r,n)}}_enqueueCommand(e,t,r,n){const i=this._nextCommandId++,o={id:i,method:e,sessionId:r,params:t||{}};this._ws.send(JSON.stringify(o),e=>{e?"function"==typeof n&&n(e):this._callbacks[i]=n})}}},3447(e){"use strict";e.exports.HOST="localhost",e.exports.PORT=9222},5507(e,t,r){"use strict";const n=r(4043),i=r(2698),o=r(3447),a=r(3801);function s(e,t,r){const s=t.secure?i:n,p={method:t.method,host:t.host||o.HOST,port:t.port||o.PORT,useHostName:t.useHostName,path:t.alterPath?t.alterPath(e):e};a(s,p,r)}function p(e){return(t,r)=>("function"==typeof t&&(r=t,t=void 0),t=t||{},"function"==typeof r?void e(t,r):new Promise((r,n)=>{e(t,(e,t)=>{e?n(e):r(t)})}))}e.exports.Protocol=p(function(e,t){if(e.local){const e=r(6602);return void t(null,e)}s("/json/protocol",e,(e,r)=>{e?t(e):t(null,JSON.parse(r))})}),e.exports.List=p(function(e,t){s("/json/list",e,(e,r)=>{e?t(e):t(null,JSON.parse(r))})}),e.exports.New=p(function(e,t){let r="/json/new";Object.prototype.hasOwnProperty.call(e,"url")&&(r+=`?${e.url}`),e.method=e.method||"PUT",s(r,e,(e,r)=>{e?t(e):t(null,JSON.parse(r))})}),e.exports.Activate=p(function(e,t){s("/json/activate/"+e.id,e,e=>{t(e||null)})}),e.exports.Close=p(function(e,t){s("/json/close/"+e.id,e,e=>{t(e||null)})}),e.exports.Version=p(function(e,t){s("/json/version",e,(e,r)=>{e?t(e):t(null,JSON.parse(r))})})},5746(e){"use strict";class t extends Error{constructor(e,t){let{message:r}=t;t.data&&(r+=` (${t.data})`),super(r),this.request=e,this.response=t}}e.exports.ProtocolError=t},8484(e,t,r){"use strict";const n=r(7007);e.exports=class extends n{constructor(e){super(),this._ws=new WebSocket(e),this._ws.onopen=()=>{this.emit("open")},this._ws.onclose=()=>{this.emit("close")},this._ws.onmessage=e=>{this.emit("message",e.data)},this._ws.onerror=()=>{this.emit("error",new Error("WebSocket error"))}}close(){this._ws.close()}send(e,t){try{this._ws.send(e),t()}catch(e){t(e)}}}},9653(e,t,r){"use strict";if(r(6813),r(7452),r(8262),r.g._babelPolyfill)throw new Error("only one instance of babel-polyfill is allowed");r.g._babelPolyfill=!0;function n(e,t,r){e[t]||Object.defineProperty(e,t,{writable:!0,configurable:!0,value:r})}n(String.prototype,"padLeft","".padStart),n(String.prototype,"padRight","".padEnd),"pop,reverse,shift,keys,values,entries,indexOf,every,some,forEach,map,filter,find,findIndex,includes,join,slice,concat,push,splice,unshift,sort,lastIndexOf,reduce,reduceRight,copyWithin,fill".split(",").forEach(function(e){[][e]&&n(Array,e,Function.call.bind([][e]))})},8075(e,t,r){"use strict";var n=r(453),i=r(487),o=i(n("String.prototype.indexOf"));e.exports=function(e,t){var r=n(e,!!t);return"function"==typeof r&&o(e,".prototype.")>-1?i(r):r}},487(e,t,r){"use strict";var n=r(6743),i=r(453),o=i("%Function.prototype.apply%"),a=i("%Function.prototype.call%"),s=i("%Reflect.apply%",!0)||n.call(a,o),p=i("%Object.getOwnPropertyDescriptor%",!0),d=i("%Object.defineProperty%",!0),c=i("%Math.max%");if(d)try{d({},"a",{value:1})}catch(e){d=null}e.exports=function(e){var t=s(n,a,arguments);p&&d&&(p(t,"length").configurable&&d(t,"length",{value:1+c(0,e.length-(arguments.length-1))}));return t};var l=function(){return s(n,o,arguments)};d?d(e.exports,"apply",{value:l}):e.exports.apply=l},8262(e,t,r){r(6289),e.exports=r(6094).RegExp.escape},3387(e){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},5122(e,t,r){var n=r(5089);e.exports=function(e,t){if("number"!=typeof e&&"Number"!=n(e))throw TypeError(t);return+e}},8184(e,t,r){var n=r(7574)("unscopables"),i=Array.prototype;null==i[n]&&r(3341)(i,n,{}),e.exports=function(e){i[n][e]=!0}},8828(e,t,r){"use strict";var n=r(1212)(!0);e.exports=function(e,t,r){return t+(r?n(e,t).length:1)}},6440(e){e.exports=function(e,t,r,n){if(!(e instanceof t)||void 0!==n&&n in e)throw TypeError(r+": incorrect invocation!");return e}},4228(e,t,r){var n=r(3305);e.exports=function(e){if(!n(e))throw TypeError(e+" is not an object!");return e}},4438(e,t,r){"use strict";var n=r(8270),i=r(157),o=r(1485);e.exports=[].copyWithin||function(e,t){var r=n(this),a=o(r.length),s=i(e,a),p=i(t,a),d=arguments.length>2?arguments[2]:void 0,c=Math.min((void 0===d?a:i(d,a))-p,a-s),l=1;for(p0;)p in r?r[s]=r[p]:delete r[s],s+=l,p+=l;return r}},5564(e,t,r){"use strict";var n=r(8270),i=r(157),o=r(1485);e.exports=function(e){for(var t=n(this),r=o(t.length),a=arguments.length,s=i(a>1?arguments[1]:void 0,r),p=a>2?arguments[2]:void 0,d=void 0===p?r:i(p,r);d>s;)t[s++]=e;return t}},956(e,t,r){var n=r(8790);e.exports=function(e,t){var r=[];return n(e,!1,r.push,r,t),r}},1464(e,t,r){var n=r(7221),i=r(1485),o=r(157);e.exports=function(e){return function(t,r,a){var s,p=n(t),d=i(p.length),c=o(a,d);if(e&&r!=r){for(;d>c;)if((s=p[c++])!=s)return!0}else for(;d>c;c++)if((e||c in p)&&p[c]===r)return e||c||0;return!e&&-1}}},6179(e,t,r){var n=r(5052),i=r(1249),o=r(8270),a=r(1485),s=r(3191);e.exports=function(e,t){var r=1==e,p=2==e,d=3==e,c=4==e,l=6==e,u=5==e||l,m=t||s;return function(t,s,h){for(var f,y,g=o(t),b=i(g),v=n(s,h,3),w=a(b.length),S=0,I=r?m(t,w):p?m(t,0):void 0;w>S;S++)if((u||S in b)&&(y=v(f=b[S],S,g),e))if(r)I[S]=y;else if(y)switch(e){case 3:return!0;case 5:return f;case 6:return S;case 2:I.push(f)}else if(c)return!1;return l?-1:d||c?c:I}}},6543(e,t,r){var n=r(3387),i=r(8270),o=r(1249),a=r(1485);e.exports=function(e,t,r,s,p){n(t);var d=i(e),c=o(d),l=a(d.length),u=p?l-1:0,m=p?-1:1;if(r<2)for(;;){if(u in c){s=c[u],u+=m;break}if(u+=m,p?u<0:l<=u)throw TypeError("Reduce of empty array with no initial value")}for(;p?u>=0:l>u;u+=m)u in c&&(s=t(s,c[u],u,d));return s}},3606(e,t,r){var n=r(3305),i=r(7981),o=r(7574)("species");e.exports=function(e){var t;return i(e)&&("function"!=typeof(t=e.constructor)||t!==Array&&!i(t.prototype)||(t=void 0),n(t)&&null===(t=t[o])&&(t=void 0)),void 0===t?Array:t}},3191(e,t,r){var n=r(3606);e.exports=function(e,t){return new(n(e))(t)}},5538(e,t,r){"use strict";var n=r(3387),i=r(3305),o=r(4877),a=[].slice,s={};e.exports=Function.bind||function(e){var t=n(this),r=a.call(arguments,1),p=function(){var n=r.concat(a.call(arguments));return this instanceof p?function(e,t,r){if(!(t in s)){for(var n=[],i=0;i1?arguments[1]:void 0,3);r=r?r.n:this._f;)for(n(r.v,r.k,this);r&&r.r;)r=r.p},has:function(e){return!!y(h(this,t),e)}}),u&&n(c.prototype,"size",{get:function(){return h(this,t)[f]}}),c},def:function(e,t,r){var n,i,o=y(e,t);return o?o.v=r:(e._l=o={i:i=m(t,!0),k:t,v:r,p:n=e._l,n:void 0,r:!1},e._f||(e._f=o),n&&(n.n=o),e[f]++,"F"!==i&&(e._i[i]=o)),e},getEntry:y,setStrong:function(e,t,r){d(e,t,function(e,r){this._t=h(e,t),this._k=r,this._l=void 0},function(){for(var e=this,t=e._k,r=e._l;r&&r.r;)r=r.p;return e._t&&(e._l=r=r?r.n:e._t._f)?c(0,"keys"==t?r.k:"values"==t?r.v:[r.k,r.v]):(e._t=void 0,c(1))},r?"entries":"values",!r,!0),l(t)}}},4490(e,t,r){var n=r(4848),i=r(956);e.exports=function(e){return function(){if(n(this)!=e)throw TypeError(e+"#toJSON isn't generic");return i(this)}}},9882(e,t,r){"use strict";var n=r(6065),i=r(2988).getWeak,o=r(4228),a=r(3305),s=r(6440),p=r(8790),d=r(6179),c=r(7917),l=r(2888),u=d(5),m=d(6),h=0,f=function(e){return e._l||(e._l=new y)},y=function(){this.a=[]},g=function(e,t){return u(e.a,function(e){return e[0]===t})};y.prototype={get:function(e){var t=g(this,e);if(t)return t[1]},has:function(e){return!!g(this,e)},set:function(e,t){var r=g(this,e);r?r[1]=t:this.a.push([e,t])},delete:function(e){var t=m(this.a,function(t){return t[0]===e});return~t&&this.a.splice(t,1),!!~t}},e.exports={getConstructor:function(e,t,r,o){var d=e(function(e,n){s(e,d,t,"_i"),e._t=t,e._i=h++,e._l=void 0,null!=n&&p(n,r,e[o],e)});return n(d.prototype,{delete:function(e){if(!a(e))return!1;var r=i(e);return!0===r?f(l(this,t)).delete(e):r&&c(r,this._i)&&delete r[this._i]},has:function(e){if(!a(e))return!1;var r=i(e);return!0===r?f(l(this,t)).has(e):r&&c(r,this._i)}}),d},def:function(e,t,r){var n=i(o(t),!0);return!0===n?f(e).set(t,r):n[e._i]=r,e},ufstore:f}},8933(e,t,r){"use strict";var n=r(7526),i=r(2127),o=r(8859),a=r(6065),s=r(2988),p=r(8790),d=r(6440),c=r(3305),l=r(9448),u=r(8931),m=r(3844),h=r(8880);e.exports=function(e,t,r,f,y,g){var b=n[e],v=b,w=y?"set":"add",S=v&&v.prototype,I={},x=function(e){var t=S[e];o(S,e,"delete"==e||"has"==e?function(e){return!(g&&!c(e))&&t.call(this,0===e?0:e)}:"get"==e?function(e){return g&&!c(e)?void 0:t.call(this,0===e?0:e)}:"add"==e?function(e){return t.call(this,0===e?0:e),this}:function(e,r){return t.call(this,0===e?0:e,r),this})};if("function"==typeof v&&(g||S.forEach&&!l(function(){(new v).entries().next()}))){var k=new v,T=k[w](g?{}:-0,1)!=k,R=l(function(){k.has(1)}),C=u(function(e){new v(e)}),$=!g&&l(function(){for(var e=new v,t=5;t--;)e[w](t,t);return!e.has(-0)});C||((v=t(function(t,r){d(t,v,e);var n=h(new b,t,v);return null!=r&&p(r,y,n[w],n),n})).prototype=S,S.constructor=v),(R||$)&&(x("delete"),x("has"),y&&x("get")),($||T)&&x(w),g&&S.clear&&delete S.clear}else v=f.getConstructor(t,e,y,w),a(v.prototype,r),s.NEED=!0;return m(v,e),I[e]=v,i(i.G+i.W+i.F*(v!=b),I),g||f.setStrong(v,e,y),v}},6094(e){var t=e.exports={version:"2.6.12"};"number"==typeof __e&&(__e=t)},7227(e,t,r){"use strict";var n=r(7967),i=r(1996);e.exports=function(e,t,r){t in e?n.f(e,t,i(0,r)):e[t]=r}},5052(e,t,r){var n=r(3387);e.exports=function(e,t,r){if(n(e),void 0===t)return e;switch(r){case 1:return function(r){return e.call(t,r)};case 2:return function(r,n){return e.call(t,r,n)};case 3:return function(r,n,i){return e.call(t,r,n,i)}}return function(){return e.apply(t,arguments)}}},5385(e,t,r){"use strict";var n=r(9448),i=Date.prototype.getTime,o=Date.prototype.toISOString,a=function(e){return e>9?e:"0"+e};e.exports=n(function(){return"0385-07-25T07:06:39.999Z"!=o.call(new Date(-50000000000001))})||!n(function(){o.call(new Date(NaN))})?function(){if(!isFinite(i.call(this)))throw RangeError("Invalid time value");var e=this,t=e.getUTCFullYear(),r=e.getUTCMilliseconds(),n=t<0?"-":t>9999?"+":"";return n+("00000"+Math.abs(t)).slice(n?-6:-4)+"-"+a(e.getUTCMonth()+1)+"-"+a(e.getUTCDate())+"T"+a(e.getUTCHours())+":"+a(e.getUTCMinutes())+":"+a(e.getUTCSeconds())+"."+(r>99?r:"0"+a(r))+"Z"}:o},107(e,t,r){"use strict";var n=r(4228),i=r(3048),o="number";e.exports=function(e){if("string"!==e&&e!==o&&"default"!==e)throw TypeError("Incorrect hint");return i(n(this),e!=o)}},3344(e){e.exports=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e}},1763(e,t,r){e.exports=!r(9448)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},6034(e,t,r){var n=r(3305),i=r(7526).document,o=n(i)&&n(i.createElement);e.exports=function(e){return o?i.createElement(e):{}}},6140(e){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},5969(e,t,r){var n=r(1311),i=r(1060),o=r(8449);e.exports=function(e){var t=n(e),r=i.f;if(r)for(var a,s=r(e),p=o.f,d=0;s.length>d;)p.call(e,a=s[d++])&&t.push(a);return t}},2127(e,t,r){var n=r(7526),i=r(6094),o=r(3341),a=r(8859),s=r(5052),p="prototype",d=function(e,t,r){var c,l,u,m,h=e&d.F,f=e&d.G,y=e&d.S,g=e&d.P,b=e&d.B,v=f?n:y?n[t]||(n[t]={}):(n[t]||{})[p],w=f?i:i[t]||(i[t]={}),S=w[p]||(w[p]={});for(c in f&&(r=t),r)u=((l=!h&&v&&void 0!==v[c])?v:r)[c],m=b&&l?s(u,n):g&&"function"==typeof u?s(Function.call,u):u,v&&a(v,c,u,e&d.U),w[c]!=u&&o(w,c,m),g&&S[c]!=u&&(S[c]=u)};n.core=i,d.F=1,d.G=2,d.S=4,d.P=8,d.B=16,d.W=32,d.U=64,d.R=128,e.exports=d},5203(e,t,r){var n=r(7574)("match");e.exports=function(e){var t=/./;try{"/./"[e](t)}catch(r){try{return t[n]=!1,!"/./"[e](t)}catch(e){}}return!0}},9448(e){e.exports=function(e){try{return!!e()}catch(e){return!0}}},9228(e,t,r){"use strict";r(4116);var n=r(8859),i=r(3341),o=r(9448),a=r(3344),s=r(7574),p=r(9600),d=s("species"),c=!o(function(){var e=/./;return e.exec=function(){var e=[];return e.groups={a:"7"},e},"7"!=="".replace(e,"$")}),l=function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var r="ab".split(e);return 2===r.length&&"a"===r[0]&&"b"===r[1]}();e.exports=function(e,t,r){var u=s(e),m=!o(function(){var t={};return t[u]=function(){return 7},7!=""[e](t)}),h=m?!o(function(){var t=!1,r=/a/;return r.exec=function(){return t=!0,null},"split"===e&&(r.constructor={},r.constructor[d]=function(){return r}),r[u](""),!t}):void 0;if(!m||!h||"replace"===e&&!c||"split"===e&&!l){var f=/./[u],y=r(a,u,""[e],function(e,t,r,n,i){return t.exec===p?m&&!i?{done:!0,value:f.call(t,r,n)}:{done:!0,value:e.call(r,t,n)}:{done:!1}}),g=y[0],b=y[1];n(String.prototype,e,g),i(RegExp.prototype,u,2==t?function(e,t){return b.call(e,this,t)}:function(e){return b.call(e,this)})}}},1158(e,t,r){"use strict";var n=r(4228);e.exports=function(){var e=n(this),t="";return e.global&&(t+="g"),e.ignoreCase&&(t+="i"),e.multiline&&(t+="m"),e.unicode&&(t+="u"),e.sticky&&(t+="y"),t}},2322(e,t,r){"use strict";var n=r(7981),i=r(3305),o=r(1485),a=r(5052),s=r(7574)("isConcatSpreadable");e.exports=function e(t,r,p,d,c,l,u,m){for(var h,f,y=c,g=0,b=!!u&&a(u,m,3);g0)y=e(t,r,h,o(h.length),y,l-1)-1;else{if(y>=9007199254740991)throw TypeError();t[y]=h}y++}g++}return y}},8790(e,t,r){var n=r(5052),i=r(7368),o=r(1508),a=r(4228),s=r(1485),p=r(762),d={},c={},l=e.exports=function(e,t,r,l,u){var m,h,f,y,g=u?function(){return e}:p(e),b=n(r,l,t?2:1),v=0;if("function"!=typeof g)throw TypeError(e+" is not iterable!");if(o(g)){for(m=s(e.length);m>v;v++)if((y=t?b(a(h=e[v])[0],h[1]):b(e[v]))===d||y===c)return y}else for(f=g.call(e);!(h=f.next()).done;)if((y=i(f,b,h.value,t))===d||y===c)return y};l.BREAK=d,l.RETURN=c},9461(e,t,r){e.exports=r(4556)("native-function-to-string",Function.toString)},7526(e){var t=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=t)},7917(e){var t={}.hasOwnProperty;e.exports=function(e,r){return t.call(e,r)}},3341(e,t,r){var n=r(7967),i=r(1996);e.exports=r(1763)?function(e,t,r){return n.f(e,t,i(1,r))}:function(e,t,r){return e[t]=r,e}},1308(e,t,r){var n=r(7526).document;e.exports=n&&n.documentElement},2956(e,t,r){e.exports=!r(1763)&&!r(9448)(function(){return 7!=Object.defineProperty(r(6034)("div"),"a",{get:function(){return 7}}).a})},8880(e,t,r){var n=r(3305),i=r(5170).set;e.exports=function(e,t,r){var o,a=t.constructor;return a!==r&&"function"==typeof a&&(o=a.prototype)!==r.prototype&&n(o)&&i&&i(e,o),e}},4877(e){e.exports=function(e,t,r){var n=void 0===r;switch(t.length){case 0:return n?e():e.call(r);case 1:return n?e(t[0]):e.call(r,t[0]);case 2:return n?e(t[0],t[1]):e.call(r,t[0],t[1]);case 3:return n?e(t[0],t[1],t[2]):e.call(r,t[0],t[1],t[2]);case 4:return n?e(t[0],t[1],t[2],t[3]):e.call(r,t[0],t[1],t[2],t[3])}return e.apply(r,t)}},1249(e,t,r){var n=r(5089);e.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==n(e)?e.split(""):Object(e)}},1508(e,t,r){var n=r(906),i=r(7574)("iterator"),o=Array.prototype;e.exports=function(e){return void 0!==e&&(n.Array===e||o[i]===e)}},7981(e,t,r){var n=r(5089);e.exports=Array.isArray||function(e){return"Array"==n(e)}},3842(e,t,r){var n=r(3305),i=Math.floor;e.exports=function(e){return!n(e)&&isFinite(e)&&i(e)===e}},3305(e){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},5411(e,t,r){var n=r(3305),i=r(5089),o=r(7574)("match");e.exports=function(e){var t;return n(e)&&(void 0!==(t=e[o])?!!t:"RegExp"==i(e))}},7368(e,t,r){var n=r(4228);e.exports=function(e,t,r,i){try{return i?t(n(r)[0],r[1]):t(r)}catch(t){var o=e.return;throw void 0!==o&&n(o.call(e)),t}}},6032(e,t,r){"use strict";var n=r(4719),i=r(1996),o=r(3844),a={};r(3341)(a,r(7574)("iterator"),function(){return this}),e.exports=function(e,t,r){e.prototype=n(a,{next:i(1,r)}),o(e,t+" Iterator")}},8175(e,t,r){"use strict";var n=r(2750),i=r(2127),o=r(8859),a=r(3341),s=r(906),p=r(6032),d=r(3844),c=r(627),l=r(7574)("iterator"),u=!([].keys&&"next"in[].keys()),m="keys",h="values",f=function(){return this};e.exports=function(e,t,r,y,g,b,v){p(r,t,y);var w,S,I,x=function(e){if(!u&&e in C)return C[e];switch(e){case m:case h:return function(){return new r(this,e)}}return function(){return new r(this,e)}},k=t+" Iterator",T=g==h,R=!1,C=e.prototype,$=C[l]||C["@@iterator"]||g&&C[g],A=$||x(g),O=g?T?x("entries"):A:void 0,P="Array"==t&&C.entries||$;if(P&&(I=c(P.call(new e)))!==Object.prototype&&I.next&&(d(I,k,!0),n||"function"==typeof I[l]||a(I,l,f)),T&&$&&$.name!==h&&(R=!0,A=function(){return $.call(this)}),n&&!v||!u&&!R&&C[l]||a(C,l,A),s[t]=A,s[k]=f,g)if(w={values:T?A:x(h),keys:b?A:x(m),entries:O},v)for(S in w)S in C||o(C,S,w[S]);else i(i.P+i.F*(u||R),t,w);return w}},8931(e,t,r){var n=r(7574)("iterator"),i=!1;try{var o=[7][n]();o.return=function(){i=!0},Array.from(o,function(){throw 2})}catch(e){}e.exports=function(e,t){if(!t&&!i)return!1;var r=!1;try{var o=[7],a=o[n]();a.next=function(){return{done:r=!0}},o[n]=function(){return a},e(o)}catch(e){}return r}},4970(e){e.exports=function(e,t){return{value:t,done:!!e}}},906(e){e.exports={}},2750(e){e.exports=!1},5551(e){var t=Math.expm1;e.exports=!t||t(10)>22025.465794806718||t(10)<22025.465794806718||-2e-17!=t(-2e-17)?function(e){return 0==(e=+e)?e:e>-1e-6&&e<1e-6?e+e*e/2:Math.exp(e)-1}:t},2122(e,t,r){var n=r(3733),i=Math.pow,o=i(2,-52),a=i(2,-23),s=i(2,127)*(2-a),p=i(2,-126);e.exports=Math.fround||function(e){var t,r,i=Math.abs(e),d=n(e);return is||r!=r?d*(1/0):d*r}},1473(e){e.exports=Math.log1p||function(e){return(e=+e)>-1e-8&&e<1e-8?e-e*e/2:Math.log(1+e)}},7836(e){e.exports=Math.scale||function(e,t,r,n,i){return 0===arguments.length||e!=e||t!=t||r!=r||n!=n||i!=i?NaN:e===1/0||e===-1/0?e:(e-t)*(i-n)/(r-t)+n}},3733(e){e.exports=Math.sign||function(e){return 0==(e=+e)||e!=e?e:e<0?-1:1}},2988(e,t,r){var n=r(4415)("meta"),i=r(3305),o=r(7917),a=r(7967).f,s=0,p=Object.isExtensible||function(){return!0},d=!r(9448)(function(){return p(Object.preventExtensions({}))}),c=function(e){a(e,n,{value:{i:"O"+ ++s,w:{}}})},l=e.exports={KEY:n,NEED:!1,fastKey:function(e,t){if(!i(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!o(e,n)){if(!p(e))return"F";if(!t)return"E";c(e)}return e[n].i},getWeak:function(e,t){if(!o(e,n)){if(!p(e))return!0;if(!t)return!1;c(e)}return e[n].w},onFreeze:function(e){return d&&l.NEED&&p(e)&&!o(e,n)&&c(e),e}}},7380(e,t,r){var n=r(3386),i=r(2127),o=r(4556)("metadata"),a=o.store||(o.store=new(r(9397))),s=function(e,t,r){var i=a.get(e);if(!i){if(!r)return;a.set(e,i=new n)}var o=i.get(t);if(!o){if(!r)return;i.set(t,o=new n)}return o};e.exports={store:a,map:s,has:function(e,t,r){var n=s(t,r,!1);return void 0!==n&&n.has(e)},get:function(e,t,r){var n=s(t,r,!1);return void 0===n?void 0:n.get(e)},set:function(e,t,r,n){s(r,n,!0).set(e,t)},keys:function(e,t){var r=s(e,t,!1),n=[];return r&&r.forEach(function(e,t){n.push(t)}),n},key:function(e){return void 0===e||"symbol"==typeof e?e:String(e)},exp:function(e){i(i.S,"Reflect",e)}}},1384(e,t,r){var n=r(7526),i=r(2780).set,o=n.MutationObserver||n.WebKitMutationObserver,a=n.process,s=n.Promise,p="process"==r(5089)(a);e.exports=function(){var e,t,r,d=function(){var n,i;for(p&&(n=a.domain)&&n.exit();e;){i=e.fn,e=e.next;try{i()}catch(n){throw e?r():t=void 0,n}}t=void 0,n&&n.enter()};if(p)r=function(){a.nextTick(d)};else if(!o||n.navigator&&n.navigator.standalone)if(s&&s.resolve){var c=s.resolve(void 0);r=function(){c.then(d)}}else r=function(){i.call(n,d)};else{var l=!0,u=document.createTextNode("");new o(d).observe(u,{characterData:!0}),r=function(){u.data=l=!l}}return function(n){var i={fn:n,next:void 0};t&&(t.next=i),e||(e=i,r()),t=i}}},4258(e,t,r){"use strict";var n=r(3387);function i(e){var t,r;this.promise=new e(function(e,n){if(void 0!==t||void 0!==r)throw TypeError("Bad Promise constructor");t=e,r=n}),this.resolve=n(t),this.reject=n(r)}e.exports.f=function(e){return new i(e)}},8206(e,t,r){"use strict";var n=r(1763),i=r(1311),o=r(1060),a=r(8449),s=r(8270),p=r(1249),d=Object.assign;e.exports=!d||r(9448)(function(){var e={},t={},r=Symbol(),n="abcdefghijklmnopqrst";return e[r]=7,n.split("").forEach(function(e){t[e]=e}),7!=d({},e)[r]||Object.keys(d({},t)).join("")!=n})?function(e,t){for(var r=s(e),d=arguments.length,c=1,l=o.f,u=a.f;d>c;)for(var m,h=p(arguments[c++]),f=l?i(h).concat(l(h)):i(h),y=f.length,g=0;y>g;)m=f[g++],n&&!u.call(h,m)||(r[m]=h[m]);return r}:d},4719(e,t,r){var n=r(4228),i=r(1626),o=r(6140),a=r(766)("IE_PROTO"),s=function(){},p="prototype",d=function(){var e,t=r(6034)("iframe"),n=o.length;for(t.style.display="none",r(1308).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write("