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