+ | { 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)
+));