From adae24db2e145070a2c8a19a0c00022eff8ea628 Mon Sep 17 00:00:00 2001 From: udo Date: Fri, 22 May 2026 10:01:27 +0000 Subject: [PATCH] Add optional realtime broker module --- README.md | 23 +- components/basic/action-button.php | 96 ++ components/navigation/tile-grid.php | 83 ++ config/settings.php | 9 + docs/backport-review-2026-05-21.md | 72 + docs/realtime-broker-options-2026-05-21.md | 130 ++ docs/realtime.md | 193 +++ js/u-wsconnection.js | 141 +- lib/components.php | 79 +- lib/filebase.class.php | 2 + lib/realtime.class.php | 99 ++ lib/ulib.php | 17 + server/realtime-broker/README.md | 50 + server/realtime-broker/broker.js | 359 +++++ server/realtime-broker/package.json | 16 + server/realtime-broker/test-broker.js | 121 ++ server/realtime-broker/vendor/ws/LICENSE | 20 + server/realtime-broker/vendor/ws/README.md | 536 +++++++ server/realtime-broker/vendor/ws/browser.js | 8 + server/realtime-broker/vendor/ws/index.js | 13 + .../vendor/ws/lib/buffer-util.js | 131 ++ .../vendor/ws/lib/constants.js | 12 + .../vendor/ws/lib/event-target.js | 292 ++++ .../vendor/ws/lib/extension.js | 203 +++ .../realtime-broker/vendor/ws/lib/limiter.js | 55 + .../vendor/ws/lib/permessage-deflate.js | 514 +++++++ .../realtime-broker/vendor/ws/lib/receiver.js | 679 +++++++++ .../realtime-broker/vendor/ws/lib/sender.js | 477 ++++++ .../realtime-broker/vendor/ws/lib/stream.js | 159 ++ .../vendor/ws/lib/subprotocol.js | 62 + .../vendor/ws/lib/validation.js | 130 ++ .../vendor/ws/lib/websocket-server.js | 531 +++++++ .../vendor/ws/lib/websocket.js | 1319 +++++++++++++++++ server/realtime-broker/vendor/ws/package.json | 68 + server/realtime-broker/vendor/ws/wrapper.mjs | 8 + tests/components/callable-fixture.php | 5 + tests/components/multi-fixture.php | 5 + tests/smoke.php | 42 +- views/api/realtime.php | 60 + views/realtime-demo.php | 40 + views/toolbox.php | 39 + 41 files changed, 6823 insertions(+), 75 deletions(-) create mode 100644 components/basic/action-button.php create mode 100644 components/navigation/tile-grid.php create mode 100644 docs/backport-review-2026-05-21.md create mode 100644 docs/realtime-broker-options-2026-05-21.md create mode 100644 docs/realtime.md create mode 100644 lib/realtime.class.php create mode 100644 server/realtime-broker/README.md create mode 100644 server/realtime-broker/broker.js create mode 100644 server/realtime-broker/package.json create mode 100644 server/realtime-broker/test-broker.js create mode 100755 server/realtime-broker/vendor/ws/LICENSE create mode 100755 server/realtime-broker/vendor/ws/README.md create mode 100755 server/realtime-broker/vendor/ws/browser.js create mode 100755 server/realtime-broker/vendor/ws/index.js create mode 100755 server/realtime-broker/vendor/ws/lib/buffer-util.js create mode 100755 server/realtime-broker/vendor/ws/lib/constants.js create mode 100755 server/realtime-broker/vendor/ws/lib/event-target.js create mode 100755 server/realtime-broker/vendor/ws/lib/extension.js create mode 100755 server/realtime-broker/vendor/ws/lib/limiter.js create mode 100755 server/realtime-broker/vendor/ws/lib/permessage-deflate.js create mode 100755 server/realtime-broker/vendor/ws/lib/receiver.js create mode 100755 server/realtime-broker/vendor/ws/lib/sender.js create mode 100755 server/realtime-broker/vendor/ws/lib/stream.js create mode 100755 server/realtime-broker/vendor/ws/lib/subprotocol.js create mode 100755 server/realtime-broker/vendor/ws/lib/validation.js create mode 100755 server/realtime-broker/vendor/ws/lib/websocket-server.js create mode 100755 server/realtime-broker/vendor/ws/lib/websocket.js create mode 100755 server/realtime-broker/vendor/ws/package.json create mode 100755 server/realtime-broker/vendor/ws/wrapper.mjs create mode 100644 tests/components/callable-fixture.php create mode 100644 tests/components/multi-fixture.php create mode 100644 views/api/realtime.php create mode 100644 views/realtime-demo.php create mode 100644 views/toolbox.php diff --git a/README.md b/README.md index 1eb29e7..3006241 100755 --- a/README.md +++ b/README.md @@ -85,6 +85,26 @@ The default starter convention is to keep reusable components in `components/`. The older `component-name:method` shorthand is still supported for backwards compatibility, but `component_call()` or an explicit `render_call` prop is the preferred API because it is clearer and easier to grep. +Downstream backports from ERP/Homeoverlord also make the loader tolerant of component files that return a single callable and/or print markup instead of returning it: printed output is buffered and returned from `component()`, so components can safely be nested or assigned into variables. Caller-relative search now uses the actual caller frame and also checks caller `components/`, `interface/`, and `packages/`-style locations. + +## Optional Realtime Broker + +The starter includes an optional vendored WebSocket-to-PHP broker under `server/realtime-broker/`. + +- `server/realtime-broker/broker.js` holds browser WebSocket connections and calls a PHP backend endpoint for app logic. +- `server/realtime-broker/vendor/ws/` vendors `ws` 8.14.2 (MIT), so normal starter use does not require a fresh npm install. +- `lib/realtime.class.php` sends backend-initiated commands such as broadcasts and connection lists. +- `views/api/realtime.php` and `views/realtime-demo.php` provide a working demo. +- Full setup, nginx, systemd, and protocol docs live in `docs/realtime.md`. + +## Downstream Toolbox Primitives + +The starter includes a few generalized primitives learned from downstream apps: + +- `components/basic/action-button.php` from `htmx-crud-starter`'s `UI::url_button()`: route-aware actions with confirmation and optional htmx-shaped attributes. +- `components/navigation/tile-grid.php` from `my.kautschuk.com`'s tile-select UI: responsive image/card navigation tiles. +- `views/toolbox.php` demonstrates these small primitives. + ## Dashboard Primitives The starter now includes a small set of dashboard-focused building blocks backported from a more complex production frontend: @@ -182,10 +202,11 @@ Profiler logs are not committed to disk. Log data for the current request is sto ## Batteries included ``` -- lib/ulib.php - convenience functions +- lib/ulib.php - convenience functions, including `short_id()` and `id_token()` for compact copy-friendly identifiers - lib/profiler.class.php - profiling - lib/log.class.php - logging - lib/url.class.php - URL routing +- lib/realtime.class.php - optional WebSocket broker command helper - lib/components.php - component rendering - lib/odt.class.php - ODT document generator - lib/db.class.php - database access diff --git a/components/basic/action-button.php b/components/basic/action-button.php new file mode 100644 index 0000000..a7819a9 --- /dev/null +++ b/components/basic/action-button.php @@ -0,0 +1,96 @@ + function($prop) { + static $assets_loaded = false; + + $label = first($prop['label'] ?? false, $prop['text'] ?? false, $prop['caption'] ?? false, 'Action'); + $route = array_key_exists('route', $prop) ? $prop['route'] : ($prop['url'] ?? ''); + $params = is_array($prop['params'] ?? false) ? $prop['params'] : array(); + $class = trim('action-button '.(string)first($prop['class'] ?? false, '')); + $variant = preg_replace('/[^a-z0-9_-]/i', '', (string)first($prop['variant'] ?? false, '')); + if($variant !== '') $class .= ' action-button--'.$variant; + $href = $route !== false ? URL::Link((string)$route, $params) : ''; + $tag = strtolower((string)first($prop['tag'] ?? false, $href !== '' ? 'a' : 'button')); + if(!in_array($tag, array('a', 'button'), true)) $tag = 'button'; + $method = strtolower((string)first($prop['method'] ?? false, 'get')); + $confirm = (string)first($prop['confirm'] ?? false, ''); + $title = (string)first($prop['title'] ?? false, $prop['hint'] ?? false, ''); + $attrs = array( + 'class' => $class, + ); + if($title !== '') $attrs['title'] = $title; + if($confirm !== '') $attrs['data-confirm'] = $confirm; + if($tag === 'a' && $href !== '') $attrs['href'] = $href; + else $attrs['type'] = first($prop['type'] ?? false, 'button'); + if(!empty($prop['disabled'])) $attrs['disabled'] = 'disabled'; + + if(!empty($prop['htmx'])) + { + $attrs[$method === 'post' ? 'hx-post' : 'hx-get'] = $href; + if($confirm !== '') $attrs['hx-confirm'] = $confirm; + if(!empty($prop['target'])) $attrs['hx-target'] = (string)$prop['target']; + if(!empty($prop['swap'])) $attrs['hx-swap'] = (string)$prop['swap']; + if(array_key_exists('push_url', $prop)) $attrs['hx-push-url'] = $prop['push_url'] ? 'true' : 'false'; + if(!empty($prop['vals']) && is_array($prop['vals'])) $attrs['hx-vals'] = json_encode($prop['vals']); + } + else if($href !== '' && $tag !== 'a') + { + $attrs['data-href'] = $href; + } + + $attr_html = ''; + foreach($attrs as $name => $value) + $attr_html .= ' '.asafe($name).'="'.asafe((string)$value).'"'; + + ob_start(); + if(!$assets_loaded) + { + $assets_loaded = true; + ?> + + + <>> 'Reusable link/button action primitive inspired by htmx-crud-starter UI::url_button.', +]; diff --git a/components/navigation/tile-grid.php b/components/navigation/tile-grid.php new file mode 100644 index 0000000..90aac35 --- /dev/null +++ b/components/navigation/tile-grid.php @@ -0,0 +1,83 @@ + function($prop) { + static $assets_loaded = false; + $items = is_array($prop['items'] ?? false) ? $prop['items'] : array(); + $class = trim('tile-grid '.(string)first($prop['class'] ?? false, '')); + $columns = (string)first($prop['columns'] ?? false, 'repeat(auto-fit, minmax(180px, 1fr))'); + $empty = (string)first($prop['empty'] ?? false, 'No tiles available.'); + ob_start(); + if(!$assets_loaded) + { + $assets_loaded = true; + ?> + + +
+ +
+ + + > + + + +
+ 'Responsive image/card navigation tiles inspired by my.kautschuk.com tile-select.', +]; diff --git a/config/settings.php b/config/settings.php index a1de44a..e1be470 100755 --- a/config/settings.php +++ b/config/settings.php @@ -131,6 +131,13 @@ 'users' => array( 'enable_signup' => true, ), + 'realtime' => array( + 'enabled' => false, + 'broker_url' => getenv('REALTIME_BROKER_URL') ?: 'http://127.0.0.1:8009/', + 'live_url' => getenv('REALTIME_LIVE_URL') ?: '', + 'command_token' => getenv('REALTIME_COMMAND_TOKEN') ?: '', + 'timeout' => 2, + ), 'menu' => array( '' => array('title' => 'Home', 'hidden' => true), 'page1' => array('title' => 'Components',), @@ -139,6 +146,8 @@ 'gauges' => array('title' => 'Gauges',), 'dashboard' => array('title' => 'Dashboard',), 'workspace' => array('title' => 'Workspace',), + 'toolbox' => array('title' => 'Toolbox',), + 'realtime-demo' => array('title' => 'Realtime',), 'themes' => array('title' => 'Themes',), 'auth/demo' => array('title' => 'Auth',), ), diff --git a/docs/backport-review-2026-05-21.md b/docs/backport-review-2026-05-21.md new file mode 100644 index 0000000..2d415ee --- /dev/null +++ b/docs/backport-review-2026-05-21.md @@ -0,0 +1,72 @@ +# Downstream Backport Review - 2026-05-21 + +## Source of truth + +- Starter reviewed/edited: `/root/projects/web-app-starter` +- ERP reference reviewed: `/root/mount_ssh/uh-erp-intranet` (`/Data/html/erp/intranet` on `uh-erp`) +- Other downstream snapshots reviewed: `/root/projects/eth-periments/site-app`, `/root/projects/tmux-pi-orchestrator`, `/root/projects/homeoverlord`, `/root/projects/meygo`, `/root/mount_ssh/k-dev/experiments/htmx-crud-starter`, `/root/mount_ssh/k-dev/experiments/crudapp`, `/root/mount_ssh/k-dev/experiments/my.kautschuk.com` + +## Backported now + +1. Component loader robustness from ERP/Homeoverlord patterns: + - caller-relative lookup now uses the actual caller frame instead of the loader frame; + - shorthand lookup also considers caller `components/`, `interface/`, and `packages/` style paths; + - ERP-style files that return a single callable are accepted as default render components; + - printed component output is buffered and returned, so ERP-style components that print markup can be assigned into variables safely; + - `:before` / `:after` hooks remain available for wrapper output. +2. Small utility helpers from downstream app reports: + - `short_id()` for compact long identifiers; + - `id_token()` for copy-friendly ID/address display. +3. Safe Filebase read behavior from `eth-periments/site-app`: + - `Filebase::read_file()` returns an empty string for a missing file instead of warning through `filesize()`. +4. Non-ERP UI primitives: + - `components/basic/action-button.php` generalizes `htmx-crud-starter`'s route-aware `UI::url_button()` helper with optional confirmation and htmx-shaped attributes; + - `components/navigation/tile-grid.php` generalizes `my.kautschuk.com`'s image tile navigation; + - `views/toolbox.php` demonstrates these additions. + +## Additional non-ERP findings + +- `meygo` has a mature closure-based view/component system where `component()` returns captured output, route views live under `views/`, and helper components prefixed with `_` are intentionally private. The starter now has the important output-buffering/callable compatibility, but may still benefit from a documented private-component convention. +- `meygo/std/data/table-common.php` shows a high-value data-grid pattern: quick search, keyboard focus, row navigation, row selection, and column resize/sort callbacks. The starter's existing `components/data/table.php` overlaps, so the right move is to add these behaviors there rather than port Meygo's ag-Grid wrapper wholesale. +- `meygo/std/form/form.php` and `std/form/input/*` are a useful schema-driven form model with per-type save adapters, CSRF, validation, tabs, and callback hooks. This is worth a larger standalone slice if the starter should grow CRUD/admin tooling. +- `meygo/std/upload/selector.php` has a self-contained drag/drop upload component with progress and CSRF. It is a good candidate after defining a starter upload receiver contract. +- `htmx-crud-starter` demonstrates partial-route conventions (`--` blank responses, `_all.php` parent render chains, and htmx navigation). These should become an optional `htmx` module, not default routing behavior. +- `my.kautschuk.com` contributes the reusable tile navigation pattern now backported as `navigation/tile-grid`. + +## Should port next + +### High value, low coupling + +- **ERP `components/ui/hover-help.php`**: reusable viewport-aware tooltips. Needs starter CSS variables and a demo page; useful across forms, dashboards, and reports. +- **ERP `components/ui/context-menu.php`**: small no-dependency menu primitive. Port with keyboard/ARIA cleanup and outside-click behavior as a shared workspace/menu component. +- **ERP `components/ui/tabview.php`**: generic tab panels with optional fit-to-bottom behavior. Port after replacing inline theme colors with CSS variables and adding accessibility state. +- **ERP `components/ui/tree-viewer.php`**: array/object inspector for JSON/debug/admin screens. Replace timestamp detection regex with explicit key checks if we want to keep the starter's regex-minimal rule. +- **ERP `components/ui/month-calendar.php`** and **`timespan-chooser.php`**: useful scheduling/report filters. Needs decoupling from ERP constants/i18n and URL helper assumptions. + +### Useful but should be generalized first + +- **ERP report wrappers** (`chart-panel`, `report-table-shell`, `stat-cards`, `pill-row`): overlap with existing starter dashboard/workspace components. Better to consolidate into existing `components/data/*` and `components/workspace/panel*` rather than copy verbatim. +- **ERP `url-multi-picker.php`**: strong querystring-backed filter UI, but long and stateful. Port as a generic filter component after extracting JS/CSS assets and adding tests. +- **Meygo data-grid enhancements**: add quick search keyboard handling, row navigation, selectable rows, and optional column-resize/sort callbacks to the starter's existing data table component rather than adding a second table stack. +- **Meygo schema form stack**: `std/form/form`, input adapters, tabbed layout, and post-button patterns are valuable but need a clean starter-level schema format and tests. +- **Meygo upload selector**: port as `components/upload/dropzone` after defining upload receiver responses, CSRF assumptions, size limits, and error display. +- **htmx partial rendering module**: optional support for htmx assets, `hx-*` action helpers, blank/fragment page templates, and parent `_all.php` render chains. +- **Downstream `Platform`/`PlatformDB` from `eth-periments/site-app`**: useful SaaS/API-key/subscription scaffold, but too product-specific. Extract only generic `json_response()`, `request_json()`, API-key storage contract, and optional DB-backed user repository if the starter grows a platform module. +- **`tmux-pi-orchestrator` theme defaults**: its hidden theme switcher/cookie consent and optional account links are good app-mode defaults, but should become config flags rather than replacing starter showcase behavior. + +### Do not port directly + +- ERP `form-components/*`, `gadgets/*`, attachment panels, audit log, workflow widgets: too ERP/database/workflow-specific for the starter. Mine them for primitives only. +- `eth-periments/site-app` contract-risk report component/theme: domain-specific. Keep as an example candidate only if the starter gets a generic report-layout demo. +- `meygo` DB table/form components verbatim: strong but tightly coupled to Meygo `DB::Info()`, table metadata, and private storage conventions. +- `eth-periments` Filebase hash change: do not backport blindly; changing the hash algorithm would break existing file-backed user/data buckets. + +## Validation + +- `php -l lib/components.php` +- `php -l lib/ulib.php` +- `php -l lib/filebase.class.php` +- `php -l components/basic/action-button.php` +- `php -l components/navigation/tile-grid.php` +- `php -l views/toolbox.php` +- `php tests/smoke.php` diff --git a/docs/realtime-broker-options-2026-05-21.md b/docs/realtime-broker-options-2026-05-21.md new file mode 100644 index 0000000..1854327 --- /dev/null +++ b/docs/realtime-broker-options-2026-05-21.md @@ -0,0 +1,130 @@ +# Realtime Broker Options - 2026-05-21 + +## Goal + +Add an optional realtime/WebSocket module to web-app-starter where the broker owns persistent WebSocket connections and delegates app-specific decisions to PHP HTTP endpoints. + +## Existing pieces reviewed + +### Node `wsbroker` + +Sources reviewed: +- ERP usage: `/root/mount_ssh/uh-erp-intranet/node/srv.js` +- vendored package: `/root/mount_ssh/uh-erp-intranet/node/node_modules/wsbroker/` +- canvas-game-starter modernization sketch: `/root/projects/canvas-game-starter/server/wsbroker.js` + +Current protocol: +- Browser connects to the broker, normally through nginx `/live/`. +- Broker captures cookies, IP, and WebSocket key into `connection.sessionInfo`. +- On connect/message/disconnect, broker POSTs to PHP backend with URL-encoded fields: + - `message=` + - `connection=` +- PHP responds with an array of commands: + - `session` to merge data into the connection session; + - `send` with optional `match` to broadcast to matching sessions; + - `close`; + - `list`; + - `log`. +- PHP can also POST commands to the broker on the same HTTP server, restricted by `backend.allow` IPs. + +Pros: +- Closest fit for web-app-starter and ERP-style PHP apps. +- Simple mental model; easy to explain in starter docs. +- JavaScript is accessible to most starter users and easy to patch without a Rust toolchain. +- Existing ERP production usage validates the architecture. +- Can be modernized to near-zero dependencies: current canvas-game-starter version already replaces `request`/lodash with stdlib helpers and current `ws`. + +Cons / modernization needed: +- Published npm package is old (`1.0.15`, Node 6-era metadata). +- Old dependencies include deprecated/high-risk packages (`request`, old `ws`, old lodash tree). +- Needs bounded message sizes, backpressure/slow-client handling, command auth token, health/status endpoints, tests, and systemd docs. +- If published as npm again, supply-chain review and vendoring policy matter. + +### Rolz Rust `ws-broker` + +Sources reviewed: +- `/root/mount_ssh/rolz-dev-115/ws/README.md` +- `/root/mount_ssh/rolz-dev-115/ws/Cargo.toml` +- `/root/mount_ssh/rolz-dev-115/ws/src/main.rs` + +Current protocol/features: +- Axum/Tokio WebSocket server plus separate internal HTTP command server. +- Reads config from `../etc/config.json`. +- Browser WebSocket server listens on `wsPort`, accepts `/`, `/live/`, and any path. +- Internal command server supports `connections`, `send`, `kick`, `multicast`, `nv_set`, `nv_get`, `rtt_stats`, `room_rtt`, `/status`. +- For client events it POSTs URL-encoded `data=` to PHP `internalAPI`. +- Merges `session` objects returned by PHP into connection session state. +- Sends `response` payload from PHP directly to the current client. +- Supports upstream `cmd`/`cmdList` command execution. +- Has keep-alive pings, RTT tracking, basic traffic/message stats, and graceful shutdown. +- Dependencies are vendored in Rolz and were recently validated offline. + +Pros: +- Operationally stronger starting point: async runtime, health/status, metrics, keepalive/RTT, graceful shutdown. +- Binaries are self-contained at runtime; no Node/npm runtime dependency. +- Existing Rolz work already has vendored Cargo discipline and recent hardening culture. +- Better candidate if the starter eventually wants a robust service daemon. + +Cons / adaptation needed: +- It is Rolz-shaped, not generic starter-shaped. +- Protocol differs from `wsbroker`/ERP: upstream field is `data=`, not `message` + `connection`; responses use `response`/`cmd`/`cmdList` instead of arrays of `type` commands. +- Contains Rolz-specific concepts (`room`, `nick`, `uid`, ephemeral room broadcast, RTT stats, message stat dimensions like `mapdata`). These must be stripped or turned into optional plugins. +- Rust toolchain and vendored crates are heavier for starter users than a small Node script. +- Current dependency footprint is substantial for a simple broker (`axum`, `tokio`, `reqwest`, `num-bigint`, etc.). +- Harder for casual PHP-starter adopters to debug or modify. + +## Recommendation + +Use a modernized in-repo Node broker as the starter default, not the Rolz Rust broker. + +Implemented in this starter as `server/realtime-broker/` with vendored `ws` 8.14.2, PHP helper `lib/realtime.class.php`, demo backend `views/api/realtime.php`, demo page `views/realtime-demo.php`, and operator docs `docs/realtime.md`. + +Rationale: +- The web-app-starter should optimize for small-app approachability and PHP integration. A single small `server/realtime-broker.js` with one dependency (`ws`, vendored or pinned) is easier to understand, run, and adapt than a Rust service with a full Cargo tree. +- The desired architecture is already the original `wsbroker` architecture: broker holds connections; PHP owns app logic. ERP proves this is useful in production. +- The Rust broker is valuable as a hardening checklist, but porting it directly would bring Rolz-specific concepts and a heavier build/distribution story. + +## Proposed starter module shape + +- `server/realtime-broker/` + - `broker.js` or `realtime-broker.js` + - `package.json` with minimal dependencies; prefer vendoring if policy requires it + - `README.md` +- `lib/realtime.class.php` + - `Realtime::broadcast($message, $match = [])` + - `Realtime::connections($match = [])` + - `Realtime::session($data, $match = null)` +- `views/realtime/broker.php` or `views/api/realtime.php` + - app-specific backend endpoint for connect/message/disconnect + - demo command responses +- `views/realtime-demo.php` +- `js/u-wsconnection.js` refresh + - remove jQuery dependency from status indicator path + - backoff reconnect + - queue flushing on open + - explicit close behavior +- `docs/realtime.md` + - protocol + - nginx `/live/` snippet + - systemd service + - troubleshooting + +## Features to borrow from Rust implementation without adopting Rust initially + +- `/status` endpoint with connection count and traffic counters. +- Separate command endpoint or command auth token rather than IP allow-list only. +- Keepalive ping/pong and stale connection cleanup. +- Message size limits. +- Bounded per-client outbound queues and slow-client drop/close behavior. +- Graceful shutdown. +- Structured logs. + +## When to choose Rust instead + +Choose the Rolz Rust lineage if one of these becomes true: +- the starter needs thousands of concurrent connections as a normal target; +- we need a single static binary and no Node runtime on deployment hosts; +- we are willing to vendor/maintain Cargo dependencies in web-app-starter; +- we want built-in metrics/RTT/room semantics as core defaults rather than optional demos. + +Until then, Node is the better default and Rust can remain the advanced/high-scale backend option later. diff --git a/docs/realtime.md b/docs/realtime.md new file mode 100644 index 0000000..cee3275 --- /dev/null +++ b/docs/realtime.md @@ -0,0 +1,193 @@ +# Realtime WebSocket Broker + +web-app-starter includes an optional WebSocket-to-PHP broker under `server/realtime-broker/`. + +The broker is deliberately generic: it owns persistent WebSocket connections, while PHP decides app-specific behavior through a normal HTTP endpoint. + +## Files + +- `server/realtime-broker/broker.js` - broker daemon and importable `RealtimeBroker` class +- `server/realtime-broker/vendor/ws/` - vendored `ws` 8.14.2, MIT license +- `server/realtime-broker/test-broker.js` - broker integration test +- `lib/realtime.class.php` - PHP helper for backend-initiated commands +- `views/api/realtime.php` - starter demo backend endpoint +- `views/realtime-demo.php` - browser demo page +- `js/u-wsconnection.js` - small browser connection helper + +## Start locally + +```bash +cd /path/to/web-app-starter +REALTIME_HOST=127.0.0.1 \ +REALTIME_PORT=8009 \ +REALTIME_BACKEND_URL=http://127.0.0.1/web-app-starter/?api/realtime \ +REALTIME_COMMAND_TOKEN=change-me \ +node server/realtime-broker/broker.js +``` + +Then open `/?realtime-demo` and connect. + +## Browser URL + +In production, proxy `/live/` to the broker and let the browser connect to the same origin: + +```nginx +location /live/ { + proxy_pass http://127.0.0.1:8009/live/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; +} +``` + +If the broker is elsewhere, set `REALTIME_LIVE_URL` or `config/settings.php` `realtime/live_url`. + +## PHP backend protocol + +For every browser connect/message/disconnect, the broker POSTs URL-encoded fields to `REALTIME_BACKEND_URL`: + +- `message`: JSON message +- `connection`: JSON connection/session info + +Message types: + +- `session-connect` +- `session-disconnect` +- browser messages prefixed as `client-*` unless already prefixed + +The PHP endpoint returns a JSON array of commands. + +```php + 'session', 'data' => ['room' => 'demo']], + ['type' => 'send', 'match' => ['room' => 'demo'], 'message' => ['type' => 'notice', 'text' => 'hello']], +])); +``` + +## Backend commands + +### `session` + +Merge data into connection session info. Without `match`, applies to the current connection. + +```json +{"type":"session","data":{"room":"demo","user_id":123}} +``` + +### `send` + +Send a JSON message to the current connection or matching connections. + +```json +{"type":"send","match":{"room":"demo"},"message":{"type":"chat","text":"hello"}} +``` + +### `close` + +Close the current or matching connections. + +```json +{"type":"close","match":{"user_id":123}} +``` + +### `list` + +Return matching connection sessions to a command caller. + +```json +{"type":"list","match":{"room":"demo"}} +``` + +### `log` + +Log a message if broker logging is enabled. + +## PHP helper + +`Realtime` wraps command POSTs to the broker: + +```php +Realtime::broadcast(['type' => 'notice', 'text' => 'hello'], ['room' => 'demo']); +Realtime::session(['room' => 'demo'], ['user_id' => 123]); +Realtime::connections(['room' => 'demo']); +Realtime::close(['user_id' => 123]); +``` + +Configure: + +```php +$GLOBALS['config']['realtime'] = [ + 'broker_url' => 'http://127.0.0.1:8009/', + 'command_token' => getenv('REALTIME_COMMAND_TOKEN') ?: '', +]; +``` + +## Command security + +If `REALTIME_COMMAND_TOKEN` is set, command POSTs must include one of: + +- `token=` form field +- `Authorization: Bearer ` +- `X-Realtime-Token: ` + +If no token is set, broker commands are accepted only from `REALTIME_COMMAND_ALLOW` IPs. Use a token for new deployments. + +## Broker status + +```bash +curl http://127.0.0.1:8009/status +``` + +Returns JSON counters including active connections, message counts, bytes, backend errors, and denied command attempts. + +## systemd example + +`/etc/systemd/system/web-app-starter-realtime.service`: + +```ini +[Unit] +Description=web-app-starter realtime broker +After=network.target + +[Service] +Type=simple +WorkingDirectory=/var/www/web-app-starter +Environment=REALTIME_HOST=127.0.0.1 +Environment=REALTIME_PORT=8009 +Environment=REALTIME_BACKEND_URL=http://127.0.0.1/web-app-starter/?api/realtime +Environment=REALTIME_COMMAND_TOKEN=change-me +ExecStart=/usr/bin/node server/realtime-broker/broker.js +Restart=on-failure +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +## Validation + +```bash +node --check server/realtime-broker/broker.js +node --check server/realtime-broker/test-broker.js +node server/realtime-broker/test-broker.js +php -l lib/realtime.class.php +php tests/smoke.php +``` + +## Notes + +- The broker is optional and not started by the PHP app. +- Keep app policy in PHP; keep the broker generic. +- Do not put secrets in messages sent to clients. +- Tune `REALTIME_MAX_MESSAGE_BYTES`, `REALTIME_MAX_BUFFERED_BYTES`, and backend timeout for larger apps. diff --git a/js/u-wsconnection.js b/js/u-wsconnection.js index 9c3b40f..8031c15 100755 --- a/js/u-wsconnection.js +++ b/js/u-wsconnection.js @@ -9,103 +9,118 @@ }(typeof self !== 'undefined' ? self : this, function () { var Connection = { + auto_reconnect: true, + established: false, + debug: true, + cmd_waiting_rels: {}, + server_url: '', + socket: null, + queue: [], + reconnect_timer: null, + reconnect_delay: 1000, + reconnect_delay_max: 15000, + closed_explicitly: false, - auto_reconnect : false, - established : false, - - update_indicator : (status) => { - var status_colors = { - 'offline' : 'red', - 'online' : 'lightgreen', - 'error' : 'DarkOrange', + update_indicator: function(status) { + var statusColors = { + offline: 'red', + online: 'lightgreen', + error: 'DarkOrange', }; - $('#connection-status').text(status).css('color', status_colors[status] || 'gray'); + var node = typeof document !== 'undefined' ? document.getElementById('connection-status') : null; + if(node) { + node.textContent = status; + node.style.color = statusColors[status] || 'gray'; + } }, - - debug : true, - auto_reconnect : true, - cmd_waiting_rels : {}, - - server_url : '', - - init : () => { - new EventSystem(Connection); + + init: function() { + if(typeof EventSystem === 'function') new EventSystem(Connection); + else { + var listeners = {}; + Connection.on = function(name, handler) { + if(!listeners[name]) listeners[name] = []; + listeners[name].push(handler); + }; + Connection.trigger = function(name, payload) { + (listeners[name] || []).forEach(function(handler) { handler(payload); }); + }; + } }, - - start : (url = null) => { - + + start: function(url) { if(url) Connection.server_url = url; - + if(!Connection.server_url) throw new Error('Connection.start() requires a WebSocket URL'); + Connection.closed_explicitly = false; Connection.update_indicator('offline'); if(Connection.socket) Connection.socket.close(); Connection.socket = new WebSocket(Connection.server_url); Connection.socket.onmessage = function(rawmsg) { - var msg = JSON.parse(rawmsg.data); + var msg = {}; + try { msg = JSON.parse(rawmsg.data); } + catch(error) { msg = { type: 'invalid-json', raw: rawmsg.data, error: String(error) }; } if(Connection.debug) console.log('CONNECTION MSG', msg); if(msg.type) Connection.trigger(msg.type, msg); Connection.trigger('message', msg); - } + }; Connection.socket.onclose = function() { Connection.update_indicator('offline'); if(Connection.debug) console.log('CONNECTION CLOSED'); Connection.established = false; Connection.trigger('close', {}); - } + Connection.schedule_reconnect(); + }; Connection.socket.onerror = function(error) { Connection.update_indicator('error'); console.error('CONNECTION', error); Connection.established = false; - } + Connection.trigger('error', error); + }; Connection.socket.onopen = function() { Connection.update_indicator('online'); if(Connection.debug) console.log('CONNECTION ESTABLISHED'); Connection.established = true; + Connection.reconnect_delay = 1000; Connection.trigger('open', {}); - } - setTimeout(Connection.reconnect, 2000); - + Connection.dequeue(); + }; }, - - deauth : () => { - Game.session = {}; - }, - - reconnect : () => { - if(!Connection.established && Connection.auto_reconnect) - Connection.start(); - else - setTimeout(Connection.reconnect, 2000); - }, - - queue : [], - dequeue : () => { - var dq = Connection.queue; - Connection.queue = []; - dq.forEach(function(fm) { - Connection.send(fm); - }); + schedule_reconnect: function() { + if(Connection.closed_explicitly || !Connection.auto_reconnect) return; + if(Connection.reconnect_timer) return; + Connection.reconnect_timer = setTimeout(function() { + Connection.reconnect_timer = null; + if(!Connection.established && Connection.auto_reconnect) Connection.start(); + }, Connection.reconnect_delay); + Connection.reconnect_delay = Math.min(Connection.reconnect_delay * 1.5, Connection.reconnect_delay_max); }, - - send : (msg) => { - if(Connection.established) { - if(typeof msg == 'function') - msg(); - else - Connection.socket.send(JSON.stringify(msg)); + + dequeue: function() { + var pending = Connection.queue; + Connection.queue = []; + pending.forEach(function(msg) { Connection.send(msg); }); + }, + + send: function(msg) { + if(Connection.established && Connection.socket && Connection.socket.readyState === WebSocket.OPEN) { + if(typeof msg === 'function') msg(); + else Connection.socket.send(JSON.stringify(msg)); } else { Connection.queue.push(msg); } }, - - close : () => { - Connection.auto_reconnect = false; - Connection.socket.close(); - }, - -} + close: function() { + Connection.closed_explicitly = true; + Connection.auto_reconnect = false; + if(Connection.reconnect_timer) clearTimeout(Connection.reconnect_timer); + Connection.reconnect_timer = null; + if(Connection.socket) Connection.socket.close(); + }, +}; + +Connection.init(); return Connection; })); - diff --git a/lib/components.php b/lib/components.php index d1179e4..9f949c6 100755 --- a/lib/components.php +++ b/lib/components.php @@ -31,10 +31,39 @@ return ''; } + function component_expand_search_paths($search_path = false) + { + $paths = array(); + if(is_array($search_path)) + $paths = $search_path; + else if(is_string($search_path) && trim($search_path) !== '') + $paths = array($search_path); + + $normalized = array(); + foreach($paths as $path) + { + $base = rtrim((string)$path, '/'); + if($base === '') + continue; + $normalized[] = $base; + if(substr($base, -11) !== '/components') + $normalized[] = $base.'/components'; + } + return array_values(array_unique($normalized)); + } + function component_caller_dir() { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); - return isset($trace[1]['file']) ? dirname($trace[1]['file']) : getcwd(); + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8); + foreach($trace as $frame) + { + if(!isset($frame['file']) || !is_string($frame['file'])) + continue; + if(realpath($frame['file']) === realpath(__FILE__)) + continue; + return dirname($frame['file']); + } + return getcwd(); } function component_normalize_name($file_name) @@ -46,10 +75,17 @@ { $component_name = component_normalize_name($file_name); $candidates = array($component_name.'.php'); - if($search_path && !str_starts_with($component_name, 'components/')) - $candidates[] = rtrim($search_path, '/').'/'.$component_name.'.php'; if(!str_starts_with($component_name, 'components/')) $candidates[] = 'components/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'components/')) + { + foreach(component_expand_search_paths($search_path) as $path) + $candidates[] = $path.'/'.$component_name.'.php'; + } + if(!str_starts_with($component_name, 'interface/')) + $candidates[] = 'interface/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'packages/')) + $candidates[] = 'packages/'.$component_name.'.php'; foreach(array_unique($candidates) as $candidate) { if(file_exists($candidate)) @@ -63,11 +99,13 @@ * * Component files are searched in this order: * 1. exact file path passed to the loader - * 2. caller-relative path (for shorthand component names) - * 3. components/{component_name}.php + * 2. components/{component_name}.php + * 3. caller-relative path and caller/components/{component_name}.php + * 4. interface/{component_name}.php and packages/{component_name}.php * * Component files should return an array with render functions like: * return ['render' => function($prop) { ... }]; + * ERP-style files that return a single callable are treated as the default render function. */ function component_get_func($file_name, $return_false_if_not_found = false, $search_path = false) { @@ -92,8 +130,18 @@ }; } if($result['component_file']) - foreach(require($result['component_file']) as $k => $v) - $result[$k] = $v; + { + $component_definition = require($result['component_file']); + if(is_callable($component_definition)) + $result['render'] = $component_definition; + else if(is_array($component_definition)) + { + foreach($component_definition as $k => $v) + $result[$k] = $v; + } + else + $result['render'] = ''; + } // Register component in global registry for future use $GLOBALS['render_funcs'][$component_name] = $result; } @@ -186,12 +234,27 @@ function component($file_name, $prop = array()) $prop['id'] = !empty($prop['id']) ? $prop['id'] : 'c'.($GLOBALS['id_counter']++); $prop['filename'] = $file_name; + if(isset($prop[':before']) && $prop[':before'] !== '') + echo $prop[':before']; + + ob_start(); if(isset($renderer[$prop['render_call']]) && is_callable($renderer[$prop['render_call']])) $result = ($renderer[$prop['render_call']]($prop, $renderer)); else if(isset($renderer[$prop['render_call']])) $result = ($renderer[$prop['render_call']]); else $result = component_error_banner('component render method not found: '.$file_name.':'.$prop['render_call']); + $printed_result = ob_get_clean(); + + if($result === null) + $result = $printed_result; + else if(is_string($result)) + $result .= $printed_result; + else + echo $printed_result; + + if(isset($prop[':after']) && $prop[':after'] !== '') + echo $prop[':after']; Profiler::log(false, -1); return($result); diff --git a/lib/filebase.class.php b/lib/filebase.class.php index 298e6a6..eacfa09 100644 --- a/lib/filebase.class.php +++ b/lib/filebase.class.php @@ -56,6 +56,8 @@ class Filebase static function read_file($file_name) { + if(!file_exists($file_name)) + return(''); $fsz = filesize($file_name); if($fsz == 0) return(''); diff --git a/lib/realtime.class.php b/lib/realtime.class.php new file mode 100644 index 0000000..0a0caae --- /dev/null +++ b/lib/realtime.class.php @@ -0,0 +1,99 @@ + false, 'error' => 'realtime_broker_url_missing'); + + $payload = http_build_query(array( + 'data' => json_encode(array_values($commands), JSON_UNESCAPED_SLASHES), + 'token' => self::command_token(), + )); + $headers = "Content-Type: application/x-www-form-urlencoded\r\n"; + if(self::command_token() !== '') + $headers .= 'Authorization: Bearer '.self::command_token()."\r\n"; + $context = stream_context_create(array('http' => array( + 'method' => 'POST', + 'header' => $headers, + 'content' => $payload, + 'timeout' => (float)first(cfg('realtime/timeout'), 2), + 'ignore_errors' => true, + ))); + $response = @file_get_contents($url, false, $context); + $status = self::response_status($http_response_header ?? array()); + if($response === false) + return array('result' => false, 'error' => 'realtime_broker_unreachable', 'status' => $status); + $decoded = json_decode($response, true); + return array('result' => $status >= 200 && $status < 300, 'status' => $status, 'response' => $decoded, 'raw' => $response); + } + + static function response_status($headers) + { + foreach((array)$headers as $header) + { + if(str_starts_with($header, 'HTTP/')) + { + $parts = explode(' ', $header); + return intval($parts[1] ?? 0); + } + } + return 0; + } + + static function broadcast($message, $match = false) + { + $command = array('type' => 'send', 'message' => $message); + if($match) $command['match'] = $match; + return self::command(array($command)); + } + + static function session($data, $match = false) + { + $command = array('type' => 'session', 'data' => $data); + if($match) $command['match'] = $match; + return self::command(array($command)); + } + + static function close($match = false) + { + $command = array('type' => 'close'); + if($match) $command['match'] = $match; + return self::command(array($command)); + } + + static function connections($match = false) + { + $command = array('type' => 'list'); + if($match) $command['match'] = $match; + return self::command(array($command)); + } +} diff --git a/lib/ulib.php b/lib/ulib.php index 07dce6b..b58b99f 100755 --- a/lib/ulib.php +++ b/lib/ulib.php @@ -83,6 +83,23 @@ function jsafe($s) return json_encode($s); } +function short_id($value, $front = 10, $back = 8) +{ + $value = (string)$value; + $front = max(0, (int)$front); + $back = max(0, (int)$back); + if(strlen($value) <= ($front + $back + 1)) return $value; + return substr($value, 0, $front).'…'.substr($value, -$back); +} + +function id_token($value, $label = '') +{ + $value = trim((string)$value); + if($value === '') return ''; + $label = $label !== '' ? (string)$label : short_id($value); + return ''.safe($label).''; +} + function clamp($v, $min, $max) { if($v < $min) return $min; diff --git a/server/realtime-broker/README.md b/server/realtime-broker/README.md new file mode 100644 index 0000000..ff270cd --- /dev/null +++ b/server/realtime-broker/README.md @@ -0,0 +1,50 @@ +# Realtime Broker + +A vendored, modernized successor to the old `wsbroker` package. + +The broker owns WebSocket connections and delegates app-specific logic to PHP over HTTP. It is optional; web-app-starter does not start it unless you configure a service for it. + +## Run + +```bash +REALTIME_HOST=127.0.0.1 \ +REALTIME_PORT=8009 \ +REALTIME_BACKEND_URL=http://127.0.0.1/web-app-starter/?api/realtime \ +REALTIME_COMMAND_TOKEN=change-me \ +node server/realtime-broker/broker.js +``` + +## Protocol + +Browser events sent to the broker become POSTs to the PHP backend: + +- `message`: JSON message. Types are prefixed with `client-` unless already prefixed. +- `connection`: JSON session info with cookies, IP, wskey, and fields merged by backend `session` commands. + +PHP responds with an array of commands: + +```json +[ + {"type":"session","data":{"room":"demo"}}, + {"type":"send","match":{"room":"demo"},"message":{"type":"notice","text":"hello"}} +] +``` + +Supported commands: + +- `session`: merge `data` into matching connection sessions; without `match`, applies to the current connection. +- `send`: send `message`/`content` to matching connections; without `match`, sends to current connection. +- `close`: close matching connections. +- `list`: return matching connection session info to command callers. +- `log`: write a broker log message when logging is enabled. + +Backend-initiated command POSTs can use: + +- `data=` +- `token=` or `Authorization: Bearer ` or `X-Realtime-Token: ` + +`GET /status` returns JSON health/counters. + +## Vendored dependency + +`vendor/ws` is `ws` 8.14.2 (MIT). It is vendored so the starter does not need to trust a fresh npm install during normal development. diff --git a/server/realtime-broker/broker.js b/server/realtime-broker/broker.js new file mode 100644 index 0000000..a01b131 --- /dev/null +++ b/server/realtime-broker/broker.js @@ -0,0 +1,359 @@ +#!/usr/bin/env node +'use strict'; + +const http = require('http'); +const https = require('https'); +const { parse: parseUrl } = require('url'); +const querystring = require('querystring'); +const { WebSocketServer, WebSocket } = require('./vendor/ws'); + +function safeParseJSON(raw, fallback = {}) { + if (raw === undefined || raw === null || raw === '') return fallback; + if (typeof raw !== 'string') return raw; + try { return JSON.parse(raw); } + catch (error) { return { json_error: `${error.message} // source: ${raw}` }; } +} + +function writeResponse(response, status, body, contentType = 'text/plain; charset=utf-8') { + response.writeHead(status, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }); + response.end(body); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of String(header).split(';')) { + const pos = part.indexOf('='); + if (pos <= 0) continue; + const name = part.slice(0, pos).trim().toLowerCase(); + const value = part.slice(pos + 1).trim(); + if (name) { + try { out[name] = decodeURIComponent(value); } + catch (_error) { out[name] = value; } + } + } + return out; +} + +function mergeObjects(target, source) { + const out = { ...(target || {}) }; + for (const [key, value] of Object.entries(source || {})) { + if (value && typeof value === 'object' && !Array.isArray(value)) out[key] = mergeObjects(out[key], value); + else out[key] = value; + } + return out; +} + +function match(criteria, sessionInfo) { + if (!criteria) return true; + for (const [key, value] of Object.entries(criteria)) { + const wanted = String(value); + const actual = String(sessionInfo[key] ?? ''); + if (wanted !== '*' && wanted.toLowerCase() !== actual.toLowerCase()) return false; + } + return true; +} + +function formPost(url, formData, timeoutMs = 8000) { + return new Promise((resolve) => { + const parsed = parseUrl(url); + const transport = parsed.protocol === 'https:' ? https : http; + const body = querystring.stringify(formData); + const request = transport.request({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.path, + method: 'POST', + timeout: timeoutMs, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body), + }, + }, (response) => { + let responseBody = ''; + response.on('data', (chunk) => { responseBody += chunk; }); + response.on('end', () => resolve({ error: null, statusCode: response.statusCode, body: responseBody })); + }); + request.on('timeout', () => request.destroy(new Error('backend request timed out'))); + request.on('error', (error) => resolve({ error, statusCode: 0, body: '' })); + request.write(body); + request.end(); + }); +} + +class RealtimeBroker { + constructor(options = {}) { + this.config = { + port: Number(options.port || process.env.REALTIME_PORT || 8009), + host: options.host || process.env.REALTIME_HOST || '127.0.0.1', + log: Boolean(options.log ?? (process.env.REALTIME_LOG === '1')), + backend: options.backend || null, + commandToken: options.commandToken || process.env.REALTIME_COMMAND_TOKEN || '', + maxMessageBytes: Number(options.maxMessageBytes || process.env.REALTIME_MAX_MESSAGE_BYTES || 65536), + maxBufferedBytes: Number(options.maxBufferedBytes || process.env.REALTIME_MAX_BUFFERED_BYTES || 1024 * 1024), + backendTimeoutMs: Number(options.backendTimeoutMs || process.env.REALTIME_BACKEND_TIMEOUT_MS || 8000), + keepaliveMs: Number(options.keepaliveMs || process.env.REALTIME_KEEPALIVE_MS || 25000), + }; + this.stats = { + startedAt: Date.now(), + connectionsAccepted: 0, + connectionsClosed: 0, + messagesIn: 0, + messagesOut: 0, + bytesIn: 0, + bytesOut: 0, + backendRequests: 0, + backendErrors: 0, + commandRequests: 0, + commandDenied: 0, + }; + this.nextConnectionId = 1; + this.server = http.createServer((request, response) => this.handleHttpRequest(request, response)); + this.websocketServer = new WebSocketServer({ server: this.server, maxPayload: this.config.maxMessageBytes }); + this.websocketServer.on('connection', (connection, request) => this.handleConnection(connection, request)); + this.websocketServer.on('error', (error) => this.log('websocket error', error)); + this.keepaliveTimer = null; + } + + log(...args) { if (this.config.log) console.log('[realtime-broker]', ...args); } + + listen(callback) { + this.server.listen(this.config.port, this.config.host, () => { + this.log(`listening on ${this.config.host}:${this.config.port}`); + if (callback) callback(); + }); + if (this.config.keepaliveMs > 0) { + this.keepaliveTimer = setInterval(() => this.keepalive(), this.config.keepaliveMs); + this.keepaliveTimer.unref?.(); + } + return this; + } + + close(callback) { + if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); + for (const client of this.websocketServer.clients) client.close(1001, 'server shutting down'); + this.websocketServer.close(() => this.server.close(callback)); + } + + connectionCount() { return this.websocketServer.clients.size; } + + buildStatus() { + return { + status: 'ok', + uptime_sec: Math.round((Date.now() - this.stats.startedAt) / 1000), + connections_active: this.connectionCount(), + ...this.stats, + }; + } + + handleHttpRequest(request, response) { + if (request.method === 'GET' && request.url.split('?')[0] === '/status') { + writeResponse(response, 200, JSON.stringify(this.buildStatus(), null, 2), 'application/json; charset=utf-8'); + return; + } + if (request.method !== 'POST') { + writeResponse(response, 200, ''); + return; + } + this.collectBody(request, (error, body) => { + if (error) { + writeResponse(response, 413, JSON.stringify({ error: error.message }), 'application/json; charset=utf-8'); + return; + } + this.handleCommandRequest(request, response, body); + }); + } + + collectBody(request, callback) { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + if (Buffer.byteLength(body) > this.config.maxMessageBytes) { + request.destroy(); + callback(new Error('request too large')); + } + }); + request.on('end', () => callback(null, body)); + request.on('error', (error) => callback(error)); + } + + remoteAddress(request) { + return request.socket.remoteAddress || ''; + } + + commandAllowed(request, params) { + if (this.config.commandToken) { + const auth = request.headers.authorization || ''; + const bearer = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + return params.token === this.config.commandToken || bearer === this.config.commandToken || request.headers['x-realtime-token'] === this.config.commandToken; + } + const allow = this.config.backend?.allow || []; + if (allow.length === 0) return false; + return allow.includes(this.remoteAddress(request)); + } + + handleCommandRequest(request, response, body) { + this.stats.commandRequests++; + const params = querystring.parse(body); + if (!this.commandAllowed(request, params)) { + this.stats.commandDenied++; + writeResponse(response, 403, JSON.stringify({ error: 'access_denied' }), 'application/json; charset=utf-8'); + return; + } + let commands = []; + if (params.data) commands = safeParseJSON(params.data, []); + else if (params.message || params.type) commands = [safeParseJSON(params.message || '{}', {})]; + if (!Array.isArray(commands)) commands = [commands]; + const result = this.applyBackendCommands(commands, null); + writeResponse(response, 200, JSON.stringify(result), 'application/json; charset=utf-8'); + } + + handleConnection(connection, request) { + connection.id = this.nextConnectionId++; + connection.isAlive = true; + connection.sessionInfo = mergeObjects(parseCookies(request.headers.cookie), { + ip: request.headers['x-forwarded-for'] || this.remoteAddress(request), + wskey: request.headers['sec-websocket-key'] || String(connection.id), + connection_id: connection.id, + }); + this.stats.connectionsAccepted++; + this.log('connection open', connection.sessionInfo.wskey); + connection.on('pong', () => { connection.isAlive = true; }); + connection.on('message', (data) => this.handleClientMessage(connection, data)); + connection.on('close', () => this.handleClose(connection)); + connection.on('error', (error) => this.log('connection error', connection.sessionInfo.wskey, error.message)); + this.sendBackendMessage(connection, { type: 'session-connect' }); + } + + handleClose(connection) { + this.stats.connectionsClosed++; + this.log('connection close', connection.sessionInfo?.wskey); + this.sendBackendMessage(connection, { type: 'session-disconnect' }); + } + + handleClientMessage(connection, rawData) { + const raw = rawData.toString(); + this.stats.messagesIn++; + this.stats.bytesIn += Buffer.byteLength(raw); + if (Buffer.byteLength(raw) > this.config.maxMessageBytes) { + this.sendClient(connection, { type: 'error', error: 'message_too_large' }); + connection.close(1009, 'message too large'); + return; + } + const message = safeParseJSON(raw, {}); + if (!message.type || !String(message.type).startsWith('client-')) message.type = `client-${message.type || 'message'}`; + this.sendBackendMessage(connection, message); + } + + async sendBackendMessage(connection, message) { + if (!this.config.backend || this.config.backend.type !== 'http' || !this.config.backend.url) return; + this.stats.backendRequests++; + const data = { + message: JSON.stringify(message), + connection: JSON.stringify(connection.sessionInfo || {}), + }; + this.log('backend <-', message.type); + const result = await formPost(this.config.backend.url, data, this.config.backendTimeoutMs); + if (result.error) { + this.stats.backendErrors++; + this.log('backend error', result.error.message); + return; + } + const commands = safeParseJSON(result.body, []); + this.applyBackendCommands(Array.isArray(commands) ? commands : [commands], connection); + } + + applyBackendCommands(commands, currentConnection) { + const result = []; + for (const command of commands) { + if (!command || typeof command !== 'object') continue; + switch (command.type) { + case 'log': + this.log('backend log', command.text || command.message || ''); + break; + case 'session': + this.applyToConnections(currentConnection, command.match, (client) => { + client.sessionInfo = mergeObjects(client.sessionInfo, command.data || {}); + }); + break; + case 'close': + this.applyToConnections(currentConnection, command.match, (client) => client.close(1000, 'closed by backend')); + break; + case 'send': { + let sent = 0; + this.applyToConnections(currentConnection, command.match, (client) => { + if (this.sendClient(client, command.message ?? command.content ?? {})) sent++; + }); + result.push({ type: 'send', sent }); + break; + } + case 'list': { + const list = []; + this.applyToConnections(null, command.match, (client) => list.push(client.sessionInfo)); + result.push(list); + break; + } + default: + this.log('unknown backend command', command); + } + } + return result; + } + + applyToConnections(currentConnection, criteria, callback) { + if (currentConnection && !criteria) { + callback(currentConnection); + return; + } + for (const client of this.websocketServer.clients) { + if (client.readyState === WebSocket.OPEN && match(criteria, client.sessionInfo || {})) callback(client); + } + } + + sendClient(connection, message) { + if (!connection || connection.readyState !== WebSocket.OPEN) return false; + if (connection.bufferedAmount > this.config.maxBufferedBytes) { + connection.close(1013, 'client too slow'); + return false; + } + const payload = typeof message === 'string' ? message : JSON.stringify(message); + connection.send(payload); + this.stats.messagesOut++; + this.stats.bytesOut += Buffer.byteLength(payload); + return true; + } + + keepalive() { + for (const client of this.websocketServer.clients) { + if (client.isAlive === false) { + client.terminate(); + continue; + } + client.isAlive = false; + client.ping(); + } + } +} + +function loadConfigFromEnv() { + return { + port: process.env.REALTIME_PORT, + host: process.env.REALTIME_HOST, + log: process.env.REALTIME_LOG === '1', + commandToken: process.env.REALTIME_COMMAND_TOKEN || '', + backend: process.env.REALTIME_BACKEND_URL ? { + type: 'http', + url: process.env.REALTIME_BACKEND_URL, + allow: (process.env.REALTIME_COMMAND_ALLOW || '127.0.0.1,::1,::ffff:127.0.0.1').split(',').map((s) => s.trim()).filter(Boolean), + } : null, + }; +} + +if (require.main === module) { + const broker = new RealtimeBroker(loadConfigFromEnv()); + process.on('SIGTERM', () => broker.close(() => process.exit(0))); + process.on('SIGINT', () => broker.close(() => process.exit(0))); + broker.listen(); +} + +module.exports = { RealtimeBroker, safeParseJSON, match, mergeObjects }; diff --git a/server/realtime-broker/package.json b/server/realtime-broker/package.json new file mode 100644 index 0000000..6880a7b --- /dev/null +++ b/server/realtime-broker/package.json @@ -0,0 +1,16 @@ +{ + "name": "web-app-starter-realtime-broker", + "version": "0.1.0", + "private": true, + "description": "Vendored WebSocket-to-PHP realtime broker for web-app-starter", + "main": "broker.js", + "license": "MIT", + "scripts": { + "test": "node test-broker.js", + "start": "node broker.js" + }, + "dependencies": {}, + "vendoredDependencies": { + "ws": "8.14.2" + } +} diff --git a/server/realtime-broker/test-broker.js b/server/realtime-broker/test-broker.js new file mode 100644 index 0000000..7308d78 --- /dev/null +++ b/server/realtime-broker/test-broker.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const http = require('http'); +const querystring = require('querystring'); +const { WebSocket } = require('./vendor/ws'); +const { RealtimeBroker } = require('./broker'); + +function listen(server, host = '127.0.0.1') { + return new Promise((resolve) => server.listen(0, host, () => resolve(server.address().port))); +} + +function close(server) { + return new Promise((resolve) => server.close(resolve)); +} + +function request(options, body = '') { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: data })); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +function wsOpen(url, headers = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + ws.once('open', () => resolve(ws)); + ws.once('error', reject); + }); +} + +function nextMessage(ws) { + return new Promise((resolve) => ws.once('message', (data) => resolve(JSON.parse(data.toString())))); +} + +(async () => { + const backendEvents = []; + const backend = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const params = querystring.parse(body); + const message = JSON.parse(params.message || '{}'); + const connection = JSON.parse(params.connection || '{}'); + backendEvents.push({ message, connection }); + const commands = []; + if (message.type === 'session-connect') { + commands.push({ type: 'session', data: { room: 'demo', account: connection.starter_session || 'none' } }); + commands.push({ type: 'send', message: { type: 'welcome', room: 'demo' } }); + } + if (message.type === 'client-echo') { + commands.push({ type: 'send', match: { room: 'demo' }, message: { type: 'echo', text: message.text, account: connection.account } }); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(commands)); + }); + }); + const backendPort = await listen(backend); + + const broker = new RealtimeBroker({ + host: '127.0.0.1', + port: 0, + backend: { type: 'http', url: `http://127.0.0.1:${backendPort}/backend`, allow: ['127.0.0.1'] }, + commandToken: 'test-token', + keepaliveMs: 0, + }); + await new Promise((resolve) => broker.server.listen(0, '127.0.0.1', resolve)); + const brokerPort = broker.server.address().port; + + const ws = await wsOpen(`ws://127.0.0.1:${brokerPort}/live/`, { Cookie: 'starter_session=abc123' }); + const welcome = await nextMessage(ws); + assert.deepStrictEqual(welcome, { type: 'welcome', room: 'demo' }); + assert.strictEqual(backendEvents[0].message.type, 'session-connect'); + assert.strictEqual(backendEvents[0].connection.starter_session, 'abc123'); + + const echoPromise = nextMessage(ws); + ws.send(JSON.stringify({ type: 'echo', text: 'hello' })); + const echo = await echoPromise; + assert.strictEqual(echo.type, 'echo'); + assert.strictEqual(echo.text, 'hello'); + assert.strictEqual(echo.account, 'abc123'); + + const commandBody = querystring.stringify({ + token: 'test-token', + data: JSON.stringify([{ type: 'send', match: { room: 'demo' }, message: { type: 'command', ok: true } }]), + }); + const commandMsgPromise = nextMessage(ws); + const commandResponse = await request({ + hostname: '127.0.0.1', + port: brokerPort, + path: '/', + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(commandBody) }, + }, commandBody); + assert.strictEqual(commandResponse.statusCode, 200); + const commandMsg = await commandMsgPromise; + assert.deepStrictEqual(commandMsg, { type: 'command', ok: true }); + + const denied = await request({ hostname: '127.0.0.1', port: brokerPort, path: '/', method: 'POST' }, 'data=[]'); + assert.strictEqual(denied.statusCode, 403); + + const status = await request({ hostname: '127.0.0.1', port: brokerPort, path: '/status', method: 'GET' }); + assert.strictEqual(status.statusCode, 200); + assert.strictEqual(JSON.parse(status.body).connections_active, 1); + + ws.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => broker.close(resolve)); + await close(backend); + console.log('Realtime broker tests passed.'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/realtime-broker/vendor/ws/LICENSE b/server/realtime-broker/vendor/ws/LICENSE new file mode 100755 index 0000000..1da5b96 --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/README.md b/server/realtime-broker/vendor/ws/README.md new file mode 100755 index 0000000..a550ca1 --- /dev/null +++ b/server/realtime-broker/vendor/ws/README.md @@ -0,0 +1,536 @@ +# 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 back end with the role of a client in the WebSocket +communication. Browser clients must use the native +[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +object. To make the same code work seamlessly on Node.js and the browser, you +can use one of the many wrappers available on npm, like +[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws). + +## Table of Contents + +- [Protocol support](#protocol-support) +- [Installing](#installing) + - [Opt-in for performance](#opt-in-for-performance) +- [API docs](#api-docs) +- [WebSocket compression](#websocket-compression) +- [Usage examples](#usage-examples) + - [Sending and receiving text data](#sending-and-receiving-text-data) + - [Sending binary data](#sending-binary-data) + - [Simple server](#simple-server) + - [External HTTP/S server](#external-https-server) + - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) + - [Client authentication](#client-authentication) + - [Server broadcast](#server-broadcast) + - [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 + +There are 2 optional modules that can be installed along side with the ws +module. These modules are binary addons that improve the performance of certain +operations. Prebuilt binaries are available for the most popular platforms so +you don't necessarily need to have a C++ compiler installed on your machine. + +- `npm install --save-optional bufferutil`: Allows to efficiently perform + operations such as masking and unmasking the data payload of the WebSocket + frames. +- `npm install --save-optional utf-8-validate`: Allows to efficiently check if a + message contains valid UTF-8. + +To not even try to require and use these modules, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) and +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment +variables. These might 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. + +The `utf-8-validate` module is not needed and is not required, even if it is +already installed, regardless of the value of the `WS_NO_UTF_8_VALIDATE` +environment variable, if [`buffer.isUtf8()`][] is available. + +## 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 { parse } from 'url'; +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 } = parse(request.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 +[changelog]: https://github.com/websockets/ws/releases +[client-report]: http://websockets.github.io/ws/autobahn/clients/ +[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent +[node-zlib-bug]: https://github.com/nodejs/node/issues/8871 +[node-zlib-deflaterawdocs]: + https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options +[permessage-deflate]: https://tools.ietf.org/html/rfc7692 +[server-report]: http://websockets.github.io/ws/autobahn/servers/ +[session-parse-example]: ./examples/express-session-parse +[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent +[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback diff --git a/server/realtime-broker/vendor/ws/browser.js b/server/realtime-broker/vendor/ws/browser.js new file mode 100755 index 0000000..ca4f628 --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/index.js b/server/realtime-broker/vendor/ws/index.js new file mode 100755 index 0000000..41edb3b --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/buffer-util.js b/server/realtime-broker/vendor/ws/lib/buffer-util.js new file mode 100755 index 0000000..f7536e2 --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/constants.js b/server/realtime-broker/vendor/ws/lib/constants.js new file mode 100755 index 0000000..d691b30 --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/constants.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = { + BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + EMPTY_BUFFER: Buffer.alloc(0), + GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), + kListener: Symbol('kListener'), + kStatusCode: Symbol('status-code'), + kWebSocket: Symbol('websocket'), + NOOP: () => {} +}; diff --git a/server/realtime-broker/vendor/ws/lib/event-target.js b/server/realtime-broker/vendor/ws/lib/event-target.js new file mode 100755 index 0000000..fea4cbc --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/extension.js b/server/realtime-broker/vendor/ws/lib/extension.js new file mode 100755 index 0000000..3d7895c --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/limiter.js b/server/realtime-broker/vendor/ws/lib/limiter.js new file mode 100755 index 0000000..3fd3578 --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/permessage-deflate.js b/server/realtime-broker/vendor/ws/lib/permessage-deflate.js new file mode 100755 index 0000000..77d918b --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/permessage-deflate.js @@ -0,0 +1,514 @@ +'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); + this.reset(); +} + +/** + * The listener of the `zlib.InflateRaw` stream `'error'` event. + * + * @param {Error} err The emitted error + * @private + */ +function inflateOnError(err) { + // + // There is no need to call `Zlib#close()` as the handle is automatically + // closed when an error is emitted. + // + this[kPerMessageDeflate]._inflate = null; + err[kStatusCode] = 1007; + this[kCallback](err); +} diff --git a/server/realtime-broker/vendor/ws/lib/receiver.js b/server/realtime-broker/vendor/ws/lib/receiver.js new file mode 100755 index 0000000..1d425ea --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/receiver.js @@ -0,0 +1,679 @@ +'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 promise = Promise.resolve(); + +// +// `queueMicrotask()` is not available in Node.js < 11. +// +const queueTask = + typeof queueMicrotask === 'function' ? queueMicrotask : queueMicrotaskShim; + +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 WAIT_MICROTASK = 6; + +/** + * HyBi Receiver implementation. + * + * @extends Writable + */ +class Receiver extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @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._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._state = GET_INFO; + this._loop = false; + } + + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); + + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + + if (n === this._buffers[0].length) return this._buffers.shift(); + + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = 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) { + let err; + this._loop = true; + + do { + switch (this._state) { + case GET_INFO: + err = this.getInfo(); + break; + case GET_PAYLOAD_LENGTH_16: + err = this.getPayloadLength16(); + break; + case GET_PAYLOAD_LENGTH_64: + err = this.getPayloadLength64(); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + err = this.getData(cb); + break; + case INFLATING: + this._loop = false; + return; + default: + // + // `WAIT_MICROTASK`. + // + this._loop = false; + + queueTask(() => { + this._state = GET_INFO; + this.startLoop(cb); + }); + return; + } + } while (this._loop); + + cb(err); + } + + /** + * Reads the first two bytes of a frame. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getInfo() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + const buf = this.consume(2); + + if ((buf[0] & 0x30) !== 0x00) { + this._loop = false; + return error( + RangeError, + 'RSV2 and RSV3 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_2_3' + ); + } + + const compressed = (buf[0] & 0x40) === 0x40; + + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + this._fin = (buf[0] & 0x80) === 0x80; + this._opcode = buf[0] & 0x0f; + this._payloadLength = buf[1] & 0x7f; + + if (this._opcode === 0x00) { + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if (!this._fragmented) { + this._loop = false; + return error( + RangeError, + 'invalid opcode 0', + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._opcode = this._fragmented; + } else if (this._opcode === 0x01 || this._opcode === 0x02) { + if (this._fragmented) { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._compressed = compressed; + } else if (this._opcode > 0x07 && this._opcode < 0x0b) { + if (!this._fin) { + this._loop = false; + return error( + RangeError, + 'FIN must be set', + true, + 1002, + 'WS_ERR_EXPECTED_FIN' + ); + } + + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if ( + this._payloadLength > 0x7d || + (this._opcode === 0x08 && this._payloadLength === 1) + ) { + this._loop = false; + return error( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + } + } else { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 0x80) === 0x80; + + if (this._isServer) { + if (!this._masked) { + this._loop = false; + return error( + RangeError, + 'MASK must be set', + true, + 1002, + 'WS_ERR_EXPECTED_MASK' + ); + } + } else if (this._masked) { + this._loop = false; + return error( + RangeError, + 'MASK must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_MASK' + ); + } + + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else return this.haveLength(); + } + + /** + * Gets extended payload length (7+16). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength16() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + this._payloadLength = this.consume(2).readUInt16BE(0); + return this.haveLength(); + } + + /** + * Gets extended payload length (7+64). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength64() { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + + // + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + // if payload length is greater than this number. + // + if (num > Math.pow(2, 53 - 32) - 1) { + this._loop = false; + return error( + RangeError, + 'Unsupported WebSocket frame: payload length > 2^53 - 1', + false, + 1009, + 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' + ); + } + + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + return this.haveLength(); + } + + /** + * Payload length has been read. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + haveLength() { + if (this._payloadLength && this._opcode < 0x08) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + this._loop = false; + return error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + } + } + + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + + this._mask = this.consume(4); + this._state = GET_DATA; + } + + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + + data = this.consume(this._payloadLength); + + if ( + this._masked && + (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0 + ) { + unmask(data, this._mask); + } + } + + if (this._opcode > 0x07) return this.controlMessage(data); + + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + + if (data.length) { + // + // This message is not compressed so its length is the sum of the payload + // length of all fragments. + // + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + + return this.dataMessage(); + } + + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + return cb( + error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ) + ); + } + + this._fragments.push(buf); + } + + const er = this.dataMessage(); + if (er) return cb(er); + + this.startLoop(cb); + }); + } + + /** + * Handles a data message. + * + * @return {(Error|undefined)} A possible error + * @private + */ + dataMessage() { + if (this._fin) { + const messageLength = this._messageLength; + const fragments = this._fragments; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + + if (this._opcode === 2) { + let data; + + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else { + data = fragments; + } + + this.emit('message', data, true); + } else { + const buf = concat(fragments, messageLength); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + this._loop = false; + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('message', buf, false); + } + } + + this._state = WAIT_MICROTASK; + } + + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data) { + if (this._opcode === 0x08) { + this._loop = false; + + if (data.length === 0) { + this.emit('conclude', 1005, EMPTY_BUFFER); + this.end(); + + this._state = GET_INFO; + } else { + const code = data.readUInt16BE(0); + + if (!isValidStatusCode(code)) { + return error( + RangeError, + `invalid status code ${code}`, + true, + 1002, + 'WS_ERR_INVALID_CLOSE_CODE' + ); + } + + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('conclude', code, buf); + this.end(); + + this._state = GET_INFO; + } + } else if (this._opcode === 0x09) { + this.emit('ping', data); + this._state = WAIT_MICROTASK; + } else { + this.emit('pong', data); + this._state = WAIT_MICROTASK; + } + } +} + +module.exports = Receiver; + +/** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ +function error(ErrorCtor, message, prefix, statusCode, errorCode) { + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + + Error.captureStackTrace(err, error); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; +} + +/** + * A shim for `queueMicrotask()`. + * + * @param {Function} cb Callback + */ +function queueMicrotaskShim(cb) { + promise.then(cb).catch(throwErrorNextTick); +} + +/** + * Throws an error. + * + * @param {Error} err The error to throw + * @private + */ +function throwError(err) { + throw err; +} + +/** + * Throws an error in the next tick. + * + * @param {Error} err The error to throw + * @private + */ +function throwErrorNextTick(err) { + process.nextTick(throwError, err); +} diff --git a/server/realtime-broker/vendor/ws/lib/sender.js b/server/realtime-broker/vendor/ws/lib/sender.js new file mode 100755 index 0000000..1ed04b0 --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/sender.js @@ -0,0 +1,477 @@ +/* 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 } = require('./constants'); +const { isValidStatusCode } = require('./validation'); +const { mask: applyMask, toBuffer } = require('./buffer-util'); + +const kByteLength = Symbol('kByteLength'); +const maskBuffer = Buffer.alloc(4); + +/** + * 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._deflating = false; + this._queue = []; + } + + /** + * 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 { + randomFillSync(mask, 0, 4); + } + + 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._deflating) { + 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 { + 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 (this._deflating) { + 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 { + 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 (this._deflating) { + 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 { + 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; + + if (perMessageDeflate) { + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } else { + this.sendFrame( + Sender.frame(data, { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1: false + }), + 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._deflating = true; + perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while data was being compressed' + ); + + if (typeof cb === 'function') cb(err); + + for (let i = 0; i < this._queue.length; i++) { + const params = this._queue[i]; + const callback = params[params.length - 1]; + + if (typeof callback === 'function') callback(err); + } + + return; + } + + this._bufferedBytes -= options[kByteLength]; + this._deflating = false; + options.readOnly = false; + this.sendFrame(Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (!this._deflating && this._queue.length) { + const params = this._queue.shift(); + + this._bufferedBytes -= params[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[]} 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; diff --git a/server/realtime-broker/vendor/ws/lib/stream.js b/server/realtime-broker/vendor/ws/lib/stream.js new file mode 100755 index 0000000..230734b --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/stream.js @@ -0,0 +1,159 @@ +'use strict'; + +const { Duplex } = require('stream'); + +/** + * Emits the `'close'` event on a stream. + * + * @param {Duplex} stream The stream. + * @private + */ +function emitClose(stream) { + stream.emit('close'); +} + +/** + * The listener of the `'end'` event. + * + * @private + */ +function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } +} + +/** + * The listener of the `'error'` event. + * + * @param {Error} err The error + * @private + */ +function duplexOnError(err) { + this.removeListener('error', duplexOnError); + this.destroy(); + if (this.listenerCount('error') === 0) { + // Do not suppress the throwing behavior. + this.emit('error', err); + } +} + +/** + * Wraps a `WebSocket` in a duplex stream. + * + * @param {WebSocket} ws The `WebSocket` to wrap + * @param {Object} [options] The options for the `Duplex` constructor + * @return {Duplex} The duplex stream + * @public + */ +function createWebSocketStream(ws, options) { + let 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/server/realtime-broker/vendor/ws/lib/subprotocol.js b/server/realtime-broker/vendor/ws/lib/subprotocol.js new file mode 100755 index 0000000..d4381e8 --- /dev/null +++ b/server/realtime-broker/vendor/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/server/realtime-broker/vendor/ws/lib/validation.js b/server/realtime-broker/vendor/ws/lib/validation.js new file mode 100755 index 0000000..c352e6e --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/validation.js @@ -0,0 +1,130 @@ +'use strict'; + +const { isUtf8 } = require('buffer'); + +// +// 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; +} + +module.exports = { + 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/server/realtime-broker/vendor/ws/lib/websocket-server.js b/server/realtime-broker/vendor/ws/lib/websocket-server.js new file mode 100755 index 0000000..b0ed7bd --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/websocket-server.js @@ -0,0 +1,531 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$" }] */ + +'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 { GUID, kWebSocket } = require('./constants'); + +const keyRegex = /^[+/0-9A-Za-z]{22}==$/; + +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; + +/** + * Class representing a WebSocket server. + * + * @extends EventEmitter + */ +class WebSocketServer extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {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 = { + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + verifyClient: null, + noServer: false, + backlog: null, // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + 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 version = +req.headers['sec-websocket-version']; + + if (req.method !== 'GET') { + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + + if (req.headers.upgrade.toLowerCase() !== 'websocket') { + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (!key || !keyRegex.test(key)) { + const message = 'Missing or invalid Sec-WebSocket-Key header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (version !== 8 && version !== 13) { + const message = 'Missing or invalid Sec-WebSocket-Version header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + 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); + + 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, { + 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 + * @private + */ +function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message); + } +} diff --git a/server/realtime-broker/vendor/ws/lib/websocket.js b/server/realtime-broker/vendor/ws/lib/websocket.js new file mode 100755 index 0000000..8685ff7 --- /dev/null +++ b/server/realtime-broker/vendor/ws/lib/websocket.js @@ -0,0 +1,1319 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$" }] */ + +'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 { + BINARY_TYPES, + 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 closeTimeout = 30 * 1000; +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._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._isServer = true; + } + } + + /** + * This deviates from the WHATWG interface since ws doesn't support the + * required default "blob" type (instead we define a custom "nodebuffer" + * type). + * + * @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 {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({ + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + this._sender = new Sender(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._socket = socket; + + receiver[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); + + // + // 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(); + } + }); + + // + // Specify a timeout for the closing handshake to complete. + // + this._closeTimer = setTimeout( + this._socket.destroy.bind(this._socket), + closeTimeout + ); + } + + /** + * 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.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 = { + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + createConnection: undefined, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + 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 = 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; + + if (res.headers.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, { + 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; + 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 = 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]); + } + + 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]; + + websocket.pong(data, !websocket._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 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; + + let chunk; + + // + // 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 and `readable.read()` + // will return `null`. 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 && + (chunk = websocket._socket.read()) !== null + ) { + 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/server/realtime-broker/vendor/ws/package.json b/server/realtime-broker/vendor/ws/package.json new file mode 100755 index 0000000..107c188 --- /dev/null +++ b/server/realtime-broker/vendor/ws/package.json @@ -0,0 +1,68 @@ +{ + "name": "ws", + "version": "8.14.2", + "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 --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "devDependencies": { + "benchmark": "^2.1.4", + "bufferutil": "^4.0.1", + "eslint": "^8.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "mocha": "^8.4.0", + "nyc": "^15.0.0", + "prettier": "^3.0.0", + "utf-8-validate": "^6.0.0" + } +} diff --git a/server/realtime-broker/vendor/ws/wrapper.mjs b/server/realtime-broker/vendor/ws/wrapper.mjs new file mode 100755 index 0000000..7245ad1 --- /dev/null +++ b/server/realtime-broker/vendor/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/tests/components/callable-fixture.php b/tests/components/callable-fixture.php new file mode 100644 index 0000000..b8eb9ff --- /dev/null +++ b/tests/components/callable-fixture.php @@ -0,0 +1,5 @@ +callable fixture function($prop) { + return 'custom fixture '.safe($prop['id']); + }, +]; diff --git a/tests/smoke.php b/tests/smoke.php index d9e0df1..34ae149 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -35,16 +35,52 @@ $workspace_route = URL::ResolveViewFile('views'); test_assert($workspace_route !== false && $workspace_route['file'] === 'views/workspace/index.php', 'nested workspace route resolves to parent index'); test_assert(($workspace_route['param'] ?? null) === 'projects', 'nested workspace route exposes the trailing path segment as param'); +reset_request('/web-app-starter/?toolbox'); +$toolbox_route = URL::ResolveViewFile('views'); +test_assert($toolbox_route !== false && $toolbox_route['file'] === 'views/toolbox.php', 'toolbox route resolves to the downstream backport demo'); + +reset_request('/web-app-starter/?realtime-demo'); +$realtime_route = URL::ResolveViewFile('views'); +test_assert($realtime_route !== false && $realtime_route['file'] === 'views/realtime-demo.php', 'realtime demo route resolves'); + +reset_request('/web-app-starter/?api/realtime'); +$realtime_api_route = URL::ResolveViewFile('views'); +test_assert($realtime_api_route !== false && $realtime_api_route['file'] === 'views/api/realtime.php', 'realtime backend API route resolves'); + test_assert(URL::Link('', ['theme' => 'portal-dark']) === '/web-app-starter/?theme=portal-dark', 'root theme link omits the empty route segment'); test_assert(URL::Link('themes', ['theme' => 'portal-dark']) === '/web-app-starter/?themes&theme=portal-dark', 'named route links keep the route segment and query parameters'); test_assert(component_exists('components/example/theme-switcher'), 'component existence works for explicit component paths'); test_assert(component_exists('example/theme-switcher'), 'component existence works for shorthand component paths'); +test_assert(component_exists('callable-fixture'), 'component existence works for caller-relative components directory'); +test_assert(component_exists('basic/action-button'), 'action-button component exists'); +test_assert(component_exists('navigation/tile-grid'), 'tile-grid component exists'); + +$callable_fixture_html = component('callable-fixture'); +test_assert(str_contains($callable_fixture_html, 'callable fixture'), 'ERP-style callable component files render through the default render method'); + +$multi_fixture_html = component_call('multi-fixture', 'custom'); +test_assert(str_contains($multi_fixture_html, 'custom fixture'), 'component_call preserves caller-relative lookup for named render methods'); + +$action_button_html = component('basic/action-button', ['label' => 'Load', 'route' => 'toolbox', 'htmx' => true, 'target' => '#target']); +test_assert(str_contains($action_button_html, 'hx-get=') && str_contains($action_button_html, '#target'), 'action-button renders htmx-shaped route actions'); + +$tile_grid_html = component('navigation/tile-grid', ['items' => [['caption' => 'Themes', 'url' => 'themes']]]); +test_assert(str_contains($tile_grid_html, 'tile-grid__tile') && str_contains($tile_grid_html, 'Themes'), 'tile-grid renders reusable navigation tiles'); + +$theme_switcher_html = component('example/theme-switcher'); +test_assert(str_contains($theme_switcher_html, 'theme-switcher'), 'component rendering returns buffered output through the shorthand component API'); ob_start(); -component('example/theme-switcher'); -$theme_switcher_html = ob_get_clean(); -test_assert(str_contains($theme_switcher_html, 'theme-switcher'), 'component rendering works through the shorthand component API'); +$wrapped_theme_switcher_html = component('example/theme-switcher', [':before' => '', ':after' => '']); +$component_hook_output = ob_get_clean(); +test_assert($wrapped_theme_switcher_html !== '' && $component_hook_output === '', 'component before/after hooks remain compatible with buffered components'); +test_assert(short_id('0x1234567890abcdef', 4, 4) === '0x12…cdef', 'short_id compacts long identifiers'); +test_assert(str_contains(id_token('0x1234567890abcdef'), 'data-copy='), 'id_token renders a copy-friendly identifier token'); +test_assert(str_starts_with(Realtime::live_url('/live/'), 'ws'), 'Realtime::live_url derives a websocket URL'); +$missing_broker_result = Realtime::command([['type' => 'list']]); +test_assert($missing_broker_result['result'] === false || isset($missing_broker_result['status']), 'Realtime::command returns structured status when broker is unavailable or reachable'); +test_assert(Filebase::read_file(__DIR__.'/../tmp/missing-filebase-read-check') === '', 'Filebase::read_file handles missing files as empty content'); $portal_dark = cfg('theme/options/portal-dark'); test_assert(!empty($portal_dark['description']) && !empty($portal_dark['footer_text']), 'theme metadata is centralized in config'); diff --git a/views/api/realtime.php b/views/api/realtime.php new file mode 100644 index 0000000..b9a8399 --- /dev/null +++ b/views/api/realtime.php @@ -0,0 +1,60 @@ + 'invalid'); +if(!is_array($connection)) $connection = array(); + +$type = (string)($message['type'] ?? ''); +$room = (string)first($message['room'] ?? false, $connection['room'] ?? false, 'starter-demo'); +$commands = array(); + +if($type === 'session-connect') +{ + $commands[] = array('type' => 'session', 'data' => array( + 'room' => $room, + 'app' => 'web-app-starter', + )); + $commands[] = array('type' => 'send', 'message' => array( + 'type' => 'realtime.welcome', + 'text' => 'Realtime connection established.', + 'room' => $room, + 'time' => date(DATE_ATOM), + )); +} +else if($type === 'client-ping') +{ + $commands[] = array('type' => 'send', 'message' => array( + 'type' => 'realtime.pong', + 'text' => 'Pong from PHP backend.', + 'time' => date(DATE_ATOM), + )); +} +else if($type === 'client-demo-broadcast') +{ + $text = trim((string)($message['text'] ?? '')); + if($text === '') $text = 'Demo broadcast'; + $commands[] = array('type' => 'send', 'match' => array('room' => $room), 'message' => array( + 'type' => 'realtime.broadcast', + 'text' => $text, + 'room' => $room, + 'time' => date(DATE_ATOM), + )); +} +else if($type === 'session-disconnect') +{ + // No command required. This hook is where an app would update presence. +} +else +{ + $commands[] = array('type' => 'send', 'message' => array( + 'type' => 'realtime.error', + 'error' => 'unknown_message_type', + 'received_type' => $type, + )); +} + +print(json_encode($commands, JSON_UNESCAPED_SLASHES)); diff --git a/views/realtime-demo.php b/views/realtime-demo.php new file mode 100644 index 0000000..6ac8cc7 --- /dev/null +++ b/views/realtime-demo.php @@ -0,0 +1,40 @@ +
+

