From 8beefdf99a4eb9e5a15115fed01469db9123aed8 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 4 May 2021 12:34:19 -0700 Subject: [PATCH] feat: add `worktop/ws` module (#48) * 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 b11a5932ee0fdaa20a2b499f5bfb7be11854cbd3. * 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. --- .github/workflows/ci.yml | 6 +-- .gitignore | 1 + bin/index.js | 1 + package.json | 7 +++- readme.md | 13 ++++-- src/cache.test.ts | 1 + src/internal/constants.ts | 1 + src/internal/ws.test.ts | 59 ++++++++++++++++++++++++++ src/internal/ws.ts | 15 +++++++ src/ws.d.ts | 54 ++++++++++++++++++++++++ src/ws.test.ts | 88 +++++++++++++++++++++++++++++++++++++++ src/ws.ts | 59 ++++++++++++++++++++++++++ tsconfig.json | 5 ++- types/check.ts | 53 +++++++++++++++++++++++ 14 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 src/internal/ws.test.ts create mode 100644 src/internal/ws.ts create mode 100644 src/ws.d.ts create mode 100644 src/ws.test.ts create mode 100644 src/ws.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad8a333..a3486cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index aab1c71..0d4d5f0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ node_modules /response /router /utils +/ws /examples/build diff --git a/bin/index.js b/bin/index.js index ca3b92b..3230066 100644 --- a/bin/index.js +++ b/bin/index.js @@ -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); diff --git a/package.json b/package.json index b62f8ed..52e3e57 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -64,7 +68,8 @@ "router", "utils", "cors", - "kv" + "kv", + "ws" ], "engines": { "node": ">=12" diff --git a/readme.md b/readme.md index 1b765f8..fbba92f 100644 --- a/readme.md +++ b/readme.md @@ -136,7 +136,7 @@ Cache.listen(API.run); > [View `worktop` API documentation](/src/router.d.ts) -The main module – concerned with routing.
This is core of most applications. Exports the [`Router`](/src/router.d.ts#L15) class. +The main module – concerned with routing.
This is core of most applications. Exports the [`Router`](/src/router.d.ts#L66) class. ### Module: `worktop/kv` @@ -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) -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. @@ -166,7 +166,7 @@ The `worktop/request` submodule contains the [`ServerRequest`](/src/request.d.ts > [View `worktop/response` API documentation](/src/response.d.ts) -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. @@ -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) + + +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 diff --git a/src/cache.test.ts b/src/cache.test.ts index b69b197..d01ddec 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -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'; diff --git a/src/internal/constants.ts b/src/internal/constants.ts index 5bb147b..895b9e8 100644 --- a/src/internal/constants.ts +++ b/src/internal/constants.ts @@ -13,6 +13,7 @@ export const STATUS_CODES: Record = { '411': 'Length Required', '413': 'Payload Too Large', '422': 'Unprocessable Entity', + '426': 'Upgrade Required', '428': 'Precondition Required', '429': 'Too Many Requests', '500': 'Internal Server Error', diff --git a/src/internal/ws.test.ts b/src/internal/ws.test.ts new file mode 100644 index 0000000..ac7f121 --- /dev/null +++ b/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 => 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(); diff --git a/src/internal/ws.ts b/src/internal/ws.ts new file mode 100644 index 0000000..569624a --- /dev/null +++ b/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) + } + }); +} diff --git a/src/ws.d.ts b/src/ws.d.ts new file mode 100644 index 0000000..5925ce3 --- /dev/null +++ b/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(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any): void; + addEventListener(type: string, listener: EventListener): void; +} + +type Context = Record; +export interface Socket { + send: WebSocket['send']; + close: WebSocket['close']; + context: C; + event: + | { type: 'open' } & Event + | { type: 'close' } & CloseEvent + | { type: 'message' } & MessageEvent + | { type: 'error' } & Event; +} + +export type SocketHandler< + P extends Params = Params, + C extends Context = Context, +> = (req: ServerRequest

, socket: Socket) => Promise|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; diff --git a/src/ws.test.ts b/src/ws.test.ts new file mode 100644 index 0000000..2ab21ac --- /dev/null +++ b/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(); diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..88dd4e7 --- /dev/null +++ b/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 + }); + }; +} diff --git a/tsconfig.json b/tsconfig.json index 59bb4b4..25a1bf2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": [ diff --git a/types/check.ts b/types/check.ts index f22ac19..bec155a 100644 --- a/types/check.ts +++ b/types/check.ts @@ -6,6 +6,7 @@ import { Database, list, paginate, until } from 'worktop/kv'; import { byteLength, HEX, uid, uuid, ulid, randomize } from 'worktop/utils'; import { listen, reply, Router, compose, STATUS_CODES } from 'worktop'; import { timingSafeEqual } from 'worktop/crypto'; +import * as ws from 'worktop/ws'; import type { KV } from 'worktop/kv'; import type { UID, UUID, ULID } from 'worktop/utils'; @@ -690,3 +691,55 @@ timingSafeEqual(u8, dv); timingSafeEqual(ab, i8); // @ts-expect-error - Mismatch timingSafeEqual(u8, u32); + +/** + * WORKTOP/WS + */ + +const onEvent1: ws.SocketHandler = async function (req, socket) { + assert(socket); + assert>(req); + + let { context, event } = socket; + assert(event); + assert(context); + assert<'open'|'close'|'message'|'error'>(event.type); + + if (event.type === 'message') { + assert(event.data); + } else { + // @ts-expect-error + event.data; + } +} + +type CustomParams = { game: string }; +type CustomContext = { score: number }; +const onEvent2: ws.SocketHandler = function (req, socket) { + let { event, context } = socket; + + if (event.type !== 'message') { + return; + } + + let { game } = req.params; + let data = JSON.parse(event.data); + context.score = context.score || 0; + + switch (data.type) { + case '+1': + case 'incr': { + return socket.send(`${game} score: ${++context.score}`); + } + case '-1': + case 'decr': { + return socket.send(`${game} score: ${--context.score}`); + } + } +} + +API.add('GET', '/score/:game', ws.listen(onEvent2)); +API.add('GET', /^[/]foobar[/]/, compose( + (req, res) => {}, + ws.listen(onEvent1) +));