diff --git a/README.md b/README.md index 0d93aa3..ab7589c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,44 @@ # deno_socket + A Deno Implementation of the Cloudflare Socket API + +Deno already has great support for TCP, but since Cloudflare proposed a +[WinterCG spec](https://sockets-api.proposal.wintercg.org/), this provides the +same interface for Deno so we could migrate easier later on. + +Inspired by `@arrowood.dev/socket`, but with Deno's runtime APIs. + +## Usage + +```js +import { connect } from "https://deno.land/x/socket/mod.js"; + +export default { + async fetch(request) { + const gopherAddr = { hostname: "gopher.floodgap.com", port: 70 }; + const url = new URL(req.url); + + try { + const socket = connect(gopherAddr); + + const writer = socket.writable.getWriter() + const encoder = new TextEncoder(); + const encoded = encoder.encode(url.pathname + "\r\n"); + await writer.write(encoded); + + return new Response(socket.readable, { headers: { "Content-Type": "text/plain" } }); + } catch (error) { + return new Response("Socket connection failed: " + error, { status: 500 }); + } + } +}; +``` + +The module can also be run as a simple CLI to send a message to a TCP server, +similar to the example above, but send response to `stdout`. + +For example: + +```shell +$ deno run --allow-net mod.js gopher.floodgap.com:70 / +``` \ No newline at end of file diff --git a/mod.js b/mod.js new file mode 100644 index 0000000..1271788 --- /dev/null +++ b/mod.js @@ -0,0 +1,212 @@ +/** + * @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} + */ + readable; + /** + * @type {WritableStream} + */ + 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} + */ + 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} + */ + closed; + + /** @type {boolean} */ + #allowHalfOpen; + #closedResolve; + #closedReject; + /** @type {'on'|'off'|'starttls'} */ + #secureTransport; + /** @type {Deno.Conn} */ + #socket; + /** @type {boolean} */ + #startTlsCalled; + + /** + * @param {SocketAddress|Promise} addressOrSocket + * @param {SocketOptions} [options] + */ + constructor(addressOrSocket, options) { + this.allowHalfOpen = options?.allowHalfOpen ?? true; + this.secureTransport = options?.secureTransport ?? "off"; + + this.closed = new Promise((resolve, reject) => { + this.#closedResolve = (...args) => { + resolve(...args); + }; + this.#closedReject = (...args) => { + reject(...args); + }; + }); + + if (isSocketAddress(addressOrSocket)) { + /** + * @type {Deno.ConnectTlsOptions} + */ + const connectOptions = { + hostname: addressOrSocket.hostname, + port: addressOrSocket.port, + // allowHalfOpen: this.allowHalfOpen, + }; + + const resolve = (conn) => { + this.#socket = conn; + return { + remoteAddress: conn.remoteAddr, + localAddress: conn.localAddr, + }; + }; + if (this.#secureTransport === "on") { + this.opened = Deno.connectTls(connectOptions).then(resolve); + } else { + this.opened = Deno.connect(connectOptions).then(resolve); + } + } else { + this.opened = addressOrSocket.then(Deno.startTls).then(resolve); + } + + const input = new TransformStream(); + const output = new TransformStream(); + this.opened.then(() => { + this.#socket.readable.pipeTo(input.writable); + output.readable.pipeTo(this.#socket.writable); + }); + + this.readable = input.readable; + this.writable = output.writable; + } + + async close() { + await this.opened; + try { + this.#socket.close(); + } catch { + // ignore + } + + this.#closedResolve(); + + return this.closed; + } + + /** + * Start TLS handshake from an existing connection + * @returns {Socket} + */ + startTls() { + if (this.#secureTransport !== "starttls") { + throw new SocketError("secureTransport must be set to 'starttls'"); + } + if (this.#startTlsCalled) { + throw new SocketError("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") + ); +} + +export class SocketError extends TypeError { + /** + * @param {string} message + */ + constructor(message) { + super(`SocketError: ${message}`); + } +} + +// Learn more at https://deno.land/manual/examples/module_metadata#concepts +if (import.meta.main) { + const [address, message] = Deno.args; + const socket = connect(address); + + const writer = socket.writable.getWriter(); + const encoder = new TextEncoder(); + const encoded = encoder.encode(message + "\r\n"); + await writer.write(encoded); + + await socket.readable.pipeTo(Deno.stdout.writable); + + await socket.close(); +}