@sntran/socket
Version:
Cross runtime Socket API
211 lines (192 loc) • 5.76 kB
JavaScript
import net from "node:net";
import tls from "node:tls";
import { Duplex } from "node:stream";
/**
* @typedef {Object} SocketAddress
*
* The hostname to connect to. Example: `gopher.floodgap.com`.
* @property {string} hostname
*
* The port number to connect to. Example: `70`.
* @property {number} port
*/
/**
* @typedef {(string|SocketAddress)} AnySocketAddress
*/
/**
* @typedef {Object} SocketOptions
*
* Specifies whether or not to use TLS when creating the TCP socket.
* `off` — Do not use TLS.
* `on` — Use TLS.
* `starttls` — Do not use TLS initially, but allow the socket to be upgraded to use TLS by calling startTls().
* @property {'on'|'off'|'starttls'} [secureTransport]
*
* Defines whether the writable side of the TCP socket will automatically close on end-of-file (EOF).
* When set to false, the writable side of the TCP socket will automatically close on EOF.
* When set to true, the writable side of the TCP socket will remain open on EOF.
* This option is similar to that offered by the Node.js net module and allows interoperability with code which utilizes it.
* @property {boolean} [allowHalfOpen]
*/
/**
* @typedef {Object} SocketInfo
* @property {string} remoteAddress
* @property {string} localAddress
*/
/**
* @param {AnySocketAddress} address
* @param {SocketOptions} [options]
* @returns {Socket}
*/
export function connect(address, options) {
if (typeof address === "string") {
const url = new URL(`https://${address}`);
address = {
hostname: url.hostname,
port: parseInt(url.port === "" ? "443" : url.port),
};
}
return new Socket(address, options);
}
export class Socket {
/**
* @type {ReadableStream<Uint8Array>}
*/
readable;
/**
* @type {WritableStream<Uint8Array>}
*/
writable;
/**
* A promise that is resolved when the socket connection has been
* successfully established, or is rejected if the connection fails.
* For sockets which use secure-transport, the resolution of the `opened`
* promise indicates the completion of the secure handshake.
* @type {Promise<SocketInfo>}
*/
opened;
/**
* A promise which can be used to keep track of the socket state. It gets
* resolved under the following circumstances:
* - the `close()` method is called on the socket
* - the socket was constructed with the `allowHalfOpen` parameter set to
* `false`, the ReadableStream is being read from, and the remote
* connection sends a FIN packet (graceful closure) or a RST packet.
* @type {Promise<void>}
*/
closed;
/** @type {boolean} */
#allowHalfOpen;
#closedResolve;
#closedReject;
/** @type {'on'|'off'|'starttls'} */
#secureTransport;
/** @type {net.Socket|tls.TLSSocket} */
#socket;
#writer;
#reader;
/** @type {boolean} */
#startTlsCalled;
/**
* @param {SocketAddress|Promise<Conn>} addressOrSocket
* @param {SocketOptions} [options]
*/
constructor(addressOrSocket, options) {
this.#allowHalfOpen = options?.allowHalfOpen ?? false;
this.#secureTransport = options?.secureTransport ?? "off";
if (isSocketAddress(addressOrSocket)) {
/**
* @type {net.SocketConnectOpts | tls.ConnectionOptions}
*/
const connectOptions = {
host: addressOrSocket.hostname,
port: addressOrSocket.port,
allowHalfOpen: this.#allowHalfOpen,
};
if (this.#secureTransport === "on") {
this.#socket = tls.connect(connectOptions);
} else {
this.#socket = net.connect(connectOptions);
}
} else {
this.#socket = new tls.TLSSocket(addressOrSocket);
}
if (this.#socket instanceof tls.TLSSocket) {
this.opened = new Promise((resolve, reject) => {
let openedIsResolved = false;
this.#socket.on("secureConnect", () => {
openedIsResolved = true;
resolve(this.#socket);
});
this.#socket.on("error", (error) => {
if (!openedIsResolved) {
reject(error);
}
});
});
} else {
this.opened = new Promise((resolve, reject) => {
let openedIsResolved = false;
this.#socket.on("connect", () => {
openedIsResolved = true;
resolve(this.#socket);
});
this.#socket.on("error", (error) => {
if (!openedIsResolved) {
reject(error);
}
});
});
}
this.closed = new Promise((resolve, reject) => {
this.#socket.on("close", (hadError) => {
if (!hadError) {
resolve();
}
});
this.#socket.on("error", (error) => {
reject(error);
});
});
// FIXME: Bun doesn't support `Duplex.toWeb` yet
const { readable, writable } = Duplex.toWeb(this.#socket);
this.readable = readable;
this.writable = writable;
}
/**
* Closes the socket
* @returns {Promise<void>}
*/
async close() {
await this.opened;
this.#socket.end();
return this.closed;
}
/**
* Start TLS handshake from an existing connection
* @returns {Socket}
*/
startTls() {
if (this.#secureTransport !== "starttls") {
throw new Error("secureTransport must be set to 'starttls'");
}
if (this.#startTlsCalled) {
throw new Error("can only call startTls once");
} else {
this.#startTlsCalled = true;
}
return new Socket(this.opened, { secureTransport: "on" });
}
}
/**
* @param {unknown} address
* @returns {boolean} whether the address is a SocketAddress
*/
function isSocketAddress(address) {
return (
typeof address === "object" &&
address !== null &&
Object.hasOwn(address, "hostname") &&
Object.hasOwn(address, "port")
);
}