Realtime Demo

+

This demo uses the optional vendored realtime broker. Start it with the command in server/realtime-broker/README.md, then connect through your nginx /live/ proxy or configured realtime URL.

+ +
+
+ Status: offline + + +
+
+ + +
+
+ +

+
+ + + diff --git a/views/toolbox.php b/views/toolbox.php new file mode 100644 index 0000000..d837b77 --- /dev/null +++ b/views/toolbox.php @@ -0,0 +1,39 @@ +
+

Toolbox Backports

+

Small reusable primitives lifted from downstream applications after generalizing them for the starter.

+ +

Action buttons

+

Inspired by htmx-crud-starter's URL button helper: route-aware buttons with optional confirmation and htmx attributes.

+
+ 'Open Dashboard', + 'route' => 'dashboard', + 'variant' => 'primary', + ]) ?> + 'Confirmable Action', + 'route' => 'toolbox', + 'confirm' => 'This is only a demo. Continue?', + ]) ?> + 'HTMX-shaped Button', + 'route' => 'toolbox', + 'tag' => 'button', + 'htmx' => true, + 'method' => 'get', + 'target' => '#toolbox-demo-target', + 'push_url' => false, + ]) ?> +
+
Target placeholder for htmx-enabled apps.
+ +

Tile grid

+

Inspired by my.kautschuk.com's image tile navigation component.

+ [ + ['caption' => 'Themes', 'description' => 'Compare visual shells', 'url' => 'themes', 'image' => 'img/cat01.jpg'], + ['caption' => 'Workspace', 'description' => 'Admin shell primitives', 'url' => 'workspace', 'image' => 'img/cat02.jpg'], + ['caption' => 'Gauges', 'description' => 'KPI visualization', 'url' => 'gauges', 'image' => 'img/cat03.jpg'], + ], + ]) ?> +