Add optional realtime broker module

This commit is contained in:
udo committed 2026-05-22 10:01:27 +00:00
1 parent 072a5fa490
commit adae24db2e
41 files changed
+6823 -75

No files matched your search

+22 -1
View File
@@ -85,6 +85,26 @@ The default starter convention is to keep reusable components in `components/`.
The older `component-name:method` shorthand is still supported for backwards compatibility, but `component_call()` or an explicit `render_call` prop is the preferred API because it is clearer and easier to grep.
Downstream backports from ERP/Homeoverlord also make the loader tolerant of component files that return a single callable and/or print markup instead of returning it: printed output is buffered and returned from `component()`, so components can safely be nested or assigned into variables. Caller-relative search now uses the actual caller frame and also checks caller `components/`, `interface/`, and `packages/`-style locations.
## Optional Realtime Broker
The starter includes an optional vendored WebSocket-to-PHP broker under `server/realtime-broker/`.
- `server/realtime-broker/broker.js` holds browser WebSocket connections and calls a PHP backend endpoint for app logic.
- `server/realtime-broker/vendor/ws/` vendors `ws` 8.14.2 (MIT), so normal starter use does not require a fresh npm install.
- `lib/realtime.class.php` sends backend-initiated commands such as broadcasts and connection lists.
- `views/api/realtime.php` and `views/realtime-demo.php` provide a working demo.
- Full setup, nginx, systemd, and protocol docs live in `docs/realtime.md`.
## Downstream Toolbox Primitives
The starter includes a few generalized primitives learned from downstream apps:
- `components/basic/action-button.php` from `htmx-crud-starter`'s `UI::url_button()`: route-aware actions with confirmation and optional htmx-shaped attributes.
- `components/navigation/tile-grid.php` from `my.kautschuk.com`'s tile-select UI: responsive image/card navigation tiles.
- `views/toolbox.php` demonstrates these small primitives.
## Dashboard Primitives
The starter now includes a small set of dashboard-focused building blocks backported from a more complex production frontend:
@@ -182,10 +202,11 @@ Profiler logs are not committed to disk. Log data for the current request is sto
## Batteries included
```
- lib/ulib.php - convenience functions
- lib/ulib.php - convenience functions, including `short_id()` and `id_token()` for compact copy-friendly identifiers
- lib/profiler.class.php - profiling
- lib/log.class.php - logging
- lib/url.class.php - URL routing
- lib/realtime.class.php - optional WebSocket broker command helper
- lib/components.php - component rendering
- lib/odt.class.php - ODT document generator
- lib/db.class.php - database access
+96
View File
@@ -0,0 +1,96 @@
<?php return [
'render' => function($prop) {
static $assets_loaded = false;
$label = first($prop['label'] ?? false, $prop['text'] ?? false, $prop['caption'] ?? false, 'Action');
$route = array_key_exists('route', $prop) ? $prop['route'] : ($prop['url'] ?? '');
$params = is_array($prop['params'] ?? false) ? $prop['params'] : array();
$class = trim('action-button '.(string)first($prop['class'] ?? false, ''));
$variant = preg_replace('/[^a-z0-9_-]/i', '', (string)first($prop['variant'] ?? false, ''));
if($variant !== '') $class .= ' action-button--'.$variant;
$href = $route !== false ? URL::Link((string)$route, $params) : '';
$tag = strtolower((string)first($prop['tag'] ?? false, $href !== '' ? 'a' : 'button'));
if(!in_array($tag, array('a', 'button'), true)) $tag = 'button';
$method = strtolower((string)first($prop['method'] ?? false, 'get'));
$confirm = (string)first($prop['confirm'] ?? false, '');
$title = (string)first($prop['title'] ?? false, $prop['hint'] ?? false, '');
$attrs = array(
'class' => $class,
);
if($title !== '') $attrs['title'] = $title;
if($confirm !== '') $attrs['data-confirm'] = $confirm;
if($tag === 'a' && $href !== '') $attrs['href'] = $href;
else $attrs['type'] = first($prop['type'] ?? false, 'button');
if(!empty($prop['disabled'])) $attrs['disabled'] = 'disabled';
if(!empty($prop['htmx']))
{
$attrs[$method === 'post' ? 'hx-post' : 'hx-get'] = $href;
if($confirm !== '') $attrs['hx-confirm'] = $confirm;
if(!empty($prop['target'])) $attrs['hx-target'] = (string)$prop['target'];
if(!empty($prop['swap'])) $attrs['hx-swap'] = (string)$prop['swap'];
if(array_key_exists('push_url', $prop)) $attrs['hx-push-url'] = $prop['push_url'] ? 'true' : 'false';
if(!empty($prop['vals']) && is_array($prop['vals'])) $attrs['hx-vals'] = json_encode($prop['vals']);
}
else if($href !== '' && $tag !== 'a')
{
$attrs['data-href'] = $href;
}
$attr_html = '';
foreach($attrs as $name => $value)
$attr_html .= ' '.asafe($name).'="'.asafe((string)$value).'"';
ob_start();
if(!$assets_loaded)
{
$assets_loaded = true;
?>
<style>
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .45rem;
min-height: 2.25rem;
padding: .5rem .8rem;
border: 1px solid var(--border, rgba(0,0,0,.18));
border-radius: .5rem;
background: var(--surface-elevated, #fff);
color: var(--text-primary, #111827);
font: inherit;
font-weight: 650;
line-height: 1.1;
text-decoration: none;
cursor: pointer;
}
.action-button:hover { border-color: var(--primary, #2563eb); text-decoration: none; }
.action-button--primary { background: var(--primary, #2563eb); border-color: var(--primary, #2563eb); color: #fff; }
.action-button--danger { background: #b91c1c; border-color: #991b1b; color: #fff; }
.action-button[disabled] { opacity: .55; cursor: default; }
</style>
<script>
(function() {
if(window.StarterActionButton) return;
window.StarterActionButton = true;
document.addEventListener('click', function(event) {
var button = event.target.closest('.action-button');
if(!button) return;
var message = button.getAttribute('data-confirm');
if(message && !window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
return false;
}
var href = button.getAttribute('data-href');
if(href) document.location.href = href;
});
})();
</script>
<?php
}
?> <<?= $tag ?><?= $attr_html ?>><?= safe($label) ?></<?= $tag ?>><?php
return ob_get_clean();
},
'about' => 'Reusable link/button action primitive inspired by htmx-crud-starter UI::url_button.',
];
+83
View File
@@ -0,0 +1,83 @@
<?php return [
'render' => function($prop) {
static $assets_loaded = false;
$items = is_array($prop['items'] ?? false) ? $prop['items'] : array();
$class = trim('tile-grid '.(string)first($prop['class'] ?? false, ''));
$columns = (string)first($prop['columns'] ?? false, 'repeat(auto-fit, minmax(180px, 1fr))');
$empty = (string)first($prop['empty'] ?? false, 'No tiles available.');
ob_start();
if(!$assets_loaded)
{
$assets_loaded = true;
?>
<style>
.tile-grid {
display: grid;
grid-template-columns: var(--tile-grid-columns, repeat(auto-fit, minmax(180px, 1fr)));
gap: 1rem;
margin: 1rem 0;
}
.tile-grid__tile {
position: relative;
display: flex;
min-height: 150px;
align-items: end;
overflow: hidden;
border: 1px solid var(--border, rgba(0,0,0,.16));
border-radius: 1rem;
background: linear-gradient(135deg, color-mix(in srgb, var(--primary, #2563eb) 24%, transparent), color-mix(in srgb, var(--surface-elevated, #fff) 80%, transparent));
background-position: center;
background-size: cover;
box-shadow: var(--shadow, 0 10px 24px rgba(0,0,0,.12));
color: #fff;
text-decoration: none;
}
.tile-grid__tile:hover { transform: translateY(-1px); text-decoration: none; }
.tile-grid__tile::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,.08), rgba(0,0,0,.66));
}
.tile-grid__caption {
position: relative;
z-index: 1;
width: 100%;
padding: .85rem 1rem;
font-weight: 750;
text-shadow: 0 1px 8px rgba(0,0,0,.65);
}
.tile-grid__description {
display: block;
margin-top: .2rem;
font-size: .82rem;
font-weight: 550;
opacity: .86;
}
</style>
<?php
}
?>
<div class="<?= asafe($class) ?>" style="--tile-grid-columns: <?= asafe($columns) ?>;">
<?php if(!$items) { ?>
<div class="tile-grid__empty"><?= safe($empty) ?></div>
<?php } ?>
<?php foreach($items as $item) {
$url = (string)first($item['url'] ?? false, $item['route'] ?? false, '');
$params = is_array($item['params'] ?? false) ? $item['params'] : array();
$href = $url !== '' ? URL::Link($url, $params) : '#';
$image = (string)first($item['image'] ?? false, '');
$caption = (string)first($item['caption'] ?? false, $item['title'] ?? false, $url);
$description = (string)first($item['description'] ?? false, '');
$style = $image !== '' ? ' style="background-image: url(\''.asafe($image).'\');"' : '';
?>
<a class="tile-grid__tile" href="<?= asafe($href) ?>"<?= $style ?>>
<span class="tile-grid__caption"><?= safe($caption) ?><?php if($description !== '') { ?><span class="tile-grid__description"><?= safe($description) ?></span><?php } ?></span>
</a>
<?php } ?>
</div>
<?php
return ob_get_clean();
},
'about' => 'Responsive image/card navigation tiles inspired by my.kautschuk.com tile-select.',
];
+9
View File
@@ -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',),
),
+72
View File
@@ -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`
+130
View File
@@ -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=<json>`
- `connection=<json>`
- 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=<json>` 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=<json>`, 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.
+193
View File
@@ -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
<?php
$message = json_decode($_POST['message'] ?? '{}', true);
$connection = json_decode($_POST['connection'] ?? '{}', true);
print(json_encode([
['type' => '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=<token>` form field
- `Authorization: Bearer <token>`
- `X-Realtime-Token: <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.
+78 -63
View File
@@ -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;
}));
+71 -8
View File
@@ -31,10 +31,39 @@
return '<div class="banner">'.safe($s).'</div>';
}
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);
+2
View File
@@ -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('');
+99
View File
@@ -0,0 +1,99 @@
<?php
class Realtime
{
static function enabled()
{
return (bool)cfg('realtime/enabled');
}
static function broker_url()
{
return rtrim((string)cfg('realtime/broker_url'), '/');
}
static function command_token()
{
return (string)cfg('realtime/command_token');
}
static function live_url($path = '/live/')
{
$path = '/'.ltrim((string)$path, '/');
$base = (string)cfg('realtime/live_url');
if($base !== '') return $base;
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
$host = first($_SERVER['HTTP_HOST'] ?? false, 'localhost');
return $scheme.'://'.$host.rtrim(url_root(), '/').$path;
}
static function command($commands)
{
if(!is_array($commands)) $commands = array($commands);
$url = self::broker_url();
if($url === '')
return array('result' => 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));
}
}
+17
View File
@@ -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 '<span class="id-token" title="'.asafe($value).'" data-copy="'.asafe($value).'"><span class="id-token-text">'.safe($label).'</span><button type="button" class="copy-token" data-copy="'.asafe($value).'" aria-label="Copy '.asafe($value).'">Copy</button></span>';
}
function clamp($v, $min, $max)
{
if($v < $min) return $min;
+50
View File
@@ -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=<json-array>`
- `token=<REALTIME_COMMAND_TOKEN>` or `Authorization: Bearer <token>` or `X-Realtime-Token: <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.
+359
View File
@@ -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 };
+16
View File
@@ -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"
}
}
+121
View File
@@ -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);
});
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+536
View File
@@ -0,0 +1,536 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a back end with the role of a client in the WebSocket
communication. Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [Round-trip time](#round-trip-time)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
There are 2 optional modules that can be installed along side with the ws
module. These modules are binary addons that improve the performance of certain
operations. Prebuilt binaries are available for the most popular platforms so
you don't necessarily need to have a C++ compiler installed on your machine.
- `npm install --save-optional bufferutil`: Allows to efficiently perform
operations such as masking and unmasking the data payload of the WebSocket
frames.
- `npm install --save-optional utf-8-validate`: Allows to efficiently check if a
message contains valid UTF-8.
To not even try to require and use these modules, use the
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) and
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment
variables. These might be useful to enhance security in systems where a user can
put a package in the package search path of an application of another user, due
to how the Node.js resolver algorithm works.
The `utf-8-validate` module is not needed and is not required, even if it is
already installed, regardless of the value of the `WS_NO_UTF_8_VALIDATE`
environment variable, if [`buffer.isUtf8()`][] is available.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client set the
`perMessageDeflate` option to `false`.
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function message(data) {
console.log('received: %s', data);
});
```
### Sending binary data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
```
### External HTTP/S server
```js
import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/path/to/cert.pem'),
key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
import { createServer } from 'http';
import { parse } from 'url';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = parse(request.url);
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
function onSocketError(err) {
console.error(err);
}
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log(`Received message ${data} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
socket.on('error', onSocketError);
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
socket.removeListener('error', onSocketError);
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
### Round-trip time
```js
import WebSocket from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
ws.on('error', console.error);
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function message(data) {
console.log(`Round-trip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
import WebSocket, { createWebSocketStream } from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
duplex.on('error', console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
ws.on('error', console.error);
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
ws.on('error', console.error);
});
```
### How to detect and close broken connections?
Sometimes the link between the server and the client can be interrupted in a way
that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
import { WebSocketServer } from 'ws';
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('error', console.error);
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
import WebSocket from 'ws';
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://websocket-echo.com/');
client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback
+8
View File
@@ -0,0 +1,8 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};
+13
View File
@@ -0,0 +1,13 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket;
+131
View File
@@ -0,0 +1,131 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}
+12
View File
@@ -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: () => {}
};
+292
View File
@@ -0,0 +1,292 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}
+203
View File
@@ -0,0 +1,203 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;
+514
View File
@@ -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);
}
+679
View File
@@ -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);
}
+477
View File
@@ -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;
+159
View File
@@ -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;
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };
+130
View File
@@ -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.
}
}
+531
View File
@@ -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 <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @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);
}
}
+1319
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -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 <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"exports": {
".": {
"browser": "./browser.js",
"import": "./wrapper.mjs",
"require": "./index.js"
},
"./package.json": "./package.json"
},
"browser": "browser.js",
"engines": {
"node": ">=10.0.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js",
"wrapper.mjs"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint --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"
}
}
+8
View File
@@ -0,0 +1,8 @@
import createWebSocketStream from './lib/stream.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
export default WebSocket;
+5
View File
@@ -0,0 +1,5 @@
<?php
return function($prop) {
?>callable fixture <?= safe($prop['id']) ?><?php
};
+5
View File
@@ -0,0 +1,5 @@
<?php return [
'custom' => function($prop) {
return 'custom fixture '.safe($prop['id']);
},
];
+39 -3
View File
@@ -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' => '<b>', ':after' => '</b>']);
$component_hook_output = ob_get_clean();
test_assert($wrapped_theme_switcher_html !== '' && $component_hook_output === '<b></b>', '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');
+60
View File
@@ -0,0 +1,60 @@
<?php
URL::$page_type = 'json';
URL::$route['content-type'] = 'json';
$message = json_decode((string)($_POST['message'] ?? '{}'), true);
$connection = json_decode((string)($_POST['connection'] ?? '{}'), true);
if(!is_array($message)) $message = array('type' => '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));
+40
View File
@@ -0,0 +1,40 @@
<section class="section">
<h1>Realtime Demo</h1>
<p>This demo uses the optional vendored realtime broker. Start it with the command in <code>server/realtime-broker/README.md</code>, then connect through your nginx <code>/live/</code> proxy or configured realtime URL.</p>
<div class="card" style="padding:1rem; margin:1rem 0;">
<div style="display:flex; gap:.75rem; flex-wrap:wrap; align-items:center;">
<strong>Status:</strong> <span id="connection-status">offline</span>
<button type="button" id="realtime-connect">Connect</button>
<button type="button" id="realtime-ping">Ping PHP</button>
</div>
<form id="realtime-form" style="display:flex; gap:.5rem; margin-top:1rem; flex-wrap:wrap;">
<input id="realtime-text" type="text" value="Hello realtime clients" style="flex:1; min-width:220px;">
<button type="submit">Broadcast</button>
</form>
</div>
<pre id="realtime-log" class="code" style="min-height:180px; white-space:pre-wrap;"></pre>
</section>
<script src="<?= url_root() ?>js/u-events.js"></script>
<script src="<?= url_root() ?>js/u-wsconnection.js"></script>
<script>
(function() {
var liveUrl = <?= json_encode(Realtime::live_url('/live/')) ?>;
var logNode = document.getElementById('realtime-log');
function log(message, data) {
logNode.textContent += '[' + new Date().toLocaleTimeString() + '] ' + message + (data ? ' ' + JSON.stringify(data) : '') + '\n';
logNode.scrollTop = logNode.scrollHeight;
}
Connection.debug = true;
Connection.on('open', function() { log('connected', { url: liveUrl }); });
Connection.on('close', function() { log('closed'); });
Connection.on('message', function(message) { log('message', message); });
document.getElementById('realtime-connect').addEventListener('click', function() { Connection.start(liveUrl); });
document.getElementById('realtime-ping').addEventListener('click', function() { Connection.send({ type: 'ping' }); });
document.getElementById('realtime-form').addEventListener('submit', function(event) {
event.preventDefault();
Connection.send({ type: 'demo-broadcast', text: document.getElementById('realtime-text').value });
});
})();
</script>
+39
View File
@@ -0,0 +1,39 @@
<section class="section">
<h1>Toolbox Backports</h1>
<p>Small reusable primitives lifted from downstream applications after generalizing them for the starter.</p>
<h2>Action buttons</h2>
<p>Inspired by <code>htmx-crud-starter</code>'s URL button helper: route-aware buttons with optional confirmation and htmx attributes.</p>
<div style="display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin:1rem 0;">
<?= component('basic/action-button', [
'label' => 'Open Dashboard',
'route' => 'dashboard',
'variant' => 'primary',
]) ?>
<?= component('basic/action-button', [
'label' => 'Confirmable Action',
'route' => 'toolbox',
'confirm' => 'This is only a demo. Continue?',
]) ?>
<?= component('basic/action-button', [
'label' => 'HTMX-shaped Button',
'route' => 'toolbox',
'tag' => 'button',
'htmx' => true,
'method' => 'get',
'target' => '#toolbox-demo-target',
'push_url' => false,
]) ?>
</div>
<div id="toolbox-demo-target" class="card" style="padding:1rem;">Target placeholder for htmx-enabled apps.</div>
<h2>Tile grid</h2>
<p>Inspired by <code>my.kautschuk.com</code>'s image tile navigation component.</p>
<?= component('navigation/tile-grid', [
'items' => [
['caption' => 'Themes', 'description' => 'Compare visual shells', 'url' => 'themes', 'image' => 'img/cat01.jpg'],
['caption' => 'Workspace', 'description' => 'Admin shell primitives', 'url' => 'workspace', 'image' => 'img/cat02.jpg'],
['caption' => 'Gauges', 'description' => 'KPI visualization', 'url' => 'gauges', 'image' => 'img/cat03.jpg'],
],
]) ?>
</section>