-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for strongly typed websockets
Signed-off-by: Marcos Candeia <[email protected]>
- Loading branch information
Showing
5 changed files
with
365 additions
and
12 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
import { Queue } from "@core/asyncutil/queue"; | ||
import { jsonSerializer } from "./serializers.ts"; | ||
|
||
export interface Channel<T> { | ||
closed: Promise<void>; | ||
signal: AbortSignal; | ||
close(): void; | ||
send(value: T): Promise<void>; | ||
recv(signal?: AbortSignal): AsyncIterableIterator<T>; | ||
} | ||
|
||
/** | ||
* Checks if a value is a channel. | ||
* | ||
* @param v - The value to check. | ||
* | ||
* @returns True if the value is a channel, false otherwise. | ||
*/ | ||
export const isChannel = < | ||
T, | ||
TChannel extends Channel<T> = Channel<T>, | ||
>(v: TChannel | unknown): v is TChannel => { | ||
return typeof (v as TChannel).recv === "function" && | ||
typeof (v as TChannel).send === "function"; | ||
}; | ||
|
||
/** | ||
* Checks if a value is a channel upgrader. | ||
* | ||
* @param v - The value to check. | ||
* | ||
* @returns True if the value is a channel upgrader, false otherwise. | ||
*/ | ||
export const isUpgrade = ( | ||
v: ChannelUpgrader<unknown, unknown> | unknown, | ||
): v is ChannelUpgrader<unknown, unknown> => { | ||
return typeof (v as ChannelUpgrader<unknown, unknown>) === "function"; | ||
}; | ||
|
||
/** | ||
* Links multiple abort signals together such that when any of them | ||
* are aborted, the returned signal is also aborted. | ||
* | ||
* @param signals - The abort signals to link together. | ||
* | ||
* @returns The linked abort signal. | ||
*/ | ||
export const link = (...signals: AbortSignal[]): AbortSignal => { | ||
const ctrl = new AbortController(); | ||
for (const signal of signals) { | ||
signal.addEventListener("abort", (evt) => { | ||
if (!ctrl.signal.aborted) { | ||
ctrl.abort(evt); | ||
} | ||
}); | ||
} | ||
return ctrl.signal; | ||
}; | ||
|
||
export class ClosedChannelError extends Error { | ||
constructor() { | ||
super("Channel is closed"); | ||
} | ||
} | ||
export const ifClosedChannel = | ||
(cb: () => Promise<void> | void) => (err: unknown) => { | ||
if (err instanceof ClosedChannelError) { | ||
return cb(); | ||
} | ||
throw err; | ||
}; | ||
|
||
export const ignoreIfClosed = ifClosedChannel(() => {}); | ||
export const makeChan = <T>(capacity = 0): Channel<T> => { | ||
let currentCapacity = capacity; | ||
const queue: Queue<{ value: T; resolve: () => void }> = new Queue(); | ||
const ctrl = new AbortController(); | ||
const abortPromise = Promise.withResolvers<void>(); | ||
ctrl.signal.onabort = () => { | ||
abortPromise.resolve(); | ||
}; | ||
|
||
const send = (value: T): Promise<void> => { | ||
return new Promise((resolve, reject) => { | ||
if (ctrl.signal.aborted) reject(new ClosedChannelError()); | ||
let mResolve = resolve; | ||
if (currentCapacity > 0) { | ||
currentCapacity--; | ||
mResolve = () => { | ||
currentCapacity++; | ||
}; | ||
resolve(); | ||
} | ||
queue.push({ value, resolve: mResolve }); | ||
}); | ||
}; | ||
|
||
const close = () => { | ||
ctrl.abort(); | ||
}; | ||
|
||
const recv = async function* ( | ||
signal?: AbortSignal, | ||
): AsyncIterableIterator<T> { | ||
const linked = signal ? link(ctrl.signal, signal) : ctrl.signal; | ||
while (true) { | ||
if (linked.aborted) { | ||
return; | ||
} | ||
try { | ||
const next = await queue.pop({ signal: linked }); | ||
next.resolve(); | ||
yield next.value; | ||
} catch (_err) { | ||
if (linked.aborted) { | ||
return; | ||
} | ||
throw _err; | ||
} | ||
} | ||
}; | ||
|
||
return { | ||
send, | ||
recv, | ||
close, | ||
signal: ctrl.signal, | ||
closed: abortPromise.promise, | ||
}; | ||
}; | ||
|
||
export interface DuplexChannel<TSend, TReceive> { | ||
send: Channel<TSend>["send"]; | ||
recv: Channel<TReceive>["recv"]; | ||
close: () => void | Promise<void>; | ||
} | ||
|
||
export type ChannelUpgrader<TSend, TReceive = TSend> = ( | ||
ch: DuplexChannel<TSend, TReceive>, | ||
) => Promise<void>; | ||
|
||
// deno-lint-ignore no-explicit-any | ||
export type Message<TMessageProperties = any> = TMessageProperties & { | ||
chunk?: Uint8Array; | ||
}; | ||
|
||
export interface MessageSerializer< | ||
TSend, | ||
TReceive, | ||
TRawFormat extends string | ArrayBufferLike | ArrayBufferView | Blob, | ||
> { | ||
binaryType?: BinaryType; | ||
serialize: ( | ||
msg: Message<TSend>, | ||
) => TRawFormat; | ||
deserialize: (str: TRawFormat) => Message<TReceive>; | ||
} | ||
|
||
export const makeWebSocket = < | ||
TSend, | ||
TReceive, | ||
TMessageFormat extends string | ArrayBufferLike | ArrayBufferView | Blob = | ||
| string | ||
| ArrayBufferLike | ||
| ArrayBufferView | ||
| Blob, | ||
>( | ||
socket: WebSocket, | ||
_serializer?: MessageSerializer<TSend, TReceive, TMessageFormat>, | ||
): Promise<DuplexChannel<Message<TSend>, Message<TReceive>>> => { | ||
const serializer = _serializer ?? | ||
jsonSerializer<Message<TSend>, Message<TReceive>>(); | ||
const sendChan = makeChan<Message<TSend>>(); | ||
const recvChan = makeChan<Message<TReceive>>(); | ||
const ch = Promise.withResolvers< | ||
DuplexChannel<Message<TSend>, Message<TReceive>> | ||
>(); | ||
socket.binaryType = serializer.binaryType ?? "blob"; | ||
socket.onclose = () => { | ||
sendChan.close(); | ||
recvChan.close(); | ||
}; | ||
socket.onerror = (err) => { | ||
socket.close(); | ||
ch.reject(err); | ||
}; | ||
socket.onmessage = async (msg) => { | ||
if (recvChan.signal.aborted) { | ||
return; | ||
} | ||
await recvChan.send(serializer.deserialize(msg.data)); | ||
}; | ||
socket.onopen = async () => { | ||
ch.resolve({ | ||
recv: recvChan.recv.bind(recvChan), | ||
send: sendChan.send.bind(recvChan), | ||
close: () => socket.close(), | ||
}); | ||
for await (const message of sendChan.recv()) { | ||
try { | ||
socket.send( | ||
serializer.serialize(message), | ||
); | ||
} catch (_err) { | ||
console.error("error sending message through socket", message); | ||
} | ||
} | ||
socket.close(); | ||
}; | ||
return ch.promise; | ||
}; |
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,14 @@ | ||
import type { MessageSerializer } from "./channel.ts"; | ||
|
||
export const jsonSerializer = <TSend, TReceive>(): MessageSerializer< | ||
TSend, | ||
TReceive, | ||
string | ||
> => { | ||
return { | ||
deserialize: (msg) => { | ||
return JSON.parse(msg); | ||
}, | ||
serialize: JSON.stringify, | ||
}; | ||
}; |
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.