UNPKG

@oazmi/kitchensink

Version:

a collection of personal utility functions

188 lines (187 loc) 8.89 kB
/** this submodule contains implementations of the {@link NetConn} * interface for udp connections running on the following js-runtimes: * - `deno`: {@link DenoUdpNetConn} * - `node`: {@link NodeUdpNetConn} * - `bun`: {@link BunUdpNetConn} * - `txiki.js`: {@link TjsUdpNetConn} * * @module */ import { math_ceil, noop, number_MAX_SAFE_INTEGER, promise_outside, promise_resolve, string_toLowerCase } from "../alias.js"; import { AwaitableQueue } from "../promiseman.js"; import { SIZE } from "./conn.js"; /** a {@link NetConn} interface implementation wrapper for `Deno.listenDatagram` (deno's udp implementation). */ export class DenoUdpNetConn { base; size; constructor(conn) { this.base = conn; // I think deno handles arbitrarily large MTUs, so I'll set the limit to the max possible (on windows). this.size = number_MAX_SAFE_INTEGER; } async read() { const base = this.base, [buf, deno_addr] = await base.receive(), { hostname, port } = deno_addr; return [buf, { hostname, port, family: 4 }]; } async send(buffer, addr) { let bytes_written = 0; const base = this.base, bytes_to_write = buffer.byteLength, { hostname, port } = addr, deno_addr = { hostname, port, transport: "udp" }; // yes, we should permit empty messages, even if the underlying `base` class ignores zero sized messages. if (bytes_to_write === 0) { return base.send(buffer, deno_addr); } while (bytes_written < bytes_to_write) { bytes_written += await base.send(buffer.subarray(bytes_written), deno_addr); } // TODO: should we assert `bytes_written === bytes_to_write`? return bytes_written; } close() { this.base.close(); } } /** a {@link NetConn} interface implementation wrapper for `tjs.connect("udp", ...)` (txiki.js's udp implementation). */ export class TjsUdpNetConn { base; buf; size; constructor(conn, bring_your_own_buffer) { this.base = conn; this.buf = bring_your_own_buffer ?? new Uint8Array(SIZE.DatagramMtu); this.size = this.buf.byteLength; } async read() { const base = this.base, buf = this.buf, { addr: tjs_addr, nread, partial } = await base.recv(buf), // the return type on tjs is incorrect. { ip: hostname, port, family } = tjs_addr; // console.log("[TJS]: received a packet! from:", tjs_addr) return [buf.slice(0, nread), { hostname, port, family: family }]; } async send(buffer, addr) { let bytes_written = 0; const base = this.base, bytes_to_write = buffer.byteLength, { hostname: ip, port, family } = addr, tjs_addr = { ip, port, family }; // yes, we should permit empty messages, even if the underlying `base` class may ignore zero sized messages. if (bytes_to_write === 0) { return base.send(buffer, tjs_addr); } // the return type on tjs is incorrect. while (bytes_written < bytes_to_write) { // the return type on tjs is incorrect. bytes_written += await base.send(buffer.subarray(bytes_written), tjs_addr); } // TODO: should we assert `bytes_written === bytes_to_write`? return bytes_written; } close() { this.base.close(); } } /** a {@link NetConn} interface implementation wrapper for node's `dgram` udp implementation. */ export class NodeUdpNetConn { base; queue; size; constructor(conn) { const dataQueue = new AwaitableQueue(); this.base = conn; this.size = conn.getRecvBufferSize(); this.queue = dataQueue; conn.on("message", (data, info) => { // TODO: as noted in "https://nodejs.org/api/dgram.html#event-message", // the `address` may contain the name of the network-interface (like "eth0", or "enp0") if the data comes from a localhost. // should I be stripping away that info or not? I don't know. const { address: hostname, family: family_str, port } = info, addr = { hostname, port, family: string_toLowerCase(family_str) === "ipv6" ? 6 : 4, }; dataQueue.push([new Uint8Array(data), addr]); }); } read() { return this.queue.shift(); } async send(buffer, addr) { const base = this.base, bytes_to_write = buffer.byteLength, { hostname, port, family } = addr, [promise, resolve, reject] = promise_outside(); // from what I read, node queues the entire buffer to be sent in one go. // however, if the buffer's size exceeds the os-level MTU size, the os will return an `"EMSGSIZE"` error, // which will mean that **nothing** has been sent; and so, _we_ will have to segment our buffer into tinier bits. // so, what I'm going to do is that each time we encounter that issue, I will split the size of the buffer into half, // and then send it as two packets instead of one. // (note: you cannot send it as an array of two buffers, as it will be concatenated onto one for a single datagram, and not _two_ datagrams) base.send(buffer, port, hostname, async (err, bytes_sent) => { // assert `bytes_sent === bytes_to_write` if no `err` exists if (err) { if (err.code === "EMSGSIZE") { try { const // the first packet will always be larger or equal to the second. // this way, it should't be possible for the first packet to get transmitted, while the second one gets rejected for its size. midway = math_ceil(bytes_to_write / 2), bytesize1 = await this.send(buffer.subarray(0, midway), addr), bytesize2 = await this.send(buffer.subarray(midway), addr); return resolve(bytesize1 + bytesize2); } catch (e) { err = e; } } reject(err); } return resolve(bytes_sent); }); return promise; } close() { this.base.close(); } } /** a {@link NetConn} interface implementation wrapper for bun's `Bun.udpSocket` udp implementation. */ export class BunUdpNetConn { base; queue; writeIsFree; writeIsFreeResolve; size; constructor(conn) { const _this = this, dataQueue = new AwaitableQueue(); this.base = conn; this.queue = dataQueue; this.writeIsFree = promise_resolve(); this.writeIsFreeResolve = noop; this.size = number_MAX_SAFE_INTEGER; // bun only permits a single handler for every even. so, to update it, we must use the `reload` method on the udp socket. conn.reload({ data(self_socket, data, port, hostname) { const addr = { hostname, port, family: 4 }; // TODO: ideally, I should be parsing `hostname` to figure out the `family`. dataQueue.push([new Uint8Array(data), addr]); }, drain(self_socket) { // when a udp packet that's too large for the os to accept is written, a `false` is returned by `this.base.send()`. // to proceed with sending more data, we must wait for bu to trigger the `drain` method/hander to indicate that it is ready. // moreover, we'll probably have to split our data the next time we attempt to send it over udp. _this.writeIsFreeResolve(); }, }); } read() { return this.queue.shift(); } async send(buffer, addr) { await this.writeIsFree; const bytes_to_write = buffer.byteLength, { hostname, port, family } = addr, status = this.base.send(buffer, port, hostname); // if the `buffer` was not sent due to being oversized, we will split it half and then try again. if (!status) { const [promise, resolve, reject] = promise_outside(); this.writeIsFree = promise; this.writeIsFreeResolve = resolve; const // the first packet will always be larger or equal to the second. // this way, it should't be possible for the first packet to get transmitted, while the second one gets rejected for its size. midway = math_ceil(bytes_to_write / 2), // below, we are implicitly waiting for the "drain" event to get triggered first and resolve `this.writeIsFree`, before continuing. bytesize1 = await this.send(buffer.subarray(0, midway), addr), bytesize2 = await this.send(buffer.subarray(midway), addr); return (bytesize1 + bytesize2); } return bytes_to_write; } close() { this.base.close(); } }