From da0b945804f19903beac71b23ff1040ebdb9b554 Mon Sep 17 00:00:00 2001 From: Yoshiya Hinosawa Date: Wed, 4 Oct 2023 11:37:39 +0900 Subject: [PATCH] feat(unstable): add unix domain socket support to Deno.serve (#20759) --- cli/tests/unit/net_test.ts | 7 +- cli/tests/unit/serve_test.ts | 35 +++++++++ cli/tests/unit/test_util.ts | 7 +- cli/tsc/dts/lib.deno.unstable.d.ts | 121 +++++++++++++++++++++++++++++ ext/http/00_serve.js | 44 ++++++++++- ext/net/01_net.js | 9 ++- ext/net/ops_unix.rs | 8 +- 7 files changed, 216 insertions(+), 15 deletions(-) diff --git a/cli/tests/unit/net_test.ts b/cli/tests/unit/net_test.ts index 54edf31fc7..2a98b5e26f 100644 --- a/cli/tests/unit/net_test.ts +++ b/cli/tests/unit/net_test.ts @@ -9,8 +9,8 @@ import { delay, execCode, execCode2, + tmpUnixSocketPath, } from "./test_util.ts"; -import { join } from "../../../test_util/std/path/mod.ts"; // Since these tests may run in parallel, ensure this port is unique to this file const listenPort = 4503; @@ -49,11 +49,6 @@ Deno.test( }, ); -function tmpUnixSocketPath(): string { - const folder = Deno.makeTempDirSync(); - return join(folder, "socket"); -} - Deno.test( { ignore: Deno.build.os === "windows", diff --git a/cli/tests/unit/serve_test.ts b/cli/tests/unit/serve_test.ts index 193b04ed14..6f58db006b 100644 --- a/cli/tests/unit/serve_test.ts +++ b/cli/tests/unit/serve_test.ts @@ -15,6 +15,7 @@ import { deferred, execCode, fail, + tmpUnixSocketPath, } from "./test_util.ts"; // Since these tests may run in parallel, ensure this port is unique to this file @@ -3715,3 +3716,37 @@ async function curlRequestWithStdErr(args: string[]) { assert(success); return [new TextDecoder().decode(stdout), new TextDecoder().decode(stderr)]; } + +Deno.test( + { + ignore: Deno.build.os === "windows", + permissions: { run: true, read: true, write: true }, + }, + async function httpServerUnixDomainSocket() { + const d = deferred(); + const ac = new AbortController(); + const filePath = tmpUnixSocketPath(); + const server = Deno.serve( + { + signal: ac.signal, + path: filePath, + onListen(info) { + d.resolve(info); + }, + onError: createOnErrorCb(ac), + }, + (_req, { remoteAddr }) => { + assertEquals(remoteAddr, { path: filePath, transport: "unix" }); + return new Response("hello world!"); + }, + ); + + assertEquals(await d, { path: filePath }); + assertEquals( + "hello world!", + await curlRequest(["--unix-socket", filePath, "http://localhost"]), + ); + ac.abort(); + await server.finished; + }, +); diff --git a/cli/tests/unit/test_util.ts b/cli/tests/unit/test_util.ts index 23713faf4a..de1e8e8c5d 100644 --- a/cli/tests/unit/test_util.ts +++ b/cli/tests/unit/test_util.ts @@ -2,7 +2,7 @@ import * as colors from "../../../test_util/std/fmt/colors.ts"; export { colors }; -import { resolve } from "../../../test_util/std/path/mod.ts"; +import { join, resolve } from "../../../test_util/std/path/mod.ts"; export { assert, assertEquals, @@ -81,3 +81,8 @@ export function execCode2(code: string) { }, }; } + +export function tmpUnixSocketPath(): string { + const folder = Deno.makeTempDirSync(); + return join(folder, "socket"); +} diff --git a/cli/tsc/dts/lib.deno.unstable.d.ts b/cli/tsc/dts/lib.deno.unstable.d.ts index 11510d144f..4d909a7893 100644 --- a/cli/tsc/dts/lib.deno.unstable.d.ts +++ b/cli/tsc/dts/lib.deno.unstable.d.ts @@ -1948,6 +1948,127 @@ declare namespace Deno { shutdown(): Promise; } + export interface ServeUnixOptions { + /** The unix domain socket path to listen on. */ + path: string; + + /** An {@linkcode AbortSignal} to close the server and all connections. */ + signal?: AbortSignal; + + /** The handler to invoke when route handlers throw an error. */ + onError?: (error: unknown) => Response | Promise; + + /** The callback which is called when the server starts listening. */ + onListen?: (params: { path: string }) => void; + } + + /** Information for a unix domain socket HTTP request. + * + * @category HTTP Server + */ + export interface ServeUnixHandlerInfo { + /** The remote address of the connection. */ + remoteAddr: Deno.UnixAddr; + } + + /** A handler for unix domain socket HTTP requests. Consumes a request and returns a response. + * + * If a handler throws, the server calling the handler will assume the impact + * of the error is isolated to the individual request. It will catch the error + * and if necessary will close the underlying connection. + * + * @category HTTP Server + */ + export type ServeUnixHandler = ( + request: Request, + info: ServeUnixHandlerInfo, + ) => Response | Promise; + + /** + * @category HTTP Server + */ + export interface ServeUnixInit { + /** The handler to invoke to process each incoming request. */ + handler: ServeUnixHandler; + } + + /** Serves HTTP requests with the given option bag and handler. + * + * You can specify the socket path with `path` option. + * + * ```ts + * Deno.serve( + * { path: "path/to/socket" }, + * (_req) => new Response("Hello, world") + * ); + * ``` + * + * You can stop the server with an {@linkcode AbortSignal}. The abort signal + * needs to be passed as the `signal` option in the options bag. The server + * aborts when the abort signal is aborted. To wait for the server to close, + * await the promise returned from the `Deno.serve` API. + * + * ```ts + * const ac = new AbortController(); + * + * const server = Deno.serve( + * { signal: ac.signal, path: "path/to/socket" }, + * (_req) => new Response("Hello, world") + * ); + * server.finished.then(() => console.log("Server closed")); + * + * console.log("Closing server..."); + * ac.abort(); + * ``` + * + * By default `Deno.serve` prints the message + * `Listening on path/to/socket` on listening. If you like to + * change this behavior, you can specify a custom `onListen` callback. + * + * ```ts + * Deno.serve({ + * onListen({ path }) { + * console.log(`Server started at ${path}`); + * // ... more info specific to your server .. + * }, + * path: "path/to/socket", + * }, (_req) => new Response("Hello, world")); + * ``` + * + * @category HTTP Server + */ + export function serve( + options: ServeUnixOptions, + handler: ServeUnixHandler, + ): Server; + /** Serves HTTP requests with the given option bag. + * + * You can specify an object with the path option, which is the + * unix domain socket to listen on. + * + * ```ts + * const ac = new AbortController(); + * + * const server = Deno.serve({ + * path: "path/to/socket", + * handler: (_req) => new Response("Hello, world"), + * signal: ac.signal, + * onListen({ path }) { + * console.log(`Server started at ${path}`); + * }, + * }); + * server.finished.then(() => console.log("Server closed")); + * + * console.log("Closing server..."); + * ac.abort(); + * ``` + * + * @category HTTP Server + */ + export function serve( + options: ServeUnixInit & ServeUnixOptions, + ): Server; + /** * A namespace containing runtime APIs available in Jupyter notebooks. * diff --git a/ext/http/00_serve.js b/ext/http/00_serve.js index aeebca93df..e74e1e71f9 100644 --- a/ext/http/00_serve.js +++ b/ext/http/00_serve.js @@ -34,11 +34,12 @@ import { ReadableStreamPrototype, resourceForReadableStream, } from "ext:deno_web/06_streams.js"; -import { listen, TcpConn } from "ext:deno_net/01_net.js"; +import { listen, listenOptionApiName, TcpConn } from "ext:deno_net/01_net.js"; import { listenTls } from "ext:deno_net/02_tls.js"; const { ArrayPrototypePush, Error, + ObjectHasOwn, ObjectPrototypeIsPrototypeOf, PromisePrototypeCatch, Symbol, @@ -272,6 +273,13 @@ class InnerRequest { } get remoteAddr() { + const transport = this.#context.listener?.addr.transport; + if (transport === "unix" || transport === "unixpacket") { + return { + transport, + path: this.#context.listener.addr.path, + }; + } if (this.#methodAndUri === undefined) { if (this.#slabId === undefined) { throw new TypeError("request closed"); @@ -337,8 +345,9 @@ class CallbackContext { serverRid; closed; closing; + listener; - constructor(signal, args) { + constructor(signal, args, listener) { // The abort signal triggers a non-graceful shutdown signal?.addEventListener( "abort", @@ -352,6 +361,7 @@ class CallbackContext { this.scheme = args[1]; this.fallbackHost = args[2]; this.closed = false; + this.listener = listener; } close() { @@ -519,11 +529,29 @@ function serve(arg1, arg2) { } const wantsHttps = options.cert || options.key; + const wantsUnix = ObjectHasOwn(options, "path"); const signal = options.signal; const onError = options.onError ?? function (error) { console.error(error); return internalServerError(); }; + + if (wantsUnix) { + const listener = listen({ + transport: "unix", + path: options.path, + [listenOptionApiName]: "Deno.serve", + }); + const path = listener.addr.path; + return serveHttpOnListener(listener, signal, handler, onError, () => { + if (options.onListen) { + options.onListen({ path }); + } else { + console.log(`Listening on ${path}`); + } + }); + } + const listenOpts = { hostname: options.hostname ?? "0.0.0.0", port: options.port ?? 8000, @@ -581,7 +609,11 @@ function serve(arg1, arg2) { * Serve HTTP/1.1 and/or HTTP/2 on an arbitrary listener. */ function serveHttpOnListener(listener, signal, handler, onError, onListen) { - const context = new CallbackContext(signal, op_http_serve(listener.rid)); + const context = new CallbackContext( + signal, + op_http_serve(listener.rid), + listener, + ); const callback = mapToCallback(context, handler, onError); onListen(context.scheme); @@ -593,7 +625,11 @@ function serveHttpOnListener(listener, signal, handler, onError, onListen) { * Serve HTTP/1.1 and/or HTTP/2 on an arbitrary connection. */ function serveHttpOnConnection(connection, signal, handler, onError, onListen) { - const context = new CallbackContext(signal, op_http_serve_on(connection.rid)); + const context = new CallbackContext( + signal, + op_http_serve_on(connection.rid), + null, + ); const callback = mapToCallback(context, handler, onError); onListen(context.scheme); diff --git a/ext/net/01_net.js b/ext/net/01_net.js index 9cdcdb78c3..f2bf5e7dfa 100644 --- a/ext/net/01_net.js +++ b/ext/net/01_net.js @@ -19,6 +19,7 @@ const { ObjectPrototypeIsPrototypeOf, PromiseResolve, SymbolAsyncIterator, + Symbol, SymbolFor, TypeError, TypedArrayPrototypeSubarray, @@ -416,6 +417,8 @@ class Datagram { } } +const listenOptionApiName = Symbol("listenOptionApiName"); + function listen(args) { switch (args.transport ?? "tcp") { case "tcp": { @@ -427,7 +430,10 @@ function listen(args) { return new Listener(rid, addr); } case "unix": { - const { 0: rid, 1: path } = ops.op_net_listen_unix(args.path); + const { 0: rid, 1: path } = ops.op_net_listen_unix( + args.path, + args[listenOptionApiName] ?? "Deno.listen", + ); const addr = { transport: "unix", path, @@ -505,6 +511,7 @@ export { Datagram, listen, Listener, + listenOptionApiName, resolveDns, shutdown, TcpConn, diff --git a/ext/net/ops_unix.rs b/ext/net/ops_unix.rs index beb41bb4a8..7a5da9fa1b 100644 --- a/ext/net/ops_unix.rs +++ b/ext/net/ops_unix.rs @@ -194,15 +194,17 @@ where pub fn op_net_listen_unix( state: &mut OpState, #[string] path: String, + #[string] api_name: String, ) -> Result<(ResourceId, Option), AnyError> where NP: NetPermissions + 'static, { let address_path = Path::new(&path); - super::check_unstable(state, "Deno.listen"); + super::check_unstable(state, &api_name); let permissions = state.borrow_mut::(); - permissions.check_read(address_path, "Deno.listen()")?; - permissions.check_write(address_path, "Deno.listen()")?; + let api_call_expr = format!("{}()", api_name); + permissions.check_read(address_path, &api_call_expr)?; + permissions.check_write(address_path, &api_call_expr)?; let listener = UnixListener::bind(address_path)?; let local_addr = listener.local_addr()?; let pathname = local_addr.as_pathname().map(pathstring).transpose()?;