diff --git a/.gitignore b/.gitignore index fb4298b..f4c6ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ dist *.crt *.key *.pem +.tmp tsconfig.tsbuildinfo diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md index b14dba3..1527355 100644 --- a/docs/1.guide/5.options.md +++ b/docs/1.guide/5.options.md @@ -64,7 +64,7 @@ If enabled, no server listening message will be printed (enabled by default when The protocol to use for the server. -Possible values are `http` and `https`. +Possible values are `http` or `https`. If `protocol` is not set, Server will use `http` as the default protocol or `https` if both `tls.cert` and `tls.key` options are provided. @@ -134,6 +134,7 @@ serve({ node: { maxHeadersize: 16384 * 2, // Double default ipv6Only: true, // Disable dual-stack support + // http2: true // Enable HTTP2 support (requires top level tls option) }, fetch: () => new Response("👋 Hello there!"), }); diff --git a/package.json b/package.json index c6e375a..9699ff1 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "prettier": "^3.5.3", "typescript": "^5.8.3", "unbuild": "^3.5.0", + "undici": "^7.8.0", "vitest": "^3.1.2" }, "packageManager": "pnpm@10.9.0", diff --git a/playground/app.mjs b/playground/app.mjs index 21b7eb7..3c7bf65 100644 --- a/playground/app.mjs +++ b/playground/app.mjs @@ -1,7 +1,8 @@ import { serve } from "srvx"; serve({ - // tls: { cert: "server.crt", key: "server.key" }, + node: { http2: true }, + tls: { cert: "server.crt", key: "server.key" }, fetch(_request) { return new Response( /*html */ ` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3ea7ce..5f5f658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: unbuild: specifier: ^3.5.0 version: 3.5.0(typescript@5.8.3) + undici: + specifier: ^7.8.0 + version: 7.8.0 vitest: specifier: ^3.1.2 version: 3.1.2(@types/node@22.14.1)(jiti@2.4.2) @@ -2138,6 +2141,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.8.0: + resolution: {integrity: sha512-vFv1GA99b7eKO1HG/4RPu2Is3FBTWBrmzqzO0mz+rLxN3yXkE4mqRcb8g8fHxzX4blEysrNZLqg5RbJLqX5buA==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -4283,6 +4290,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.8.0: {} + unicorn-magic@0.3.0: {} unist-util-stringify-position@2.0.3: diff --git a/src/_node-compat/headers.ts b/src/_node-compat/headers.ts index 1de7167..69fa100 100644 --- a/src/_node-compat/headers.ts +++ b/src/_node-compat/headers.ts @@ -1,25 +1,26 @@ -import type NodeHttp from "node:http"; import { splitSetCookieString } from "cookie-es"; import { kNodeInspect } from "./_common.ts"; +import type { NodeServerRequest, NodeServerResponse } from "../types.ts"; + export const NodeRequestHeaders: { new (nodeCtx: { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; + req: NodeServerRequest; + res?: NodeServerResponse; }): globalThis.Headers; } = /* @__PURE__ */ (() => { const _Headers = class Headers implements globalThis.Headers { - _node: { req: NodeHttp.IncomingMessage; res?: NodeHttp.ServerResponse }; + _node: { + req: NodeServerRequest; + res?: NodeServerResponse; + }; - constructor(nodeCtx: { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; - }) { + constructor(nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }) { this._node = nodeCtx; } append(name: string, value: string): void { - name = name.toLowerCase(); + name = validateHeader(name); const _headers = this._node.req.headers; const _current = _headers[name]; if (_current) { @@ -34,12 +35,12 @@ export const NodeRequestHeaders: { } delete(name: string): void { - name = name.toLowerCase(); + name = validateHeader(name); this._node.req.headers[name] = undefined; } get(name: string): string | null { - name = name.toLowerCase(); + name = validateHeader(name); const rawValue = this._node.req.headers[name]; if (rawValue === undefined) { return null; @@ -56,12 +57,12 @@ export const NodeRequestHeaders: { } has(name: string): boolean { - name = name.toLowerCase(); + name = validateHeader(name); return !!this._node.req.headers[name]; } set(name: string, value: string): void { - name = name.toLowerCase(); + name = validateHeader(name); this._node.req.headers[name] = value; } @@ -104,9 +105,13 @@ export const NodeRequestHeaders: { } *entries(): HeadersIterator<[string, string]> { - const _headers = this._node.req.headers; - for (const key in _headers) { - yield [key, _normalizeValue(_headers[key])]; + const headers = this._node.req.headers; + const isHttp2 = this._node.req.httpVersion === "2.0"; + + for (const key in headers) { + if (!isHttp2 || !key.startsWith(":")) { + yield [key, _normalizeValue(headers[key])]; + } } } @@ -144,17 +149,14 @@ export const NodeRequestHeaders: { export const NodeResponseHeaders: { new (nodeCtx: { - req?: NodeHttp.IncomingMessage; - res: NodeHttp.ServerResponse; + req?: NodeServerRequest; + res: NodeServerResponse; }): globalThis.Headers; } = /* @__PURE__ */ (() => { const _Headers = class Headers implements globalThis.Headers { - _node: { req?: NodeHttp.IncomingMessage; res: NodeHttp.ServerResponse }; + _node: { req?: NodeServerRequest; res: NodeServerResponse }; - constructor(nodeCtx: { - req?: NodeHttp.IncomingMessage; - res: NodeHttp.ServerResponse; - }) { + constructor(nodeCtx: { req?: NodeServerRequest; res: NodeServerResponse }) { this._node = nodeCtx; } @@ -275,3 +277,10 @@ function _normalizeValue( } return typeof value === "string" ? value : String(value ?? ""); } + +function validateHeader(name: string): string { + if (name[0] === ":" || name.includes(":")) { + throw new TypeError("Invalid name"); + } + return name.toLowerCase(); +} diff --git a/src/_node-compat/request.ts b/src/_node-compat/request.ts index 45d5700..2367a00 100644 --- a/src/_node-compat/request.ts +++ b/src/_node-compat/request.ts @@ -1,13 +1,18 @@ -import type NodeHttp from "node:http"; -import type NodeStream from "node:stream"; -import type { ServerRequest, ServerRuntimeContext } from "../types.ts"; import { kNodeInspect } from "./_common.ts"; import { NodeRequestHeaders } from "./headers.ts"; import { NodeRequestURL } from "./url.ts"; +import type NodeStream from "node:stream"; +import type { + NodeServerRequest, + NodeServerResponse, + ServerRequest, + ServerRuntimeContext, +} from "../types.ts"; + export type NodeRequestContext = { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; + req: NodeServerRequest; + res?: NodeServerResponse; upgrade?: { socket: NodeStream.Duplex; header: Buffer; @@ -28,7 +33,7 @@ export const NodeRequest = /* @__PURE__ */ (() => { #textBody?: Promise; #bodyStream?: undefined | ReadableStream; - _node: { req: NodeHttp.IncomingMessage; res?: NodeHttp.ServerResponse }; + _node: { req: NodeServerRequest; res?: NodeServerResponse }; runtime: ServerRuntimeContext; constructor(nodeCtx: NodeRequestContext) { diff --git a/src/_node-compat/response.ts b/src/_node-compat/response.ts index c42a9cf..0fee1a3 100644 --- a/src/_node-compat/response.ts +++ b/src/_node-compat/response.ts @@ -1,6 +1,7 @@ +import { splitSetCookieString } from "cookie-es"; + import type NodeHttp from "node:http"; import type { Readable as NodeReadable } from "node:stream"; -import { splitSetCookieString } from "cookie-es"; export type NodeResponse = InstanceType; diff --git a/src/_node-compat/send.ts b/src/_node-compat/send.ts index 558910c..aebbe74 100644 --- a/src/_node-compat/send.ts +++ b/src/_node-compat/send.ts @@ -1,10 +1,13 @@ +import { splitSetCookieString } from "cookie-es"; + +import type { Duplex, Readable as NodeReadable } from "node:stream"; import type NodeHttp from "node:http"; +import NodeHttp2 from "node:http2"; +import type { NodeServerResponse } from "../types.ts"; import type { NodeResponse } from "./response.ts"; -import type { Duplex, Readable as NodeReadable } from "node:stream"; -import { splitSetCookieString } from "cookie-es"; export async function sendNodeResponse( - nodeRes: NodeHttp.ServerResponse, + nodeRes: NodeServerResponse, webRes: Response | NodeResponse, ): Promise { if (!webRes) { @@ -16,7 +19,7 @@ export async function sendNodeResponse( if ((webRes as NodeResponse).nodeResponse) { const res = (webRes as NodeResponse).nodeResponse(); if (!nodeRes.headersSent) { - nodeRes.writeHead(res.status, res.statusText, res.headers.flat()); + nodeRes.writeHead(res.status, res.statusText, res.headers.flat() as any); } if (res.body) { if (res.body instanceof ReadableStream) { @@ -25,7 +28,8 @@ export async function sendNodeResponse( (res.body as NodeReadable).pipe(nodeRes); return new Promise((resolve) => nodeRes.on("close", resolve)); } - nodeRes.write(res.body); + // TODO: FastResponse write with http2? + (nodeRes as NodeHttp.ServerResponse).write(res.body); } return endNodeResponse(nodeRes); } @@ -42,11 +46,16 @@ export async function sendNodeResponse( } if (!nodeRes.headersSent) { - nodeRes.writeHead( - webRes.status || 200, - webRes.statusText, - headerEntries.flat(), - ); + // TODO: use faster method to check http2 + if (nodeRes instanceof NodeHttp2.Http2ServerResponse) { + nodeRes.writeHead(webRes.status || 200, headerEntries.flat() as any); + } else { + nodeRes.writeHead( + webRes.status || 200, + webRes.statusText, + headerEntries.flat() as any, + ); + } } return webRes.body @@ -76,13 +85,13 @@ export async function sendNodeUpgradeResponse( }); } -function endNodeResponse(nodeRes: NodeHttp.ServerResponse) { +function endNodeResponse(nodeRes: NodeServerResponse) { return new Promise((resolve) => nodeRes.end(resolve)); } export function streamBody( stream: ReadableStream, - nodeRes: NodeHttp.ServerResponse, + nodeRes: NodeServerResponse, ): Promise | void { // stream is already destroyed if (nodeRes.destroyed) { @@ -108,7 +117,7 @@ export function streamBody( if (done) { // End the response nodeRes.end(); - } else if (nodeRes.write(value)) { + } else if ((nodeRes as NodeHttp.ServerResponse).write(value)) { // Continue reading recursively reader.read().then(streamHandle, streamCancel); } else { diff --git a/src/_node-compat/url.ts b/src/_node-compat/url.ts index 0bbea2c..4a96a35 100644 --- a/src/_node-compat/url.ts +++ b/src/_node-compat/url.ts @@ -1,14 +1,15 @@ -import type NodeHttp from "node:http"; import { kNodeInspect } from "./_common.ts"; +import type { NodeServerRequest, NodeServerResponse } from "../types.ts"; + export const NodeRequestURL: { - new (nodeCtx: { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; - }): URL; + new (nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }): URL; } = /* @__PURE__ */ (() => { const _URL = class URL implements Partial { - _node: { req: NodeHttp.IncomingMessage; res?: NodeHttp.ServerResponse }; + _node: { + req: NodeServerRequest; + res?: NodeServerResponse; + }; _hash = ""; _username = ""; @@ -21,10 +22,7 @@ export const NodeRequestURL: { _search?: string; _searchParams?: URLSearchParams; - constructor(nodeCtx: { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; - }) { + constructor(nodeCtx: { req: NodeServerRequest; res?: NodeServerResponse }) { this._node = nodeCtx; } @@ -54,7 +52,11 @@ export const NodeRequestURL: { // host get host() { - return this._node.req.headers.host || ""; + return ( + this._node.req.headers.host || + (this._node.req.headers[":authority"] as string) || + "" + ); } set host(value: string) { this._hostname = undefined; diff --git a/src/adapters/node.ts b/src/adapters/node.ts index 7e56404..8a2ee31 100644 --- a/src/adapters/node.ts +++ b/src/adapters/node.ts @@ -1,12 +1,6 @@ -import type { - FetchHandler, - NodeHttpHandler, - Server, - ServerHandler, - ServerOptions, -} from "../types.ts"; import NodeHttp from "node:http"; import NodeHttps from "node:https"; +import NodeHttp2 from "node:http2"; import { sendNodeResponse, sendNodeUpgradeResponse, @@ -21,6 +15,16 @@ import { import { wrapFetch } from "../_plugin.ts"; import { errorPlugin } from "../_error.ts"; +import type { + FetchHandler, + NodeHttpHandler, + NodeServerRequest, + NodeServerResponse, + Server, + ServerHandler, + ServerOptions, +} from "../types.ts"; + export { FastURL } from "../_url.ts"; export { @@ -36,10 +40,7 @@ export function serve(options: ServerOptions): Server { } export function toNodeHandler(fetchHandler: FetchHandler): NodeHttpHandler { - return ( - nodeReq: NodeHttp.IncomingMessage, - nodeRes: NodeHttp.ServerResponse, - ) => { + return (nodeReq, nodeRes) => { const request = new NodeRequest({ req: nodeReq, res: nodeRes }); const res = fetchHandler(request); return res instanceof Promise @@ -50,13 +51,14 @@ export function toNodeHandler(fetchHandler: FetchHandler): NodeHttpHandler { // https://nodejs.org/api/http.html // https://nodejs.org/api/https.html - +// https://nodejs.org/api/http2.html class NodeServer implements Server { readonly runtime = "node"; readonly options: ServerOptions; readonly node: Server["node"]; readonly serveOptions: ServerOptions["node"]; readonly fetch: ServerHandler; + readonly #isSecure: boolean; #listeningPromise?: Promise; @@ -66,8 +68,8 @@ class NodeServer implements Server { const fetchHandler = (this.fetch = wrapFetch(this, [errorPlugin])); const handler = ( - nodeReq: NodeHttp.IncomingMessage, - nodeRes: NodeHttp.ServerResponse, + nodeReq: NodeServerRequest, + nodeRes: NodeServerResponse, ) => { const request = new NodeRequest({ req: nodeReq, res: nodeRes }); const res = fetchHandler(request); @@ -88,18 +90,41 @@ class NodeServer implements Server { ...this.options.node, }; - // Create HTTPS server if HTTPS options are provided, otherwise create HTTP server - const server = (this.serveOptions as { cert: string }).cert - ? NodeHttps.createServer( - this.serveOptions as NodeHttps.ServerOptions, + this.#isSecure = + !!(this.serveOptions as { cert?: string }).cert && + this.options.protocol !== "http"; + + this.node = { handler }; + + if (this.options.node?.http2) { + if (!this.#isSecure) { + // unencrypted HTTP2 is not supported in browsers + // https://http2.github.io/faq/#does-http2-require-encryption + throw new Error("node.http2 option requires tls certificate!"); + } + this.node.server = NodeHttp2.createSecureServer( + { allowHTTP1: true, ...this.serveOptions }, + handler, + ); + } else if (this.#isSecure) { + this.node.server = NodeHttps.createServer( + this.serveOptions as NodeHttps.ServerOptions, + handler, + ); + } else { + this.node = { + server: NodeHttp.createServer( + this.serveOptions as NodeHttp.ServerOptions, handler, - ) - : NodeHttp.createServer(this.serveOptions, handler); + ), + handler, + }; + } // Listen to upgrade events if there is a hook const upgradeHandler = this.options.upgrade; if (upgradeHandler) { - server.on("upgrade", (nodeReq, socket, header) => { + this.node.server!.on("upgrade", (nodeReq, socket, header) => { const request = new NodeRequest({ req: nodeReq, upgrade: { socket, header }, @@ -113,8 +138,6 @@ class NodeServer implements Server { }); } - this.node = { server, handler }; - if (!options.manual) { this.serve(); } @@ -140,11 +163,7 @@ class NodeServer implements Server { return typeof addr === "string" ? addr /* socket */ - : fmtURL( - addr.address, - addr.port, - this.node!.server! instanceof NodeHttps.Server, - ); + : fmtURL(addr.address, addr.port, this.#isSecure); } ready(): Promise { @@ -154,7 +173,7 @@ class NodeServer implements Server { close(closeAll?: boolean): Promise { return new Promise((resolve, reject) => { if (closeAll) { - this.node?.server?.closeAllConnections?.(); + (this.node?.server as NodeHttp.Server)?.closeAllConnections?.(); } this.node?.server?.close((error?: Error) => error ? reject(error) : resolve(), diff --git a/src/types.ts b/src/types.ts index d8d26b1..635d0ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type * as NodeHttp from "node:http"; import type * as NodeHttps from "node:https"; +import type * as NodeHttp2 from "node:http2"; import type * as NodeNet from "node:net"; import type * as Bun from "bun"; import type * as CF from "@cloudflare/workers-types"; @@ -93,7 +94,7 @@ export interface ServerOptions { * * If `protocol` is not set, Server will use `http` as the default protocol or `https` if both `tls.cert` and `tls.key` options are provided. */ - protocol?: "http" | "https"; + protocol?: "http" | "https" | "http2"; /** * If set to `true`, server will not print the listening address. @@ -123,8 +124,12 @@ export interface ServerOptions { /** * Node.js server options. */ - node?: (NodeHttp.ServerOptions | NodeHttps.ServerOptions) & - NodeNet.ListenOptions; + node?: ( + | NodeHttp.ServerOptions + | NodeHttps.ServerOptions + | NodeHttp2.ServerOptions + ) & + NodeNet.ListenOptions & { http2?: boolean }; /** * Bun server options @@ -183,10 +188,10 @@ export interface Server { * Node.js context. */ readonly node?: { - server?: NodeHttp.Server; + server?: NodeHttp.Server | NodeHttp2.Http2Server; handler: ( - nodeReq: NodeHttp.IncomingMessage, - nodeRes: NodeHttp.ServerResponse, + req: NodeServerRequest, + res: NodeServerResponse, ) => void | Promise; }; @@ -248,8 +253,8 @@ export interface ServerRuntimeContext { * Underlying Node.js server request info. */ node?: { - req: NodeHttp.IncomingMessage; - res?: NodeHttp.ServerResponse; + req: NodeServerRequest; + res?: NodeServerResponse; }; /** @@ -305,9 +310,17 @@ export type DenoFetchHandler = ( info?: Deno.ServeHandlerInfo, ) => Response | Promise; +export type NodeServerRequest = + | NodeHttp.IncomingMessage + | NodeHttp2.Http2ServerRequest; + +export type NodeServerResponse = + | NodeHttp.ServerResponse + | NodeHttp2.Http2ServerResponse; + export type NodeHttpHandler = ( - nodeReq: NodeHttp.IncomingMessage, - nodeRes: NodeHttp.ServerResponse, + req: NodeServerRequest, + res: NodeServerResponse, ) => void | Promise; export type CloudflareFetchHandler = CF.ExportedHandlerFetchHandler; diff --git a/test/_fixture.ts b/test/_fixture.ts index f9f3500..f987644 100644 --- a/test/_fixture.ts +++ b/test/_fixture.ts @@ -1,4 +1,4 @@ -import type { Server } from "../src/types.ts"; +import type { ServerOptions } from "../src/types.ts"; // prettier-ignore const runtime = (globalThis as any).Deno ? "deno" : (globalThis.Bun ? "bun" : "node"); @@ -6,13 +6,17 @@ const { serve } = (await import( `../src/adapters/${runtime}.ts` )) as typeof import("../src/types.ts"); -export const server: Server = serve({ +export const fixture: ( + opts?: Partial, + _Response?: typeof globalThis.Response, +) => ServerOptions = (opts, _Response = globalThis.Response) => ({ + ...opts, hostname: "localhost", plugins: [ { fetch(req, next) { if (req.headers.has("X-plugin-req")) { - return new Response("response from req plugin"); + return new _Response("response from req plugin"); } return next(); }, @@ -30,16 +34,14 @@ export const server: Server = serve({ ], async error(err) { - return new Response(`error: ${(err as Error).message}`, { status: 500 }); + return new _Response(`error: ${(err as Error).message}`, { status: 500 }); }, - async fetch(req) { - const Response = - (globalThis as any).TEST_RESPONSE_CTOR || globalThis.Response; + async fetch(req) { const url = new URL(req.url); switch (url.pathname) { case "/": { - return new Response("ok"); + return new _Response("ok"); } case "/headers": { // Trigger Node.js writeHead slowpath to reproduce https://github.com/h3js/srvx/pull/40 @@ -59,28 +61,30 @@ export const server: Server = serve({ ); } case "/body/binary": { - return new Response(req.body); + return new _Response(req.body); } case "/body/text": { - return new Response(await req.text()); + return new _Response(await req.text()); } case "/ip": { - return new Response(`ip: ${req.ip}`); + return new _Response(`ip: ${req.ip}`); } case "/req-instanceof": { - return new Response(req instanceof Request ? "yes" : "no"); + return new _Response(req instanceof Request ? "yes" : "no"); } case "/req-headers-instanceof": { - return new Response(req.headers instanceof Headers ? "yes" : "no"); + return new _Response(req.headers instanceof Headers ? "yes" : "no"); } case "/error": { throw new Error("test error"); } } - return new Response("404", { status: 404 }); + return new _Response("404", { status: 404 }); }, }); -await server.ready(); - -// console.log(`Listening on ${server.url}`); +if (import.meta.main) { + console.log("main", import.meta.main); + const server = serve(fixture({})); + await server.ready(); +} diff --git a/test/_tests.ts b/test/_tests.ts index df21ecd..0fc2a92 100644 --- a/test/_tests.ts +++ b/test/_tests.ts @@ -1,10 +1,12 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { describe, expect, test } from "vitest"; -export function addTests( - url: (path: string) => string, - { runtime }: { runtime?: string } = {}, -): void { +export function addTests(opts: { + url: (path: string) => string; + runtime: string; + fetch?: typeof globalThis.fetch; +}): void { + const { url, fetch = globalThis.fetch } = opts; + test("GET works", async () => { const response = await fetch(url("/")); expect(response.status).toBe(200); diff --git a/test/_utils.ts b/test/_utils.ts index 4f26a90..414d5f5 100644 --- a/test/_utils.ts +++ b/test/_utils.ts @@ -1,6 +1,9 @@ +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { readFile, mkdir } from "node:fs/promises"; import { afterAll, beforeAll } from "vitest"; import { execa, type ResultPromise as ExecaRes } from "execa"; -import { fileURLToPath } from "node:url"; import { getRandomPort, waitForPort } from "get-port-please"; import { addTests } from "./_tests.ts"; @@ -8,7 +11,7 @@ const testDir = fileURLToPath(new URL(".", import.meta.url)); export function testsExec( cmd: string, - opts: { runtime?: string; silent?: boolean }, + opts: { runtime: string; silent?: boolean }, ): void { let childProc: ExecaRes; let baseURL: string; @@ -43,5 +46,44 @@ export function testsExec( await childProc.kill(); }); - addTests((path) => baseURL + path.slice(1)); + addTests({ + url: (path) => baseURL + path.slice(1), + ...opts, + }); +} + +export async function getTLSCert(): Promise<{ + ca: string; + cert: string; + key: string; +}> { + const certDir = join(testDir, ".tmp/tls"); + + const caFile = join(certDir, "ca.crt"); + const certFile = join(certDir, "server.crt"); + const keyFile = join(certDir, "server.key"); + + if (!existsSync(caFile) || !existsSync(certFile) || !existsSync(keyFile)) { + await mkdir(certDir, { recursive: true }); + + // Generate CA key and certificate + await execa({ + cwd: certDir, + })`openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 365 -subj /CN=::1,127.0.0.1,localhost`; + + // Generate server key and CSR + await execa({ + cwd: certDir, + })`openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj /CN=::1,127.0.0.1,localhost`; + + // Sign server certificate with CA + await execa({ + cwd: certDir, + })`openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365`; + } + return { + ca: await readFile(caFile, "utf8"), + cert: await readFile(certFile, "utf8"), + key: await readFile(keyFile, "utf8"), + }; } diff --git a/test/node-fast.test.ts b/test/node-fast.test.ts deleted file mode 100644 index 3b88f84..0000000 --- a/test/node-fast.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, beforeAll, afterAll } from "vitest"; -import { addTests } from "./_tests.ts"; -import { serve } from "../src/adapters/node.ts"; -import { NodeResponse } from "../src/_node-compat/response.ts"; - -describe("node (fast-res)", () => { - let server: ReturnType | undefined; - - beforeAll(async () => { - process.env.PORT = "0"; - (globalThis as any).TEST_RESPONSE_CTOR = NodeResponse; - server = await import("./_fixture.ts").then((m) => m.server); - await server!.ready(); - }); - - afterAll(async () => { - delete (globalThis as any).TEST_RESPONSE_CTOR; - await server?.close(); - }); - - addTests((path) => server!.url! + path.slice(1), { - runtime: "node-fast", - }); -}); diff --git a/test/node.test.ts b/test/node.test.ts index f080c22..a5d67c7 100644 --- a/test/node.test.ts +++ b/test/node.test.ts @@ -1,21 +1,69 @@ import { describe, beforeAll, afterAll } from "vitest"; +import { fetch, Agent } from "undici"; import { addTests } from "./_tests.ts"; -import { serve } from "../src/adapters/node.ts"; +import { serve, FastResponse } from "../src/adapters/node.ts"; +import { getTLSCert } from "./_utils.ts"; +import { fixture } from "./_fixture.ts"; -describe("node", () => { - let server: ReturnType | undefined; +// TODO: fix CA! +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - beforeAll(async () => { - process.env.PORT = "0"; - server = await import("./_fixture.ts").then((m) => m.server); - await server!.ready(); - }); +const tls = await getTLSCert(); - afterAll(async () => { - await server?.close(); - }); +const testConfigs = [ + { + name: "http1", + Response: globalThis.Response, + }, + { + name: "http1, FastResponse", + Response: FastResponse, + }, + { + name: "http2", + Response: globalThis.Response, + useHttp2Agent: true, + serveOptions: { tls, node: { http2: true, allowHTTP1: false } }, + }, + { + name: "http2, FastResponse", + Response: FastResponse, + useHttp2Agent: true, + serveOptions: { tls, node: { http2: true, allowHTTP1: false } }, + }, +]; + +for (const config of testConfigs) { + describe.sequential(`node (${config.name})`, () => { + // https://undici.nodejs.org/#/docs/api/Client.md?id=parameter-clientoptions + // https://github.com/nodejs/undici/issues/2750#issuecomment-1941009554 + const h2Agent = new Agent({ allowH2: true, connect: { ...tls } }); + const fetchWithHttp2 = ((input: any, init?: any) => + fetch(input, { + ...init, + dispatcher: h2Agent, + })) as unknown as typeof globalThis.fetch; + let server: ReturnType | undefined; + + beforeAll(async () => { + server = serve( + fixture({ + port: 0, + ...config.serveOptions, + }), + ); + await server!.ready(); + }); + + afterAll(async () => { + await h2Agent.close(); + await server!.close(); + }); - addTests((path) => server!.url! + path.slice(1), { - runtime: "node", + addTests({ + url: (path) => server!.url! + path.slice(1), + runtime: "node", + fetch: config.useHttp2Agent ? fetchWithHttp2 : undefined, + }); }); -}); +}