diff --git a/Makefile b/Makefile index f080534f2059..b5d3f9f4c083 100644 --- a/Makefile +++ b/Makefile @@ -634,6 +634,9 @@ boringssl-debug: boringssl-build-debug boringssl-copy compile-ffi-test: clang $(OPTIMIZATION_LEVEL) -shared -undefined dynamic_lookup -o /tmp/bun-ffi-test.dylib -fPIC ./test/js/bun/ffi/ffi-test.c +.PHONY: compile-direct-fd-test +compile-direct-fd-test: + zig build-lib ./test/js/bun/net/direct-fd-test.zig -dynamic -OReleaseFast -femit-bin=/tmp/libdirect-fd-test sqlite: diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index f00c2cd451fb..732e1d05e026 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1512,6 +1512,18 @@ declare module "bun" { */ hostname?: string; + /** + * Instead of binding and listening to a hostname and port, the server + * can operate off a socket which has already been bound and listened + * by a separate process. This enables "socket activated" deployments. + * + * @example + * ```js + * process.env.LISTEN_FDS // Use fd passed by systemd socket activation + * ``` + */ + fd?: string | number; + /** * What URI should be used to make {@link Request.url} absolute? * @@ -3030,8 +3042,9 @@ declare module "bun" { interface TCPSocketListenOptions extends SocketOptions { - hostname: string; - port: number; + fd?: number; + hostname?: string; + port?: number; tls?: TLSOptions; } @@ -3075,6 +3088,7 @@ declare module "bun" { * @param options.data The per-instance data context * @param options.hostname The hostname to connect to * @param options.port The port to connect to + * @param options.fd The bound socket to attach to * @param options.tls The TLS configuration object * @param options.unix The unix socket to connect to * diff --git a/src/bun.js/api/bun/socket.zig b/src/bun.js/api/bun/socket.zig index 8b78cf035c9b..2a85c8183daa 100644 --- a/src/bun.js/api/bun/socket.zig +++ b/src/bun.js/api/bun/socket.zig @@ -219,6 +219,7 @@ const Handlers = struct { pub const SocketConfig = struct { hostname_or_unix: JSC.ZigString.Slice, port: ?u16 = null, + fd: ?uws.socket_t = null, ssl: ?JSC.API.ServerConfig.SSLConfig = null, handlers: Handlers, default_data: JSC.JSValue = .zero, @@ -229,6 +230,7 @@ pub const SocketConfig = struct { globalObject: *JSC.JSGlobalObject, exception: JSC.C.ExceptionRef, ) ?SocketConfig { + var fd: ?uws.socket_t = null; var hostname_or_unix: JSC.ZigString.Slice = JSC.ZigString.Slice.empty; var port: ?u16 = null; var exclusive = false; @@ -252,71 +254,76 @@ pub const SocketConfig = struct { } } - hostname_or_unix: { - if (opts.getTruthy(globalObject, "unix")) |unix_socket| { - if (!unix_socket.isString()) { - exception.* = JSC.toInvalidArguments("Expected \"unix\" to be a string", .{}, globalObject).asObjectRef(); - return null; - } + // Currently excludes 0 (stdin) + if (opts.getTruthy(globalObject, "fd")) |fd_value| { + if (!fd_value.isNumber() or fd_value.toInt64() < 0) { + exception.* = JSC.toInvalidArguments("Need \"fd\" to be a nonnegative integer", .{}, globalObject).asObjectRef(); + return null; + } - hostname_or_unix = unix_socket.getZigString(globalObject).toSlice(bun.default_allocator); + fd = @as(uws.socket_t, fd_value.toInt32()); + } else { + hostname_or_unix: { + if (opts.getTruthy(globalObject, "unix")) |unix_socket| { + if (!unix_socket.isString()) { + exception.* = JSC.toInvalidArguments("Expected \"unix\" to be a string", .{}, globalObject).asObjectRef(); + return null; + } - if (strings.hasPrefixComptime(hostname_or_unix.slice(), "file://") or strings.hasPrefixComptime(hostname_or_unix.slice(), "unix://") or strings.hasPrefixComptime(hostname_or_unix.slice(), "sock://")) { - hostname_or_unix.ptr += 7; - hostname_or_unix.len -|= 7; - } + hostname_or_unix = unix_socket.getZigString(globalObject).toSlice(bun.default_allocator); - if (hostname_or_unix.len > 0) { - break :hostname_or_unix; - } - } + if (strings.hasPrefixComptime(hostname_or_unix.slice(), "file://") or strings.hasPrefixComptime(hostname_or_unix.slice(), "unix://") or strings.hasPrefixComptime(hostname_or_unix.slice(), "sock://")) { + hostname_or_unix.ptr += 7; + hostname_or_unix.len -|= 7; + } - if (opts.getTruthy(globalObject, "exclusive")) |_| { - exclusive = true; - } + if (hostname_or_unix.len > 0) { + break :hostname_or_unix; + } + } - if (opts.getTruthy(globalObject, "hostname") orelse opts.getTruthy(globalObject, "host")) |hostname| { - if (!hostname.isString()) { - exception.* = JSC.toInvalidArguments("Expected \"hostname\" to be a string", .{}, globalObject).asObjectRef(); - return null; + if (opts.getTruthy(globalObject, "exclusive")) |_| { + exclusive = true; } - var port_value = opts.get(globalObject, "port") orelse JSValue.zero; - hostname_or_unix = hostname.getZigString(globalObject).toSlice(bun.default_allocator); + if (opts.getTruthy(globalObject, "hostname") orelse opts.getTruthy(globalObject, "host")) |hostname| { + if (!hostname.isString()) { + exception.* = JSC.toInvalidArguments("Expected \"hostname\" to be a string", .{}, globalObject).asObjectRef(); + return null; + } + + var port_value = opts.get(globalObject, "port") orelse JSValue.zero; + hostname_or_unix = hostname.getZigString(globalObject).toSlice(bun.default_allocator); - if (port_value.isEmptyOrUndefinedOrNull() and hostname_or_unix.len > 0) { - const parsed_url = bun.URL.parse(hostname_or_unix.slice()); - if (parsed_url.getPort()) |port_num| { - port_value = JSValue.jsNumber(port_num); - hostname_or_unix.ptr = parsed_url.hostname.ptr; - hostname_or_unix.len = @truncate(u32, parsed_url.hostname.len); + if (port_value.isEmptyOrUndefinedOrNull() and hostname_or_unix.len > 0) { + const parsed_url = bun.URL.parse(hostname_or_unix.slice()); + if (parsed_url.getPort()) |port_num| { + port_value = JSValue.jsNumber(port_num); + hostname_or_unix.ptr = parsed_url.hostname.ptr; + hostname_or_unix.len = @truncate(u32, parsed_url.hostname.len); + } } - } - if (port_value.isEmptyOrUndefinedOrNull() or !port_value.isNumber() or port_value.toInt64() > std.math.maxInt(u16) or port_value.toInt64() < 0) { - exception.* = JSC.toInvalidArguments("Expected \"port\" to be a number between 0 and 65535", .{}, globalObject).asObjectRef(); - return null; - } + if (port_value.isEmptyOrUndefinedOrNull() or !port_value.isNumber() or port_value.toInt64() > std.math.maxInt(u16) or port_value.toInt64() < 0) { + exception.* = JSC.toInvalidArguments("Expected \"port\" to be a number between 0 and 65535", .{}, globalObject).asObjectRef(); + return null; + } - port = port_value.toU16(); + port = port_value.toU16(); - if (hostname_or_unix.len == 0) { - exception.* = JSC.toInvalidArguments("Expected \"hostname\" to be a non-empty string", .{}, globalObject).asObjectRef(); - return null; - } + if (hostname_or_unix.len == 0) { + exception.* = JSC.toInvalidArguments("Expected \"hostname\" to be a non-empty string", .{}, globalObject).asObjectRef(); + return null; + } - if (hostname_or_unix.len > 0) { - break :hostname_or_unix; + if (hostname_or_unix.len > 0) { + break :hostname_or_unix; + } } - } - if (hostname_or_unix.len == 0) { - exception.* = JSC.toInvalidArguments("Expected \"unix\" or \"hostname\" to be a non-empty string", .{}, globalObject).asObjectRef(); + exception.* = JSC.toInvalidArguments("Expected \"fd\", \"hostname\" or \"unix\"", .{}, globalObject).asObjectRef(); return null; } - - exception.* = JSC.toInvalidArguments("Expected either \"hostname\" or \"unix\"", .{}, globalObject).asObjectRef(); - return null; } const handlers = Handlers.fromJS(globalObject, opts.get(globalObject, "socket") orelse JSValue.zero, exception) orelse { @@ -329,6 +336,7 @@ pub const SocketConfig = struct { } return SocketConfig{ + .fd = fd, .hostname_or_unix = hostname_or_unix, .port = port, .ssl = ssl, @@ -345,7 +353,7 @@ pub const Listener = struct { handlers: Handlers, listener: ?*uws.ListenSocket = null, poll_ref: JSC.PollRef = JSC.PollRef.init(), - connection: UnixOrHost, + connection: FdOrUnixOrHost, socket_context: ?*uws.SocketContext = null, ssl: bool = false, @@ -372,15 +380,19 @@ pub const Listener = struct { return true; } - const UnixOrHost = union(enum) { + const FdOrUnixOrHost = union(enum) { + fd: uws.socket_t, unix: []const u8, host: struct { host: []const u8, port: u16, }, - pub fn deinit(this: UnixOrHost) void { + pub fn deinit(this: FdOrUnixOrHost) void { switch (this) { + .fd => { + // nothing + }, .unix => |u| { bun.default_allocator.destroy(@intToPtr([*]u8, @ptrToInt(u.ptr))); }, @@ -439,6 +451,7 @@ pub const Listener = struct { var socket_config = SocketConfig.fromJS(opts, globalObject, exception) orelse { return .zero; }; + var fd = socket_config.fd; var hostname_or_unix = socket_config.hostname_or_unix; var port = socket_config.port; var ssl = socket_config.ssl; @@ -515,7 +528,9 @@ pub const Listener = struct { ); } - var connection: Listener.UnixOrHost = if (port) |port_| .{ + var connection: Listener.FdOrUnixOrHost = if (fd) |fd_| .{ + .fd = fd_ + } else if (port) |port_| .{ .host = .{ .host = (hostname_or_unix.cloneIfNeeded(bun.default_allocator) catch unreachable).slice(), .port = port_ }, } else .{ .unix = (hostname_or_unix.cloneIfNeeded(bun.default_allocator) catch unreachable).slice(), @@ -523,6 +538,9 @@ pub const Listener = struct { var listen_socket: *uws.ListenSocket = brk: { switch (connection) { + .fd => |f| { + break :brk uws.us_socket_context_listen_direct(@boolToInt(ssl_enabled), socket_context, f, socket_flags, 8); + }, .host => |c| { var host = bun.default_allocator.dupeZ(u8, c.host) catch unreachable; defer bun.default_allocator.free(host); @@ -729,6 +747,14 @@ pub const Listener = struct { return JSValue.jsNumber(this.connection.host.port); } + + pub fn getFD(this: *Listener, _: *JSC.JSGlobalObject) callconv(.C) JSValue { + if (this.connection != .fd) { + return JSValue.jsUndefined(); + } + + return JSValue.jsNumber(this.connection.fd); + } pub fn ref(this: *Listener, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSValue { var this_value = callframe.this(); @@ -774,7 +800,7 @@ pub const Listener = struct { globalObject.bunVM().eventLoop().ensureWaker(); var socket_context = uws.us_create_bun_socket_context(@boolToInt(ssl_enabled), uws.Loop.get().?, @sizeOf(usize), ctx_opts).?; - var connection: Listener.UnixOrHost = if (port) |port_| .{ + var connection: Listener.FdOrUnixOrHost = if (port) |port_| .{ .host = .{ .host = (hostname_or_unix.cloneIfNeeded(bun.default_allocator) catch unreachable).slice(), .port = port_ }, } else .{ .unix = (hostname_or_unix.cloneIfNeeded(bun.default_allocator) catch unreachable).slice(), @@ -913,8 +939,12 @@ fn NewSocket(comptime ssl: bool) type { return this.has_pending_activity.load(.Acquire); } - pub fn doConnect(this: *This, connection: Listener.UnixOrHost, socket_ctx: *uws.SocketContext) !void { + pub fn doConnect(this: *This, connection: Listener.FdOrUnixOrHost, socket_ctx: *uws.SocketContext) !void { switch (connection) { + .fd => { + // fd used only for listen + return error.ConnectionFailed; + }, .host => |c| { _ = @This().Socket.connectPtr( normalizeHost(c.host), diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 762d35bb6540..b2d34a748a78 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -92,6 +92,7 @@ const DateTime = bun.DateTime; const linux = std.os.linux; pub const ServerConfig = struct { + fd: ?uws.socket_t = null, port: u16 = 0, hostname: [*:0]const u8 = "localhost", @@ -556,6 +557,12 @@ pub const ServerConfig = struct { } } + if (arg.getTruthy(global, "fd")) |fd_| { + args.fd = @intCast( + i32, + fd_.coerce(i32, global)); + } + if (arg.getTruthy(global, "port")) |port_| { args.port = @intCast( u16, @@ -5206,6 +5213,7 @@ pub fn NewServer(comptime ssl_enabled_: bool, comptime debug_mode_: bool) type { } this.app.listenWithConfig(*ThisServer, this, onListen, .{ + .fd = this.config.fd orelse 0, .port = this.config.port, .host = host, .options = 0, diff --git a/src/deps/_libusockets.h b/src/deps/_libusockets.h index a8bbf180b155..0a8396bcec99 100644 --- a/src/deps/_libusockets.h +++ b/src/deps/_libusockets.h @@ -15,6 +15,17 @@ typedef struct StringPointer { } StringPointer; #endif +/* Define what a socket descriptor is based on platform */ +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#define LIBUS_SOCKET_DESCRIPTOR SOCKET +#else +#define LIBUS_SOCKET_DESCRIPTOR int +#endif + #ifdef __cplusplus extern "C" { #endif @@ -63,7 +74,7 @@ enum uws_opcode_t : int32_t { enum uws_sendstatus_t : uint32_t { BACKPRESSURE, SUCCESS, DROPPED }; typedef struct { - + LIBUS_SOCKET_DESCRIPTOR fd; int port; const char *host; int options; @@ -167,8 +178,9 @@ void uws_app_run(int ssl, uws_app_t *); void uws_app_listen(int ssl, uws_app_t *app, int port, uws_listen_handler handler, void *user_data); -void uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, - uint16_t port, int32_t options, +void uws_app_listen_with_config(int ssl, uws_app_t *app, + LIBUS_SOCKET_DESCRIPTOR fd, uint16_t port, + const char *host, int32_t options, uws_listen_handler handler, void *user_data); void uws_app_listen_domain(int ssl, uws_app_t *app, const char *domain, uws_listen_domain_handler handler, void *user_data); @@ -336,4 +348,4 @@ void uws_app_close(int ssl, uws_app_t *app); } #endif -#endif \ No newline at end of file +#endif diff --git a/src/deps/libuwsockets.cpp b/src/deps/libuwsockets.cpp index 1533787ee316..266fa1fada62 100644 --- a/src/deps/libuwsockets.cpp +++ b/src/deps/libuwsockets.cpp @@ -336,30 +336,43 @@ extern "C" } } - void uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, - uint16_t port, int32_t options, + void uws_app_listen_with_config(int ssl, uws_app_t *app, + LIBUS_SOCKET_DESCRIPTOR fd, uint16_t port, + const char *host, int32_t options, uws_listen_handler handler, void *user_data) { std::string hostname = host && host[0] ? std::string(host, strlen(host)) : ""; + + /* branching is getting untidy */ if (ssl) { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + if (fd) { + uwsApp->listen( + options, + [handler, user_data](struct us_listen_socket_t *listen_socket) { + handler((struct us_listen_socket_t *)listen_socket, user_data); + }, + fd); + } else { + uwsApp->listen(hostname, port, options, [handler, user_data](struct us_listen_socket_t *listen_socket) + { handler((struct us_listen_socket_t *)listen_socket, user_data); }); + } } else { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + uWS::App *uwsApp = (uWS::App *)app; + if (fd) { + uwsApp->listen( + options, + [handler, user_data](struct us_listen_socket_t *listen_socket) { + handler((struct us_listen_socket_t *)listen_socket, user_data); + }, + fd); + } else { + uwsApp->listen(hostname, port, options, [handler, user_data](struct us_listen_socket_t *listen_socket) + { handler((struct us_listen_socket_t *)listen_socket, user_data); }); + } } } diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 7a4ae7b8605a..68776ef5608d 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -10,6 +10,9 @@ pub const u_int64_t = c_ulonglong; pub const LIBUS_LISTEN_DEFAULT: i32 = 0; pub const LIBUS_LISTEN_EXCLUSIVE_PORT: i32 = 1; pub const Socket = opaque {}; +// Need a concrete socket type for some uSockets calls +// os.socket_t coincides with LIBUS_SOCKET_DESCRIPTOR +pub const socket_t = std.os.socket_t; const uws = @This(); @@ -658,6 +661,7 @@ extern fn us_socket_context_ext(ssl: i32, context: ?*SocketContext) ?*anyopaque; pub extern fn us_socket_context_listen(ssl: i32, context: ?*SocketContext, host: [*c]const u8, port: i32, options: i32, socket_ext_size: i32) ?*ListenSocket; pub extern fn us_socket_context_listen_unix(ssl: i32, context: ?*SocketContext, path: [*c]const u8, options: i32, socket_ext_size: i32) ?*ListenSocket; +pub extern fn us_socket_context_listen_direct(ssl: i32, context: ?*SocketContext, fd: socket_t, options: i32, socket_ext_size: i32) ?*ListenSocket; pub extern fn us_socket_context_connect(ssl: i32, context: ?*SocketContext, host: [*c]const u8, port: i32, source_host: [*c]const u8, options: i32, socket_ext_size: i32) ?*Socket; pub extern fn us_socket_context_connect_unix(ssl: i32, context: ?*SocketContext, path: [*c]const u8, options: i32, socket_ext_size: i32) ?*Socket; pub extern fn us_socket_is_established(ssl: i32, s: ?*Socket) i32; @@ -1303,7 +1307,7 @@ pub fn NewApp(comptime ssl: bool) type { } } }; - return uws_app_listen_with_config(ssl_flag, @ptrCast(*uws_app_t, app), config.host, @intCast(u16, config.port), config.options, Wrapper.handle, user_data); + return uws_app_listen_with_config(ssl_flag, @ptrCast(*uws_app_t, app), config.fd, @intCast(u16, config.port), config.host, config.options, Wrapper.handle, user_data); } pub fn constructorFailed(app: *ThisApp) bool { return uws_constructor_failed(ssl_flag, app); @@ -1705,8 +1709,9 @@ extern fn uws_app_listen(ssl: i32, app: *uws_app_t, port: i32, handler: uws_list extern fn uws_app_listen_with_config( ssl: i32, app: *uws_app_t, - host: [*c]const u8, + fd: socket_t, port: u16, + host: [*c]const u8, options: i32, handler: uws_listen_handler, user_data: ?*anyopaque, @@ -1836,6 +1841,7 @@ pub const SendStatus = enum(c_uint) { dropped = 2, }; pub const uws_app_listen_config_t = extern struct { + fd: socket_t, port: i32, host: [*c]const u8 = null, options: i32, diff --git a/test/js/bun/net/direct-fd-test.zig b/test/js/bun/net/direct-fd-test.zig new file mode 100644 index 000000000000..42b47ea0d556 --- /dev/null +++ b/test/js/bun/net/direct-fd-test.zig @@ -0,0 +1,25 @@ +const std = @import("std"); +const os = std.os; + +pub export fn bind_listen(port: u16) os.socket_t { + const fd = _bind_listen(port) catch { + return -1; + }; + return fd; +} + +pub fn _bind_listen(port: u16) !os.socket_t { + const address = try std.net.Address.resolveIp("127.0.0.1", port); + const fd = try std.os.socket( + address.any.family, + os.SOCK.STREAM | os.SOCK.CLOEXEC | os.SOCK.NONBLOCK, + os.IPPROTO.TCP); + var socklen = address.getOsSockLen(); + try os.bind(fd, &address.any, socklen); + try os.listen(fd, 128); + return fd; +} + +pub export fn close(fd: os.socket_t) void { + os.closeSocket(fd); +} diff --git a/test/js/bun/net/direct-fd.test.js b/test/js/bun/net/direct-fd.test.js new file mode 100644 index 000000000000..c796c630501d --- /dev/null +++ b/test/js/bun/net/direct-fd.test.js @@ -0,0 +1,103 @@ +import { expect, it } from "bun:test"; +import { dlopen, FFIType, suffix } from "bun:ffi"; + +const hostname = '127.0.0.1'; +const fdTest = async (lib, name, test) => { + if (lib) { + it(name, async () => { + let port = 2000 + Math.floor(Math.random() * 30000); + let fd = lib.symbols.bind_listen(port); + if (fd < 0) throw "Couldn't get socket"; + try { + await test(fd, port); + } catch (e) { + throw e; + } finally { + lib.symbols.close(fd); + } + }); + } else { + it.skip(name, () => {}); + } +} + +let lib; +try { + const path = '/tmp/libdirect-fd-test'; + lib = dlopen(path, { + bind_listen: { + args: [FFIType.u16], + returns: FFIType.i32, + }, + + close: { + args: [FFIType.i32] + } + }); +} catch { + console.log("To enable this test, run `make compile-direct-fd-test`."); +} + +await fdTest(lib, "directly listen on fd", async (fd, port) => { + let serverResolve, serverReject, clientResolve, clientReject; + const serverPromise = new Promise((resolve, reject) => { + serverResolve = resolve; + serverReject = reject; + }); + const clientPromise = new Promise((resolve, reject) => { + clientResolve = resolve; + clientReject = reject; + }); + + const hello = new Uint8Array([ 72, 101, 108, 108, 111 ]); + const server = Bun.listen({ + fd, + socket: { + data(socket, data) { + socket.write(hello); + setTimeout(() => { + socket.end(); + serverResolve(); + }); + }, + error(socket, error) { + serverReject(error); + } + } + }); + const client = Bun.connect({ + hostname, + port, + socket: { + open(socket) { + socket.write("Hi"); + }, + data(socket, data) { + expect(data).toEqual(hello); + setTimeout(() => { + socket.end(); + clientResolve(); + }); + }, + error(socket, error) { + clientReject(error); + } + } + }); + + await Promise.all([serverPromise, clientPromise]); + server.stop(true); + server.unref(); +}); + +await fdTest(lib, "directly serve on fd", async (fd, port) => { + const server = Bun.serve({ + fd, + fetch() { + return new Response("Hello"); + } + }); + const response = await fetch(`http://${hostname}:${port}`); + expect(await response.text()).toBe("Hello"); + server.stop(true); +});