Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
14 changed files
with
354 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,5 +13,6 @@ node_modules | |
/response | ||
/router | ||
/utils | ||
/ws | ||
|
||
/examples/build |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
}); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.