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..c909645 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +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" + patterns: + - "*" + # TypeScript related dependencies + dev-dependencies-typescript: + patterns: + - "@types/*" + - "tstyche" + - "typescript" + # Development dependencies with breaking changes + dev-dependencies: + dependency-type: "development" + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd784e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +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@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0 + with: + node-versions: '["24", "26"]' + 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..0af95fa --- /dev/null +++ b/biome.json @@ -0,0 +1,49 @@ +{ + "$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" + } + } + }, + "overrides": [ + { + "includes": ["test/security.test.js"], + "linter": { + "rules": { + "suspicious": { + "noProto": "off" + } + } + } + } + ] +} 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..050982b --- /dev/null +++ b/lib/index.js @@ -0,0 +1,199 @@ +const { selectServer, normalizeServerOptions } = require("./server"); +const { listenLookupHosts, normalizeListenOptions } = require("./listen"); +const { + normalizeCallback, + listeningOrigin, + stateClose, + syncInternalServers, +} = require("./utils"); +const { kState, kRaw, kInternalServers } = require("./symbols"); +const { ERR_SERVER_ALREADY_LISTEN } = require("./errors"); + +/** @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 serverOptions = normalizeServerOptions(options); + const { type, server } = selectServer(serverOptions, requestListener); + const state = { + listen: false, + listening: false, + closed: false, + closing: false, + error: false, + aborted: false, + }; + + // 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; + if (name === "listeningOrigin") + return listeningOrigin(server, serverOptions); + return Reflect.get(target, name, receiver); + }, + set(target, name, value, receiver) { + syncInternalServers(type, internalServers, 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, + serverOptions, + 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(state, internalServers); + server.emit("fastify.close"); + cb(); + } else { + for (const internalServer of internalServers) { + function internalOnClose() { + internalServer.off("close", internalOnClose); + bound--; + if (bound === 0) { + stateClose(state, internalServers); + 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..b19be7e --- /dev/null +++ b/lib/listen.js @@ -0,0 +1,206 @@ +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.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"; + } + + // 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 ( + options.signal !== null && + 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 { server: 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(); + } + + 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..a78b630 --- /dev/null +++ b/lib/server.js @@ -0,0 +1,263 @@ +/** @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; + + if ( + typeof options.http2SessionTimeout === "number" && + Number.isInteger(options.http2SessionTimeout) + ) + serverOptions.http2SessionTimeout = options.http2SessionTimeout; + else serverOptions.http2SessionTimeout = 72000; + + 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 server = https.createServer(options.https, 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 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 result; +} + +module.exports.isHTTPS = isHTTPS; +module.exports.selectServer = selectServer; +module.exports.normalizeServerOptions = normalizeServerOptions; 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..b54cfe2 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,91 @@ +const { isHTTPS } = require("./server"); + +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 pCallback(error, data) { + if (error) { + reject(error); + } else { + resolve(data); + } + } + pCallback.promise = promise; + return pCallback; + } 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 }; +} + +function listeningOrigin(server, options) { + const address = server.address(); + if (typeof address === "string") return address; + const host = + address.family === "IPv6" ? `[${address.address}]` : address.address; + return `${isHTTPS(options) ? "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; +} + +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 new file mode 100644 index 0000000..83b605e --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "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 --100 npm run test:unit", + "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", + "node-forge": "^1.4.0", + "tstyche": "^7.2.2", + "typescript": "^7.0.2" + }, + "dependencies": { + "@fastify/error": "^4.2.0" + }, + "publishConfig": { + "access": "public" + }, + "c8": { + "reporter": [ + "html", + "text" + ] + } +} diff --git a/test/abort-signal.test.js b/test/abort-signal.test.js new file mode 100644 index 0000000..d216bc3 --- /dev/null +++ b/test/abort-signal.test.js @@ -0,0 +1,96 @@ +"use strict"; +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/close/double-close.test.js b/test/close/double-close.test.js new file mode 100644 index 0000000..76c2877 --- /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); + }); +}); diff --git a/test/close/http2-session-timeout.test.js b/test/close/http2-session-timeout.test.js new file mode 100644 index 0000000..f523626 --- /dev/null +++ b/test/close/http2-session-timeout.test.js @@ -0,0 +1,89 @@ +"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"); + +describe("http2SessionTimeout", () => { + before(buildCertificate); + + 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(); + }); + + test("http2s close with async-await", async () => { + 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) => { + 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); + }); +}); diff --git a/test/listen/dns.test.js b/test/listen/dns.test.js new file mode 100644 index 0000000..4aea64c --- /dev/null +++ b/test/listen/dns.test.js @@ -0,0 +1,136 @@ +"use strict"; +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..289b2b5 --- /dev/null +++ b/test/listen/error.test.js @@ -0,0 +1,180 @@ +"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" }); + 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; + }); + }); + + 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 new file mode 100644 index 0000000..edc5b25 --- /dev/null +++ b/test/listen/host.test.js @@ -0,0 +1,229 @@ +"use strict"; +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..99fe675 --- /dev/null +++ b/test/listen/listen-after-close.test.js @@ -0,0 +1,65 @@ +"use strict"; +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/normalize.test.js b/test/listen/normalize.test.js new file mode 100644 index 0000000..7d35c96 --- /dev/null +++ b/test/listen/normalize.test.js @@ -0,0 +1,155 @@ +"use strict"; +const { describe, test } = require("node:test"); +const { normalizeListenOptions } = require("../../lib/listen"); +const { Socket } = require("node:net"); + +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); + }); + } +}); diff --git a/test/listen/socket.test.js b/test/listen/socket.test.js new file mode 100644 index 0000000..707920c --- /dev/null +++ b/test/listen/socket.test.js @@ -0,0 +1,68 @@ +"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 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; + }); + + // Refs: https://github.com/fastify/fastify/pull/6937 + test(".listen({ path, host })", { skip: !sockPath }, async (t) => { + t.plan(4); + + 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 new file mode 100644 index 0000000..262282e --- /dev/null +++ b/test/listening-origin.test.js @@ -0,0 +1,140 @@ +"use strict"; +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(); + }); + + 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..99f9ae3 --- /dev/null +++ b/test/options/connection-timeout.test.js @@ -0,0 +1,83 @@ +"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 new file mode 100644 index 0000000..616b75c --- /dev/null +++ b/test/options/keep-alive-timeout.test.js @@ -0,0 +1,83 @@ +"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("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("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); + + 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/test/options/max-requests-per-socket.test.js b/test/options/max-requests-per-socket.test.js new file mode 100644 index 0000000..587788d --- /dev/null +++ b/test/options/max-requests-per-socket.test.js @@ -0,0 +1,83 @@ +"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, http: {} }); + 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..b632f68 --- /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, http: true }); + t.assert.strictEqual(server.requestTimeout, 1); + }); + + test("https", (t) => { + t.plan(1); + const server = createServer({ requestTimeout: 2, https: true }); + 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 new file mode 100644 index 0000000..75bb722 --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,25 @@ +"use strict"; +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); + }); +}); diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..ac8b4d2 --- /dev/null +++ b/test/utils.js @@ -0,0 +1,35 @@ +"use strict"; +const { hostname } = require("node:os"); +const { pki } = require("node-forge"); + +function buildCertificate() { + 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); + + certificate.sign(privateKey); + + global.context = { + privateKey: pki.privateKeyToPem(privateKey), + publicKey: pki.publicKeyToPem(publicKey), + certificate: pki.certificateToPem(certificate), + }; + } +} + +module.exports.buildCertificate = buildCertificate; 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..93323cd --- /dev/null +++ b/types/server.d.ts @@ -0,0 +1,80 @@ +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; + /** @default 72000 */ + http2SessionTimeout?: number; +} +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..36d9a1b --- /dev/null +++ b/types/utils.d.ts @@ -0,0 +1,9 @@ +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; +};