mirror of
https://github.com/LukeHagar/crossws.git
synced 2025-12-06 12:27:46 +00:00
initial commit
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[*.js]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{package.json,*.yml,*.cjson}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
types
|
||||||
7
.eslintrc
Normal file
7
.eslintrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": ["eslint-config-unjs"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": 0,
|
||||||
|
"no-useless-constructor": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
26
.github/workflows/autofix.yml
vendored
Normal file
26
.github/workflows/autofix.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: autofix.ci # needed to securely identify the workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
autofix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: corepack enable
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: "pnpm"
|
||||||
|
- run: pnpm install
|
||||||
|
- name: Fix lint issues
|
||||||
|
run: pnpm run lint:fix
|
||||||
|
- uses: autofix-ci/action@bee19d72e71787c12ca0f29de72f2833e437e4c9
|
||||||
|
with:
|
||||||
|
commit-message: "chore: apply automated fixes"
|
||||||
26
.github/workflows/ci.yml
vendored
Normal file
26
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: corepack enable
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: "pnpm"
|
||||||
|
- run: pnpm install
|
||||||
|
- run: pnpm lint
|
||||||
|
- run: pnpm test:types
|
||||||
|
- run: pnpm build
|
||||||
|
- run: pnpm vitest --coverage
|
||||||
|
- uses: codecov/codecov-action@v3
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
types
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
.eslintcache
|
||||||
|
*.log*
|
||||||
|
*.conf*
|
||||||
|
*.env*
|
||||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
46
LICENSE
Normal file
46
LICENSE
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Pooya Parsa <pooya@pi0.io>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bundled with https://github.com/websockets/ws
|
||||||
|
|
||||||
|
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.
|
||||||
187
README.md
Normal file
187
README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# ⛨ CrossWS
|
||||||
|
|
||||||
|
[![npm version][npm-version-src]][npm-version-href]
|
||||||
|
[![bundle][bundle-src]][bundle-href]
|
||||||
|
|
||||||
|
<!-- [![npm downloads][npm-downloads-src]][npm-downloads-href] -->
|
||||||
|
|
||||||
|
<!-- [![Codecov][codecov-src]][codecov-href] -->
|
||||||
|
|
||||||
|
> [!WIP]
|
||||||
|
> This project and API is under heavy development and opened to test integrations. Don't rely on it for production yet. Feedbacks welcome about API design!
|
||||||
|
|
||||||
|
Cross-platform WebSocket server adapters:
|
||||||
|
|
||||||
|
- Elegant, typed and simple interface to define WebSocket handlers
|
||||||
|
- Performant per-server handlers instead of per-connection events api ([why](https://bun.sh/docs/api/websockets#lcYFjkFYJC-summary))
|
||||||
|
- Zero dependencies with bundled [ws](https://github.com/websockets/ws) types and runtime for [Node.js](https://nodejs.org/) support
|
||||||
|
- Native integration with [Bun](https://bun.sh/) and [Deno](https://deno.com/) WebSocket API
|
||||||
|
- Super lightweight tree-shakable packaging
|
||||||
|
- Developer-Friendly logging
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# npm
|
||||||
|
npm install crossws
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn add crossws
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install crossws
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install crossws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
CrossWS allows integrating your WebSocket handlers with different runtimes and platforms using built-in adapters. Each runtime has specific method of integrating WebSocket. Once integrated, your custom handlers (such as `onMessage`) will work consitent even if you change the runtime!
|
||||||
|
|
||||||
|
### Integration with **Node.js**
|
||||||
|
|
||||||
|
In order to integrate crosws with your Node.js HTTP server, you need to connect `upgrade` event to `handleUpgrade` method returned from adapter. Behind the scenes CrossWS uses an embdeded version of [ws](https://github.com/websockets/ws).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Initialize Server
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
res.end(
|
||||||
|
`<script>new WebSocket("ws://localhost:3000").addEventListener('open', (e) => e.target.send("Hello from client!"));</script>`,
|
||||||
|
);
|
||||||
|
}).listen(3000);
|
||||||
|
|
||||||
|
// Initialize WebSocket Handler
|
||||||
|
import nodeWSAdapter from "crossws/adapters/node";
|
||||||
|
|
||||||
|
const { handleUpgrade } = nodeWSAdapter({ onMessage: console.log });
|
||||||
|
server.on("upgrade", handleUpgrade);
|
||||||
|
```
|
||||||
|
|
||||||
|
See [playground/node.ts](./playground/node.ts) for demo and [src/adapters/node.ts](./src/adapters/node.ts) for implementation.
|
||||||
|
|
||||||
|
## Integration with **Bun**
|
||||||
|
|
||||||
|
In order to integrate crosws with your Bun server, you need to check for `server.upgrade` and also pass `websocket` object returned from adapter to server options. CrossWS leverages native Bun WebSocket API.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import bunAdapter from "./dist/adapters/bun";
|
||||||
|
|
||||||
|
const { websocket } = bunAdapter({ onMessage: console.log });
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
fetch(req, server) {
|
||||||
|
if (server.upgrade(req)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
`<script>new WebSocket("ws://localhost:3000").addEventListener('open', (e) => e.target.send("Hello from client!"));</script>`,
|
||||||
|
{ headers: { "content-type": "text/html" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
websocket,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See [playground/bun.ts](./playground/bun.ts) for demo and [src/adapters/bun.ts](./src/adapters/bun.ts) for implementation.
|
||||||
|
|
||||||
|
## Integration with **Deno**
|
||||||
|
|
||||||
|
In order to integrate crosws with your Deno server, you need to check for `upgrade` header than then call `handleUpgrade` method from adapter passing the incoming request object. Returned value is server upgrade response.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import denoAdapter from "crossws/adapters/deno";
|
||||||
|
|
||||||
|
const { handleUpgrade } = denoAdapter({ onMessage: console.log });
|
||||||
|
|
||||||
|
Deno.serve({ port: 3000 }, (req) => {
|
||||||
|
if (req.headers.get("upgrade") === "websocket") {
|
||||||
|
return handleUpgrade(req);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
`<script>new WebSocket("ws://localhost:3000").addEventListener("open", (e) => e.target.send("Hello from client!"));</script>`,
|
||||||
|
{ headers: { "content-type": "text/html" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See [playground/deno.ts](./playground/deno.ts) for demo and [src/adapters/deno.ts](./src/adapters/deno.ts) for implementation.
|
||||||
|
|
||||||
|
## Integration with other runtimes
|
||||||
|
|
||||||
|
You can define your custom adapters using `defineWebSocketAdapter` wrapper.
|
||||||
|
|
||||||
|
See other adapter implementations in [./src/adapters](./src/adapters/) to get and idea how adapters can be implemented and feel free to directly make a Pull Request to support your environment in CrossWS!
|
||||||
|
|
||||||
|
## Handler API
|
||||||
|
|
||||||
|
Previously you saw in the adapter examples that we pass `onMessage` option.
|
||||||
|
|
||||||
|
First object passed to adapters is a list of global handlers that will get called during lifecycle of a WebSocket connection. You can use `defineWebSocketHandler` utility to make a typed websocket handler object and pass it to the actual adapter when needed.
|
||||||
|
|
||||||
|
**Note: API is subject to change! Feedbacks Welcome!**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineWebSocketHandler } from "crossws";
|
||||||
|
|
||||||
|
const websocketHandler = defineWebSocketHandler({
|
||||||
|
onMessage: (peer, message) => {
|
||||||
|
console.log("message", peer, message);
|
||||||
|
if (message.text().includes("ping")) {
|
||||||
|
peer.send("pong");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (peer, error) => {
|
||||||
|
console.log("error", peer, error);
|
||||||
|
},
|
||||||
|
onOpen: (peer) => {
|
||||||
|
console.log("open", peer);
|
||||||
|
},
|
||||||
|
onClose: (peer, code, reason) => {
|
||||||
|
console.log("close", peer, code, reason);
|
||||||
|
},
|
||||||
|
onEvent: (event, ...args) => {
|
||||||
|
console.log("event", event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WebSocketPeer`
|
||||||
|
|
||||||
|
Websocket handler methods accept a peer instance as first argument. peer is a wrapper over platform natives WebSocket connection instance and alows to send message.
|
||||||
|
|
||||||
|
**Tip:** You can safely log a peer instance to console using `console.log` it will be automatically stringified with useful information including remote address and connection status!
|
||||||
|
|
||||||
|
### `WebSocketMessage`
|
||||||
|
|
||||||
|
Second argument to `onMessage` event handler is a message object. You can access raw data using `message.rawData` or stringified message using `message.text()`.
|
||||||
|
|
||||||
|
**Tip:** You can safely log `message` object to console using `console.log` it will be automatically stringified!
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Clone this repository
|
||||||
|
- Install latest LTS version of [Node.js](https://nodejs.org/en/)
|
||||||
|
- Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable`
|
||||||
|
- Install dependencies using `pnpm install`
|
||||||
|
- Run interactive tests using `pnpm dev`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Made with 💛
|
||||||
|
|
||||||
|
Published under [MIT License](./LICENSE).
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
|
||||||
|
[npm-version-src]: https://img.shields.io/npm/v/crossws?style=flat&colorA=18181B&colorB=F0DB4F
|
||||||
|
[npm-version-href]: https://npmjs.com/package/crossws
|
||||||
|
[npm-downloads-src]: https://img.shields.io/npm/dm/crossws?style=flat&colorA=18181B&colorB=F0DB4F
|
||||||
|
[npm-downloads-href]: https://npmjs.com/package/crossws
|
||||||
|
[codecov-src]: https://img.shields.io/codecov/c/gh/unjs/crossws/main?style=flat&colorA=18181B&colorB=F0DB4F
|
||||||
|
[codecov-href]: https://codecov.io/gh/unjs/crossws
|
||||||
|
[bundle-src]: https://img.shields.io/bundlephobia/minzip/crossws?style=flat&colorA=18181B&colorB=F0DB4F
|
||||||
|
[bundle-href]: https://bundlephobia.com/result?p=crossws
|
||||||
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "crossws",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "",
|
||||||
|
"repository": "unjs/crossws",
|
||||||
|
"license": "MIT",
|
||||||
|
"sideEffects": false,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"./adapters/bun": {
|
||||||
|
"types": "./dist/adapters/bun.d.ts",
|
||||||
|
"import": "./dist/adapters/bun.mjs",
|
||||||
|
"require": "./dist/adapters/bun.cjs"
|
||||||
|
},
|
||||||
|
"./adapters/deno": {
|
||||||
|
"types": "./dist/adapters/deno.d.ts",
|
||||||
|
"import": "./dist/adapters/deno.mjs",
|
||||||
|
"require": "./dist/adapters/deno.cjs"
|
||||||
|
},
|
||||||
|
"./adapters/node": {
|
||||||
|
"types": "./dist/adapters/node.d.ts",
|
||||||
|
"import": "./dist/adapters/node.mjs",
|
||||||
|
"require": "./dist/adapters/node.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "unbuild",
|
||||||
|
"dev": "vitest dev",
|
||||||
|
"play:node": "jiti playground/node.ts",
|
||||||
|
"play:bun": "bun playground/bun.ts",
|
||||||
|
"play:deno": "deno run -A playground/deno.ts",
|
||||||
|
"lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src test",
|
||||||
|
"lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src test -w",
|
||||||
|
"prepack": "pnpm run build",
|
||||||
|
"release": "pnpm test && changelogen --release && npm publish && git push --follow-tags",
|
||||||
|
"test": "pnpm lint && pnpm test:types && vitest run --coverage",
|
||||||
|
"test:types": "tsc --noEmit --skipLibCheck"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.3",
|
||||||
|
"@types/web": "^0.0.125",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@vitest/coverage-v8": "^1.0.1",
|
||||||
|
"changelogen": "^0.5.5",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-config-unjs": "^0.2.1",
|
||||||
|
"jiti": "^1.21.0",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.33.0",
|
||||||
|
"unbuild": "^2.0.0",
|
||||||
|
"vitest": "^1.0.1",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.11.0"
|
||||||
|
}
|
||||||
38
playground/_common.ts
Normal file
38
playground/_common.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
defineWebSocketHandler,
|
||||||
|
type WebSocketAdapter,
|
||||||
|
type WebSocketHandler,
|
||||||
|
} from "../src";
|
||||||
|
|
||||||
|
export const indexHTMLURL = new URL("_index.html", import.meta.url);
|
||||||
|
|
||||||
|
export const log = (arg0: string, ...args) =>
|
||||||
|
console.log(`[ws] [${arg0}]`, ...args);
|
||||||
|
|
||||||
|
const websocketHandler = defineWebSocketHandler({
|
||||||
|
onMessage: (peer, message) => {
|
||||||
|
log("message", peer, message);
|
||||||
|
if (message.text().includes("ping")) {
|
||||||
|
peer.send("pong");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (peer, error) => {
|
||||||
|
log("error", peer, error);
|
||||||
|
},
|
||||||
|
onOpen: (peer) => {
|
||||||
|
log("open", peer);
|
||||||
|
},
|
||||||
|
onClose: (peer, code, reason) => {
|
||||||
|
log("close", peer, code, reason);
|
||||||
|
},
|
||||||
|
onEvent: (event, ...args) => {
|
||||||
|
log("event", event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createDemo<T extends WebSocketAdapter>(
|
||||||
|
adapter: T,
|
||||||
|
opts?: Parameters<T>[1],
|
||||||
|
): ReturnType<T> {
|
||||||
|
return adapter(websocketHandler, opts);
|
||||||
|
}
|
||||||
28
playground/_index.html
Normal file
28
playground/_index.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<head>
|
||||||
|
<title>CrossWS Test Page</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="logs"></div>
|
||||||
|
<script type="module">
|
||||||
|
const url = `ws://${location.host}`;
|
||||||
|
const logsEl = document.querySelector("#logs");
|
||||||
|
const log = (...args) => {
|
||||||
|
console.log("[ws]", ...args);
|
||||||
|
logsEl.innerHTML += `<p>[${new Date().toJSON()}] ${args.join(" ")}</p>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`Connecting to "${url}""...`);
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
log("Message from server:", event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
log("Waiting for connection...");
|
||||||
|
await new Promise((resolve) => ws.addEventListener("open", resolve));
|
||||||
|
|
||||||
|
log("Sending ping...");
|
||||||
|
ws.send("ping from client");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
19
playground/bun.ts
Normal file
19
playground/bun.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// You can run this demo using `bun --bun ./bun.ts` or `npm run play:bun` in repo
|
||||||
|
|
||||||
|
import bunAdapter from "../src/adapters/bun";
|
||||||
|
import { createDemo, indexHTMLURL } from "./_common";
|
||||||
|
|
||||||
|
const adapter = createDemo(bunAdapter);
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: 3001,
|
||||||
|
fetch(req, server) {
|
||||||
|
if (server.upgrade(req)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new Response(Bun.file(indexHTMLURL), {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
websocket: adapter.websocket,
|
||||||
|
});
|
||||||
20
playground/deno.ts
Normal file
20
playground/deno.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// You can run this demo using `deno run -A ./deno.ts` or `npm run play:deno` in repo
|
||||||
|
|
||||||
|
import denoAdapter from "../dist/adapters/deno.mjs";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import type * as _Deno from "../types/lib.deno.d.ts";
|
||||||
|
|
||||||
|
import { createDemo, indexHTMLURL } from "./_common.ts";
|
||||||
|
|
||||||
|
const adapter = createDemo(denoAdapter);
|
||||||
|
|
||||||
|
Deno.serve({ port: 3001 }, (req) => {
|
||||||
|
if (req.headers.get("upgrade") === "websocket") {
|
||||||
|
return adapter.handleUpgrade(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(Deno.readFileSync(indexHTMLURL), {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
});
|
||||||
22
playground/node.ts
Normal file
22
playground/node.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// You can run this demo using `npm run play:node` in repo
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import nodeAdapter from "../src/adapters/node";
|
||||||
|
import { createDemo, indexHTMLURL } from "./_common";
|
||||||
|
|
||||||
|
const adapter = createDemo(nodeAdapter);
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" });
|
||||||
|
const indexHTML = readFileSync(indexHTMLURL, "utf8");
|
||||||
|
res.end(indexHTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", adapter.handleUpgrade);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
server.listen(3001, () => {
|
||||||
|
console.log(`Server running at http://localhost:${port}/`);
|
||||||
|
});
|
||||||
4727
pnpm-lock.yaml
generated
Normal file
4727
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["github>unjs/renovate-config"]
|
||||||
|
}
|
||||||
12
src/adapter.ts
Normal file
12
src/adapter.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { WebSocketHandler } from "./handler";
|
||||||
|
|
||||||
|
export type WebSocketAdapter<RT = any, OT = any> = (
|
||||||
|
handler: WebSocketHandler,
|
||||||
|
opts: OT,
|
||||||
|
) => RT;
|
||||||
|
|
||||||
|
export function defineWebSocketAdapter<RT, OT>(
|
||||||
|
factory: WebSocketAdapter<RT, OT>,
|
||||||
|
) {
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
66
src/adapters/bun.ts
Normal file
66
src/adapters/bun.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// https://bun.sh/docs/api/websockets
|
||||||
|
|
||||||
|
import type { BunWSOptions, BunServerWebSocket } from "../../types/bun";
|
||||||
|
import { WebSocketMessage } from "../message";
|
||||||
|
import { WebSocketError } from "../error";
|
||||||
|
import { WebSocketPeer } from "../peer";
|
||||||
|
import { defineWebSocketAdapter } from "../adapter";
|
||||||
|
|
||||||
|
export const WebSocket = globalThis.WebSocket;
|
||||||
|
|
||||||
|
export interface AdapterOptions {}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
websocket: BunWSOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineWebSocketAdapter<Adapter, AdapterOptions>(
|
||||||
|
(handler, opts = {}) => {
|
||||||
|
return {
|
||||||
|
websocket: {
|
||||||
|
message: (ws, message) => {
|
||||||
|
handler.onEvent?.("bun:message", ws, message);
|
||||||
|
const peer = new BunWebSocketPeer(ws);
|
||||||
|
handler.onMessage?.(peer, new WebSocketMessage(message));
|
||||||
|
},
|
||||||
|
open: (ws) => {
|
||||||
|
handler.onEvent?.("bun:open", ws);
|
||||||
|
const peer = new BunWebSocketPeer(ws);
|
||||||
|
handler.onOpen?.(peer);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
handler.onEvent?.("bun:close", ws);
|
||||||
|
const peer = new BunWebSocketPeer(ws);
|
||||||
|
handler.onClose?.(peer, 0, "");
|
||||||
|
},
|
||||||
|
error: (ws, error) => {
|
||||||
|
handler.onEvent?.("bun:error", ws, error);
|
||||||
|
const peer = new BunWebSocketPeer(ws);
|
||||||
|
handler.onError?.(peer, new WebSocketError(error));
|
||||||
|
},
|
||||||
|
drain: (ws) => {
|
||||||
|
handler.onEvent?.("bun:drain", ws);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class BunWebSocketPeer extends WebSocketPeer {
|
||||||
|
constructor(private _ws: BunServerWebSocket) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this._ws.remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyState() {
|
||||||
|
return this._ws.readyState as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: string | ArrayBuffer) {
|
||||||
|
this._ws.send(message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/adapters/deno.ts
Normal file
71
src/adapters/deno.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// https://deno.land/api?s=WebSocket
|
||||||
|
// https://deno.land/api?s=Deno.upgradeWebSocket
|
||||||
|
// https://examples.deno.land/http-server-websocket
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
import type * as _DENO_TYPES_ from "../../types/lib.deno.d.ts";
|
||||||
|
|
||||||
|
import { WebSocketMessage } from "../message";
|
||||||
|
import { WebSocketError } from "../error";
|
||||||
|
import { WebSocketPeer } from "../peer";
|
||||||
|
import { defineWebSocketAdapter } from "../adapter.js";
|
||||||
|
|
||||||
|
export const WebSocket = globalThis.WebSocket;
|
||||||
|
|
||||||
|
export interface AdapterOptions {}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
handleUpgrade(req: Deno.Request): Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineWebSocketAdapter<Adapter, AdapterOptions>(
|
||||||
|
(handler, opts = {}) => {
|
||||||
|
const handleUpgrade = (req: Request) => {
|
||||||
|
const upgrade = Deno.upgradeWebSocket(req);
|
||||||
|
upgrade.socket.addEventListener("open", () => {
|
||||||
|
handler.onEvent?.("deno:open", upgrade.socket);
|
||||||
|
const peer = new DenoWebSocketPeer(upgrade.socket);
|
||||||
|
handler.onOpen?.(peer);
|
||||||
|
});
|
||||||
|
upgrade.socket.addEventListener("message", (event) => {
|
||||||
|
handler.onEvent?.("deno:message", upgrade.socket, event);
|
||||||
|
const peer = new DenoWebSocketPeer(upgrade.socket);
|
||||||
|
handler.onMessage?.(peer, new WebSocketMessage(event.data));
|
||||||
|
});
|
||||||
|
upgrade.socket.addEventListener("close", () => {
|
||||||
|
handler.onEvent?.("deno:close", upgrade.socket);
|
||||||
|
const peer = new DenoWebSocketPeer(upgrade.socket);
|
||||||
|
handler.onClose?.(peer, 0, "");
|
||||||
|
});
|
||||||
|
upgrade.socket.addEventListener("error", (error) => {
|
||||||
|
handler.onEvent?.("deno:error", upgrade.socket, error);
|
||||||
|
const peer = new DenoWebSocketPeer(upgrade.socket);
|
||||||
|
handler.onError?.(peer, new WebSocketError(error));
|
||||||
|
});
|
||||||
|
return upgrade.response;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUpgrade,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class DenoWebSocketPeer extends WebSocketPeer {
|
||||||
|
constructor(private _ws: DenoWebSocketPeer) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this._ws.remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyState() {
|
||||||
|
return this._ws.readyState as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: string | ArrayBuffer) {
|
||||||
|
this._ws.send(message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/adapters/node.ts
Normal file
135
src/adapters/node.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// https://github.com/websockets/ws
|
||||||
|
// https://github.com/websockets/ws/blob/master/doc/ws.md
|
||||||
|
|
||||||
|
import type { ClientRequest, IncomingMessage } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
|
import {
|
||||||
|
WebSocketServer as _WebSocketServer,
|
||||||
|
WebSocket as _WebSocket,
|
||||||
|
} from "ws";
|
||||||
|
import type {
|
||||||
|
ServerOptions,
|
||||||
|
RawData,
|
||||||
|
WebSocketServer,
|
||||||
|
WebSocket as WebSocketT,
|
||||||
|
} from "../../types/ws";
|
||||||
|
import { WebSocketPeer } from "../peer";
|
||||||
|
import { WebSocketMessage } from "../message";
|
||||||
|
import { WebSocketError } from "../error";
|
||||||
|
import { defineWebSocketAdapter } from "../adapter";
|
||||||
|
|
||||||
|
export const WebSocket = _WebSocket as unknown as WebSocketT;
|
||||||
|
|
||||||
|
export interface AdapterOptions {
|
||||||
|
wss?: WebSocketServer;
|
||||||
|
serverOptions?: ServerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineWebSocketAdapter<Adapter, AdapterOptions>(
|
||||||
|
(handler, opts = {}) => {
|
||||||
|
const wss: WebSocketServer =
|
||||||
|
opts.wss ||
|
||||||
|
(new _WebSocketServer({
|
||||||
|
noServer: true,
|
||||||
|
...(opts.serverOptions as any),
|
||||||
|
}) as WebSocketServer);
|
||||||
|
|
||||||
|
// Unmanaged server-level events
|
||||||
|
wss.on("error", (error) => {
|
||||||
|
handler.onEvent?.("node:server-error", error);
|
||||||
|
});
|
||||||
|
wss.on("headers", (headers, request) => {
|
||||||
|
handler.onEvent?.("node:server-headers", headers, request);
|
||||||
|
});
|
||||||
|
wss.on("listening", () => {
|
||||||
|
handler.onEvent?.("node:server-listening");
|
||||||
|
});
|
||||||
|
wss.on("close", () => {
|
||||||
|
handler.onEvent?.("node:server-close");
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
const peer = new NodeWebSocketPeer(ws, req);
|
||||||
|
|
||||||
|
// Managed socket-level events
|
||||||
|
ws.on("message", (data: RawData, isBinary: boolean) => {
|
||||||
|
handler.onEvent?.("node:message", ws, data, isBinary);
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
data = Buffer.concat(data);
|
||||||
|
}
|
||||||
|
handler.onMessage?.(peer, new WebSocketMessage(data, isBinary));
|
||||||
|
});
|
||||||
|
ws.on("error", (error: Error) => {
|
||||||
|
handler.onEvent?.("node:error", ws, error);
|
||||||
|
handler.onError?.(peer, new WebSocketError(error));
|
||||||
|
});
|
||||||
|
ws.on("close", (code: number, reason: Buffer) => {
|
||||||
|
handler.onEvent?.("node:close", ws, code, reason);
|
||||||
|
handler.onClose?.(peer, code, reason?.toString());
|
||||||
|
});
|
||||||
|
ws.on("open", () => {
|
||||||
|
handler.onEvent?.("node:open", ws);
|
||||||
|
handler.onOpen?.(peer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unmanaged socket-level events
|
||||||
|
ws.on("ping", (data: Buffer) => {
|
||||||
|
handler.onEvent?.("node:ping", ws, data);
|
||||||
|
});
|
||||||
|
ws.on("pong", (data: Buffer) => {
|
||||||
|
handler.onEvent?.("node:pong", ws, data);
|
||||||
|
});
|
||||||
|
ws.on(
|
||||||
|
"unexpected-response",
|
||||||
|
(request: ClientRequest, response: IncomingMessage) => {
|
||||||
|
handler.onEvent?.("node:unexpected-response", ws, request, response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.on("upgrade", (request: IncomingMessage) => {
|
||||||
|
handler.onEvent?.("node:upgrade", ws, request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUpgrade: (req, socket, head) => {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class NodeWebSocketPeer extends WebSocketPeer {
|
||||||
|
constructor(
|
||||||
|
private _ws: WebSocketT,
|
||||||
|
private _req: IncomingMessage,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
const socket = this._req?.socket;
|
||||||
|
if (!socket) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const addr =
|
||||||
|
socket.remoteFamily === "IPv6"
|
||||||
|
? `[${socket.remoteAddress}]`
|
||||||
|
: socket.remoteAddress;
|
||||||
|
return `${addr}:${socket.remotePort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyState() {
|
||||||
|
return this._ws.readyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: string, compress?: boolean) {
|
||||||
|
this._ws.send(message, { compress });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/error.ts
Normal file
6
src/error.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class WebSocketError extends Error {
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
super(...args);
|
||||||
|
this.name = "WebSocketError";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/handler.ts
Normal file
25
src/handler.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { WebSocketError } from "./error";
|
||||||
|
import type { WebSocketMessage } from "./message";
|
||||||
|
import type { WebSocketPeer } from "./peer";
|
||||||
|
|
||||||
|
export interface WebSocketHandler {
|
||||||
|
onEvent?(event: string, ...args: any[]): void;
|
||||||
|
|
||||||
|
/** A message is received */
|
||||||
|
onMessage?(peer: WebSocketPeer, message: WebSocketMessage): void;
|
||||||
|
|
||||||
|
/** A socket is opened */
|
||||||
|
onOpen?(peer: WebSocketPeer): void;
|
||||||
|
|
||||||
|
/** A socket is closed */
|
||||||
|
onClose?(peer: WebSocketPeer, code: number, reason: string): void;
|
||||||
|
|
||||||
|
/** An error occurs */
|
||||||
|
onError?(peer: WebSocketPeer, error: WebSocketError): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineWebSocketHandler(
|
||||||
|
handler: WebSocketHandler,
|
||||||
|
): WebSocketHandler {
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
5
src/index.ts
Normal file
5
src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./adapter";
|
||||||
|
export * from "./error";
|
||||||
|
export * from "./message";
|
||||||
|
export * from "./peer";
|
||||||
|
export * from "./handler";
|
||||||
18
src/message.ts
Normal file
18
src/message.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export class WebSocketMessage {
|
||||||
|
constructor(
|
||||||
|
public readonly rawData: string | ArrayBuffer | Uint8Array,
|
||||||
|
public readonly isBinary?: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return this.rawData.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `[WebSocketMessage] ${this.text()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.for("nodejs.util.inspect.custom")]() {
|
||||||
|
return this.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/peer.ts
Normal file
33
src/peer.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
|
||||||
|
type ReadyState = 0 | 1 | 2 | 3;
|
||||||
|
const ReadyStateMap = {
|
||||||
|
"-1": "unkown",
|
||||||
|
0: "connecting",
|
||||||
|
1: "open",
|
||||||
|
2: "closing",
|
||||||
|
3: "closed",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export abstract class WebSocketPeer {
|
||||||
|
get id(): string | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyState(): ReadyState | -1 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract send(
|
||||||
|
message: string | ArrayBuffer | Uint8Array,
|
||||||
|
compress?: boolean,
|
||||||
|
): number;
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
const readyState = ReadyStateMap[this.readyState];
|
||||||
|
return `[WebSocketPeer] ${this.id || "-"} (${readyState})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.for("nodejs.util.inspect.custom")]() {
|
||||||
|
return this.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
test/index.test.ts
Normal file
8
test/index.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { expect, it, describe } from "vitest";
|
||||||
|
import {} from "../src";
|
||||||
|
|
||||||
|
describe("crossws", () => {
|
||||||
|
it.todo("pass", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user