Skip to content

Commit

Permalink
feat: add worktop/ws module (#48)
Browse files Browse the repository at this point in the history
* chore: setup `worktop/ws` module

* feat: begin `ws` module

* feat(ws): finalize export methods

* chore(ws): begin tests

* feat(router): add `ws` alias for `GET` route

* wip: begin `socket.context` addition

* wip: begin types check;

- not happy w/ the data/context hints

* revert(router): remove `ws` alias for `GET` route

This reverts commit b11a593.

* fix(ws): bind socket methods

* feat(ws): socket handler responds to all events

* chore(ws): clean up notes

* chore: attach `worktop/ws` to readme

* chore(ci): build before test;

- TODO: fix register script. this has to be done cuz ws is the first tested module that has "self-referencing" import, and so the distribution file(s) are expected to exist.
  • Loading branch information
lukeed committed May 4, 2021
1 parent e14e1bd commit 8beefdf
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 9 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Expand Up @@ -37,11 +37,11 @@ jobs:
- name: Install
run: pnpm install

- name: Compiles
run: pnpm run build

- name: Run Tests
run: pnpm test

- name: Check Types
run: pnpm run types

- name: Compiles
run: pnpm run build
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -13,5 +13,6 @@ node_modules
/response
/router
/utils
/ws

/examples/build
1 change: 1 addition & 0 deletions bin/index.js
Expand Up @@ -41,4 +41,5 @@ Promise.all([
bundle('src/utils.ts', pkg.exports['./utils']),
bundle('src/cors.ts', pkg.exports['./cors']),
bundle('src/kv.ts', pkg.exports['./kv']),
bundle('src/ws.ts', pkg.exports['./ws']),
]).then(table);
7 changes: 6 additions & 1 deletion package.json
Expand Up @@ -52,6 +52,10 @@
"import": "./utils/index.mjs",
"require": "./utils/index.js"
},
"./ws": {
"import": "./ws/index.mjs",
"require": "./ws/index.js"
},
"./package.json": "./package.json"
},
"files": [
Expand All @@ -64,7 +68,8 @@
"router",
"utils",
"cors",
"kv"
"kv",
"ws"
],
"engines": {
"node": ">=12"
Expand Down
13 changes: 10 additions & 3 deletions readme.md
Expand Up @@ -136,7 +136,7 @@ Cache.listen(API.run);
> [View `worktop` API documentation](/src/router.d.ts)
<!-- > [View `worktop` API documentation](/docs/module.router.md) -->
The main module – concerned with routing. <br>This is core of most applications. Exports the [`Router`](/src/router.d.ts#L15) class.
The main module – concerned with routing. <br>This is core of most applications. Exports the [`Router`](/src/router.d.ts#L66) class.

### Module: `worktop/kv`

Expand All @@ -157,7 +157,7 @@ The `worktop/cache` submodule contains all utilities related to [Cloudflare's Ca
> [View `worktop/request` API documentation](/src/request.d.ts)
<!-- > [View `worktop/request` API documentation](/docs/module.request.md) -->
The `worktop/request` submodule contains the [`ServerRequest`](/src/request.d.ts#L123) class, which provides an interface similar to the request instance(s) found in most other Node.js frameworks.
The `worktop/request` submodule contains the [`ServerRequest`](/src/request.d.ts#L117) class, which provides an interface similar to the request instance(s) found in most other Node.js frameworks.

> **Note:** This module is used internally and will (very likely) never be imported by your application.
Expand All @@ -166,7 +166,7 @@ The `worktop/request` submodule contains the [`ServerRequest`](/src/request.d.ts
> [View `worktop/response` API documentation](/src/response.d.ts)
<!-- > [View `worktop/response` API documentation](/docs/module.response.md) -->
The `worktop/response` submodule contains the [`ServerResponse`](/src/response.d.ts#L9) class, which provides an interface similar to the [`IncomingMessage`](https://nodejs.org/api/http.html#http_class_http_incomingmessage) (aka, "response") object that Node.js provides.
The `worktop/response` submodule contains the [`ServerResponse`](/src/response.d.ts#L6) class, which provides an interface similar to the [`IncomingMessage`](https://nodejs.org/api/http.html#http_class_http_incomingmessage) (aka, "response") object that Node.js provides.

> **Note:** This module is used internally and will (very likely) never be imported by your application.
Expand Down Expand Up @@ -205,6 +205,13 @@ The `worktop/crypto` submodule is a collection of cryptographic functionalities.
The `worktop/utils` submodule is a collection of standalone, general-purpose utilities that you may find useful. These may include – but are not limited to – hashing functions and unique identifier generators.

### Module: `worktop/ws`

> [View `worktop/ws` API documentation](/src/ws.d.ts)
<!-- > [View `worktop/ws` API documentation](/docs/module.ws.md) -->
The `worktop/ws` submodule contains the [`WebSocket`](/src/ws.d.ts#L18) and [`WebSocketPair`](/src/ws.d.ts#L4) class definitions, as well as two middleware handlers for validating and/or setting up a [`SocketHandler`](/src/ws.d.ts#L38) for the WebSocket connection.


## License

Expand Down
1 change: 1 addition & 0 deletions src/cache.test.ts
Expand Up @@ -109,6 +109,7 @@ globalThis.Headers = class Headers extends Map {
globalThis.Response = function Response(body: BodyInit, init: ResponseInit = {}) {
var $ = this as any;
$.headers = init.headers || new Headers;
$.statusText = init.statusText || '';
$.status = init.status || 200;
$.body = body || null;
$.clone = () => 'cloned';
Expand Down
1 change: 1 addition & 0 deletions src/internal/constants.ts
Expand Up @@ -13,6 +13,7 @@ export const STATUS_CODES: Record<string|number, string> = {
'411': 'Length Required',
'413': 'Payload Too Large',
'422': 'Unprocessable Entity',
'426': 'Upgrade Required',
'428': 'Precondition Required',
'429': 'Too Many Requests',
'500': 'Internal Server Error',
Expand Down
59 changes: 59 additions & 0 deletions src/internal/ws.test.ts
@@ -0,0 +1,59 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import * as ws from './ws';

// @ts-ignore -> workaround for bad/lazy Response mock
const toHeaders = (h: Headers): Record<string, string> => h;

const abort = suite('abort');

abort('should be a function', () => {
assert.type(ws.abort, 'function');
});

abort('should return `Response` instance', () => {
let out = ws.abort(400);
assert.instance(out, Response);

let headers = toHeaders(out.headers);
assert.is(headers['Content-Type'], 'text/plain');
assert.is(headers['Connection'], 'close');
});

abort('should handle `400` status', () => {
let text = 'Bad Request';
let res = ws.abort(400);

assert.is(res.status, 400);
assert.is(res.statusText, text);
assert.is(res.body, text);

let clen = toHeaders(res.headers)['Content-Length'];
assert.is(clen, '' + text.length);
});

abort('should handle `405` status', () => {
let text = 'Method Not Allowed';
let res = ws.abort(405);

assert.is(res.status, 405);
assert.is(res.statusText, text);
assert.is(res.body, text);

let clen = toHeaders(res.headers)['Content-Length'];
assert.is(clen, '' + text.length);
});

abort('should handle `426` status', () => {
let text = 'Upgrade Required';
let res = ws.abort(426);

assert.is(res.status, 426);
assert.is(res.statusText, text);
assert.is(res.body, text);

let clen = toHeaders(res.headers)['Content-Length'];
assert.is(clen, '' + text.length);
});

abort.run();
15 changes: 15 additions & 0 deletions src/internal/ws.ts
@@ -0,0 +1,15 @@
import { STATUS_CODES } from 'worktop';
import { byteLength } from 'worktop/utils';

export function abort(code: number): Response {
let message = STATUS_CODES[code];
return new Response(message, {
status: code,
statusText: message,
headers: {
'Connection': 'close',
'Content-Type': 'text/plain',
'Content-Length': '' + byteLength(message)
}
});
}
54 changes: 54 additions & 0 deletions src/ws.d.ts
@@ -0,0 +1,54 @@
import type { ServerRequest, Params } from 'worktop/request';

declare global {
const WebSocketPair: {
new(): {
/** the `client` socket */
0: WebSocket,
/** the `server` socket */
1: WebSocket,
};
};

interface ResponseInit {
webSocket?: WebSocket;
}
}

export interface WebSocket {
accept(): void;
send(message: number | string): void;
close(code?: number, reason?: string): void;
addEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any): void;
addEventListener(type: string, listener: EventListener): void;
}

type Context = Record<string, any>;
export interface Socket<C extends Context = Context> {
send: WebSocket['send'];
close: WebSocket['close'];
context: C;
event:
| { type: 'open' } & Event
| { type: 'close' } & CloseEvent
| { type: 'message' } & MessageEvent<string>
| { type: 'error' } & Event;
}

export type SocketHandler<
P extends Params = Params,
C extends Context = Context,
> = (req: ServerRequest<P>, socket: Socket<C>) => Promise<void>|void;

/**
* Ensure the incoming `Request` can be upgraded to a Websocket connection.
* @NOTE This is called automatically within the `listen()` method.
*/
export const connect: Handler;

/**
* Establish a Websocket connection.
* Attach the `handler` as the 'message' event listener.
* @NOTE Invokes the `connect()` middleware automatically.
*/
export function listen(handler: MessageHandler): Handler;
88 changes: 88 additions & 0 deletions src/ws.test.ts
@@ -0,0 +1,88 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import * as ws from './ws';

const connect = suite('connect');

connect('should be a function', () => {
assert.type(ws.connect, 'function');
});

connect('should throw 405 if not GET request', () => {
// @ts-ignore
let out = ws.connect({ method: 'POST' });
assert.instance(out, Response);
// @ts-ignore - not promise
assert.is(out.status, 405);
});

connect('should throw 426 if missing `Upgrade: websocket` header', () => {
let headers = new Headers([
['upgrade', 'other']
]);

// @ts-ignore
let res = ws.connect({ method: 'GET', headers });
assert.instance(res, Response);

// @ts-ignore - not promise
assert.is(res.status, 426);
});

connect('should throw 400 if missing `sec-websocket-key` header', () => {
let headers = new Headers([
['upgrade', 'websocket'],
['Sec-WebSocket-Key', 'dGhlIHNhbXBub25jZQ==']
]);

// @ts-ignore
let res = ws.connect({ method: 'GET', headers });
assert.instance(res, Response);

// @ts-ignore - not promise
assert.is(res.status, 400);
});

connect('should throw 400 if invalid `sec-websocket-version` header', () => {
let headers = new Headers([
['upgrade', 'websocket'],
['Sec-WebSocket-Version', '3']
]);

// @ts-ignore
let res = ws.connect({ method: 'GET', headers });
assert.instance(res, Response);

// @ts-ignore - not promise
assert.is(res.status, 400);
});

connect('should now throw error if valid handshake', () => {
let headers = new Headers([
['Upgrade', 'websocket'],
['Connection', 'Upgrade'],
['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='],
['Sec-WebSocket-Version', '13']
]);

// @ts-ignore
let res = ws.connect({ method: 'GET', headers });
assert.is(res, undefined);
});

connect.run();

// ---

const listen = suite('listen');

listen('should be a function', () => {
assert.type(ws.listen, 'function');
});

listen('should return a function', () => {
let out = ws.listen(() => {});
assert.type(out, 'function');
});

listen.run();
59 changes: 59 additions & 0 deletions src/ws.ts
@@ -0,0 +1,59 @@
import { abort } from './internal/ws';

import type { Handler } from 'worktop';
import type { SocketHandler } from 'worktop/ws';

// TODO: Might need to only be 400 code?
// @see https://datatracker.ietf.org/doc/rfc6455/?include_text=1
// @see https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
export const connect: Handler = function (req) {
if (req.method !== 'GET') return abort(405);

let value = req.headers.get('upgrade');
if (value !== 'websocket') return abort(426);

value = (req.headers.get('sec-websocket-key') || '').trim();
if (!/^[+/0-9A-Za-z]{22}==$/.test(value)) return abort(400);

value = req.headers.get('sec-websocket-version');
if (value !== '13') return abort(400);
}

export function listen(handler: SocketHandler): Handler {
return function (req, res) {
let error = connect(req, res);
if (error) return error;

let { 0: client, 1: server } = new WebSocketPair;

let context = {};
function caller(evt: Event) {
return handler(req, {
send: server.send.bind(server),
close: server.close.bind(server),
context: context,
// @ts-ignore
event: evt
})
}

async function closer(evt: Event) {
try { await caller(evt) }
finally { server.close() }
}

server.accept();

// NOTE: currently "open" is never called
// server.addEventListener('open', caller);
server.addEventListener('close', closer);
server.addEventListener('message', caller);
server.addEventListener('error', closer);

return new Response(null, {
status: 101,
statusText: 'Switching Protocols',
webSocket: client
});
};
}
5 changes: 3 additions & 2 deletions tsconfig.json
Expand Up @@ -23,8 +23,9 @@
"worktop/response": ["./response.d.ts", "./response.ts"],
"worktop/cookie": ["./cookie.d.ts", "./cookie.ts"],
"worktop/utils": ["./utils.d.ts", "./utils.ts"],
"worktop/cors": ["./cors.d.ts", "./cors.js"],
"worktop/kv": ["./kv.d.ts", "./kv.js"],
"worktop/cors": ["./cors.d.ts", "./cors.ts"],
"worktop/kv": ["./kv.d.ts", "./kv.ts"],
"worktop/ws": ["./ws.d.ts", "./ws.ts"],
}
},
"include": [
Expand Down

0 comments on commit 8beefdf

Please sign in to comment.