@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
440 lines (403 loc) • 15.4 kB
text/typescript
import chan from "../chan.ts";
import { fromErrorEvent } from "../error.ts";
function isFunction(val: unknown): val is (...args: any[]) => any {
return typeof val === "function";
}
async function* resolveAsyncIterable<T>(promise: Promise<ReadableStream<T>>): AsyncIterable<T> {
const stream = await promise;
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
yield value;
}
} finally {
reader.releaseLock();
}
}
export function resolveReadableStream<T>(promise: Promise<ReadableStream<T>>): ReadableStream<T> {
const { readable, writable } = new TransformStream();
promise.then(stream => stream.pipeTo(writable));
return readable;
}
/**
* If the given `promise` resolves to a `ReadableStream<Uint8Array>`, this
* function will return a new `ReadableStream<Uint8Array>` object that can be
* used to read the byte stream without the need to wait for the promise to
* resolve.
*
* This function is optimized for zero-copy read, so it's recommended to use
* this function when the source stream is a byte stream.
*/
export function resolveByteStream(
promise: Promise<ReadableStream<Uint8Array>>
): ReadableStream<Uint8Array> {
let reader: ReadableStreamBYOBReader
| ReadableStreamDefaultReader<Uint8Array>;
return new ReadableStream<Uint8Array>({
type: "bytes",
async start() {
const source = await promise;
try { // zero-copy read from the source stream
reader = source.getReader({ mode: "byob" });
} catch {
reader = source.getReader();
}
},
async pull(controller) {
try {
let request: ReadableStreamBYOBRequest | undefined;
let view: Uint8Array | undefined;
let result: ReadableStreamReadResult<Uint8Array>;
if ("byobRequest" in controller && controller.byobRequest?.view) {
// This stream is requested for zero-copy read.
request = controller.byobRequest;
view = request.view as Uint8Array;
} else if (reader instanceof ReadableStreamBYOBReader) {
view = new Uint8Array(4096);
}
if (reader instanceof ReadableStreamBYOBReader) {
// The source stream supports zero-copy read, we can read its
// data directly into the request view's buffer.
result = await reader.read(view!);
} else {
// The source stream does not support zero-copy read, we need to
// copy its data to a new buffer.
result = await reader.read();
}
if (request) {
if (result.done) {
controller.close();
// The final chunk may be empty, but still needs to be
// responded in order to close the request reader.
if (result.value !== undefined) {
request.respondWithNewView(result.value);
} else {
request.respond(0);
}
} else if (reader instanceof ReadableStreamBYOBReader
|| (view && result.value.buffer.byteLength === view.buffer.byteLength)
) {
// Respond to the request reader with the same underlying
// buffer of the source stream.
// Or the source stream doesn't support zero-copy read, but
// the result bytes has the same buffer size as the request
// view.
request.respondWithNewView(result.value);
} else {
// This stream is requested for zero-copy read, but the
// source stream doesn't support it. We need to copy and
// deliver the new buffer instead.
controller.enqueue(result.value);
}
} else {
if (result.done) {
controller.close();
} else {
controller.enqueue(result.value);
}
}
} catch (err) {
reader.releaseLock();
controller.error(err);
}
},
cancel(reason = undefined) {
reader.cancel(reason);
reader.releaseLock();
},
});
}
/**
* Converts the given `source` into an `AsyncIterable` object if it's not one
* already, returns `null` if failed.
*/
export function asAsyncIterable(source: any): AsyncIterable<any> | null {
if (typeof source[Symbol.asyncIterator] === "function") {
return source;
} else if (typeof source[Symbol.iterator] === "function") {
return {
[Symbol.asyncIterator]: async function* () {
for (const value of source) {
yield value;
}
},
};
} else if (typeof ReadableStream === "function"
&& source instanceof ReadableStream
) {
const reader = source.getReader();
return {
[Symbol.asyncIterator]: async function* () {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
yield value;
}
} catch (err) {
reader.cancel(err);
} finally {
reader.releaseLock();
}
},
};
} else if (typeof source["then"] === "function") {
return resolveAsyncIterable(source);
}
return null;
}
/**
* Wraps a source as an `AsyncIterable` object that can be used in the
* `for await...of...` loop for reading streaming data.
*/
export function toAsyncIterable<T>(iterable: AsyncIterable<T> | Iterable<T>): AsyncIterable<T>;
/**
* @example
* ```ts
* import { toAsyncIterable } from "@ayonli/jsext/reader";
*
* const res = new Response("Hello, World!");
*
* for await (const chunk of toAsyncIterable(res.body!)) {
* console.log("receive chunk:", chunk);
* }
* ```
*/
export function toAsyncIterable<T>(
stream: ReadableStream<T> | Promise<ReadableStream<T>>
): AsyncIterable<T>;
/**
* @example
* ```ts
* import { toAsyncIterable } from "@ayonli/jsext/reader";
*
* // listen to the `onmessage`
* const sse = new EventSource("/sse/message");
*
* for await (const msg of toAsyncIterable(sse)) {
* console.log("receive message:", msg);
* }
*
* // listen to a specific event
* const channel = new EventSource("/sse/broadcast");
*
* for await (const msg of toAsyncIterable(channel, { event: "broadcast" })) {
* console.log("receive message:", msg);
* }
* ```
*/
export function toAsyncIterable(es: EventSource, options?: { event?: string; }): AsyncIterable<string>;
/**
* @example
* ```ts
* import { toAsyncIterable } from "@ayonli/jsext/reader";
*
* const ws = new WebSocket("/ws");
*
* for await (const msg of toAsyncIterable(ws)) {
* if (typeof data === "string") {
* console.log("receive text message:", data);
* } else {
* console.log("receive binary data:", data);
* }
* }
* ```
*/
export function toAsyncIterable<T extends Uint8Array | string>(ws: WebSocket): AsyncIterable<T>;
/**
* @example
* ```ts
* import { toAsyncIterable } from "@ayonli/jsext/reader";
*
* for await (const msg of toAsyncIterable(self)) {
* console.log("receive message from the parent window:", msg);
* }
* ```
*/
export function toAsyncIterable<T>(target: EventTarget, eventMap?: {
message?: string;
error?: string;
close?: string;
}): AsyncIterable<T>;
/**
* @example
* ```ts
* import { toAsyncIterable } from "@ayonli/jsext/reader";
*
* for await (const msg of toAsyncIterable(process)) {
* console.log("receive message from the parent process:", msg);
* }
* ```
*/
export function toAsyncIterable<T>(target: NodeJS.EventEmitter, eventMap?: {
data?: string;
error?: string;
close?: string;
}): AsyncIterable<T>;
export function toAsyncIterable<T>(source: any, eventMap: {
event?: string; // for EventSource custom event
message?: string;
data?: string;
error?: string;
close?: string;
} | undefined = undefined): AsyncIterable<T> {
const iterable = asAsyncIterable(source);
if (iterable) {
return iterable;
}
const channel = chan<T>(Infinity);
const handleMessage = channel.send.bind(channel);
const handleClose = channel.close.bind(channel);
const handleBrowserErrorEvent = (ev: Event) => {
let err: Error;
if (typeof ErrorEvent === "function" && ev instanceof ErrorEvent) {
err = ev.error || fromErrorEvent(ev);
} else {
// @ts-ignore
err = new Error("something went wrong", { cause: ev });
}
handleClose(err);
};
const proto = Object.getPrototypeOf(source);
const msgDesc = Object.getOwnPropertyDescriptor(proto, "onmessage");
if (msgDesc?.set && isFunction(source["close"])) { // WebSocket or EventSource
const errDesc = Object.getOwnPropertyDescriptor(proto, "onerror");
const closeDesc = Object.getOwnPropertyDescriptor(proto, "onclose");
let cleanup: () => void;
if (eventMap?.event &&
eventMap?.event !== "message" &&
isFunction(source["addEventListener"])
) { // for EventSource listening on custom events
const es = source as EventSource;
const eventName = eventMap.event;
const msgListener = (ev: MessageEvent<T>) => {
handleMessage(ev.data);
};
es.addEventListener(eventName, msgListener);
cleanup = () => {
es.removeEventListener(eventName, msgListener);
};
} else {
msgDesc.set.call(source, (ev: MessageEvent<T>) => {
handleMessage(ev.data);
});
cleanup = () => {
msgDesc.set?.call(source, null);
};
}
errDesc?.set?.call(source, handleBrowserErrorEvent);
if (closeDesc?.set) { // WebSocket
closeDesc.set.call(source, () => {
handleClose();
closeDesc.set?.call(source, null);
errDesc?.set?.call(source, null);
cleanup?.();
});
} else if (!closeDesc?.set && isFunction(source["close"])) { // EventSource
// EventSource by default does not trigger close event, we need to
// make sure when it calls the close() function, the iterator is
// automatically closed.
const es = source as EventSource;
const _close = es.close;
Object.defineProperty(es, "close", {
configurable: true,
writable: true,
value: function close() {
_close.call(es);
handleClose();
Object.defineProperty(es, "close", {
configurable: true,
writable: true,
value: _close,
});
errDesc?.set?.call(source, null);
cleanup?.();
}
});
}
} else if (isFunction(source["send"]) && isFunction(source["close"])) {
// non-standard WebSocket implementation or WebSocket-like object
const ws = source as WebSocket;
if (typeof ws.addEventListener === "function") {
const msgListener = (ev: MessageEvent<T>) => {
handleMessage(ev.data);
};
ws.addEventListener("message", msgListener);
ws.addEventListener("error", handleBrowserErrorEvent);
ws.addEventListener("close", () => {
handleClose();
ws.removeEventListener("message", msgListener);
ws.removeEventListener("error", handleBrowserErrorEvent);
});
} else {
ws.onmessage = (ev: MessageEvent<T>) => {
handleMessage(ev.data);
};
ws.onerror = handleBrowserErrorEvent;
ws.onclose = () => {
handleClose();
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
};
}
} else if (isFunction(source["addEventListener"])) { // EventTarget
const target = source as EventTarget;
const msgEvent = eventMap?.message || "message";
const errEvent = eventMap?.error || "error";
const closeEvent = eventMap?.close || "close";
const msgListener = (ev: Event) => {
if (ev instanceof MessageEvent) {
handleMessage(ev.data);
}
};
target.addEventListener(msgEvent, msgListener);
target.addEventListener(errEvent, handleBrowserErrorEvent);
target.addEventListener(closeEvent, function closeListener() {
handleClose();
target.removeEventListener(closeEvent, closeListener);
target.removeEventListener(msgEvent, msgListener);
target.removeEventListener(errEvent, handleBrowserErrorEvent);
});
} else if (isFunction(source["on"])) { // EventEmitter
const target = source as NodeJS.EventEmitter;
let dataEvent: string;
let errEvent: string;
let closeEvent: string;
if (typeof process === "object" && source === process) {
dataEvent = "message";
errEvent = "uncaughtException";
closeEvent = "exit";
} else if (
(isFunction(source["send"]) && isFunction(source["kill"])) || // child process
(isFunction(source["postMessage"]) && isFunction(source["terminate"])) || // worker thread
(isFunction(source["postMessage"]) && isFunction(source["close"])) // message port
) {
dataEvent = "message";
errEvent = "error";
closeEvent = "exit";
} else {
dataEvent = eventMap?.data || "data";
errEvent = eventMap?.error || "error";
closeEvent = eventMap?.close || "close";
}
target.on(dataEvent, handleMessage);
target.once(errEvent, handleClose);
target.once(closeEvent, () => {
handleClose();
target.off(dataEvent, handleMessage);
target.off(errEvent, handleClose);
});
} else {
throw new TypeError("The source cannot be converted to an async iterable object.");
}
return {
[Symbol.asyncIterator]: channel[Symbol.asyncIterator].bind(channel),
};
}