-
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.
- Loading branch information
Showing
2 changed files
with
254 additions
and
0 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 |
---|---|---|
@@ -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 / | ||
``` |
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,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<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 {Deno.Conn} */ | ||
#socket; | ||
/** @type {boolean} */ | ||
#startTlsCalled; | ||
|
||
/** | ||
* @param {SocketAddress|Promise<Conn>} 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(); | ||
} |