From 7887823e761da95a091ce3cd77487c5bbe9a4c5e Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 4 Aug 2026 23:33:55 +0800 Subject: [PATCH 1/8] feat: initial implementation --- .gitattributes | 2 + .github/dependabot.yml | 45 +++++ .github/workflows/ci.yml | 33 +++ .github/workflows/lock-threads.yml | 19 ++ .gitignore | 143 +++++++++++++ .npmrc | 3 + README.md | 65 ++++++ biome.json | 37 ++++ lib/errors.js | 11 + lib/index.js | 216 ++++++++++++++++++++ lib/listen.js | 199 +++++++++++++++++++ lib/server.js | 254 ++++++++++++++++++++++++ lib/symbols.js | 3 + lib/utils.js | 34 ++++ package.json | 56 ++++++ test/abort-signal.test.js | 95 +++++++++ test/listen/dns.test.js | 135 +++++++++++++ test/listen/error.test.js | 154 ++++++++++++++ test/listen/host.test.js | 228 +++++++++++++++++++++ test/listen/listen-after-close.test.js | 64 ++++++ test/listen/socket.test.js | 50 +++++ test/options/keep-alive-timeout.test.js | 72 +++++++ tsconfig.json | 8 + types/errors.d.ts | 6 + types/index.d.ts | 66 ++++++ types/index.tst.ts | 20 ++ types/listen.d.ts | 29 +++ types/server.d.ts | 78 ++++++++ types/utils.d.ts | 5 + 29 files changed, 2130 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/lock-threads.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 biome.json create mode 100644 lib/errors.js create mode 100644 lib/index.js create mode 100644 lib/listen.js create mode 100644 lib/server.js create mode 100644 lib/symbols.js create mode 100644 lib/utils.js create mode 100644 package.json create mode 100644 test/abort-signal.test.js create mode 100644 test/listen/dns.test.js create mode 100644 test/listen/error.test.js create mode 100644 test/listen/host.test.js create mode 100644 test/listen/listen-after-close.test.js create mode 100644 test/listen/socket.test.js create mode 100644 test/options/keep-alive-timeout.test.js create mode 100644 tsconfig.json create mode 100644 types/errors.d.ts create mode 100644 types/index.d.ts create mode 100644 types/index.tst.ts create mode 100644 types/listen.d.ts create mode 100644 types/server.d.ts create mode 100644 types/utils.d.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..83beb31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set default behavior to automatically convert line endings +* text=auto eol=lf \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e7c1a63 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + commit-message: + include: "scope" + prefix: "ci" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + allow: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + commit-message: + include: "scope" + prefix: "build" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + versioning-strategy: "increase-if-necessary" + allow: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + open-pull-requests-limit: 10 + groups: + # Production dependencies with breaking changes + dependencies: + dependency-type: "production" + # TypeScript related dependencies + dev-dependencies-typescript: + patterns: + - "@types/*" + - "tstyche" + - "typescript" + # Development dependencies with breaking changes + dev-dependencies: + dependency-type: "development" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..64ad0bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: + - main + - next + - "v*" + paths-ignore: + - "docs/**" + - "*.md" + pull_request: + paths-ignore: + - "docs/**" + - "*.md" + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + permissions: + contents: write + pull-requests: write + uses: fastify/workflows/.github/workflows/plugins-ci.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0 + with: + license-check: true + lint: true diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml new file mode 100644 index 0000000..ca4f713 --- /dev/null +++ b/.github/workflows/lock-threads.yml @@ -0,0 +1,19 @@ +name: Lock Threads + +on: + schedule: + - cron: "0 0 1 * *" + workflow_dispatch: + +concurrency: + group: lock + +permissions: + contents: read + +jobs: + lock-threads: + permissions: + issues: write + pull-requests: write + uses: fastify/workflows/.github/workflows/lock-threads.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cf7be9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..55e7da5 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +ignore-scripts=true +min-release-age=7 +package-lock=false \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ba2498 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# @fastify/server + +## Install + +```shell +npm install @fastify/server +``` + +## Example + +This example show the basic usage of create web server. + +```typescript +import { createServer } from '@fastify/server' + +const server = createServer({}, function(request, response) { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}) +server.listen({ port: 3000 }) +``` + +## API + +## Difference from `node:http`, `node:https` and `node:http2`. + +### All-in-one `createServer` + +The `createServer` will determine using which `node` built-in server +based on the options provided. You do not need to change the import +module and just safely update the options accordingly. + +### Remove overload of `.listen` + +The `.listen` method only takes `options` and `callback` as arguments. +It simplify the API and the process of normalize the arguments. + +### Solves `localhost` dual-stack problem + +`localhost` may resolve to either `IPv4` or `IPv6` based on operating +system. Sometimes operating system will fallback to `IPv4` if you listen +on `IPv6`. We instead listen on both to prevent this problem. + +|Host | IPv4 | IPv6 | +|----------------------------------------------------------------------------------|----------------|------| +|`::` | ✅* | ✅ | +|`::` + [`ipv6Only`](https://nodejs.org/api/net.html#serverlistenoptions-callback) | 🚫 | ✅ | +|`0.0.0.0` | ✅ | 🚫 | +|`localhost` | ✅ | ✅ | +|`127.0.0.1` | ✅ | 🚫 | +|`::1` | 🚫 | ✅ | + +* Using `::` for the address will listen on all IPv6 addresses and, +depending on OS, may also listen on [all IPv4 +addresses](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback). + +### Dual `Promise API` and `Callback API` + +Unlike `Node.js` built-in, the `.listen` and `.close` will return `Promise` +when you do not provide `callback` function. You can choose to use either +API in your application. + +## License + +Licensed under [MIT](./LICENSE). diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..15dec1b --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "correctness": { + "noInnerDeclarations": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..55c7f66 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,11 @@ +const { createError } = require("@fastify/error"); + +/** @type {import('../types/errors.d.ts').ERR_SERVER_ALREADY_LISTEN} */ +const ERR_SERVER_ALREADY_LISTEN = createError( + "ERR_SERVER_ALREADY_LISTEN", + "Listen method has been called more than once without closing.", +); + +module.exports = { + ERR_SERVER_ALREADY_LISTEN, +}; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..9b78386 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,216 @@ +const { selectServer } = require("./server"); +const { listenLookupHosts, normalizeListenOptions } = require("./listen"); +const { normalizeCallback, withResolvers } = require("./utils"); +const { kState, kRaw, kInternalServers } = require("./symbols"); +const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); + +const commonProperty = new Set([ + "keepAliveTimeout", + "headersTimeout", + "maxHeadersCount", + "requestTimeout", + "maxRequestsPerSocket", + "timeout", + "keepAliveTimeoutBuffer", +]); + +/** @typedef {import('../types/server').ServerOptions} ServerOptions */ +/** @typedef {import('../types/server').HTTPServerOptions} HTTPServerOptions */ +/** @typedef {import('../types/server').HTTPSServerOptions} HTTPSServerOptions */ +/** @typedef {import('../types/server').HTTP2ServerOptions} HTTP2ServerOptions */ +/** @typedef {import('../types/server').HTTP2SecureServerOptions} HTTP2SecureServerOptions */ +/** @typedef {import('../types/server').CustomServerOptions} CustomServerOptions */ +/** @typedef {import('../types/server').RequestListener} RequestListener */ +/** @typedef {import('../types/server').HTTPRequestListener} HTTPRequestListener */ +/** @typedef {import('../types/server').HTTPSRequestListener} HTTPSRequestListener */ +/** @typedef {import('../types/server').HTTP2RequestListener} HTTP2RequestListener */ +/** @typedef {import('../types/server').HTTP2SecureRequestListener} HTTP2SecureRequestListener */ +/** @typedef {import('../types/index').ProxyServer} ProxyServer */ +/** @typedef {import('../types/server').ProxyHTTPServer} ProxyHTTPServer */ +/** @typedef {import('../types/server').ProxyHTTPSServer} ProxyHTTPSServer */ +/** @typedef {import('../types/server').ProxyHTTP2Server} ProxyHTTP2Server */ +/** @typedef {import('../types/server').ProxyHTTP2SecureServer} ProxyHTTP2SecureServer */ + +/** + * @overload + * @param {HTTPServerOptions} options + * @param {HTTPRequestListener} requestListener + * @returns {ProxyHTTPServer} + */ + +/** + * @overload + * @param {HTTPSServerOptions} options + * @param {HTTPSRequestListener} requestListener + * @returns {ProxyHTTPSServer} + */ + +/** + * @overload + * @param {HTTP2SecureServerOptions} options + * @param {HTTP2SecureRequestListener} requestListener + * @returns {ProxyHTTP2SecureServer} + */ + +/** + * @overload + * @param {HTTP2ServerOptions} options + * @param {HTTP2RequestListener} requestListener + * @returns {ProxyHTTP2Server} + */ + +/** + * @overload + * @param {CustomServerOptions} options + * @param {RequestListener} requestListener + * @returns {ProxyServer} + */ + +/** + * + * @param {ServerOptions} options + * @param {RequestListener} requestListener + * @returns {ProxyServer} + */ +function createServer(options, requestListener) { + const internalServers = []; + const server = selectServer(options, requestListener); + const state = { + listen: false, + listening: false, + closed: false, + closing: false, + error: false, + aborted: false, + }; + + function stateClose() { + state.listen = false; + state.listening = false; + state.closed = true; + state.closing = false; + state.error = false; + state.aborted = false; + internalServers.length = 0; + } + + // when there are internal servers + // proxy to sync between differece servers + const proxy = new Proxy(server, { + get(target, name, receiver) { + if (name === kRaw) return server; + if (name === kState) return state; + if (name === kInternalServers) return internalServers; + if (name === "listen") return listen; + if (name === "close") return close; + if (name === "addresses") return addresses; + return Reflect.get(target, name, receiver); + }, + set(target, name, value, receiver) { + // set common property + if (commonProperty.has(name)) { + for (const server of internalServers) { + server[name] = value; + } + } + // always passthrough in last + return Reflect.set(target, name, value, receiver); + }, + }); + + function listen(listenOptions = {}, callback) { + const cb = normalizeCallback(callback) + if (state.listen) { + cb(new ERR_SERVER_ALREADY_LISTEN()) + return cb.promise + } + + const normalizedListenOptions = normalizeListenOptions(listenOptions); + // exit early when aborted + if (normalizedListenOptions.signal) { + if (normalizedListenOptions.signal.aborted) return; + state.aborted = normalizedListenOptions.signal.aborted; + normalizedListenOptions.signal.addEventListener( + "abort", + () => { + state.aborted = true; + close(); + }, + { once: true }, + ); + } + + listenLookupHosts( + proxy, + requestListener, + options, + normalizedListenOptions, + (error) => { + if (error) { + state.listen = false; + cb(error) + } else { + state.listening = true; + state.closed = false; + // we do not override the server original event + // instead using custom prefix event to signal when all server is listening + server.emit("fastify.listening"); + cb(null, server.address()) + } + }, + ); + + return cb.promise + } + + function close(callback) { + if (state.closed || state.closing) return; + + const cb = normalizeCallback(callback) + + function onClose() { + server.off("close", onClose); + let bound = internalServers.length; + if (bound === 0) { + stateClose(); + server.emit("fastify.close"); + cb() + } else { + for (const internalServer of internalServers) { + function internalOnClose() { + internalServer.off("close", internalOnClose); + bound--; + if (bound === 0) { + stateClose(); + server.emit("fastify.close"); + cb() + } + } + internalServer.on("close", internalOnClose); + } + } + } + + state.closing = true; + // delay close callback to all server closed + server.on("close", onClose); + // server.close will signal the internalServer through close event + server.close(); + + return cb.promise + } + + function addresses() { + if (internalServers.length === 0) { + return [server.address()]; + } else { + const addresses = internalServers.map((server) => server.address()); + addresses.unshift(server.address()); + return addresses; + } + } + + return proxy; +} + +module.exports.createServer = createServer; diff --git a/lib/listen.js b/lib/listen.js new file mode 100644 index 0000000..e3d7ff5 --- /dev/null +++ b/lib/listen.js @@ -0,0 +1,199 @@ +const { selectServer } = require("./server"); +const { kRaw, kInternalServers, kState } = require("./symbols"); +const { withResolvers } = require("./utils"); + +/** @typedef {import('../types/listen').ListenOptions} ListenOptions */ + +/** + * Normalize listen options to be compatible with Node.js server.listen() method. + * @param {ListenOptions} options - The options object to normalize. + * @returns {ListenOptions} - The normalized listen options. + */ +function normalizeListenOptions(options = {}) { + const listenOptions = {}; + if (typeof options.handle === "object" && options.handle !== null) { + // handle take highest proirity + listenOptions.handle = options.handle; + } else if (typeof options.path === "string") { + // unix-socket take precedence over host/port + listenOptions.path = options.path; + } else { + listenOptions.port = options.port ?? 0; + listenOptions.host = options.host ?? "localhost"; + } + + // copy other options + // e.g. exclusive, readableAll, writableAll, ipv6Only, signal + if (typeof options.backlog === "number") + listenOptions.backlog = options.backlog; + if (typeof options.exclusive === "boolean") + listenOptions.exclusive = options.exclusive; + if (typeof options.ipv6Only === "boolean") + listenOptions.ipv6Only = options.ipv6Only; + if (typeof options.reusePort === "boolean") + listenOptions.reusePort = options.reusePort; + if (typeof options.readableAll === "boolean") + listenOptions.readableAll = options.readableAll; + if (typeof options.writableAll === "boolean") + listenOptions.writableAll = options.writableAll; + if ( + typeof options.signal === "object" && + typeof options.signal.addEventListener === "function" + ) { + listenOptions.signal = options.signal; + } + + return listenOptions; +} + +/** @typedef {import('../types/listen').LookupHostsCallback} LookupHostsCallback */ +/** @typedef {import("../types/index").ServerState} ServerState */ + +/** + * Lookup the host addresses for the given listen options. + * @param {ListenOptions} listenOptions + * @param {ServerState} state + * @param {LookupHostsCallback} callback + * @returns {void} + */ +function lookupHosts(listenOptions, state, callback) { + // https://github.com/nodejs/node/issues/9390 + // If listening to 'localhost', listen to both 127.0.0.1 or ::1 if they are available. + // If listening to 127.0.0.1, only listen to 127.0.0.1. + // If listening to ::1, only listen to ::1. + if (!listenOptions.host || listenOptions.host !== "localhost") { + return callback([]); + } else { + const dns = require("node:dns"); + dns.lookup(listenOptions.host, { all: true }, (err, addresses) => { + if (err || state.aborted) { + // dns lookup failure should not affect the server listen + return callback([]); + } + callback(addresses); + }); + } +} + +/** @typedef {import('../types/listen').ListenLookupHostsCallback} ListenLookupHostsCallback */ +/** @typedef {import('../types/index').ProxyServer} ProxyServer */ +/** @typedef {import('../types/server').Server} Server */ +/** @typedef {import('../types/server').RequestListener} RequestListener */ +/** @typedef {import('../types/server').ServerOptions} ServerOptions */ + +/** + * Lookup the host and listen + * @param {ProxyServer} proxy + * @param {RequestListener} requestHandler + * @param {ServerOptions} serverOptions + * @param {ListenOptions} listenOptions + * @param {ListenLookupHostsCallback} callback + */ +function listenLookupHosts( + proxy, + requestHandler, + serverOptions, + listenOptions, + callback, +) { + server = proxy[kRaw]; + state = proxy[kState]; + const servers = proxy[kInternalServers]; + + if (!state.aborted) { + // signal.abort() will call server.close() implicitly + // and fire "close" event + // we need to remove the signal to prevent double fires + listenOptions.signal = undefined; + server.on("listening", onListening); + server.listen(listenOptions); + // state.listen = true when actually call server.listen + state.listen = true; + state.closed = false; + } + + // use event listeners to handle callback + function cleanup() { + server.off("error", onError); + server.off("listening", onListening); + } + function onError(error) { + cleanup(); + callback(error); + } + server.on("error", onError); + function onListening() { + cleanup(); + const mainAddress = server.address(); + const onUpgrade = server.emit.bind(server, "upgrade"); + lookupHosts(listenOptions, state, (addresses) => { + const promises = []; + + for (const address of addresses) { + // skip when the address is the same as the main address + if (address.address === mainAddress.address) continue; + + const { promise, resolve, reject } = withResolvers(); + promises.push(promise); + + const newListenOptions = { + ...listenOptions, + host: address.address, + port: mainAddress.port, + }; + + const newServer = selectServer(serverOptions, requestHandler); + + newServer.on("error", onError); + newServer.on("listening", onListening); + + if (state.closing || state.closed || state.aborted) { + // promise will delay the child server listen to next event cycle + // we need to check if there are error or close on main server + // before listening + resolve(); + } else { + newServer.listen(newListenOptions); + } + + function closeServer() { + newServer.off("upgrade", onUpgrade); + newServer.off("close", closeServer); + server.off("error", closeServer); + newServer.close(); + if (serverOptions.forceCloseConnections === true) + newServer.closeAllConnections(); + } + + function cleanup() { + newServer.off("error", onError); + newServer.off("listening", onListening); + } + + function onError(error) { + // secondary server listen failure should not affect the main server listen + cleanup(); + reject(error); + } + + function onListening() { + cleanup(); + newServer.on("upgrade", onUpgrade); + server.on("close", closeServer); + server.on("error", closeServer); + servers.push(newServer); + resolve(); + } + } + + // use promise to parrallel wait internal server listen + // then unwrap promise when done + Promise.allSettled(promises).then(() => callback(null), callback); + }); + } +} + +module.exports = { + listenLookupHosts, + normalizeListenOptions, +}; diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..0e683ca --- /dev/null +++ b/lib/server.js @@ -0,0 +1,254 @@ +/** @typedef {import('../types/server').ServerOptions} ServerOptions */ + +/** + * normalize server options + * @param {ServerOptions} options + * @returns {ServerOptions} + */ +function normalizeServerOptions(options) { + const serverOptions = {}; + if (typeof options.serverFactory === "function") + serverOptions.serverFactory = options.serverFactory; + + if (options.http === true) serverOptions.http = {}; + if (typeof options.http === "object" && options.http !== null) + serverOptions.http = options.http; + + if (options.https === true) serverOptions.https = {}; + if (typeof options.https === "object" && options.https !== null) + serverOptions.https = options.https; + + if (options.http2 === true) serverOptions.http2 = true; + + if ( + typeof options.keepAliveTimeout === "number" && + Number.isInteger(options.keepAliveTimeout) + ) + serverOptions.keepAliveTimeout = options.keepAliveTimeout; + else serverOptions.keepAliveTimeout = 72000; + + if ( + typeof options.connectionTimeout === "number" && + Number.isInteger(options.connectionTimeout) + ) + serverOptions.connectionTimeout = options.connectionTimeout; + else serverOptions.connectionTimeout = 0; + + if ( + typeof options.maxRequestsPerSocket === "number" && + Number.isInteger(options.maxRequestsPerSocket) + ) + serverOptions.maxRequestsPerSocket = options.maxRequestsPerSocket; + else serverOptions.maxRequestsPerSocket = 0; + + if ( + typeof options.requestTimeout === "number" && + Number.isInteger(options.requestTimeout) + ) + serverOptions.requestTimeout = options.requestTimeout; + else serverOptions.requestTimeout = 0; + + return serverOptions; +} + +/** @typedef {import('../types/server').HTTPServerOptions} HTTPServerOptions */ +/** @typedef {import('../types/server').HTTPRequestListener} HTTPRequestListener */ +/** @typedef {import('../types/server').HTTPServer} HTTPServer */ + +/** + * + * @param {HTTPServerOptions} options + * @param {HTTPRequestListener} requestListener + * @returns {HTTPServer} + */ +function createHTTPServer(options, requestListener) { + const http = require("node:http"); + + const server = http.createServer(options.http, requestListener); + + server.keepAliveTimeout = options.keepAliveTimeout; + server.requestTimeout = options.requestTimeout; + server.setTimeout(options.connectionTimeout); + if (options.maxRequestsPerSocket > 0) + server.maxRequestsPerSocket = options.maxRequestsPerSocket; + + return server; +} + +/** + * + * @param {ServerOptions} options + * @returns {boolean} + */ +function isHTTPS(options) { + return typeof options.https === "object"; +} + +/** @typedef {import('../types/server').HTTPSServerOptions} HTTPSServerOptions */ +/** @typedef {import('../types/server').HTTPSRequestListener} HTTPSRequestListener */ +/** @typedef {import('../types/server').HTTPSServer} HTTPSServer */ + +/** + * + * @param {HTTPSServerOptions} options + * @param {HTTPSRequestListener} requestListener + * @returns {HTTPSServer} + */ +function createHTTPSServer(options, requestListener) { + const https = require("node:https"); + + const serverOptions = options.https === true ? {} : options.https; + + const server = https.createServer(serverOptions, requestListener); + + server.keepAliveTimeout = options.keepAliveTimeout; + server.requestTimeout = options.requestTimeout; + server.setTimeout(options.connectionTimeout); + if (options.maxRequestsPerSocket > 0) { + server.maxRequestsPerSocket = options.maxRequestsPerSocket; + } + + return server; +} + +/** + * + * @param {ServerOptions} options + * @returns {boolean} + */ +function isHTTP2(options) { + return options.http2 === true; +} + +/** @typedef {import('../types/server').HTTP2ServerOptions} HTTP2ServerOptions */ +/** @typedef {import('../types/server').HTTP2RequestListener} HTTP2RequestListener */ +/** @typedef {import('../types/server').HTTP2Server} HTTP2Server */ +/** @typedef {import('../types/server').HTTP2SecureServerOptions} HTTP2SecureServerOptions */ +/** @typedef {import('../types/server').HTTP2SecureRequestListener} HTTP2SecureRequestListener */ +/** @typedef {import('../types/server').HTTP2SecureServer} HTTP2SecureServer */ + +/** + * @overload + * @param {HTTP2SecureServerOptions} options + * @param {HTTP2SecureRequestListener} requestListener + * @returns {HTTP2SecureServer} + */ + +/** + * @overload + * @param {HTTP2ServerOptions} options + * @param {HTTP2RequestListener} requestListener + * @returns {HTTP2Server} + */ + +/** + * + * @param {HTTP2ServerOptions | HTTP2SecureServerOptions} options + * @param {HTTP2RequestListener | HTTP2SecureRequestListener} requestListener + * @returns {HTTP2Server | HTTP2SecureServer} + */ +function createHTTP2Server(options, requestListener) { + const http2 = require("node:http2"); + + let server = null; + if (isHTTPS(options)) { + server = http2.createSecureServer(options.https ?? {}, requestListener); + } else { + server = http2.createServer(options.http ?? {}, requestListener); + } + + // update options + server.on("session", (session) => { + session.setTimeout(options.http2SessionTimeout, () => { + session.close(); + }); + }); + + server.setTimeout(options.connectionTimeout); + + return server; +} + +/** + * + * @param {ServerOptions} options + * @returns {boolean} + */ +function isCustom(options) { + return options && typeof options.serverFactory === "function"; +} + +/** @typedef {import('../types/server').CustomServerOptions} CustomServerOptions */ +/** @typedef {import('../types/server').RequestListener} RequestListener */ +/** @typedef {import('../types/server').Server} Server */ + +/** + * + * @param {CustomServerOptions} options + * @param {RequestListener} requestListener + * @returns {Server} + */ +function createCustomServer(options, requestListener) { + return options.serverFactory(requestListener, options); +} + +/** + * @overload + * @param {HTTPServerOptions} options + * @param {HTTPRequestListener} requestListener + * @returns {HTTPServer} + */ + +/** + * @overload + * @param {HTTPSServerOptions} options + * @param {HTTPSRequestListener} requestListener + * @returns {HTTPSServer} + */ + +/** + * @overload + * @param {HTTP2SecureServerOptions} options + * @param {HTTP2SecureRequestListener} requestListener + * @returns {HTTP2SecureServer} + */ + +/** + * @overload + * @param {HTTP2ServerOptions} options + * @param {HTTP2RequestListener} requestListener + * @returns {HTTP2Server} + */ + +/** + * @overload + * @param {CustomServerOptions} options + * @param {RequestListener} requestListener + * @returns {Server} + */ + +/** + * + * @param {ServerOptions} options + * @param {RequestListener} requestListener + * @returns {Server} + */ +function selectServer(options, requestListener) { + const serverOptions = normalizeServerOptions(options); + + if (isCustom(serverOptions)) { + return createCustomServer(serverOptions, requestListener); + } + + if (isHTTP2(serverOptions)) { + return createHTTP2Server(serverOptions, requestListener); + } + + if (isHTTPS(serverOptions)) { + return createHTTPSServer(serverOptions, requestListener); + } + + return createHTTPServer(serverOptions, requestListener); +} + +module.exports.selectServer = selectServer; diff --git a/lib/symbols.js b/lib/symbols.js new file mode 100644 index 0000000..d7122f5 --- /dev/null +++ b/lib/symbols.js @@ -0,0 +1,3 @@ +module.exports.kRaw = Symbol("fastify.server.raw"); +module.exports.kState = Symbol("fastify.server.state"); +module.exports.kInternalServers = Symbol("fastify.server.internal.servers"); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..a70c648 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,34 @@ +function normalizeCallback(callback) { + if (typeof callback !== "function") { + // promise chain should only happen on return + // internal API should always use callback + // to minimize the event cycle + const { promise, resolve, reject } = withResolvers() + function callback(error, data) { + if (error) { + reject(error) + } else { + resolve(data) + } + } + callback.promise = promise + return callback + } else { + return callback + } +} + +// TODO(20.x): remove when node@20 is not supported +function withResolvers() { + let res, rej; + const promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + return { promise, resolve: res, reject: rej }; +} + +module.exports = { + normalizeCallback, + withResolvers, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a9f2ed8 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@fastify/server", + "version": "0.0.0", + "description": "A tool to create web server determine by options.", + "main": "./lib/index.js", + "type": "commonjs", + "types": "./types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*" + ], + "scripts": { + "test": "npm run test:unit && npm run test:types", + "test:unit": "node --test", + "test:coverage": "c8 node --test", + "test:types": "tstyche", + "lint": "npx @biomejs/biome check", + "lint:fix": "npx @biomejs/biome check --write" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/fastify/server.git" + }, + "author": { + "name": "KaKa", + "email": "kaka@kakang.dev" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/fastify/server/issues" + }, + "homepage": "https://github.com/fastify/server#readme", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "devDependencies": { + "@biomejs/biome": "2.5.6", + "@types/node": "^26.1.2", + "c8": "^12.0.0", + "tstyche": "^7.2.2", + "typescript": "^7.0.2" + }, + "dependencies": { + "@fastify/error": "^4.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/test/abort-signal.test.js b/test/abort-signal.test.js new file mode 100644 index 0000000..b1db664 --- /dev/null +++ b/test/abort-signal.test.js @@ -0,0 +1,95 @@ +const { describe, test } = require("node:test"); +const { createServer } = require("../lib"); +const { once } = require("node:stream"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("abort signal", () => { + test("should close server when aborted after", async (t) => { + t.plan(1); + + const controller = new AbortController(); + + const server = createServer({}, handler); + server.listen({ signal: controller.signal }); + + await once(server, "fastify.listening"); + controller.abort(); + await once(server, "fastify.close"); + t.assert.strictEqual(server.listening, false); + }); + + test("should close server when aborted before", async (t) => { + t.plan(1); + + const controller = new AbortController(); + controller.abort(); + + const server = createServer({}, handler); + server.on("listening", () => { + t.assert.fail("should not reach"); + }); + server.on("close", () => { + t.assert.fail("should not reach"); + }); + + server.listen({ signal: controller.signal }); + t.assert.strictEqual(server.listening, false); + }); + + test("should not start server when aborted", async (t) => { + t.plan(2); + + const controller = new AbortController(); + + const server = createServer({}, handler); + server.on("listening", () => { + t.assert.fail("should not reach"); + }); + // signal.abort() will call server.close explicitly + let closeCount = 0; + server.on("close", () => { + closeCount++; + t.assert.strictEqual(closeCount, 1); + }); + + server.listen({ signal: controller.signal }); + controller.abort(); + + t.assert.strictEqual(server.listening, false); + await once(server, "close"); + }); + + test("should not start server when already aborted", async (t) => { + t.plan(1); + + const controller = new AbortController(); + + const server = createServer({}, handler); + server.on("listening", () => { + t.assert.fail("should not reach"); + }); + server.on("close", () => { + t.assert.fail("should not reach"); + }); + + controller.abort(); + server.listen({ signal: controller.signal }); + + t.assert.strictEqual(server.listening, false); + }); + + test("should ignore invalid signal", async (t) => { + t.plan(1); + + const server = createServer({}, handler); + server.listen({ signal: { aborted: true } }); + await once(server, "listening"); + t.assert.strictEqual(server.listening, true); + server.close(); + await once(server, "close"); + }); +}); diff --git a/test/listen/dns.test.js b/test/listen/dns.test.js new file mode 100644 index 0000000..a078aa0 --- /dev/null +++ b/test/listen/dns.test.js @@ -0,0 +1,135 @@ +const { describe, test } = require("node:test"); +const dns = require("node:dns"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("dns", () => { + test("throw error", async (t) => { + t.plan(4); + + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + // node:http internally call dns.lookup to resolve localhost + // we need to alter the first call only + if (hostname === "localhost" && mock.callCount() === 1) { + callback(new Error("Kaboom!")); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + mock.restore(); + resolve(); + }); + + server.listen(); + + await promise; + }); + + test("return empty record", async (t) => { + t.plan(4); + + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + // node:http internally call dns.lookup to resolve localhost + // we need to alter the first call only + if (hostname === "localhost" && mock.callCount() === 1) { + callback(null, []); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + mock.restore(); + resolve(); + }); + + server.listen(); + + await promise; + }); + + test("return > 2 records", async (t) => { + t.plan(4); + + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + // node:http internally call dns.lookup to resolve localhost + // we need to alter the first call only + if (hostname === "localhost" && mock.callCount() === 1) { + callback(null, [ + { address: "::1", family: 6 }, + { address: "127.0.0.1", family: 4 }, + { address: "0.0.0.0", family: 4 }, + ]); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 3); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + mock.restore(); + resolve(); + }); + + server.listen(); + + await promise; + }); +}); diff --git a/test/listen/error.test.js b/test/listen/error.test.js new file mode 100644 index 0000000..674d210 --- /dev/null +++ b/test/listen/error.test.js @@ -0,0 +1,154 @@ +const { describe, test } = require("node:test"); +const { once } = require("node:stream"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("error", () => { + describe("ERR_SERVER_ALREADY_LISTEN", () => { + test(".listen()", async (t) => { + t.plan(5); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({}, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.listen({}, (error) => { + t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); + server.close(); + }); + }); + + await promise; + }); + + test(".listen({ host: '127.0.0.1' })", async (t) => { + t.plan(5); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ host: "127.0.0.1" }, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.listen({ host: "127.0.0.1" }, (error) => { + t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); + server.close(); + }); + }); + + await promise; + }); + + + test("await .listen()", async (t) => { + t.plan(3); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + await server.listen() + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + + try { + await server.listen() + } catch (error) { + t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); + } finally { + await server.close(); + } + }); + + test("await .listen({ host: '127.0.0.1' })", async (t) => { + t.plan(3); + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + await server.listen({ host: '127.0.0.1' }) + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + + try { + await server.listen({ host: '127.0.0.1' }) + } catch (error) { + t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); + } finally { + await server.close(); + } + }); + }); + + describe("EADDRINUSE", () => { + test("listen conflict address and port", async (t) => { + t.plan(3); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + const conflict = createServer({}, handler); + + server.listen({}, (error, address) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + + conflict.listen({ port: address.port }, (error) => { + t.assert.strictEqual(error.code, "EADDRINUSE"); + server.close(resolve); + }); + }); + + await promise; + }); + + test("allow listen after conflict", async (t) => { + t.plan(3); + const { promise, resolve } = withResolvers(); + const net = require("node:net"); + const conflict = net.createServer(); + conflict.listen({ port: 0, host: "127.0.0.1" }); + await once(conflict, "listening"); + + const server = createServer({}, handler); + + const listenOptions = { + port: conflict.address().port, + host: "127.0.0.1", + }; + server.listen(listenOptions, (error) => { + t.assert.strictEqual(error.code, "EADDRINUSE"); + + conflict.close(); + + server.listen(listenOptions, (error) => { + t.assert.ifError(error); + t.assert.strictEqual(server.listening, true); + + server.close(resolve); + }); + }); + + await promise; + }); + }); +}); diff --git a/test/listen/host.test.js b/test/listen/host.test.js new file mode 100644 index 0000000..b636d06 --- /dev/null +++ b/test/listen/host.test.js @@ -0,0 +1,228 @@ +const { describe, test } = require("node:test"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("host/port", () => { + test(".listen()", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen(); + + await promise; + }); + + test(".listen(undefined)", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen(undefined); + + await promise; + }); + + test(".listen(null)", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen(undefined); + + await promise; + }); + + test(".listen({ host: '::1' })", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ host: "::1" }); + + await promise; + }); + + test(".listen({ host: '127.0.0.1' })", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ host: "127.0.0.1" }); + + await promise; + }); + + test(".listen({ host: null })", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ host: null }); + + await promise; + }); + + test(".listen({ host: undefined })", async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ host: undefined }); + + await promise; + }); + + test(".listen({ ... })", async (t) => { + t.plan(5); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen( + { + port: 0, + host: "localhost", + backlog: 511, + exclusive: false, + readableAll: false, + writableAll: false, + ipv6Only: false, + }, + (error) => { + t.assert.ifError(error); + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }, + ); + + await promise; + }); + + test(".listen({}, callback)", async (t) => { + t.plan(5); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({}, (error) => { + t.assert.ifError(error); + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(); + }); + + await promise; + }); +}); diff --git a/test/listen/listen-after-close.test.js b/test/listen/listen-after-close.test.js new file mode 100644 index 0000000..c9e0d78 --- /dev/null +++ b/test/listen/listen-after-close.test.js @@ -0,0 +1,64 @@ +const { describe, test } = require("node:test"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("listen again after close", () => { + test(".listen()", async (t) => { + t.plan(6); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.listen({}, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + + server.close(() => { + t.assert.strictEqual(server.listening, false); + + server.listen({}, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 2); + server.close(resolve); + }); + }); + }); + + await promise; + }); + + test(".listen({ host: '127.0.0.1' })", async (t) => { + t.plan(6); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.listen({ host: "127.0.0.1" }, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + + server.close(() => { + t.assert.strictEqual(server.listening, false); + + server.listen({ host: "127.0.0.1" }, (error) => { + t.assert.ifError(error); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + server.close(resolve); + }); + }); + }); + + await promise; + }); +}); diff --git a/test/listen/socket.test.js b/test/listen/socket.test.js new file mode 100644 index 0000000..d584456 --- /dev/null +++ b/test/listen/socket.test.js @@ -0,0 +1,50 @@ +const { describe, test } = require("node:test"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("socket", () => { + let sockPath = ""; + if (os.platform() !== "win32") { + sockPath = path.join( + os.tmpdir(), + `${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server.sock`, + ); + try { + fs.unlinkSync(sockFile); + } catch {} + } else { + sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; + } + + test(".listen({ path })", { skip: !sockPath }, async (t) => { + t.plan(5); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + server.once("fastify.listening", () => { + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + t.assert.deepStrictEqual(addresses, [sockPath]); + server.close(); + }); + server.once("fastify.close", () => { + t.assert.strictEqual(server.listening, false); + resolve(); + }); + + server.listen({ path: sockPath }); + + await promise; + }); +}); diff --git a/test/options/keep-alive-timeout.test.js b/test/options/keep-alive-timeout.test.js new file mode 100644 index 0000000..cffcd11 --- /dev/null +++ b/test/options/keep-alive-timeout.test.js @@ -0,0 +1,72 @@ +const http = require("node:http"); +const { describe, test } = require("node:test"); +const { once } = require("node:stream"); +const { createServer } = require("../../lib"); +const { kInternalServers } = require("../../lib/symbols"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("keepAliveTimeout", () => { + test("[]", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: [] }); + t.assert.strictEqual(server.keepAliveTimeout, 72000); + }); + + test("1.3", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: 1.3 }); + t.assert.strictEqual(server.keepAliveTimeout, 72000); + }); + + test("http", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: 1 }); + t.assert.strictEqual(server.keepAliveTimeout, 1); + }); + + test("https", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: 2, https: {} }); + t.assert.strictEqual(server.keepAliveTimeout, 2); + }); + + test("http2", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: 3, http2: true }); + t.assert.notStrictEqual(server.keepAliveTimeout, 3); + }); + + test("serverFactory", (t) => { + t.plan(1); + + function serverFactory(requestHandler) { + const server = http.createServer((request, response) => { + requestHandler(request, response); + }); + server.keepAliveTimeout = 5; + return server; + } + + const server = createServer({ keepAliveTimeout: 4, serverFactory }); + t.assert.strictEqual(server.keepAliveTimeout, 5); + }); + + test("update all servers", async (t) => { + t.plan(3); + const server = createServer({ keepAliveTimeout: 1 }, handler); + t.assert.strictEqual(server.keepAliveTimeout, 1); + server.listen(); + await once(server, "fastify.listening"); + server.keepAliveTimeout = 5; + t.assert.strictEqual(server.keepAliveTimeout, 5); + for (const internal of server[kInternalServers]) { + t.assert.strictEqual(internal.keepAliveTimeout, 5); + } + server.close(); + await once(server, "fastify.close"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..58484d3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["./types"], + "compilerOptions": { + "types": ["node"], + "allowJs": true, + "noEmit": true + } +} diff --git a/types/errors.d.ts b/types/errors.d.ts new file mode 100644 index 0000000..9a833f3 --- /dev/null +++ b/types/errors.d.ts @@ -0,0 +1,6 @@ +import type { FastifyErrorConstructor } from "@fastify/error"; + +export const ERR_SERVER_ALREADY_LISTEN: FastifyErrorConstructor< + { code: "ERR_SERVER_ALREADY_LISTEN" }, + [] +>; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..1870b01 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,66 @@ +import type { AddressInfo } from "node:net"; +import type { kInternalServers, kRaw, kState } from "../lib/symbols"; +import type { + CustomServerOptions, + HTTP2RequestListener, + HTTP2SecureRequestListener, + HTTP2SecureServer, + HTTP2SecureServerOptions, + HTTP2Server, + HTTP2ServerOptions, + HTTPRequestListener, + HTTPServer, + HTTPServerOptions, + HTTPSRequestListener, + HTTPSServer, + HTTPSServerOptions, + RequestListener, +} from "./server"; + +export interface ServerState { + listen: boolean; + listening: boolean; + closed: boolean; + closing: boolean; + error: boolean; + aborted: boolean; +} + +interface ProxyProperties { + [kRaw]: T; + [kState]: ServerState; + [kInternalServers]: Array; + addresses: () => Array; +} + +export type ProxyHTTPServer = HTTPServer & ProxyProperties; +export type ProxyHTTPSServer = HTTPSServer & ProxyProperties; +export type ProxyHTTP2Server = HTTP2Server & ProxyProperties; +export type ProxyHTTP2SecureServer = HTTP2SecureServer & + ProxyProperties; +export type ProxyServer = + | ProxyHTTPServer + | ProxyHTTPSServer + | ProxyHTTP2Server + | ProxyHTTP2SecureServer; + +export function createServer( + options: T, + requestListener: RequestListener, +): ProxyHTTP2SecureServer; +export function createServer( + options: T, + requestListener: HTTP2SecureRequestListener, +): ProxyHTTP2SecureServer; +export function createServer( + options: T, + requestListener: HTTP2RequestListener, +): ProxyHTTP2Server; +export function createServer( + options: T, + requestListener: HTTPSRequestListener, +): ProxyHTTPSServer; +export function createServer( + options: T, + requestListener: HTTPRequestListener, +): ProxyHTTPServer; diff --git a/types/index.tst.ts b/types/index.tst.ts new file mode 100644 index 0000000..b26b4aa --- /dev/null +++ b/types/index.tst.ts @@ -0,0 +1,20 @@ +import type { AddressInfo } from "node:net"; +import { expect } from "tstyche"; +import { + createServer, + type ProxyHTTP2SecureServer, + type ProxyHTTP2Server, + type ProxyHTTPServer, + type ProxyHTTPSServer, +} from "."; + +expect(createServer({}, () => {})).type.toBe(); +expect(createServer({ https: true }, () => {})).type.toBe(); +expect(createServer({ https: {} }, () => {})).type.toBe(); +expect(createServer({ http2: true }, () => {})).type.toBe(); +expect( + createServer({ http2: true, https: {} }, () => {}), +).type.toBe(); + +const proxy = createServer({}, () => {}); +expect(proxy.addresses()).type.toBe>(); diff --git a/types/listen.d.ts b/types/listen.d.ts new file mode 100644 index 0000000..25b05b5 --- /dev/null +++ b/types/listen.d.ts @@ -0,0 +1,29 @@ +import type { AddressInfo, ListenOptions as NetListenOptions } from "node:net"; +import { ProxyServer, ServerState } from "."; +import { RequestListener, ServerOptions } from "./server"; + +export interface ListenOptions extends NetListenOptions {} + +export type NormalizeListenOptionsFn = ( + options?: ListenOptions, +) => ListenOptions; + +export type LookupHostsCallback = ( + addresses: Array, +) => void; + +export type LookupHostsFn = ( + listenOptions: ListenOptions, + state: ServerState, + callback: LookupHostsCallback, +) => void; + +export type ListenLookupHostsCallback = (error?: null | Error) => void; + +export type ListenLookupHostsFn = ( + proxy: ProxyServer, + requestHandler: RequestListener, + serverOptions: ServerOptions, + listenOptions: ListenOptions, + callback: ListenLookupHostsCallback, +) => void; diff --git a/types/server.d.ts b/types/server.d.ts new file mode 100644 index 0000000..689864a --- /dev/null +++ b/types/server.d.ts @@ -0,0 +1,78 @@ +import type { + Server as NodeHTTPServer, + ServerOptions as NodeHTTPServerOptions, + RequestListener as NodeRequestListener, +} from "node:http"; +import type { + Http2SecureServer as NodeHTTP2SecureServer, + SecureServerOptions as NodeHTTP2SecureServerOptions, + Http2Server as NodeHTTP2Server, + ServerOptions as NodeHTTP2ServerOptions, +} from "node:http2"; +import type { + Server as NodeHTTPSServer, + ServerOptions as NodeHTTPSServerOptions, +} from "node:https"; + +interface CommonServerOptions { + /** @default 72000 */ + keepAliveTimeout?: number; + /** @default 0 */ + connectionTimeout?: number; + /** @default 0 */ + maxRequestsPerSocket?: number; + /** @default 0 */ + requestTimeout?: number; +} + +export interface HTTPServerOptions extends CommonServerOptions { + http?: true | NodeHTTPServerOptions; + https?: false; + http2?: false; +} +export type HTTPRequestListener = NodeRequestListener; +export type HTTPServer = NodeHTTPServer; + +export interface HTTPSServerOptions extends CommonServerOptions { + http?: false; + https: true | NodeHTTPSServerOptions; + http2?: false; +} +export type HTTPSRequestListener = NodeRequestListener; +export type HTTPSServer = NodeHTTPSServer; + +export interface HTTP2ServerOptions extends CommonServerOptions { + http?: NodeHTTP2ServerOptions; + https?: false; + http2: true; +} +export type HTTP2RequestListener = NodeRequestListener; +export type HTTP2Server = NodeHTTP2Server; + +export interface HTTP2SecureServerOptions extends CommonServerOptions { + http?: false; + https: NodeHTTP2SecureServerOptions; + http2: true; +} +export type HTTP2SecureRequestListener = NodeRequestListener; +export type HTTP2SecureServer = NodeHTTP2SecureServer; + +export interface CustomServerOptions extends CommonServerOptions { + serverFactory: ( + requestListener: RequestListener, + options: ServerOptions, + ) => Server; +} + +export type ServerOptions = + | HTTPServerOptions + | HTTPSServerOptions + | HTTP2ServerOptions + | HTTP2SecureServerOptions + | CustomServerOptions; +export type RequestListener = + | HTTPRequestListener + | HTTPSRequestListener + | HTTP2RequestListener + | HTTP2SecureRequestListener; +export type Server = HTTPServer | HTTPSServer | HTTP2Server | HTTP2SecureServer; diff --git a/types/utils.d.ts b/types/utils.d.ts new file mode 100644 index 0000000..98cd142 --- /dev/null +++ b/types/utils.d.ts @@ -0,0 +1,5 @@ +type Callback = (error?: null | Error, data?: T) => void + +export function normalizeCallback(callback?: Callback): Callback + +export function withResolvers(): { promise: Promise, resolve: (data?: T) => void, reject: (error?: null | Error) => void } \ No newline at end of file From 0822e471fd2e0e1462d6b99c530b615c6fa50bda Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 4 Aug 2026 23:50:40 +0800 Subject: [PATCH 2/8] fixup --- lib/index.js | 22 +++++++++++----------- lib/utils.js | 14 +++++++------- test/listen/error.test.js | 9 ++++----- types/utils.d.ts | 10 +++++++--- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/lib/index.js b/lib/index.js index 9b78386..dc978ba 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,6 @@ const { selectServer } = require("./server"); const { listenLookupHosts, normalizeListenOptions } = require("./listen"); -const { normalizeCallback, withResolvers } = require("./utils"); +const { normalizeCallback } = require("./utils"); const { kState, kRaw, kInternalServers } = require("./symbols"); const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); @@ -119,10 +119,10 @@ function createServer(options, requestListener) { }); function listen(listenOptions = {}, callback) { - const cb = normalizeCallback(callback) + const cb = normalizeCallback(callback); if (state.listen) { - cb(new ERR_SERVER_ALREADY_LISTEN()) - return cb.promise + cb(new ERR_SERVER_ALREADY_LISTEN()); + return cb.promise; } const normalizedListenOptions = normalizeListenOptions(listenOptions); @@ -148,25 +148,25 @@ function createServer(options, requestListener) { (error) => { if (error) { state.listen = false; - cb(error) + cb(error); } else { state.listening = true; state.closed = false; // we do not override the server original event // instead using custom prefix event to signal when all server is listening server.emit("fastify.listening"); - cb(null, server.address()) + cb(null, server.address()); } }, ); - return cb.promise + return cb.promise; } function close(callback) { if (state.closed || state.closing) return; - const cb = normalizeCallback(callback) + const cb = normalizeCallback(callback); function onClose() { server.off("close", onClose); @@ -174,7 +174,7 @@ function createServer(options, requestListener) { if (bound === 0) { stateClose(); server.emit("fastify.close"); - cb() + cb(); } else { for (const internalServer of internalServers) { function internalOnClose() { @@ -183,7 +183,7 @@ function createServer(options, requestListener) { if (bound === 0) { stateClose(); server.emit("fastify.close"); - cb() + cb(); } } internalServer.on("close", internalOnClose); @@ -197,7 +197,7 @@ function createServer(options, requestListener) { // server.close will signal the internalServer through close event server.close(); - return cb.promise + return cb.promise; } function addresses() { diff --git a/lib/utils.js b/lib/utils.js index a70c648..41b848a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -3,18 +3,18 @@ function normalizeCallback(callback) { // promise chain should only happen on return // internal API should always use callback // to minimize the event cycle - const { promise, resolve, reject } = withResolvers() - function callback(error, data) { + const { promise, resolve, reject } = withResolvers(); + function pCallback(error, data) { if (error) { - reject(error) + reject(error); } else { - resolve(data) + resolve(data); } } - callback.promise = promise - return callback + pCallback.promise = promise; + return pCallback; } else { - return callback + return callback; } } diff --git a/test/listen/error.test.js b/test/listen/error.test.js index 674d210..1ed02a9 100644 --- a/test/listen/error.test.js +++ b/test/listen/error.test.js @@ -60,19 +60,18 @@ describe("error", () => { await promise; }); - test("await .listen()", async (t) => { t.plan(3); const server = createServer({}, handler); t.assert.strictEqual(server.listening, false); - await server.listen() + await server.listen(); const addresses = server.addresses(); t.assert.strictEqual(addresses.length, 2); try { - await server.listen() + await server.listen(); } catch (error) { t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); } finally { @@ -85,12 +84,12 @@ describe("error", () => { const server = createServer({}, handler); t.assert.strictEqual(server.listening, false); - await server.listen({ host: '127.0.0.1' }) + await server.listen({ host: "127.0.0.1" }); const addresses = server.addresses(); t.assert.strictEqual(addresses.length, 1); try { - await server.listen({ host: '127.0.0.1' }) + await server.listen({ host: "127.0.0.1" }); } catch (error) { t.assert.strictEqual(error.code, "ERR_SERVER_ALREADY_LISTEN"); } finally { diff --git a/types/utils.d.ts b/types/utils.d.ts index 98cd142..36d9a1b 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -1,5 +1,9 @@ -type Callback = (error?: null | Error, data?: T) => void +type Callback = (error?: null | Error, data?: T) => void; -export function normalizeCallback(callback?: Callback): Callback +export function normalizeCallback(callback?: Callback): Callback; -export function withResolvers(): { promise: Promise, resolve: (data?: T) => void, reject: (error?: null | Error) => void } \ No newline at end of file +export function withResolvers(): { + promise: Promise; + resolve: (data?: T) => void; + reject: (error?: null | Error) => void; +}; From bcdfb2253cb0e6a0bee7e456a2d50fcf9578ddab Mon Sep 17 00:00:00 2001 From: KaKa Date: Wed, 5 Aug 2026 00:31:54 +0800 Subject: [PATCH 3/8] feat: listeningOrigin --- biome.json | 14 +++++- lib/index.js | 17 ++----- lib/utils.js | 20 ++++++++ test/listen/socket.test.js | 4 +- test/listening-origin.test.js | 87 +++++++++++++++++++++++++++++++++++ test/security.test.js | 24 ++++++++++ 6 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 test/listening-origin.test.js create mode 100644 test/security.test.js diff --git a/biome.json b/biome.json index 15dec1b..0af95fa 100644 --- a/biome.json +++ b/biome.json @@ -33,5 +33,17 @@ "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["test/security.test.js"], + "linter": { + "rules": { + "suspicious": { + "noProto": "off" + } + } + } + } + ] } diff --git a/lib/index.js b/lib/index.js index dc978ba..53b1ad7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,6 @@ const { selectServer } = require("./server"); const { listenLookupHosts, normalizeListenOptions } = require("./listen"); -const { normalizeCallback } = require("./utils"); +const { normalizeCallback, listeningOrigin, stateClose } = require("./utils"); const { kState, kRaw, kInternalServers } = require("./symbols"); const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); @@ -84,16 +84,6 @@ function createServer(options, requestListener) { aborted: false, }; - function stateClose() { - state.listen = false; - state.listening = false; - state.closed = true; - state.closing = false; - state.error = false; - state.aborted = false; - internalServers.length = 0; - } - // when there are internal servers // proxy to sync between differece servers const proxy = new Proxy(server, { @@ -104,6 +94,7 @@ function createServer(options, requestListener) { if (name === "listen") return listen; if (name === "close") return close; if (name === "addresses") return addresses; + if (name === "listeningOrigin") return listeningOrigin(server, options); return Reflect.get(target, name, receiver); }, set(target, name, value, receiver) { @@ -172,7 +163,7 @@ function createServer(options, requestListener) { server.off("close", onClose); let bound = internalServers.length; if (bound === 0) { - stateClose(); + stateClose(state, internalServers); server.emit("fastify.close"); cb(); } else { @@ -181,7 +172,7 @@ function createServer(options, requestListener) { internalServer.off("close", internalOnClose); bound--; if (bound === 0) { - stateClose(); + stateClose(state, internalServers); server.emit("fastify.close"); cb(); } diff --git a/lib/utils.js b/lib/utils.js index 41b848a..12c8c95 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -28,7 +28,27 @@ function withResolvers() { return { promise, resolve: res, reject: rej }; } +function listeningOrigin(server, options) { + const address = server.address(); + if (typeof address === "string") return address; + const host = + address.family === "IPv6" ? `[${address.address}]` : address.address; + return `${options.https ? "https" : "http"}://${host}:${address.port}`; +} + +function stateClose(state, internalServers) { + state.listen = false; + state.listening = false; + state.closed = true; + state.closing = false; + state.error = false; + state.aborted = false; + internalServers.length = 0; +} + module.exports = { normalizeCallback, withResolvers, + listeningOrigin, + stateClose, }; diff --git a/test/listen/socket.test.js b/test/listen/socket.test.js index d584456..abd90b7 100644 --- a/test/listen/socket.test.js +++ b/test/listen/socket.test.js @@ -1,9 +1,9 @@ const { describe, test } = require("node:test"); -const { createServer } = require("../../lib"); -const { withResolvers } = require("../../lib/utils"); const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); +const { createServer } = require("../../lib"); +const { withResolvers } = require("../../lib/utils"); const handler = (_request, response) => { response.writeHead(200, { "Content-Type": "application/json" }); diff --git a/test/listening-origin.test.js b/test/listening-origin.test.js new file mode 100644 index 0000000..33794cf --- /dev/null +++ b/test/listening-origin.test.js @@ -0,0 +1,87 @@ +const { describe, test } = require("node:test"); +const dns = require("node:dns"); +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const { createServer } = require("../lib"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("listeningOrigin", () => { + test("ipv4", async (t) => { + t.plan(1); + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + if (hostname === "localhost") { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const server = createServer({}, handler); + await server.listen(); + const address = server.address(); + t.assert.strictEqual( + server.listeningOrigin, + `http://127.0.0.1:${address.port}`, + ); + await server.close(); + mock.restore(); + }); + + test("ipv6", async (t) => { + t.plan(1); + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + if (hostname === "localhost") { + callback(null, [{ address: "::1", family: 6 }]); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const server = createServer({}, handler); + await server.listen(); + const address = server.address(); + t.assert.strictEqual( + server.listeningOrigin, + `http://[::1]:${address.port}`, + ); + await server.close(); + mock.restore(); + }); + + let sockPath = ""; + if (os.platform() !== "win32") { + sockPath = path.join( + os.tmpdir(), + `${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server.sock`, + ); + try { + fs.unlinkSync(sockFile); + } catch {} + } else { + sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; + } + + test("path", { skip: !sockPath }, async (t) => { + t.plan(1); + + const server = createServer({}, handler); + await server.listen({ path: sockPath }); + t.assert.strictEqual(server.listeningOrigin, sockPath); + await server.close(); + }); +}); diff --git a/test/security.test.js b/test/security.test.js new file mode 100644 index 0000000..f9cca68 --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,24 @@ +const { describe, test } = require("node:test"); +const http = require("node:http"); +const { createServer } = require("../lib"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("security", () => { + test("__proto__ should be Node built-in server", (t) => { + const server = createServer({}, handler); + const nodeServer = http.createServer({}, handler); + + t.assert.deepStrictEqual(server.__proto__, nodeServer.__proto__); + }); + + test("prototype should be Node built-in server", (t) => { + const server = createServer({}, handler); + const nodeServer = http.createServer({}, handler); + + t.assert.deepStrictEqual(server.prototype, nodeServer.prototype); + }); +}); From b93cd75ad55472128ba969a81fe73f3be0fe5092 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 13 Aug 2026 04:41:57 +0800 Subject: [PATCH 4/8] fixup --- lib/index.js | 28 +--- lib/listen.js | 16 +- lib/server.js | 53 ++++--- lib/utils.js | 39 ++++- package.json | 11 +- test/abort-signal.test.js | 1 + test/close/double-close.test.js | 19 +++ test/close/http2-session-timeout.test.js | 91 +++++++++++ test/listen/dns.test.js | 1 + test/listen/error.test.js | 27 ++++ test/listen/host.test.js | 1 + test/listen/listen-after-close.test.js | 1 + test/listen/normalize.test.js | 156 +++++++++++++++++++ test/listen/socket.test.js | 22 ++- test/listening-origin.test.js | 55 ++++++- test/options/connection-timeout.test.js | 79 ++++++++++ test/options/keep-alive-timeout.test.js | 7 + test/options/max-requests-per-socket.test.js | 79 ++++++++++ test/options/request-timeout.test.js | 79 ++++++++++ test/security.test.js | 1 + test/utils.js | 36 +++++ types/server.d.ts | 2 + 22 files changed, 754 insertions(+), 50 deletions(-) create mode 100644 test/close/double-close.test.js create mode 100644 test/close/http2-session-timeout.test.js create mode 100644 test/listen/normalize.test.js create mode 100644 test/options/connection-timeout.test.js create mode 100644 test/options/max-requests-per-socket.test.js create mode 100644 test/options/request-timeout.test.js create mode 100644 test/utils.js diff --git a/lib/index.js b/lib/index.js index 53b1ad7..75c4b73 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,19 +1,9 @@ -const { selectServer } = require("./server"); +const { selectServer, normalizeServerOptions } = require("./server"); const { listenLookupHosts, normalizeListenOptions } = require("./listen"); -const { normalizeCallback, listeningOrigin, stateClose } = require("./utils"); +const { normalizeCallback, listeningOrigin, stateClose, syncInternalServers } = require("./utils"); const { kState, kRaw, kInternalServers } = require("./symbols"); const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); -const commonProperty = new Set([ - "keepAliveTimeout", - "headersTimeout", - "maxHeadersCount", - "requestTimeout", - "maxRequestsPerSocket", - "timeout", - "keepAliveTimeoutBuffer", -]); - /** @typedef {import('../types/server').ServerOptions} ServerOptions */ /** @typedef {import('../types/server').HTTPServerOptions} HTTPServerOptions */ /** @typedef {import('../types/server').HTTPSServerOptions} HTTPSServerOptions */ @@ -74,7 +64,8 @@ const commonProperty = new Set([ */ function createServer(options, requestListener) { const internalServers = []; - const server = selectServer(options, requestListener); + const serverOptions = normalizeServerOptions(options); + const { type, server } = selectServer(serverOptions, requestListener); const state = { listen: false, listening: false, @@ -94,16 +85,11 @@ function createServer(options, requestListener) { if (name === "listen") return listen; if (name === "close") return close; if (name === "addresses") return addresses; - if (name === "listeningOrigin") return listeningOrigin(server, options); + if (name === "listeningOrigin") return listeningOrigin(server, serverOptions); return Reflect.get(target, name, receiver); }, set(target, name, value, receiver) { - // set common property - if (commonProperty.has(name)) { - for (const server of internalServers) { - server[name] = value; - } - } + syncInternalServers(type, internalServers, name, value) // always passthrough in last return Reflect.set(target, name, value, receiver); }, @@ -134,7 +120,7 @@ function createServer(options, requestListener) { listenLookupHosts( proxy, requestListener, - options, + serverOptions, normalizedListenOptions, (error) => { if (error) { diff --git a/lib/listen.js b/lib/listen.js index e3d7ff5..f274e0c 100644 --- a/lib/listen.js +++ b/lib/listen.js @@ -14,12 +14,17 @@ function normalizeListenOptions(options = {}) { if (typeof options.handle === "object" && options.handle !== null) { // handle take highest proirity listenOptions.handle = options.handle; - } else if (typeof options.path === "string") { - // unix-socket take precedence over host/port + } else if (typeof options.port === "number") { + // port take precedence over unix-socket + listenOptions.port = options.port; + listenOptions.host = options.host ?? "localhost"; + } else if (typeof options.path === 'string') { + // path is the lowest proirity listenOptions.path = options.path; } else { + // fallback to port/host when all option is not specified listenOptions.port = options.port ?? 0; - listenOptions.host = options.host ?? "localhost"; + listenOptions.host = options.host ?? 'localhost' } // copy other options @@ -37,6 +42,7 @@ function normalizeListenOptions(options = {}) { if (typeof options.writableAll === "boolean") listenOptions.writableAll = options.writableAll; if ( + options.signal !== null && typeof options.signal === "object" && typeof options.signal.addEventListener === "function" ) { @@ -142,7 +148,7 @@ function listenLookupHosts( port: mainAddress.port, }; - const newServer = selectServer(serverOptions, requestHandler); + const { server: newServer } = selectServer(serverOptions, requestHandler); newServer.on("error", onError); newServer.on("listening", onListening); @@ -161,8 +167,6 @@ function listenLookupHosts( newServer.off("close", closeServer); server.off("error", closeServer); newServer.close(); - if (serverOptions.forceCloseConnections === true) - newServer.closeAllConnections(); } function cleanup() { diff --git a/lib/server.js b/lib/server.js index 0e683ca..7f386c0 100644 --- a/lib/server.js +++ b/lib/server.js @@ -25,28 +25,41 @@ function normalizeServerOptions(options) { Number.isInteger(options.keepAliveTimeout) ) serverOptions.keepAliveTimeout = options.keepAliveTimeout; - else serverOptions.keepAliveTimeout = 72000; + else + serverOptions.keepAliveTimeout = 72000; if ( typeof options.connectionTimeout === "number" && Number.isInteger(options.connectionTimeout) ) serverOptions.connectionTimeout = options.connectionTimeout; - else serverOptions.connectionTimeout = 0; + else + serverOptions.connectionTimeout = 0; if ( typeof options.maxRequestsPerSocket === "number" && Number.isInteger(options.maxRequestsPerSocket) ) serverOptions.maxRequestsPerSocket = options.maxRequestsPerSocket; - else serverOptions.maxRequestsPerSocket = 0; + else + serverOptions.maxRequestsPerSocket = 0; if ( typeof options.requestTimeout === "number" && Number.isInteger(options.requestTimeout) ) serverOptions.requestTimeout = options.requestTimeout; - else serverOptions.requestTimeout = 0; + else + serverOptions.requestTimeout = 0; + + if ( + typeof options.http2SessionTimeout === "number" && + Number.isInteger(options.http2SessionTimeout) + ) + serverOptions.http2SessionTimeout = options.http2SessionTimeout; + else + serverOptions.http2SessionTimeout = 72000; + return serverOptions; } @@ -152,7 +165,7 @@ function createHTTP2Server(options, requestListener) { let server = null; if (isHTTPS(options)) { - server = http2.createSecureServer(options.https ?? {}, requestListener); + server = http2.createSecureServer(options.https, requestListener); } else { server = http2.createServer(options.http ?? {}, requestListener); } @@ -234,21 +247,25 @@ function createCustomServer(options, requestListener) { * @returns {Server} */ function selectServer(options, requestListener) { - const serverOptions = normalizeServerOptions(options); - - if (isCustom(serverOptions)) { - return createCustomServer(serverOptions, requestListener); - } - - if (isHTTP2(serverOptions)) { - return createHTTP2Server(serverOptions, requestListener); - } - - if (isHTTPS(serverOptions)) { - return createHTTPSServer(serverOptions, requestListener); + const result = { type: '', server: null } + + if (isCustom(options)) { + result.type = 'custom' + result.server = createCustomServer(options, requestListener) + } else if (isHTTP2(options)) { + result.type = 'http2' + result.server = createHTTP2Server(options, requestListener) + } else if (isHTTPS(options)) { + result.type = 'https' + result.server = createHTTPSServer(options, requestListener); + } else { + result.type = 'http' + result.server = createHTTPServer(options, requestListener); } - return createHTTPServer(serverOptions, requestListener); + return result; } +module.exports.isHTTPS = isHTTPS; module.exports.selectServer = selectServer; +module.exports.normalizeServerOptions = normalizeServerOptions; diff --git a/lib/utils.js b/lib/utils.js index 12c8c95..89ce38d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,3 +1,5 @@ +const { isHTTPS } = require("./server"); + function normalizeCallback(callback) { if (typeof callback !== "function") { // promise chain should only happen on return @@ -33,7 +35,7 @@ function listeningOrigin(server, options) { if (typeof address === "string") return address; const host = address.family === "IPv6" ? `[${address.address}]` : address.address; - return `${options.https ? "https" : "http"}://${host}:${address.port}`; + return `${isHTTPS(options) ? "https" : "http"}://${host}:${address.port}`; } function stateClose(state, internalServers) { @@ -46,9 +48,44 @@ function stateClose(state, internalServers) { internalServers.length = 0; } +const netProperties = new Set([ + 'maxConnections', // https://nodejs.org/docs/latest-v26.x/api/net.html#servermaxconnections + 'dropMaxConnection', // https://nodejs.org/docs/latest-v26.x/api/net.html#serverdropmaxconnection +]) +const httpProperties = new Set([ + ...netProperties, + 'headersTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#serverheaderstimeout + 'maxHeadersCount', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxheaderscount + 'requestTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + 'maxRequestsPerSocket', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + 'timeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + 'keepAliveTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + 'keepAliveTimeoutBuffer' // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket +]) +const http2Properties = new Set([ + ...netProperties, + 'timeout' // https://nodejs.org/docs/latest-v26.x/api/http2.html#servertimeout +]) +const commonProperties = { + http: httpProperties, + https: httpProperties, // https.Server extends http.Server + http2: http2Properties, + custom: new Set() // do not sync for custom server +} + +function syncInternalServers(type, servers, name, value) { + const properites = commonProperties[type] + if (properites.has(name)) { + for (const server of servers) { + server[name] = value + } + } +} + module.exports = { normalizeCallback, withResolvers, listeningOrigin, stateClose, + syncInternalServers }; diff --git a/package.json b/package.json index a9f2ed8..b4a99a3 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ ], "scripts": { "test": "npm run test:unit && npm run test:types", - "test:unit": "node --test", - "test:coverage": "c8 node --test", + "test:unit": "node --test --test-timeout=500", + "test:coverage": "c8 npm run test:unit", "test:types": "tstyche", "lint": "npx @biomejs/biome check", "lint:fix": "npx @biomejs/biome check --write" @@ -44,6 +44,7 @@ "@biomejs/biome": "2.5.6", "@types/node": "^26.1.2", "c8": "^12.0.0", + "node-forge": "^1.4.0", "tstyche": "^7.2.2", "typescript": "^7.0.2" }, @@ -52,5 +53,11 @@ }, "publishConfig": { "access": "public" + }, + "c8": { + "reporter": [ + "html", + "text" + ] } } diff --git a/test/abort-signal.test.js b/test/abort-signal.test.js index b1db664..449f170 100644 --- a/test/abort-signal.test.js +++ b/test/abort-signal.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const { createServer } = require("../lib"); const { once } = require("node:stream"); diff --git a/test/close/double-close.test.js b/test/close/double-close.test.js new file mode 100644 index 0000000..18b382e --- /dev/null +++ b/test/close/double-close.test.js @@ -0,0 +1,19 @@ +const { describe, test } = require("node:test"); +const { createServer } = require("../../lib"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe('double close', () => { + test('should not emit close twice', async (t) => { + let count = 0 + const server = createServer({}, handler) + server.on('fastify.close', () => count++) + await server.listen() + await server.close() + await server.close() + t.assert.strictEqual(count, 1) + }) +}) \ No newline at end of file diff --git a/test/close/http2-session-timeout.test.js b/test/close/http2-session-timeout.test.js new file mode 100644 index 0000000..76fc1bf --- /dev/null +++ b/test/close/http2-session-timeout.test.js @@ -0,0 +1,91 @@ +'use strict' +const { describe, test, before } = require("node:test"); +const { createServer } = require("../../lib/index"); +const { connect, constants } = require("node:http2"); +const { once } = require("node:stream"); +const { buildCertificate } = require("../utils"); +const { withResolvers } = require("../../lib/utils"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe('http2SessionTimeout', () => { + before(buildCertificate) + + test('http2 close with async-await', async (t) => { + const server = createServer({ + http2: true, + http2SessionTimeout: 100 + }) + await server.listen() + const session = connect(server.listeningOrigin) + session.on('error', () => { }) + await once(session, 'connect') + await server.close() + }) + + test('http2s close with async-await', async (t) => { + const server = createServer({ + http2: true, + https: { + key: global.context.privateKey, + cert: global.context.certificate + }, + http2SessionTimeout: 100 + }) + await server.listen() + const session = connect(server.listeningOrigin, { rejectUnauthorized: false }) + session.on('error', () => { }) + await once(session, 'connect') + await server.close() + }) + + test('http2 server-side emit timeout ', async (t) => { + t.plan(1) + + const server = createServer({ + http2: true, + http2SessionTimeout: 100 + }) + const { promise, resolve } = withResolvers() + server.on('stream', (stream, headers, flags) => { + stream.session.once('timeout', resolve) + stream.respond({ + [constants.HTTP2_HEADER_STATUS]: 200, + [constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json; charset=utf-8', + }); + stream.end('{}') + }) + await server.listen() + const session = connect(server.listeningOrigin) + session.on('error', () => { }) + await once(session, 'connect') + const request = session.request({ ':method': 'GET', ':path': '/' }).end() + const [headers] = await once(request, 'response') + t.assert.strictEqual(headers[':status'], 200) + request.resume() + await promise + await server.close() + }) + + test('http2 server-side emit timeout ', async (t) => { + t.plan(1) + + const server = createServer({ + http2: true, + http2SessionTimeout: 100 + }) + await server.listen() + const waitSessionConnect = once(server, 'session') + const session = connect(server.listeningOrigin) + session.on('error', () => { }) + await once(session, 'connect') + await waitSessionConnect + const waitSessionClosed = once(session, 'close') + await server.close() + await waitSessionClosed + t.assert.strictEqual(session.closed, true) + }) +}) \ No newline at end of file diff --git a/test/listen/dns.test.js b/test/listen/dns.test.js index a078aa0..6ef322f 100644 --- a/test/listen/dns.test.js +++ b/test/listen/dns.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const dns = require("node:dns"); const { createServer } = require("../../lib"); diff --git a/test/listen/error.test.js b/test/listen/error.test.js index 1ed02a9..bf8079c 100644 --- a/test/listen/error.test.js +++ b/test/listen/error.test.js @@ -1,7 +1,10 @@ +'use strict' const { describe, test } = require("node:test"); const { once } = require("node:stream"); +const http = require("node:http"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); +const { nextTick } = require("node:process"); const handler = (_request, response) => { response.writeHead(200, { "Content-Type": "application/json" }); @@ -150,4 +153,28 @@ describe("error", () => { await promise; }); }); + + test('second server listen error', async (t) => { + t.plan(1) + + const httpCreateServer = http.createServer + let count = 0 + const { mock } = t.mock.method(http, 'createServer', (...args) => { + const server = httpCreateServer(...args) + if (count === 1) { + // delay to simulate the error throw in new server + nextTick(() => { + server.emit('error', new Error('kaboom!')) + server.close() + }) + } + count++ + return server + }) + const server = createServer({}, handler); + await server.listen() + t.assert.strictEqual(server.addresses().length, 1) + await server.close() + mock.restore() + }) }); diff --git a/test/listen/host.test.js b/test/listen/host.test.js index b636d06..ed9b8c9 100644 --- a/test/listen/host.test.js +++ b/test/listen/host.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); diff --git a/test/listen/listen-after-close.test.js b/test/listen/listen-after-close.test.js index c9e0d78..f0e037d 100644 --- a/test/listen/listen-after-close.test.js +++ b/test/listen/listen-after-close.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); diff --git a/test/listen/normalize.test.js b/test/listen/normalize.test.js new file mode 100644 index 0000000..69d35ac --- /dev/null +++ b/test/listen/normalize.test.js @@ -0,0 +1,156 @@ +'use strict' +const { describe, test } = require("node:test"); +const { normalizeListenOptions } = require("../../lib/listen"); +const { Socket } = require("node:net"); +const { inspect } = require("node:util"); + +describe('listen options normalize', () => { + const handle = Socket() + const abort = new AbortController() + + const cases = [ + { + name: '{ handle }', + actual: { handle }, + expect: { handle } + }, + { + name: '{ handle, path }', + actual: { handle, path: '' }, + expect: { handle } + }, + { + name: '{ handle, host }', + actual: { handle, host: '127.0.0.1' }, + expect: { handle } + }, + { + name: '{ handle, host, port }', + actual: { handle, host: '127.0.0.1', port: 1 }, + expect: { handle } + }, + { + name: '{ path }', + actual: { path: '' }, + expect: { path: '' } + }, + { + name: '{ path, host }', + actual: { path: '', host: '127.0.0.1' }, + expect: { path: '' } + }, + { + name: '{ path, port }', + actual: { path: '', port: 1 }, + expect: { host: 'localhost', port: 1 } + }, + { + name: '{ path, host, port }', + actual: { path: '', host: '127.0.0.1', port: 1 }, + expect: { host: '127.0.0.1', port: 1 } + }, + { + name: '{ port }', + actual: { port: 1 }, + expect: { host: 'localhost', port: 1 } + }, + { + name: '{ host }', + actual: { host: '127.0.0.1' }, + expect: { host: '127.0.0.1', port: 0 } + }, + { + name: '{ host, port }', + actual: { host: '127.0.0.1', port: 1 }, + expect: { host: '127.0.0.1', port: 1 } + }, + { + name: '{ backlog: 1 }', + actual: { backlog: 1 }, + expect: { host: 'localhost', port: 0, backlog: 1 } + }, + { + name: '{ backlog: true }', + actual: { backlog: true }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ exclusive: 1 }', + actual: { exclusive: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ exclusive: true }', + actual: { exclusive: true }, + expect: { host: 'localhost', port: 0, exclusive: true } + }, + { + name: '{ ipv6Only: 1 }', + actual: { ipv6Only: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ ipv6Only: true }', + actual: { ipv6Only: true }, + expect: { host: 'localhost', port: 0, ipv6Only: true } + }, + { + name: '{ reusePort: 1 }', + actual: { reusePort: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ reusePort: true }', + actual: { reusePort: true }, + expect: { host: 'localhost', port: 0, reusePort: true } + }, + { + name: '{ readableAll: 1 }', + actual: { readableAll: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ readableAll: true }', + actual: { readableAll: true }, + expect: { host: 'localhost', port: 0, readableAll: true } + }, + { + name: '{ writableAll: 1 }', + actual: { writableAll: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ writableAll: true }', + actual: { writableAll: true }, + expect: { host: 'localhost', port: 0, writableAll: true } + }, + { + name: '{ signal: 1 }', + actual: { signal: 1 }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ signal: {} }', + actual: { signal: {} }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ signal: null }', + actual: { signal: null }, + expect: { host: 'localhost', port: 0 } + }, + { + name: '{ signal }', + actual: { signal: abort.signal }, + expect: { host: 'localhost', port: 0, signal: abort.signal } + } + ] + + for (const { name, actual, expect } of cases) { + test(name, (t) => { + t.plan(1) + const result = normalizeListenOptions(actual) + t.assert.deepStrictEqual(result, expect) + }) + } +}) \ No newline at end of file diff --git a/test/listen/socket.test.js b/test/listen/socket.test.js index abd90b7..b76c256 100644 --- a/test/listen/socket.test.js +++ b/test/listen/socket.test.js @@ -1,9 +1,11 @@ +'use strict' const { describe, test } = require("node:test"); const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); +const { once } = require("node:stream"); const handler = (_request, response) => { response.writeHead(200, { "Content-Type": "application/json" }); @@ -19,7 +21,7 @@ describe("socket", () => { ); try { fs.unlinkSync(sockFile); - } catch {} + } catch { } } else { sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; } @@ -47,4 +49,22 @@ describe("socket", () => { await promise; }); + + // Refs: https://github.com/fastify/fastify/pull/6937 + test(".listen({ path, host })", { skip: !sockPath }, async (t) => { + t.plan(4); + const { promise, resolve } = withResolvers(); + + const server = createServer({}, handler); + t.assert.strictEqual(server.listening, false); + + await server.listen({ path: sockPath, host: 'localhost' }); + + t.assert.strictEqual(server.listening, true); + const addresses = server.addresses(); + t.assert.strictEqual(addresses.length, 1); + t.assert.deepStrictEqual(addresses, [sockPath]); + + await server.close(); + }) }); diff --git a/test/listening-origin.test.js b/test/listening-origin.test.js index 33794cf..24597d6 100644 --- a/test/listening-origin.test.js +++ b/test/listening-origin.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const dns = require("node:dns"); const os = require("node:os"); @@ -71,7 +72,7 @@ describe("listeningOrigin", () => { ); try { fs.unlinkSync(sockFile); - } catch {} + } catch { } } else { sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; } @@ -84,4 +85,56 @@ describe("listeningOrigin", () => { t.assert.strictEqual(server.listeningOrigin, sockPath); await server.close(); }); + + test("https + ipv4", async (t) => { + t.plan(1); + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + if (hostname === "localhost") { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const server = createServer({ https: {} }, handler); + await server.listen(); + const address = server.address(); + t.assert.strictEqual( + server.listeningOrigin, + `https://127.0.0.1:${address.port}`, + ); + await server.close(); + mock.restore(); + }); + + test("ipv6", async (t) => { + t.plan(1); + const lookup = dns.lookup; + const { mock } = t.mock.method( + dns, + "lookup", + (hostname, options, callback) => { + if (hostname === "localhost") { + callback(null, [{ address: "::1", family: 6 }]); + } else { + lookup(hostname, options, callback); + } + }, + ); + + const server = createServer({ https: {} }, handler); + await server.listen(); + const address = server.address(); + t.assert.strictEqual( + server.listeningOrigin, + `https://[::1]:${address.port}`, + ); + await server.close(); + mock.restore(); + }); }); diff --git a/test/options/connection-timeout.test.js b/test/options/connection-timeout.test.js new file mode 100644 index 0000000..d5c5655 --- /dev/null +++ b/test/options/connection-timeout.test.js @@ -0,0 +1,79 @@ +'use strict' +const http = require("node:http"); +const { describe, test } = require("node:test"); +const { once } = require("node:stream"); +const { createServer } = require("../../lib"); +const { kInternalServers } = require("../../lib/symbols"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("connectionTimeout", () => { + test("[]", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: [] }); + t.assert.strictEqual(server.timeout, 0); + }); + + test("1.3", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: 1.3 }); + t.assert.strictEqual(server.timeout, 0); + }); + + test("http", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: 1 }); + t.assert.strictEqual(server.timeout, 1); + }); + + test("https", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: 2, https: {} }); + t.assert.strictEqual(server.timeout, 2); + }); + + test("http2", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: 3, http2: true }); + t.assert.strictEqual(server.timeout, 3); + }); + + test("http2 + https", (t) => { + t.plan(1); + const server = createServer({ connectionTimeout: 3, http2: true, https: {} }); + t.assert.strictEqual(server.timeout, 3); + }); + + test("serverFactory", (t) => { + t.plan(1); + + function serverFactory(requestHandler) { + const server = http.createServer((request, response) => { + requestHandler(request, response); + }); + server.setTimeout(5); + return server; + } + + const server = createServer({ connectionTimeout: 4, serverFactory }); + t.assert.strictEqual(server.timeout, 5); + }); + + test("update all servers", async (t) => { + t.plan(3); + const server = createServer({ connectionTimeout: 1 }, handler); + t.assert.strictEqual(server.timeout, 1); + server.listen(); + await once(server, "fastify.listening"); + server.setTimeout(5); + t.assert.strictEqual(server.timeout, 5); + for (const internal of server[kInternalServers]) { + t.assert.strictEqual(internal.timeout, 5); + } + server.close(); + await once(server, "fastify.close"); + }); +}); diff --git a/test/options/keep-alive-timeout.test.js b/test/options/keep-alive-timeout.test.js index cffcd11..df30e57 100644 --- a/test/options/keep-alive-timeout.test.js +++ b/test/options/keep-alive-timeout.test.js @@ -1,3 +1,4 @@ +'use strict' const http = require("node:http"); const { describe, test } = require("node:test"); const { once } = require("node:stream"); @@ -40,6 +41,12 @@ describe("keepAliveTimeout", () => { t.assert.notStrictEqual(server.keepAliveTimeout, 3); }); + test("http2 + https", (t) => { + t.plan(1); + const server = createServer({ keepAliveTimeout: 3, http2: true, https: {} }); + t.assert.notStrictEqual(server.keepAliveTimeout, 3); + }); + test("serverFactory", (t) => { t.plan(1); diff --git a/test/options/max-requests-per-socket.test.js b/test/options/max-requests-per-socket.test.js new file mode 100644 index 0000000..f8459ae --- /dev/null +++ b/test/options/max-requests-per-socket.test.js @@ -0,0 +1,79 @@ +'use strict' +const http = require("node:http"); +const { describe, test } = require("node:test"); +const { once } = require("node:stream"); +const { createServer } = require("../../lib"); +const { kInternalServers } = require("../../lib/symbols"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("maxRequestsPerSocket", () => { + test("[]", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: [] }); + t.assert.strictEqual(server.maxRequestsPerSocket, 0); + }); + + test("1.3", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: 1.3 }); + t.assert.strictEqual(server.maxRequestsPerSocket, 0); + }); + + test("http", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: 1 }); + t.assert.strictEqual(server.maxRequestsPerSocket, 1); + }); + + test("https", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: 2, https: {} }); + t.assert.strictEqual(server.maxRequestsPerSocket, 2); + }); + + test("http2", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: 3, http2: true }); + t.assert.notStrictEqual(server.maxRequestsPerSocket, 3); + }); + + test("http2 + https", (t) => { + t.plan(1); + const server = createServer({ maxRequestsPerSocket: 3, http2: true, https: {} }); + t.assert.notStrictEqual(server.maxRequestsPerSocket, 3); + }); + + test("serverFactory", (t) => { + t.plan(1); + + function serverFactory(requestHandler) { + const server = http.createServer((request, response) => { + requestHandler(request, response); + }); + server.maxRequestsPerSocket = 5; + return server; + } + + const server = createServer({ maxRequestsPerSocket: 4, serverFactory }); + t.assert.strictEqual(server.maxRequestsPerSocket, 5); + }); + + test("update all servers", async (t) => { + t.plan(3); + const server = createServer({ maxRequestsPerSocket: 1 }, handler); + t.assert.strictEqual(server.maxRequestsPerSocket, 1); + server.listen(); + await once(server, "fastify.listening"); + server.maxRequestsPerSocket = 5; + t.assert.strictEqual(server.maxRequestsPerSocket, 5); + for (const internal of server[kInternalServers]) { + t.assert.strictEqual(internal.maxRequestsPerSocket, 5); + } + server.close(); + await once(server, "fastify.close"); + }); +}); diff --git a/test/options/request-timeout.test.js b/test/options/request-timeout.test.js new file mode 100644 index 0000000..0dd7bc2 --- /dev/null +++ b/test/options/request-timeout.test.js @@ -0,0 +1,79 @@ +'use strict' +const http = require("node:http"); +const { describe, test } = require("node:test"); +const { once } = require("node:stream"); +const { createServer } = require("../../lib"); +const { kInternalServers } = require("../../lib/symbols"); + +const handler = (_request, response) => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: "Hello World!" })); +}; + +describe("requestTimeout", () => { + test("[]", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: [] }); + t.assert.strictEqual(server.requestTimeout, 0); + }); + + test("1.3", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 1.3 }); + t.assert.strictEqual(server.requestTimeout, 0); + }); + + test("http", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 1 }); + t.assert.strictEqual(server.requestTimeout, 1); + }); + + test("https", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 2, https: {} }); + t.assert.strictEqual(server.requestTimeout, 2); + }); + + test("http2", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 3, http2: true }); + t.assert.notStrictEqual(server.requestTimeout, 3); + }); + + test("http2 + https", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 3, http2: true, https: {} }); + t.assert.notStrictEqual(server.requestTimeout, 3); + }); + + test("serverFactory", (t) => { + t.plan(1); + + function serverFactory(requestHandler) { + const server = http.createServer((request, response) => { + requestHandler(request, response); + }); + server.requestTimeout = 5; + return server; + } + + const server = createServer({ requestTimeout: 4, serverFactory }); + t.assert.strictEqual(server.requestTimeout, 5); + }); + + test("update all servers", async (t) => { + t.plan(3); + const server = createServer({ requestTimeout: 1 }, handler); + t.assert.strictEqual(server.requestTimeout, 1); + server.listen(); + await once(server, "fastify.listening"); + server.requestTimeout = 5; + t.assert.strictEqual(server.requestTimeout, 5); + for (const internal of server[kInternalServers]) { + t.assert.strictEqual(internal.requestTimeout, 5); + } + server.close(); + await once(server, "fastify.close"); + }); +}); diff --git a/test/security.test.js b/test/security.test.js index f9cca68..75b410f 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -1,3 +1,4 @@ +'use strict' const { describe, test } = require("node:test"); const http = require("node:http"); const { createServer } = require("../lib"); diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..125528a --- /dev/null +++ b/test/utils.js @@ -0,0 +1,36 @@ +'use strict' +const { hostname, networkInterfaces } = require('node:os') +const { pki, util } = require('node-forge') + + +function buildCertificate() { + if (!global.context || !global.context.certificate || !global.context.privateKey) { + const now = new Date() + const { publicKey, privateKey } = pki.rsa.generateKeyPair(2048) + const certificate = pki.createCertificate() + certificate.publicKey = publicKey + certificate.serialNumber = '01' + certificate.validity.notBefore = now + certificate.validity.notAfter = new Date(Number(now) + 86400000) + const attrs = [ + { shortName: 'CN', value: hostname() }, + { shortName: 'C', value: 'US' }, + { shortName: 'ST', value: 'California' }, + { shortName: 'L', value: 'San Francisco' }, + { shortName: 'O', value: 'Fastify' }, + { shortName: 'OU', value: 'Development Team' } + ] + certificate.setSubject(attrs) + certificate.setIssuer(attrs) + + certificate.sign(privateKey) + + global.context = { + privateKey: pki.privateKeyToPem(privateKey), + publicKey: pki.publicKeyToPem(publicKey), + certificate: pki.certificateToPem(certificate) + } + } +} + +module.exports.buildCertificate = buildCertificate \ No newline at end of file diff --git a/types/server.d.ts b/types/server.d.ts index 689864a..d635fea 100644 --- a/types/server.d.ts +++ b/types/server.d.ts @@ -45,6 +45,8 @@ export interface HTTP2ServerOptions extends CommonServerOptions { http?: NodeHTTP2ServerOptions; https?: false; http2: true; + /** @default 72000 */ + http2SessionTimeout?: number } export type HTTP2RequestListener = NodeRequestListener; export type HTTP2Server = NodeHTTP2Server; From 9af9fe21aabcad5841b1fb68c8c5f6d2e2a205e9 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 13 Aug 2026 04:45:23 +0800 Subject: [PATCH 5/8] fixup --- lib/index.js | 12 +- lib/listen.js | 9 +- lib/server.js | 30 ++-- lib/utils.js | 38 ++--- test/abort-signal.test.js | 2 +- test/close/double-close.test.js | 22 +-- test/close/http2-session-timeout.test.js | 126 ++++++++-------- test/listen/dns.test.js | 2 +- test/listen/error.test.js | 36 ++--- test/listen/host.test.js | 2 +- test/listen/listen-after-close.test.js | 2 +- test/listen/normalize.test.js | 149 +++++++++---------- test/listen/socket.test.js | 10 +- test/listening-origin.test.js | 4 +- test/options/connection-timeout.test.js | 8 +- test/options/keep-alive-timeout.test.js | 8 +- test/options/max-requests-per-socket.test.js | 8 +- test/options/request-timeout.test.js | 2 +- test/security.test.js | 2 +- test/utils.js | 49 +++--- types/server.d.ts | 2 +- 21 files changed, 266 insertions(+), 257 deletions(-) diff --git a/lib/index.js b/lib/index.js index 75c4b73..050982b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,11 @@ const { selectServer, normalizeServerOptions } = require("./server"); const { listenLookupHosts, normalizeListenOptions } = require("./listen"); -const { normalizeCallback, listeningOrigin, stateClose, syncInternalServers } = require("./utils"); +const { + normalizeCallback, + listeningOrigin, + stateClose, + syncInternalServers, +} = require("./utils"); const { kState, kRaw, kInternalServers } = require("./symbols"); const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); @@ -85,11 +90,12 @@ function createServer(options, requestListener) { if (name === "listen") return listen; if (name === "close") return close; if (name === "addresses") return addresses; - if (name === "listeningOrigin") return listeningOrigin(server, serverOptions); + if (name === "listeningOrigin") + return listeningOrigin(server, serverOptions); return Reflect.get(target, name, receiver); }, set(target, name, value, receiver) { - syncInternalServers(type, internalServers, name, value) + syncInternalServers(type, internalServers, name, value); // always passthrough in last return Reflect.set(target, name, value, receiver); }, diff --git a/lib/listen.js b/lib/listen.js index f274e0c..b19be7e 100644 --- a/lib/listen.js +++ b/lib/listen.js @@ -18,13 +18,13 @@ function normalizeListenOptions(options = {}) { // port take precedence over unix-socket listenOptions.port = options.port; listenOptions.host = options.host ?? "localhost"; - } else if (typeof options.path === 'string') { + } else if (typeof options.path === "string") { // path is the lowest proirity listenOptions.path = options.path; } else { // fallback to port/host when all option is not specified listenOptions.port = options.port ?? 0; - listenOptions.host = options.host ?? 'localhost' + listenOptions.host = options.host ?? "localhost"; } // copy other options @@ -148,7 +148,10 @@ function listenLookupHosts( port: mainAddress.port, }; - const { server: newServer } = selectServer(serverOptions, requestHandler); + const { server: newServer } = selectServer( + serverOptions, + requestHandler, + ); newServer.on("error", onError); newServer.on("listening", onListening); diff --git a/lib/server.js b/lib/server.js index 7f386c0..4a38626 100644 --- a/lib/server.js +++ b/lib/server.js @@ -25,41 +25,35 @@ function normalizeServerOptions(options) { Number.isInteger(options.keepAliveTimeout) ) serverOptions.keepAliveTimeout = options.keepAliveTimeout; - else - serverOptions.keepAliveTimeout = 72000; + else serverOptions.keepAliveTimeout = 72000; if ( typeof options.connectionTimeout === "number" && Number.isInteger(options.connectionTimeout) ) serverOptions.connectionTimeout = options.connectionTimeout; - else - serverOptions.connectionTimeout = 0; + else serverOptions.connectionTimeout = 0; if ( typeof options.maxRequestsPerSocket === "number" && Number.isInteger(options.maxRequestsPerSocket) ) serverOptions.maxRequestsPerSocket = options.maxRequestsPerSocket; - else - serverOptions.maxRequestsPerSocket = 0; + else serverOptions.maxRequestsPerSocket = 0; if ( typeof options.requestTimeout === "number" && Number.isInteger(options.requestTimeout) ) serverOptions.requestTimeout = options.requestTimeout; - else - serverOptions.requestTimeout = 0; + else serverOptions.requestTimeout = 0; if ( typeof options.http2SessionTimeout === "number" && Number.isInteger(options.http2SessionTimeout) ) serverOptions.http2SessionTimeout = options.http2SessionTimeout; - else - serverOptions.http2SessionTimeout = 72000; - + else serverOptions.http2SessionTimeout = 72000; return serverOptions; } @@ -247,19 +241,19 @@ function createCustomServer(options, requestListener) { * @returns {Server} */ function selectServer(options, requestListener) { - const result = { type: '', server: null } + const result = { type: "", server: null }; if (isCustom(options)) { - result.type = 'custom' - result.server = createCustomServer(options, requestListener) + result.type = "custom"; + result.server = createCustomServer(options, requestListener); } else if (isHTTP2(options)) { - result.type = 'http2' - result.server = createHTTP2Server(options, requestListener) + result.type = "http2"; + result.server = createHTTP2Server(options, requestListener); } else if (isHTTPS(options)) { - result.type = 'https' + result.type = "https"; result.server = createHTTPSServer(options, requestListener); } else { - result.type = 'http' + result.type = "http"; result.server = createHTTPServer(options, requestListener); } diff --git a/lib/utils.js b/lib/utils.js index 89ce38d..b54cfe2 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -49,35 +49,35 @@ function stateClose(state, internalServers) { } const netProperties = new Set([ - 'maxConnections', // https://nodejs.org/docs/latest-v26.x/api/net.html#servermaxconnections - 'dropMaxConnection', // https://nodejs.org/docs/latest-v26.x/api/net.html#serverdropmaxconnection -]) + "maxConnections", // https://nodejs.org/docs/latest-v26.x/api/net.html#servermaxconnections + "dropMaxConnection", // https://nodejs.org/docs/latest-v26.x/api/net.html#serverdropmaxconnection +]); const httpProperties = new Set([ ...netProperties, - 'headersTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#serverheaderstimeout - 'maxHeadersCount', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxheaderscount - 'requestTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket - 'maxRequestsPerSocket', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket - 'timeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket - 'keepAliveTimeout', // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket - 'keepAliveTimeoutBuffer' // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket -]) + "headersTimeout", // https://nodejs.org/docs/latest-v26.x/api/http.html#serverheaderstimeout + "maxHeadersCount", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxheaderscount + "requestTimeout", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + "maxRequestsPerSocket", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + "timeout", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + "keepAliveTimeout", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket + "keepAliveTimeoutBuffer", // https://nodejs.org/docs/latest-v26.x/api/http.html#servermaxrequestspersocket +]); const http2Properties = new Set([ ...netProperties, - 'timeout' // https://nodejs.org/docs/latest-v26.x/api/http2.html#servertimeout -]) + "timeout", // https://nodejs.org/docs/latest-v26.x/api/http2.html#servertimeout +]); const commonProperties = { http: httpProperties, - https: httpProperties, // https.Server extends http.Server + https: httpProperties, // https.Server extends http.Server http2: http2Properties, - custom: new Set() // do not sync for custom server -} + custom: new Set(), // do not sync for custom server +}; function syncInternalServers(type, servers, name, value) { - const properites = commonProperties[type] + const properites = commonProperties[type]; if (properites.has(name)) { for (const server of servers) { - server[name] = value + server[name] = value; } } } @@ -87,5 +87,5 @@ module.exports = { withResolvers, listeningOrigin, stateClose, - syncInternalServers + syncInternalServers, }; diff --git a/test/abort-signal.test.js b/test/abort-signal.test.js index 449f170..d216bc3 100644 --- a/test/abort-signal.test.js +++ b/test/abort-signal.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const { createServer } = require("../lib"); const { once } = require("node:stream"); diff --git a/test/close/double-close.test.js b/test/close/double-close.test.js index 18b382e..76c2877 100644 --- a/test/close/double-close.test.js +++ b/test/close/double-close.test.js @@ -6,14 +6,14 @@ const handler = (_request, response) => { response.end(JSON.stringify({ data: "Hello World!" })); }; -describe('double close', () => { - test('should not emit close twice', async (t) => { - let count = 0 - const server = createServer({}, handler) - server.on('fastify.close', () => count++) - await server.listen() - await server.close() - await server.close() - t.assert.strictEqual(count, 1) - }) -}) \ No newline at end of file +describe("double close", () => { + test("should not emit close twice", async (t) => { + let count = 0; + const server = createServer({}, handler); + server.on("fastify.close", () => count++); + await server.listen(); + await server.close(); + await server.close(); + t.assert.strictEqual(count, 1); + }); +}); diff --git a/test/close/http2-session-timeout.test.js b/test/close/http2-session-timeout.test.js index 76fc1bf..f523626 100644 --- a/test/close/http2-session-timeout.test.js +++ b/test/close/http2-session-timeout.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test, before } = require("node:test"); const { createServer } = require("../../lib/index"); const { connect, constants } = require("node:http2"); @@ -6,86 +6,84 @@ const { once } = require("node:stream"); const { buildCertificate } = require("../utils"); const { withResolvers } = require("../../lib/utils"); -const handler = (_request, response) => { - response.writeHead(200, { "Content-Type": "application/json" }); - response.end(JSON.stringify({ data: "Hello World!" })); -}; +describe("http2SessionTimeout", () => { + before(buildCertificate); -describe('http2SessionTimeout', () => { - before(buildCertificate) - - test('http2 close with async-await', async (t) => { + test("http2 close with async-await", async () => { const server = createServer({ http2: true, - http2SessionTimeout: 100 - }) - await server.listen() - const session = connect(server.listeningOrigin) - session.on('error', () => { }) - await once(session, 'connect') - await server.close() - }) + http2SessionTimeout: 100, + }); + await server.listen(); + const session = connect(server.listeningOrigin); + session.on("error", () => {}); + await once(session, "connect"); + await server.close(); + }); - test('http2s close with async-await', async (t) => { + test("http2s close with async-await", async () => { const server = createServer({ http2: true, https: { key: global.context.privateKey, - cert: global.context.certificate + cert: global.context.certificate, }, - http2SessionTimeout: 100 - }) - await server.listen() - const session = connect(server.listeningOrigin, { rejectUnauthorized: false }) - session.on('error', () => { }) - await once(session, 'connect') - await server.close() - }) + http2SessionTimeout: 100, + }); + await server.listen(); + const session = connect(server.listeningOrigin, { + rejectUnauthorized: false, + }); + session.on("error", () => {}); + await once(session, "connect"); + await server.close(); + }); - test('http2 server-side emit timeout ', async (t) => { - t.plan(1) + test("http2 server-side emit timeout ", async (t) => { + t.plan(1); const server = createServer({ http2: true, - http2SessionTimeout: 100 - }) - const { promise, resolve } = withResolvers() - server.on('stream', (stream, headers, flags) => { - stream.session.once('timeout', resolve) + http2SessionTimeout: 100, + }); + const { promise, resolve } = withResolvers(); + server.on("stream", (stream) => { + stream.session.once("timeout", resolve); stream.respond({ [constants.HTTP2_HEADER_STATUS]: 200, - [constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json; charset=utf-8', + [constants.HTTP2_HEADER_CONTENT_TYPE]: + "application/json; charset=utf-8", }); - stream.end('{}') - }) - await server.listen() - const session = connect(server.listeningOrigin) - session.on('error', () => { }) - await once(session, 'connect') - const request = session.request({ ':method': 'GET', ':path': '/' }).end() - const [headers] = await once(request, 'response') - t.assert.strictEqual(headers[':status'], 200) - request.resume() - await promise - await server.close() - }) + stream.end("{}"); + }); + await server.listen(); + const session = connect(server.listeningOrigin); + session.on("error", () => {}); + await once(session, "connect"); + const request = session.request({ ":method": "GET", ":path": "/" }).end(); + const [headers] = await once(request, "response"); + t.assert.strictEqual(headers[":status"], 200); + request.resume(); + await promise; + await server.close(); + }); - test('http2 server-side emit timeout ', async (t) => { - t.plan(1) + test("http2 server-side emit timeout ", async (t) => { + t.plan(1); const server = createServer({ http2: true, - http2SessionTimeout: 100 - }) - await server.listen() - const waitSessionConnect = once(server, 'session') - const session = connect(server.listeningOrigin) - session.on('error', () => { }) - await once(session, 'connect') - await waitSessionConnect - const waitSessionClosed = once(session, 'close') - await server.close() - await waitSessionClosed - t.assert.strictEqual(session.closed, true) - }) -}) \ No newline at end of file + http2SessionTimeout: 100, + }); + await server.listen(); + const waitSessionConnect = once(server, "session"); + const session = connect(server.listeningOrigin); + session.on("error", () => {}); + await once(session, "connect"); + await waitSessionConnect; + const waitSessionClosed = once(session, "close"); + await server.close(); + await waitSessionClosed; + t.assert.strictEqual(session.closed, true); + }); +}); diff --git a/test/listen/dns.test.js b/test/listen/dns.test.js index 6ef322f..4aea64c 100644 --- a/test/listen/dns.test.js +++ b/test/listen/dns.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const dns = require("node:dns"); const { createServer } = require("../../lib"); diff --git a/test/listen/error.test.js b/test/listen/error.test.js index bf8079c..289b2b5 100644 --- a/test/listen/error.test.js +++ b/test/listen/error.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const { once } = require("node:stream"); const http = require("node:http"); @@ -154,27 +154,27 @@ describe("error", () => { }); }); - test('second server listen error', async (t) => { - t.plan(1) + test("second server listen error", async (t) => { + t.plan(1); - const httpCreateServer = http.createServer - let count = 0 - const { mock } = t.mock.method(http, 'createServer', (...args) => { - const server = httpCreateServer(...args) + const httpCreateServer = http.createServer; + let count = 0; + const { mock } = t.mock.method(http, "createServer", (...args) => { + const server = httpCreateServer(...args); if (count === 1) { // delay to simulate the error throw in new server nextTick(() => { - server.emit('error', new Error('kaboom!')) - server.close() - }) + server.emit("error", new Error("kaboom!")); + server.close(); + }); } - count++ - return server - }) + count++; + return server; + }); const server = createServer({}, handler); - await server.listen() - t.assert.strictEqual(server.addresses().length, 1) - await server.close() - mock.restore() - }) + await server.listen(); + t.assert.strictEqual(server.addresses().length, 1); + await server.close(); + mock.restore(); + }); }); diff --git a/test/listen/host.test.js b/test/listen/host.test.js index ed9b8c9..edc5b25 100644 --- a/test/listen/host.test.js +++ b/test/listen/host.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); diff --git a/test/listen/listen-after-close.test.js b/test/listen/listen-after-close.test.js index f0e037d..99fe675 100644 --- a/test/listen/listen-after-close.test.js +++ b/test/listen/listen-after-close.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); diff --git a/test/listen/normalize.test.js b/test/listen/normalize.test.js index 69d35ac..7d35c96 100644 --- a/test/listen/normalize.test.js +++ b/test/listen/normalize.test.js @@ -1,156 +1,155 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const { normalizeListenOptions } = require("../../lib/listen"); const { Socket } = require("node:net"); -const { inspect } = require("node:util"); -describe('listen options normalize', () => { - const handle = Socket() - const abort = new AbortController() +describe("listen options normalize", () => { + const handle = Socket(); + const abort = new AbortController(); const cases = [ { - name: '{ handle }', + name: "{ handle }", actual: { handle }, - expect: { handle } + expect: { handle }, }, { - name: '{ handle, path }', - actual: { handle, path: '' }, - expect: { handle } + name: "{ handle, path }", + actual: { handle, path: "" }, + expect: { handle }, }, { - name: '{ handle, host }', - actual: { handle, host: '127.0.0.1' }, - expect: { handle } + name: "{ handle, host }", + actual: { handle, host: "127.0.0.1" }, + expect: { handle }, }, { - name: '{ handle, host, port }', - actual: { handle, host: '127.0.0.1', port: 1 }, - expect: { handle } + name: "{ handle, host, port }", + actual: { handle, host: "127.0.0.1", port: 1 }, + expect: { handle }, }, { - name: '{ path }', - actual: { path: '' }, - expect: { path: '' } + name: "{ path }", + actual: { path: "" }, + expect: { path: "" }, }, { - name: '{ path, host }', - actual: { path: '', host: '127.0.0.1' }, - expect: { path: '' } + name: "{ path, host }", + actual: { path: "", host: "127.0.0.1" }, + expect: { path: "" }, }, { - name: '{ path, port }', - actual: { path: '', port: 1 }, - expect: { host: 'localhost', port: 1 } + name: "{ path, port }", + actual: { path: "", port: 1 }, + expect: { host: "localhost", port: 1 }, }, { - name: '{ path, host, port }', - actual: { path: '', host: '127.0.0.1', port: 1 }, - expect: { host: '127.0.0.1', port: 1 } + name: "{ path, host, port }", + actual: { path: "", host: "127.0.0.1", port: 1 }, + expect: { host: "127.0.0.1", port: 1 }, }, { - name: '{ port }', + name: "{ port }", actual: { port: 1 }, - expect: { host: 'localhost', port: 1 } + expect: { host: "localhost", port: 1 }, }, { - name: '{ host }', - actual: { host: '127.0.0.1' }, - expect: { host: '127.0.0.1', port: 0 } + name: "{ host }", + actual: { host: "127.0.0.1" }, + expect: { host: "127.0.0.1", port: 0 }, }, { - name: '{ host, port }', - actual: { host: '127.0.0.1', port: 1 }, - expect: { host: '127.0.0.1', port: 1 } + name: "{ host, port }", + actual: { host: "127.0.0.1", port: 1 }, + expect: { host: "127.0.0.1", port: 1 }, }, { - name: '{ backlog: 1 }', + name: "{ backlog: 1 }", actual: { backlog: 1 }, - expect: { host: 'localhost', port: 0, backlog: 1 } + expect: { host: "localhost", port: 0, backlog: 1 }, }, { - name: '{ backlog: true }', + name: "{ backlog: true }", actual: { backlog: true }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ exclusive: 1 }', + name: "{ exclusive: 1 }", actual: { exclusive: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ exclusive: true }', + name: "{ exclusive: true }", actual: { exclusive: true }, - expect: { host: 'localhost', port: 0, exclusive: true } + expect: { host: "localhost", port: 0, exclusive: true }, }, { - name: '{ ipv6Only: 1 }', + name: "{ ipv6Only: 1 }", actual: { ipv6Only: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ ipv6Only: true }', + name: "{ ipv6Only: true }", actual: { ipv6Only: true }, - expect: { host: 'localhost', port: 0, ipv6Only: true } + expect: { host: "localhost", port: 0, ipv6Only: true }, }, { - name: '{ reusePort: 1 }', + name: "{ reusePort: 1 }", actual: { reusePort: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ reusePort: true }', + name: "{ reusePort: true }", actual: { reusePort: true }, - expect: { host: 'localhost', port: 0, reusePort: true } + expect: { host: "localhost", port: 0, reusePort: true }, }, { - name: '{ readableAll: 1 }', + name: "{ readableAll: 1 }", actual: { readableAll: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ readableAll: true }', + name: "{ readableAll: true }", actual: { readableAll: true }, - expect: { host: 'localhost', port: 0, readableAll: true } + expect: { host: "localhost", port: 0, readableAll: true }, }, { - name: '{ writableAll: 1 }', + name: "{ writableAll: 1 }", actual: { writableAll: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ writableAll: true }', + name: "{ writableAll: true }", actual: { writableAll: true }, - expect: { host: 'localhost', port: 0, writableAll: true } + expect: { host: "localhost", port: 0, writableAll: true }, }, { - name: '{ signal: 1 }', + name: "{ signal: 1 }", actual: { signal: 1 }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ signal: {} }', + name: "{ signal: {} }", actual: { signal: {} }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ signal: null }', + name: "{ signal: null }", actual: { signal: null }, - expect: { host: 'localhost', port: 0 } + expect: { host: "localhost", port: 0 }, }, { - name: '{ signal }', + name: "{ signal }", actual: { signal: abort.signal }, - expect: { host: 'localhost', port: 0, signal: abort.signal } - } - ] + expect: { host: "localhost", port: 0, signal: abort.signal }, + }, + ]; for (const { name, actual, expect } of cases) { test(name, (t) => { - t.plan(1) - const result = normalizeListenOptions(actual) - t.assert.deepStrictEqual(result, expect) - }) + t.plan(1); + const result = normalizeListenOptions(actual); + t.assert.deepStrictEqual(result, expect); + }); } -}) \ No newline at end of file +}); diff --git a/test/listen/socket.test.js b/test/listen/socket.test.js index b76c256..707920c 100644 --- a/test/listen/socket.test.js +++ b/test/listen/socket.test.js @@ -1,11 +1,10 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { createServer } = require("../../lib"); const { withResolvers } = require("../../lib/utils"); -const { once } = require("node:stream"); const handler = (_request, response) => { response.writeHead(200, { "Content-Type": "application/json" }); @@ -21,7 +20,7 @@ describe("socket", () => { ); try { fs.unlinkSync(sockFile); - } catch { } + } catch {} } else { sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; } @@ -53,12 +52,11 @@ describe("socket", () => { // Refs: https://github.com/fastify/fastify/pull/6937 test(".listen({ path, host })", { skip: !sockPath }, async (t) => { t.plan(4); - const { promise, resolve } = withResolvers(); const server = createServer({}, handler); t.assert.strictEqual(server.listening, false); - await server.listen({ path: sockPath, host: 'localhost' }); + await server.listen({ path: sockPath, host: "localhost" }); t.assert.strictEqual(server.listening, true); const addresses = server.addresses(); @@ -66,5 +64,5 @@ describe("socket", () => { t.assert.deepStrictEqual(addresses, [sockPath]); await server.close(); - }) + }); }); diff --git a/test/listening-origin.test.js b/test/listening-origin.test.js index 24597d6..262282e 100644 --- a/test/listening-origin.test.js +++ b/test/listening-origin.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const dns = require("node:dns"); const os = require("node:os"); @@ -72,7 +72,7 @@ describe("listeningOrigin", () => { ); try { fs.unlinkSync(sockFile); - } catch { } + } catch {} } else { sockPath = `\\\\.\\pipe\\${(`${Math.random().toString(16)}0000000`).slice(2, 10)}-server-sock`; } diff --git a/test/options/connection-timeout.test.js b/test/options/connection-timeout.test.js index d5c5655..99f9ae3 100644 --- a/test/options/connection-timeout.test.js +++ b/test/options/connection-timeout.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const http = require("node:http"); const { describe, test } = require("node:test"); const { once } = require("node:stream"); @@ -43,7 +43,11 @@ describe("connectionTimeout", () => { test("http2 + https", (t) => { t.plan(1); - const server = createServer({ connectionTimeout: 3, http2: true, https: {} }); + const server = createServer({ + connectionTimeout: 3, + http2: true, + https: {}, + }); t.assert.strictEqual(server.timeout, 3); }); diff --git a/test/options/keep-alive-timeout.test.js b/test/options/keep-alive-timeout.test.js index df30e57..616b75c 100644 --- a/test/options/keep-alive-timeout.test.js +++ b/test/options/keep-alive-timeout.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const http = require("node:http"); const { describe, test } = require("node:test"); const { once } = require("node:stream"); @@ -43,7 +43,11 @@ describe("keepAliveTimeout", () => { test("http2 + https", (t) => { t.plan(1); - const server = createServer({ keepAliveTimeout: 3, http2: true, https: {} }); + const server = createServer({ + keepAliveTimeout: 3, + http2: true, + https: {}, + }); t.assert.notStrictEqual(server.keepAliveTimeout, 3); }); diff --git a/test/options/max-requests-per-socket.test.js b/test/options/max-requests-per-socket.test.js index f8459ae..f60a3fe 100644 --- a/test/options/max-requests-per-socket.test.js +++ b/test/options/max-requests-per-socket.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const http = require("node:http"); const { describe, test } = require("node:test"); const { once } = require("node:stream"); @@ -43,7 +43,11 @@ describe("maxRequestsPerSocket", () => { test("http2 + https", (t) => { t.plan(1); - const server = createServer({ maxRequestsPerSocket: 3, http2: true, https: {} }); + const server = createServer({ + maxRequestsPerSocket: 3, + http2: true, + https: {}, + }); t.assert.notStrictEqual(server.maxRequestsPerSocket, 3); }); diff --git a/test/options/request-timeout.test.js b/test/options/request-timeout.test.js index 0dd7bc2..84bb38e 100644 --- a/test/options/request-timeout.test.js +++ b/test/options/request-timeout.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const http = require("node:http"); const { describe, test } = require("node:test"); const { once } = require("node:stream"); diff --git a/test/security.test.js b/test/security.test.js index 75b410f..75bb722 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -1,4 +1,4 @@ -'use strict' +"use strict"; const { describe, test } = require("node:test"); const http = require("node:http"); const { createServer } = require("../lib"); diff --git a/test/utils.js b/test/utils.js index 125528a..ac8b4d2 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,36 +1,35 @@ -'use strict' -const { hostname, networkInterfaces } = require('node:os') -const { pki, util } = require('node-forge') - +"use strict"; +const { hostname } = require("node:os"); +const { pki } = require("node-forge"); function buildCertificate() { - if (!global.context || !global.context.certificate || !global.context.privateKey) { - const now = new Date() - const { publicKey, privateKey } = pki.rsa.generateKeyPair(2048) - const certificate = pki.createCertificate() - certificate.publicKey = publicKey - certificate.serialNumber = '01' - certificate.validity.notBefore = now - certificate.validity.notAfter = new Date(Number(now) + 86400000) + if (!global.context?.certificate || !global.context?.privateKey) { + const now = new Date(); + const { publicKey, privateKey } = pki.rsa.generateKeyPair(2048); + const certificate = pki.createCertificate(); + certificate.publicKey = publicKey; + certificate.serialNumber = "01"; + certificate.validity.notBefore = now; + certificate.validity.notAfter = new Date(Number(now) + 86400000); const attrs = [ - { shortName: 'CN', value: hostname() }, - { shortName: 'C', value: 'US' }, - { shortName: 'ST', value: 'California' }, - { shortName: 'L', value: 'San Francisco' }, - { shortName: 'O', value: 'Fastify' }, - { shortName: 'OU', value: 'Development Team' } - ] - certificate.setSubject(attrs) - certificate.setIssuer(attrs) + { shortName: "CN", value: hostname() }, + { shortName: "C", value: "US" }, + { shortName: "ST", value: "California" }, + { shortName: "L", value: "San Francisco" }, + { shortName: "O", value: "Fastify" }, + { shortName: "OU", value: "Development Team" }, + ]; + certificate.setSubject(attrs); + certificate.setIssuer(attrs); - certificate.sign(privateKey) + certificate.sign(privateKey); global.context = { privateKey: pki.privateKeyToPem(privateKey), publicKey: pki.publicKeyToPem(publicKey), - certificate: pki.certificateToPem(certificate) - } + certificate: pki.certificateToPem(certificate), + }; } } -module.exports.buildCertificate = buildCertificate \ No newline at end of file +module.exports.buildCertificate = buildCertificate; diff --git a/types/server.d.ts b/types/server.d.ts index d635fea..93323cd 100644 --- a/types/server.d.ts +++ b/types/server.d.ts @@ -46,7 +46,7 @@ export interface HTTP2ServerOptions extends CommonServerOptions { https?: false; http2: true; /** @default 72000 */ - http2SessionTimeout?: number + http2SessionTimeout?: number; } export type HTTP2RequestListener = NodeRequestListener; export type HTTP2Server = NodeHTTP2Server; From c1132051509a281eb884a66620ffed322a790ed3 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 13 Aug 2026 04:46:50 +0800 Subject: [PATCH 6/8] fixup --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b4a99a3..de74ecb 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ ], "scripts": { "test": "npm run test:unit && npm run test:types", - "test:unit": "node --test --test-timeout=500", + "test:unit": "node --test --test-timeout=2000", "test:coverage": "c8 npm run test:unit", "test:types": "tstyche", "lint": "npx @biomejs/biome check", From db49feac9ce6dfba0d11dc5ab49ac338a4614148 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 13 Aug 2026 04:50:30 +0800 Subject: [PATCH 7/8] fixup --- .github/dependabot.yml | 4 ++++ .github/workflows/ci.yml | 3 ++- package.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e7c1a63..c909645 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,6 +34,8 @@ updates: # Production dependencies with breaking changes dependencies: dependency-type: "production" + patterns: + - "*" # TypeScript related dependencies dev-dependencies-typescript: patterns: @@ -43,3 +45,5 @@ updates: # Development dependencies with breaking changes dev-dependencies: dependency-type: "development" + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ad0bf..dd784e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,8 @@ jobs: permissions: contents: write pull-requests: write - uses: fastify/workflows/.github/workflows/plugins-ci.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0 + uses: fastify/workflows/.github/workflows/plugins-ci.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0 with: + node-versions: '["24", "26"]' license-check: true lint: true diff --git a/package.json b/package.json index de74ecb..545a0b5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ ], "scripts": { "test": "npm run test:unit && npm run test:types", - "test:unit": "node --test --test-timeout=2000", + "test:unit": "node --test", "test:coverage": "c8 npm run test:unit", "test:types": "tstyche", "lint": "npx @biomejs/biome check", From 83fe355ccf6b1647934d1f72df81880d937489e0 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 13 Aug 2026 04:53:43 +0800 Subject: [PATCH 8/8] fixup --- lib/server.js | 4 +--- package.json | 2 +- test/options/max-requests-per-socket.test.js | 2 +- test/options/request-timeout.test.js | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/server.js b/lib/server.js index 4a38626..a78b630 100644 --- a/lib/server.js +++ b/lib/server.js @@ -104,9 +104,7 @@ function isHTTPS(options) { function createHTTPSServer(options, requestListener) { const https = require("node:https"); - const serverOptions = options.https === true ? {} : options.https; - - const server = https.createServer(serverOptions, requestListener); + const server = https.createServer(options.https, requestListener); server.keepAliveTimeout = options.keepAliveTimeout; server.requestTimeout = options.requestTimeout; diff --git a/package.json b/package.json index 545a0b5..83b605e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "test": "npm run test:unit && npm run test:types", "test:unit": "node --test", - "test:coverage": "c8 npm run test:unit", + "test:coverage": "c8 --100 npm run test:unit", "test:types": "tstyche", "lint": "npx @biomejs/biome check", "lint:fix": "npx @biomejs/biome check --write" diff --git a/test/options/max-requests-per-socket.test.js b/test/options/max-requests-per-socket.test.js index f60a3fe..587788d 100644 --- a/test/options/max-requests-per-socket.test.js +++ b/test/options/max-requests-per-socket.test.js @@ -25,7 +25,7 @@ describe("maxRequestsPerSocket", () => { test("http", (t) => { t.plan(1); - const server = createServer({ maxRequestsPerSocket: 1 }); + const server = createServer({ maxRequestsPerSocket: 1, http: {} }); t.assert.strictEqual(server.maxRequestsPerSocket, 1); }); diff --git a/test/options/request-timeout.test.js b/test/options/request-timeout.test.js index 84bb38e..b632f68 100644 --- a/test/options/request-timeout.test.js +++ b/test/options/request-timeout.test.js @@ -25,13 +25,13 @@ describe("requestTimeout", () => { test("http", (t) => { t.plan(1); - const server = createServer({ requestTimeout: 1 }); + const server = createServer({ requestTimeout: 1, http: true }); t.assert.strictEqual(server.requestTimeout, 1); }); test("https", (t) => { t.plan(1); - const server = createServer({ requestTimeout: 2, https: {} }); + const server = createServer({ requestTimeout: 2, https: true }); t.assert.strictEqual(server.requestTimeout, 2); });