diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 55957076e8..a7fe3d578e 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2404,6 +2404,8 @@ declare module "bun" { */ readonly pendingWebSockets: number; + readonly url: URL; + readonly port: number; /** * The hostname the server is listening on. Does not include the port diff --git a/src/bun.js/api/server.classes.ts b/src/bun.js/api/server.classes.ts index 236e5188c2..cd59d10909 100644 --- a/src/bun.js/api/server.classes.ts +++ b/src/bun.js/api/server.classes.ts @@ -49,6 +49,10 @@ function generate(name) { getter: "getAddress", cache: true, }, + url: { + getter: "getURL", + cache: true, + }, protocol: { getter: "getProtocol", }, diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 4992c2a6ee..7f52d43e79 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -5266,8 +5266,9 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp this: *ThisServer, _: *JSC.JSGlobalObject, ) callconv(.C) JSC.JSValue { - if (this.config.address != .tcp) { - return JSValue.undefined; + switch (this.config.address) { + .unix => return .undefined, + else => {}, } var listener = this.listener orelse return JSC.JSValue.jsNumber(this.config.address.tcp.port); @@ -5328,7 +5329,38 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } } + pub fn getURL(this: *ThisServer, globalThis: *JSGlobalObject) callconv(.C) JSC.JSValue { + const fmt = switch (this.config.address) { + .unix => |unix| strings.URLFormatter{ + .proto = .unix, + .hostname = bun.sliceTo(@constCast(unix), 0), + }, + .tcp => |tcp| blk: { + var port: u16 = tcp.port; + if (this.listener) |listener| { + port = @intCast(listener.getLocalPort()); + } + break :blk strings.URLFormatter{ + .proto = if (comptime ssl_enabled_) .https else .http, + .hostname = if (tcp.hostname) |hostname| bun.sliceTo(@constCast(hostname), 0) else null, + .port = port, + }; + }, + }; + + var buf = std.fmt.allocPrint(default_allocator, "{any}", .{fmt}) catch @panic("Out of memory"); + defer default_allocator.free(buf); + + var value = bun.String.create(buf); + return value.toJSDOMURL(globalThis); + } + pub fn getHostname(this: *ThisServer, globalThis: *JSGlobalObject) callconv(.C) JSC.JSValue { + switch (this.config.address) { + .unix => return .undefined, + else => {}, + } + if (this.cached_hostname.isEmpty()) { if (this.listener) |listener| { var buf: [1024]u8 = [_]u8{0} ** 1024; diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 08e7202f35..82a8d64620 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -1419,6 +1419,20 @@ extern "C" JSC__JSValue ZigString__toJSONObject(const ZigString* strPtr, JSC::JS return JSValue::encode(result); } +// TODO: Move this to BunString.cpp +extern "C" JSC__JSValue BunString__toJSDOMURL(JSC::JSGlobalObject* lexicalGlobalObject, BunString* bunString) +{ + auto& globalObject = *reinterpret_cast(lexicalGlobalObject); + auto& vm = globalObject.vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + auto str = Bun::toWTFString(*bunString); + + auto object = WebCore::DOMURL::create(str, String()); + auto jsValue = WebCore::toJSNewlyCreated>(*lexicalGlobalObject, globalObject, throwScope, WTFMove(object)); + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(jsValue)); +} + JSC__JSValue SystemError__toErrorInstance(const SystemError* arg0, JSC__JSGlobalObject* globalObject) { diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 981d7c111e..2c16f3cfc8 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -236,11 +236,19 @@ pub const ZigString = extern struct { } extern fn ZigString__toJSONObject(this: *const ZigString, *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + pub fn toJSONObject(this: ZigString, globalThis: *JSC.JSGlobalObject) JSValue { JSC.markBinding(@src()); return ZigString__toJSONObject(&this, globalThis); } + extern fn BunString__toURL(this: *const ZigString, *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + + pub fn toURL(this: ZigString, globalThis: *JSC.JSGlobalObject) JSValue { + JSC.markBinding(@src()); + return BunString__toURL(&this, globalThis); + } + pub fn hasPrefixChar(this: ZigString, char: u8) bool { if (this.len == 0) return false; diff --git a/src/bun.js/webcore/request.zig b/src/bun.js/webcore/request.zig index 4fe9c0ed6b..8f49927121 100644 --- a/src/bun.js/webcore/request.zig +++ b/src/bun.js/webcore/request.zig @@ -341,7 +341,7 @@ pub const Request = struct { const req_url = req.url(); if (req_url.len > 0 and req_url[0] == '/') { if (req.header("host")) |host| { - const fmt = ZigURL.HostFormatter{ + const fmt = strings.HostFormatter{ .is_https = this.https, .host = host, }; @@ -368,7 +368,7 @@ pub const Request = struct { const req_url = req.url(); if (req_url.len > 0 and req_url[0] == '/') { if (req.header("host")) |host| { - const fmt = ZigURL.HostFormatter{ + const fmt = strings.HostFormatter{ .is_https = this.https, .host = host, }; diff --git a/src/http/websocket_http_client.zig b/src/http/websocket_http_client.zig index c3b98f5a80..27af3e74dc 100644 --- a/src/http/websocket_http_client.zig +++ b/src/http/websocket_http_client.zig @@ -91,7 +91,7 @@ fn buildRequestBody( host_.deinit(); } - const host_fmt = ZigURL.HostFormatter{ + const host_fmt = strings.HostFormatter{ .is_https = is_https, .host = host_.slice(), .port = port, diff --git a/src/string.zig b/src/string.zig index c093953d9e..e7c9f5b5f3 100644 --- a/src/string.zig +++ b/src/string.zig @@ -510,6 +510,12 @@ pub const String = extern struct { return BunString__toJSWithLength(globalObject, this, len); } + pub fn toJSDOMURL(this: *String, globalObject: *bun.JSC.JSGlobalObject) JSC.JSValue { + JSC.markBinding(@src()); + + return BunString__toJSDOMURL(globalObject, this); + } + pub fn toJSConst(this: *const String, globalObject: *bun.JSC.JSGlobalObject) JSC.JSValue { JSC.markBinding(@src()); var a = this.*; @@ -693,6 +699,7 @@ pub const String = extern struct { extern fn BunString__fromJS(globalObject: *JSC.JSGlobalObject, value: bun.JSC.JSValue, out: *String) bool; extern fn BunString__toJS(globalObject: *JSC.JSGlobalObject, in: *String) JSC.JSValue; extern fn BunString__toJSWithLength(globalObject: *JSC.JSGlobalObject, in: *String, usize) JSC.JSValue; + extern fn BunString__toJSDOMURL(globalObject: *JSC.JSGlobalObject, in: *String) JSC.JSValue; extern fn BunString__toWTFString(this: *String) void; pub fn ref(this: String) void { diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 16bb895eb3..831974cceb 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -4961,3 +4961,68 @@ pub fn concatIfNeeded( } std.debug.assert(remain.len == 0); } + +pub const HostFormatter = struct { + host: string, + port: ?u16 = null, + is_https: bool = false, + + pub fn format(formatter: HostFormatter, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + if (strings.indexOfChar(formatter.host, ':') != null) { + try writer.writeAll(formatter.host); + return; + } + + try writer.writeAll(formatter.host); + + const is_port_optional = formatter.port == null or (formatter.is_https and formatter.port == 443) or + (!formatter.is_https and formatter.port == 80); + if (!is_port_optional) { + try writer.print(":{d}", .{formatter.port.?}); + return; + } + } +}; + +const Proto = enum { + http, + https, + unix, +}; + +pub const URLFormatter = struct { + proto: Proto = .http, + hostname: ?string = null, + port: ?u16 = null, + + pub fn format(this: URLFormatter, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + try writer.print("{s}://", .{switch (this.proto) { + .http => "http", + .https => "https", + .unix => "unix", + }}); + + if (this.hostname) |hostname| { + const needs_brackets = hostname[0] != '[' and strings.isIPV6Address(hostname); + if (needs_brackets) { + try writer.print("[{s}]", .{hostname}); + } else { + try writer.writeAll(hostname); + } + } else { + try writer.writeAll("localhost"); + } + + if (this.proto == .unix) { + return; + } + + const is_port_optional = this.port == null or (this.proto == .https and this.port == 443) or + (this.proto == .http and this.port == 80); + if (is_port_optional) { + try writer.writeAll("/"); + } else { + try writer.print(":{d}/", .{this.port.?}); + } + } +}; diff --git a/src/url.zig b/src/url.zig index 90bc002ba6..8190b59ca3 100644 --- a/src/url.zig +++ b/src/url.zig @@ -101,29 +101,8 @@ pub const URL = struct { return "localhost"; } - pub const HostFormatter = struct { - host: string, - port: ?u16 = null, - is_https: bool = false, - - pub fn format(formatter: HostFormatter, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { - if (strings.indexOfChar(formatter.host, ':') != null) { - try writer.writeAll(formatter.host); - return; - } - - try writer.writeAll(formatter.host); - - const is_port_optional = formatter.port == null or (formatter.is_https and formatter.port == 443) or - (!formatter.is_https and formatter.port == 80); - if (!is_port_optional) { - try writer.print(":{d}", .{formatter.port.?}); - return; - } - } - }; - pub fn displayHost(this: *const URL) HostFormatter { - return HostFormatter{ + pub fn displayHost(this: *const URL) strings.HostFormatter { + return strings.HostFormatter{ .host = if (this.host.len > 0) this.host else this.displayHostname(), .port = if (this.port.len > 0) this.getPort() else null, .is_https = this.isHTTPS(), diff --git a/test/js/bun/http/fixtures/cert.key b/test/js/bun/http/fixtures/cert.key new file mode 100644 index 0000000000..bf41b78835 --- /dev/null +++ b/test/js/bun/http/fixtures/cert.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCIzOJskt6VkEJY +XKSJv/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwV +x16Q0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+ +UXUOzSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb +8MsDmT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo +1EHvYSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1J +oEUjrLKtAgMBAAECggEACInVNhaiqu4infZGVMy0rXMV8VwSlapM7O2SLtFsr0nK +XUmaLK6dvGzBPKK9dxdiYCFzPlMKQTkhzsAvYFWSmm3tRmikG+11TFyCRhXLpc8/ +ark4vD9Io6ZkmKUmyKLwtXNjNGcqQtJ7RXc7Ga3nAkueN6JKZHqieZusXVeBGQ70 +YH1LKyVNBeJggbj+g9rqaksPyNJQ8EWiNTJkTRQPazZ0o1VX/fzDFyr/a5npFtHl +4BHfafv9o1Xyr70Kie8CYYRJNViOCN+ylFs7Gd3XRaAkSkgMT/7DzrHdEM2zrrHK +yNg2gyDVX9UeEJG2X5UtU0o9BVW7WBshz/2hqIUHoQKBgQC8zsRFvC7u/rGr5vRR +mhZZG+Wvg03/xBSuIgOrzm+Qie6mAzOdVmfSL/pNV9EFitXt1yd2ROo31AbS7Evy +Bm/QVKr2mBlmLgov3B7O/e6ABteooOL7769qV/v+yo8VdEg0biHmsfGIIXDe3Lwl +OT0XwF9r/SeZLbw1zfkSsUVG/QKBgQC5fANM3Dc9LEek+6PHv5+eC1cKkyioEjUl +/y1VUD00aABI1TUcdLF3BtFN2t/S6HW0hrP3KwbcUfqC25k+GDLh1nM6ZK/gI3Yn +IGtCHxtE3S6jKhE9QcK/H+PzGVKWge9SezeYRP0GHJYDrTVTA8Kt9HgoZPPeReJl ++Ss9c8ThcQKBgECX6HQHFnNzNSufXtSQB7dCoQizvjqTRZPxVRoxDOABIGExVTYt +umUhPtu5AGyJ+/hblEeU+iBRbGg6qRzK8PPwE3E7xey8MYYAI5YjL7YjISKysBUL +AhM6uJ6Jg/wOBSnSx8xZ8kzlS+0izUda1rjKeprCSArSp8IsjlrDxPStAoGAEcPr ++P+altRX5Fhpvmb/Hb8OTif8G+TqjEIdkG9H/W38oP0ywg/3M2RGxcMx7txu8aR5 +NjI7zPxZFxF7YvQkY3cLwEsGgVxEI8k6HLIoBXd90Qjlb82NnoqqZY1GWL4HMwo0 +L/Rjm6M/Rwje852Hluu0WoIYzXA6F/Q+jPs6nzECgYAxx4IbDiGXuenkwSF1SUyj +NwJXhx4HDh7U6EO/FiPZE5BHE3BoTrFu3o1lzverNk7G3m+j+m1IguEAalHlukYl +rip9iUISlKYqbYZdLBoLwHAfHhszdrjqn8/v6oqbB5yR3HXjPFUWJo0WJ2pqJp56 +ZshgmQQ/5Khoj6x0/dMPSg== +-----END PRIVATE KEY----- diff --git a/test/js/bun/http/fixtures/cert.pem b/test/js/bun/http/fixtures/cert.pem new file mode 100644 index 0000000000..8ae1c1ea43 --- /dev/null +++ b/test/js/bun/http/fixtures/cert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID5jCCAs6gAwIBAgIUN7coIsdMcLo9amZfkwogu0YkeLEwDQYJKoZIhvcNAQEL +BQAwfjELMAkGA1UEBhMCU0UxDjAMBgNVBAgMBVN0YXRlMREwDwYDVQQHDAhMb2Nh +dGlvbjEaMBgGA1UECgwRT3JnYW5pemF0aW9uIE5hbWUxHDAaBgNVBAsME09yZ2Fu +aXphdGlvbmFsIFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMzA5MjExNDE2 +MjNaFw0yNDA5MjAxNDE2MjNaMH4xCzAJBgNVBAYTAlNFMQ4wDAYDVQQIDAVTdGF0 +ZTERMA8GA1UEBwwITG9jYXRpb24xGjAYBgNVBAoMEU9yZ2FuaXphdGlvbiBOYW1l +MRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRIwEAYDVQQDDAlsb2NhbGhv +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCIzOJskt6VkEJYXKSJ +v/Gdil3XYkjk3NVc/+m+kzqnkTRbPtT9w+IGWgmJhuf9DJPLCwHFAEFarVwVx16Q +0PbU4ajXaLRHEYGhrH10oTMjQnJ24xVm26mxRXPQa5vaLpWJqNyIdNLIQLe+UXUO +zSGGsFTRMAjvYrkzjBe4ZUnaZV+aFY/ug0jfzeA1dJjzKZs6+yTJRbsuWUEb8MsD +mT4v+kBZDKdaDn7AFDWRVqx/38BnqsRzkM0CxpnyT2kRzw5zQajIE13gdTJo1EHv +YSUkkxrY5m30Rl9BuBBZBjhMzOHq0fYVVooHO+sf4XHPgvFTTxJum85u7J1JoEUj +rLKtAgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIDiDATBgNVHSUEDDAKBggrBgEFBQcD +ATAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFNzx4Rfs9m8XR5ML0WsI +sorKmB4PMA0GCSqGSIb3DQEBCwUAA4IBAQB87iQy8R0fiOky9WTcyzVeMaavS3MX +iTe1BRn1OCyDq+UiwwoNz7zdzZJFEmRtFBwPNFOe4HzLu6E+7yLFR552eYRHlqIi +/fiLb5JiZfPtokUHeqwELWBsoXtU8vKxViPiLZ09jkWOPZWo7b/xXd6QYykBfV91 +usUXLzyTD2orMagpqNksLDGS3p3ggHEJBZtRZA8R7kPEw98xZHznOQpr26iv8kYz +ZWdLFoFdwgFBSfxePKax5rfo+FbwdrcTX0MhbORyiu2XsBAghf8s2vKDkHg2UQE8 +haonxFYMFaASfaZ/5vWKYDTCJkJ67m/BtkpRafFEO+ad1i1S61OjfxH4 +-----END CERTIFICATE----- diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts new file mode 100644 index 0000000000..7ffad3e2e8 --- /dev/null +++ b/test/js/bun/http/serve-listen.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect } from "bun:test"; +import { file, serve } from "bun"; +import type { NetworkInterfaceInfo } from "node:os"; +import { tmpdir, networkInterfaces } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; + +const networks = Object.values(networkInterfaces()).flat() as NetworkInterfaceInfo[]; +const hasIPv4 = networks.some(({ family }) => family === "IPv4"); +const hasIPv6 = networks.some(({ family }) => family === "IPv6"); + +const unix = join(mkdtempSync(join(tmpdir(), "bun-serve-")), "unix.sock"); +const tls = { + cert: file(new URL("./fixtures/cert.pem", import.meta.url)), + key: file(new URL("./fixtures/cert.key", import.meta.url)), +}; + +describe.each([ + { + options: { + hostname: undefined, + port: 0, + }, + url: { + protocol: "http:", + }, + }, + { + options: { + hostname: undefined, + port: 0, + tls, + }, + url: { + protocol: "https:", + }, + }, + { + options: { + hostname: "localhost", + port: 0, + }, + hostname: "localhost", + url: { + protocol: "http:", + hostname: "localhost", + }, + }, + { + options: { + hostname: "localhost", + port: 0, + tls, + }, + hostname: "localhost", + url: { + protocol: "https:", + hostname: "localhost", + }, + }, + { + if: hasIPv4, + options: { + hostname: "127.0.0.1", + port: 0, + }, + hostname: "127.0.0.1", + url: { + protocol: "http:", + hostname: "127.0.0.1", + }, + }, + { + if: hasIPv4, + options: { + hostname: "127.0.0.1", + port: 0, + tls, + }, + hostname: "127.0.0.1", + url: { + protocol: "https:", + hostname: "127.0.0.1", + }, + }, + { + if: hasIPv6, + options: { + hostname: "::1", + port: 0, + }, + hostname: "::1", + url: { + protocol: "http:", + hostname: "[::1]", + }, + }, + { + if: hasIPv6, + options: { + hostname: "::1", + port: 0, + tls, + }, + hostname: "::1", + url: { + protocol: "https:", + hostname: "[::1]", + }, + }, + { + if: process.platform !== "win32", + options: { + unix, + }, + url: { + protocol: "unix:", + pathname: unix, + }, + }, +])("Bun.serve()", ({ if: enabled = true, options, hostname, url }) => { + const title = Bun.inspect(options).replaceAll("\n", " "); + const unix = options.unix; + + describe.if(enabled)(title, () => { + const server = serve({ + ...options, + fetch() { + return new Response(); + }, + }); + test(".hostname", () => { + if (unix) { + expect(server.hostname).toBeUndefined(); + } else if (hostname) { + expect(server.hostname).toBe(hostname); + } else { + expect(server.hostname).toBeString(); + } + }); + test(".port", () => { + if (unix) { + expect(server.port).toBeUndefined(); + } else { + expect(server.port).toBeInteger(); + expect(server.port).toBeWithin(1, 65536 + 1); + } + }); + test(".url", () => { + expect(server.url).toBeInstanceOf(URL); + expect(server.url).toBe(server.url); // check if URL is properly cached + const { protocol, hostname, port, pathname } = server.url; + expect({ protocol, hostname, port, pathname }).toMatchObject(url); + }); + }); +});