+ '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 '
'.safe($s).'
';
}
+ 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
+
+[](https://www.npmjs.com/package/ws)
+[](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
+[](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 = safe($prop['id']) ?> 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.