diff --git a/README.md b/README.md index 05298ea..3e8fa27 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,13 @@ When the keystore is password protected also set `passphrase`. You can also provide an inline cert and key instead of reading from the filesystem. In this case, they should start with `--`. +### `http2` + +- Type: Boolean +- Default: `false` + +HTTP-Versions 1 and 2 will be used when enabled; otherwise only HTTP 1 is used. + ### `showURL` - Default: `true` (force disabled on a test environment) diff --git a/package.json b/package.json index 62d5375..fb8f57e 100644 --- a/package.json +++ b/package.json @@ -73,4 +73,4 @@ "vitest": "^1.3.1" }, "packageManager": "pnpm@8.15.4" -} \ No newline at end of file +} diff --git a/src/cli.ts b/src/cli.ts index 879e99d..6e67798 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -121,6 +121,11 @@ export function getArgs() { description: "Comma seperated list of domains and IPs, the autogenerated certificate should be valid for (https: true)", }, + http2: { + type: "boolean", + description: "Enable serving HTTP Protocol Version 2 requests", + required: false, + }, publicURL: { type: "string", description: "Displayed public URL (used for QR code)", @@ -172,5 +177,6 @@ export function parseArgs(args: ParsedListhenArgs): Partial { : undefined, } : false, + http2: args.http2, }; } diff --git a/src/listen.ts b/src/listen.ts index f3e4d3c..bbdcb5d 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -1,40 +1,65 @@ -import { createServer } from "node:http"; -import type { Server as HTTPServer } from "node:https"; -import { createServer as createHTTPSServer } from "node:https"; +import { + createServer as createHttpServer, + IncomingMessage, + ServerResponse, +} from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import { + createSecureServer as createHttps2Server, + createServer as createHttp2Server, + Http2ServerRequest, + Http2ServerResponse, +} from "node:http2"; import { promisify } from "node:util"; -import type { RequestListener, Server } from "node:http"; -import type { AddressInfo } from "node:net"; +import { createServer as createRawTcpIpcServer, AddressInfo } from "node:net"; import { getPort } from "get-port-please"; import addShutdown from "http-shutdown"; import consola from "consola"; import { defu } from "defu"; -import { ColorName, getColor, colors } from "consola/utils"; +import { ColorName, colors, getColor } from "consola/utils"; import { renderUnicodeCompact as renderQRCode } from "uqr"; import type { Tunnel } from "untun"; -import type { AdapterOptions as CrossWSOptions } from "crossws"; +import type { Adapter as CrossWSOptions } from "crossws"; import { open } from "./lib/open"; import type { - ListenOptions, - Listener, - ShowURLOptions, + GetURLOptions, HTTPSOptions, + Listener, + ListenOptions, ListenURL, - GetURLOptions, + Server, + ShowURLOptions, } from "./types"; import { formatURL, - getNetworkInterfaces, - isLocalhost, - isAnyhost, - getPublicURL, generateURL, getDefaultHost, + getNetworkInterfaces, + getPublicURL, + isAnyhost, + isLocalhost, validateHostname, } from "./_utils"; import { resolveCertificate } from "./_cert"; import { isWsl } from "./lib/wsl"; import { isDocker } from "./lib/docker"; +type RequestListenerHttp1x< + Request extends typeof IncomingMessage = typeof IncomingMessage, + Response extends + typeof ServerResponse = typeof ServerResponse, +> = ( + req: InstanceType, + res: InstanceType & { req: InstanceType }, +) => void; + +type RequestListenerHttp2< + Request extends typeof Http2ServerRequest = typeof Http2ServerRequest, + Response extends typeof Http2ServerResponse = typeof Http2ServerResponse, +> = (request: InstanceType, response: InstanceType) => void; + +type RequestListener = RequestListenerHttp1x | RequestListenerHttp2; + export async function listen( handle: RequestListener, _options: Partial = {}, @@ -53,6 +78,7 @@ export async function listen( const listhenOptions = defu(_options, { name: "", https: false, + http2: false, port: process.env.PORT || 3000, hostname: _hostname ?? getDefaultHost(_public), showURL: true, @@ -109,31 +135,67 @@ export async function listen( })); // --- Listen --- - let server: Server | HTTPServer; + let server: Server; + let wsTargetServer: Server | undefined; let https: Listener["https"] = false; const httpsOptions = listhenOptions.https as HTTPSOptions; let _addr: AddressInfo; - if (httpsOptions) { - https = await resolveCertificate(httpsOptions); - server = createHTTPSServer(https, handle); - addShutdown(server); + + async function bind() { // @ts-ignore await promisify(server.listen.bind(server))(port, listhenOptions.hostname); _addr = server.address() as AddressInfo; listhenOptions.port = _addr.port; + } + if (httpsOptions) { + https = await resolveCertificate(httpsOptions); + server = listhenOptions.http2 + ? createHttps2Server( + { + ...https, + allowHTTP1: true, + }, + handle as RequestListenerHttp2, + ) + : createHttpsServer(https, handle as RequestListenerHttp1x); + addShutdown(server); + await bind(); + } else if (listhenOptions.http2) { + const h1Server = createHttpServer(handle as RequestListenerHttp1x); + const h2Server = createHttp2Server(handle as RequestListenerHttp2); + server = createRawTcpIpcServer(async (socket) => { + const chunk = await new Promise((resolve) => + socket.once("data", resolve), + ); + // @ts-expect-error + socket._readableState.flowing = undefined; + socket.unshift(chunk); + if ((chunk as any).toString("utf8", 0, 3) === "PRI") { + h2Server.emit("connection", socket); + return; + } + h1Server.emit("connection", socket); + }); + + // websockets need to listen for upgrades here when both http1 and http2 and running without https + wsTargetServer = h1Server; + + addShutdown(server); + await bind(); } else { - server = createServer(handle); + server = createHttpServer(handle as RequestListenerHttp1x); addShutdown(server); - // @ts-ignore - await promisify(server.listen.bind(server))(port, listhenOptions.hostname); - _addr = server.address() as AddressInfo; - listhenOptions.port = _addr.port; + await bind(); } // --- WebSocket --- if (listhenOptions.ws) { if (typeof listhenOptions.ws === "function") { - server.on("upgrade", listhenOptions.ws); + if (wsTargetServer) { + wsTargetServer.on("upgrade", listhenOptions.ws); + } else { + server.on("upgrade", listhenOptions.ws); + } } else { consola.warn( "[listhen] Using experimental websocket API. Learn more: `https://crossws.unjs.io`", @@ -142,9 +204,13 @@ export async function listen( (r) => r.default || r, ); const { handleUpgrade } = nodeWSAdapter({ - ...(listhenOptions.ws as CrossWSOptions), + ...(listhenOptions.ws as CrossWSOptions), }); - server.on("upgrade", handleUpgrade); + if (wsTargetServer) { + wsTargetServer.on("upgrade", handleUpgrade); + } else { + server.on("upgrade", handleUpgrade); + } } } @@ -206,6 +272,16 @@ export async function listen( _addURL("local", getURL(listhenOptions.hostname, getURLOptions.baseURL)); } + if (listhenOptions.ws) { + _addURL( + "local", + getURL(listhenOptions.hostname, getURLOptions.baseURL).replace( + "http", + "ws", + ), + ); + } + // Add tunnel URL if (tunnel) { _addURL("tunnel", await tunnel.getURL()); @@ -305,6 +381,7 @@ export async function listen( url: getURL(), https, server, + // @ts-ignoref address: _addr, open: _open, showURL, diff --git a/src/server/dev.ts b/src/server/dev.ts index c68c8f3..4ba8469 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,5 +1,7 @@ import { existsSync, statSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; +import { Http2ServerRequest, Http2ServerResponse } from "node:http2"; +import { IncomingMessage, ServerResponse } from "node:http"; import { consola } from "consola"; import { dirname, join, resolve } from "pathe"; import type { ConsolaInstance } from "consola"; @@ -14,6 +16,11 @@ export interface DevServerOptions { ws?: ListenOptions["ws"]; } +type NodeListener = ( + req: IncomingMessage | Http2ServerRequest, + res: ServerResponse | Http2ServerResponse, +) => void; + export async function createDevServer( entry: string, options: DevServerOptions, @@ -61,7 +68,7 @@ export async function createDevServer( if (_ws && typeof _ws !== "function") { _ws = { ...(options.ws as CrossWSOptions), - async resolve(info) { + async resolve(info: any) { return { ...(await (options.ws as CrossWSOptions)?.resolve?.(info)), ...dynamicWS.hooks, @@ -186,7 +193,7 @@ export async function createDevServer( return { cwd, resolver, - nodeListener: toNodeListener(app), + nodeListener: toNodeListener(app) as NodeListener, reload: (_initial?: boolean) => loadHandle(_initial), _ws, _entry: resolveEntry(), diff --git a/src/types.ts b/src/types.ts index d1316d5..13d0c1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,19 @@ -import type { IncomingMessage, Server } from "node:http"; -import type { Server as HTTPServer } from "node:https"; -import { AddressInfo } from "node:net"; +import type { Server as HttpServer, IncomingMessage } from "node:http"; +import type { Server as HttpsServer } from "node:https"; +import type { Http2Server, Http2SecureServer } from "node:http2"; +import type { AddressInfo, Server as RawTcpIpcServer } from "node:net"; import type { GetPortInput } from "get-port-please"; import type { NodeOptions } from "crossws/adapters/node"; export type CrossWSOptions = NodeOptions; +export type Server = + | HttpServer + | HttpsServer + | Http2Server + | Http2SecureServer + | RawTcpIpcServer; + export interface Certificate { key: string; cert: string; @@ -29,6 +37,7 @@ export interface ListenOptions { baseURL: string; open: boolean; https: boolean | HTTPSOptions; + http2: boolean; clipboard: boolean; isTest: boolean; isProd: boolean; @@ -87,7 +96,7 @@ export interface ListenURL { export interface Listener { url: string; address: AddressInfo; - server: Server | HTTPServer; + server: Server; https: false | Certificate; close: () => Promise; open: () => Promise; diff --git a/test/index.test.ts b/test/index.test.ts index 3cf2142..e3a3373 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,8 @@ import { resolve } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { request } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { connect } from "node:http2"; import { describe, afterEach, test, expect } from "vitest"; import { listen, Listener } from "../src"; @@ -7,7 +10,57 @@ import { listen, Listener } from "../src"; // console.log = fn() function handle(request: IncomingMessage, response: ServerResponse) { - response.end(request.url); + response.end( + JSON.stringify({ + path: request.url, + httpVersion: request.httpVersion, + }), + ); +} + +// disable TLS certificate checks +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +function sendRequest(url: string, https = false) { + return new Promise((resolve) => { + (https ? httpsRequest : request)(url, (res) => { + const data: any[] = []; + res.on("data", (chunk) => { + data.push(chunk); + }); + res.on("end", () => { + resolve(data.join("")); + }); + }).end(); + }); +} + +function sendHttp2Request(url: string) { + // eslint-disable-next-line promise/param-names + return new Promise((resolve1, reject) => { + const client = connect(url); + + client.on("error", (err: Error) => { + reject(err); + client.close(); + }); + + const req = client.request({ + ":path": "/", + }); + + let data = ""; + req.on("data", (chunk) => { + data += chunk; + }); + + req.on("end", () => { + resolve1(data); + client.close(); + }); + + req.end(); + }); } describe("listhen", () => { @@ -45,9 +98,60 @@ describe("listhen", () => { expect(listener.url.endsWith("/foo/bar")).toBe(true); // eslint-disable-next-line no-console // expect(console.log).toHaveBeenCalledWith(expect.stringMatching('\n > Local: http://localhost:3000/foo/bar')) + const response = (await sendRequest(listener.url)) as string; + expect(JSON.parse(response)).toEqual({ + path: "/foo/bar", + httpVersion: "1.1", + }); + }); + + // see https://http2.github.io/faq/#does-http2-require-encryption + test("listen (http2): http1 client", async () => { + listener = await listen(handle, { + http2: true, + }); + expect(listener.url.startsWith("http://")).toBeTruthy(); + + const response = (await sendRequest(listener.url, false)) as string; + expect(JSON.parse(response)).toEqual({ + path: "/", + httpVersion: "1.1", + }); + }); + test("listhen (http2): http2 client", async () => { + listener = await listen(handle, { + http2: true, + }); + expect(listener.url.startsWith("http://")).toBeTruthy(); + + const response = (await sendHttp2Request(listener.url)) as string; + expect(JSON.parse(response)).toEqual({ + path: "/", + httpVersion: "2.0", + }); }); describe("https", () => { + test("listen (http2)", async () => { + listener = await listen(handle, { + https: true, + http2: true, + }); + expect(listener.url.startsWith("https:")).toBeTruthy(); + + let response = (await sendRequest(listener.url, true)) as string; + expect(JSON.parse(response)).toEqual({ + path: "/", + httpVersion: "1.1", + }); + + response = (await sendHttp2Request(listener.url)) as string; + expect(JSON.parse(response)).toEqual({ + path: "/", + httpVersion: "2.0", + }); + }); + test("listen (https - selfsigned)", async () => { listener = await listen(handle, { https: true, hostname: "localhost" }); expect(listener.url.startsWith("https://")).toBe(true);