mirror of
https://github.com/LukeHagar/crossws.git
synced 2025-12-06 04:19:26 +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