From c338f03ce19924c0a470e91c15b022286785febb Mon Sep 17 00:00:00 2001 From: Monaam Aouini Date: Mon, 28 Apr 2025 18:08:00 +0100 Subject: [PATCH 1/4] feat(http2): add HTTP/2 support --- examples/http2/README.md | 1 + examples/http2/http2.js | 72 +++++++ lib/express.js | 6 + lib/http2.js | 409 +++++++++++++++++++++++++++++++++++++++ test/exports.js | 6 + 5 files changed, 494 insertions(+) create mode 100644 examples/http2/README.md create mode 100644 examples/http2/http2.js create mode 100644 lib/http2.js diff --git a/examples/http2/README.md b/examples/http2/README.md new file mode 100644 index 00000000000..afffe2c6d28 --- /dev/null +++ b/examples/http2/README.md @@ -0,0 +1 @@ +# HTTP/2 Support\n\nThis directory contains an example demonstrating HTTP/2 support in Express.\n\n## Running the example\n\n1. Generate self-signed certificates:\n\n```bash\nopenssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \\n -keyout private-key.pem -out certificate.pem\n```\n\n2. Run the example:\n\n```bash\nnode http2.js\n```\n\n3. Visit https://localhost:3000 in your browser\n\nNote: Your browser may show a security warning because of the self-signed certificate. diff --git a/examples/http2/http2.js b/examples/http2/http2.js new file mode 100644 index 00000000000..b7c1ee664bf --- /dev/null +++ b/examples/http2/http2.js @@ -0,0 +1,72 @@ +'use strict'; + +/** + * Example application showcasing Express with HTTP/2 support + * + * Note: To run this example, you need to generate self-signed certificates first: + * + * $ openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + * -keyout examples/http2/private-key.pem \ + * -out examples/http2/certificate.pem + */ + +const express = require('../..'); +const fs = require('node:fs'); +const path = require('node:path'); +const app = express(); + +// --------------- +// BASIC ROUTES +// --------------- + +app.get('/', (req, res) => { + res.json({ + message: 'Express with HTTP/2 support', + protocol: req.httpVersion === '2.0' ? 'HTTP/2' : 'HTTP/1.1', + method: req.method, + url: req.url + }); +}); + +app.get('/stream', (req, res) => { + // HTTP/2 streaming example + res.write(JSON.stringify({ message: 'This is the first part' }) + '\n'); + + setTimeout(() => { + res.write(JSON.stringify({ message: 'This is the second part' }) + '\n'); + + setTimeout(() => { + res.write(JSON.stringify({ message: 'This is the final part' }) + '\n'); + res.end(); + }, 1000); + }, 1000); +}); + +// Try to load certificates for HTTP/2 HTTPS +let server; +try { + const options = { + key: fs.readFileSync(path.join(__dirname, 'private-key.pem')), + cert: fs.readFileSync(path.join(__dirname, 'certificate.pem')), + allowHTTP1: true // Allow HTTP/1.1 fallback + }; + + // Create HTTP/2 secure server + server = app.http2Secure(app, options); + + server.listen(3000, () => { + console.log('Express HTTP/2 server running on https://localhost:3000'); + console.log('Note: Since this uses a self-signed certificate, your browser may show a security warning'); + }); +} catch (err) { + console.error('Could not load certificates for HTTPS:', err.message); + console.log('Falling back to plain HTTP/2...'); + + // Create plain HTTP/2 server + server = app.http2(app, {}); + + server.listen(3000, () => { + console.log('Express HTTP/2 server running on http://localhost:3000'); + console.log('Note: Some browsers only support HTTP/2 over HTTPS'); + }); +} diff --git a/lib/express.js b/lib/express.js index 2d502eb54e4..33613a3192a 100644 --- a/lib/express.js +++ b/lib/express.js @@ -19,6 +19,7 @@ var proto = require('./application'); var Router = require('router'); var req = require('./request'); var res = require('./response'); +var http2 = require('./http2'); /** * Expose `createApplication()`. @@ -51,6 +52,10 @@ function createApplication() { app: { configurable: true, enumerable: true, writable: true, value: app } }) + // Add HTTP/2 support + app.http2 = http2.createServer; + app.http2Secure = http2.createSecureServer; + app.init(); return app; } @@ -79,3 +84,4 @@ exports.raw = bodyParser.raw exports.static = require('serve-static'); exports.text = bodyParser.text exports.urlencoded = bodyParser.urlencoded +exports.http2 = http2; diff --git a/lib/http2.js b/lib/http2.js new file mode 100644 index 00000000000..be2ee028e1b --- /dev/null +++ b/lib/http2.js @@ -0,0 +1,409 @@ +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict'; + +/** + * Module dependencies. + * @private + */ + +var http2 = require('node:http2'); +var debug = require('debug')('express:http2'); + +/** + * HTTP/2 constants + */ +const { + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_HEADER_STATUS, + HTTP2_HEADER_CONTENT_TYPE +} = http2.constants; + +/** + * Create an HTTP/2 server for an Express app + * + * @param {Object} app Express application + * @param {Object} options HTTP/2 server options + * @return {Object} HTTP/2 server instance + * @public + */ +exports.createServer = function createServer(app, options) { + debug('Creating HTTP/2 server'); + + // Create the HTTP/2 server + const server = http2.createServer(options); + + // Handle HTTP/2 streams + server.on('stream', (stream, headers) => { + debug('Received HTTP/2 stream'); + + // Create mock request and response objects compatible with Express + const req = createRequest(stream, headers); + const res = createResponse(stream); + + // Set req and res references to each other + req.res = res; + res.req = req; + + // Set app as req.app and res.app + req.app = res.app = app; + + // Create a custom finalhandler that works with HTTP/2 + const done = function(err) { + if (err && !stream.destroyed) { + const statusCode = err.status || err.statusCode || 500; + stream.respond({ + [HTTP2_HEADER_STATUS]: statusCode, + [HTTP2_HEADER_CONTENT_TYPE]: 'text/plain', + }); + stream.end(statusCode === 500 ? 'Internal Server Error' : err.message); + } else if (!res.headersSent && !stream.destroyed) { + // Default 404 handler + stream.respond({ + [HTTP2_HEADER_STATUS]: 404, + [HTTP2_HEADER_CONTENT_TYPE]: 'text/plain', + }); + stream.end('Not Found'); + } + }; + + // Handle the request with Express + app(req, res, done); + }); + + return server; +}; + +/** + * Create an HTTP/2 secure server for an Express app + * + * @param {Object} app Express application + * @param {Object} options HTTP/2 secure server options + * @return {Object} HTTP/2 secure server instance + * @public + */ +exports.createSecureServer = function createSecureServer(app, options) { + debug('Creating HTTP/2 secure server'); + + if (!options.key || !options.cert) { + throw new Error('HTTP/2 secure server requires key and cert options'); + } + + // Create the HTTP/2 secure server + const server = http2.createSecureServer(options); + + // Handle HTTP/2 streams + server.on('stream', (stream, headers) => { + debug('Received HTTP/2 secure stream'); + + // Create mock request and response objects compatible with Express + const req = createRequest(stream, headers); + const res = createResponse(stream); + + // Set req and res references to each other + req.res = res; + res.req = req; + + // Set app as req.app and res.app + req.app = res.app = app; + + // Create a custom finalhandler that works with HTTP/2 + const done = function(err) { + if (err && !stream.destroyed) { + const statusCode = err.status || err.statusCode || 500; + stream.respond({ + [HTTP2_HEADER_STATUS]: statusCode, + [HTTP2_HEADER_CONTENT_TYPE]: 'text/plain', + }); + stream.end(statusCode === 500 ? 'Internal Server Error' : err.message); + } else if (!res.headersSent && !stream.destroyed) { + // Default 404 handler + stream.respond({ + [HTTP2_HEADER_STATUS]: 404, + [HTTP2_HEADER_CONTENT_TYPE]: 'text/plain', + }); + stream.end('Not Found'); + } + }; + + // Handle the request with Express + app(req, res, done); + }); + + return server; +}; + +/** + * Create a mock request object compatible with Express + * + * @param {Object} stream HTTP/2 stream + * @param {Object} headers HTTP/2 headers + * @return {Object} Express-compatible request object + * @private + */ +function createRequest(stream, headers) { + const method = headers[HTTP2_HEADER_METHOD] || 'GET'; + const url = headers[HTTP2_HEADER_PATH] || '/'; + + debug(`HTTP/2 ${method} ${url}`); + + // Create basic request object + const req = { + stream: stream, + httpVersionMajor: 2, + httpVersionMinor: 0, + httpVersion: '2.0', + complete: false, + headers: Object.assign({}, headers), + rawHeaders: [], + trailers: {}, + rawTrailers: [], + aborted: false, + upgrade: false, + url: url, + method: method, + statusCode: null, + statusMessage: null, + socket: stream.session.socket, + connection: stream.session.socket, + + // Body parsing + body: {}, + + // Express extensions + params: {}, + query: {}, + res: null, + + // Properties needed by finalhandler + _readableState: { pipes: null }, + unpipe: function() { return this; }, + removeListener: function() { return this; }, + + // Stream-like methods + on: stream.on.bind(stream), + once: stream.once.bind(stream), + pipe: function() { return this; }, + addListener: function(type, listener) { + stream.on(type, listener); + return this; + }, + removeAllListeners: function() { + return this; + }, + emit: stream.emit.bind(stream), + setEncoding: function(encoding) { + stream.setEncoding(encoding); + return this; + }, + pause: function() { + stream.pause && stream.pause(); + return this; + }, + resume: function() { + stream.resume && stream.resume(); + return this; + }, + + // Request reading methods + read: stream.read ? stream.read.bind(stream) : () => null, + }; + + // Parse URL parts + parseUrl(req); + + // Add data event handling + const chunks = []; + + stream.on('data', chunk => { + chunks.push(chunk); + }); + + stream.on('end', () => { + req.complete = true; + req.rawBody = Buffer.concat(chunks); + + // Try to parse as JSON if content-type is application/json + if (req.headers['content-type'] === 'application/json') { + try { + req.body = JSON.parse(req.rawBody.toString()); + } catch (e) { + debug('Failed to parse request body as JSON', e); + } + } + }); + + return req; +} + +/** + * Create a mock response object compatible with Express + * + * @param {Object} stream HTTP/2 stream + * @return {Object} Express-compatible response object + * @private + */ +function createResponse(stream) { + const res = { + stream: stream, + headersSent: false, + finished: false, + statusCode: 200, + locals: Object.create(null), + req: null, + + // Properties needed by finalhandler + _header: '', + _implicitHeader: function() {}, + connection: { writable: true }, + + // Response methods + status: function(code) { + this.statusCode = code; + return this; + }, + + send: function(body) { + if (this.headersSent) { + debug('Headers already sent, ignoring send'); + return this; + } + + if (!this.getHeader('Content-Type')) { + if (typeof body === 'string') { + this.setHeader('Content-Type', 'text/html'); + } else if (Buffer.isBuffer(body)) { + this.setHeader('Content-Type', 'application/octet-stream'); + } else if (typeof body === 'object') { + this.setHeader('Content-Type', 'application/json'); + body = JSON.stringify(body); + } + } + + const headers = { + [HTTP2_HEADER_STATUS]: this.statusCode, + }; + + // Convert traditional headers to HTTP/2 headers + for (const name in this._headers) { + headers[name.toLowerCase()] = this._headers[name]; + } + + stream.respond(headers); + this.headersSent = true; + + if (body !== undefined) { + stream.end(body); + this.finished = true; + } + + return this; + }, + + json: function(obj) { + if (!this.getHeader('Content-Type')) { + this.setHeader('Content-Type', 'application/json'); + } + + return this.send(JSON.stringify(obj)); + }, + + sendStatus: function(code) { + const status = code.toString(); + this.statusCode = code; + return this.send(status); + }, + + // Response streaming methods + write: function(data, encoding) { + if (!this.headersSent) { + const headers = { + [HTTP2_HEADER_STATUS]: this.statusCode, + }; + + // Convert traditional headers to HTTP/2 headers + for (const name in this._headers) { + headers[name.toLowerCase()] = this._headers[name]; + } + + stream.respond(headers); + this.headersSent = true; + } + + return stream.write(data, encoding); + }, + + // Headers management + _headers: {}, + getHeaders: function() { + return Object.assign({}, this._headers); + }, + + setHeader: function(name, value) { + this._headers[name] = value; + return this; + }, + + getHeader: function(name) { + return this._headers[name]; + }, + + removeHeader: function(name) { + delete this._headers[name]; + return this; + }, + + end: function(data) { + if (this.finished) { + debug('Response already finished, ignoring end'); + return this; + } + + if (!this.headersSent) { + const headers = { + [HTTP2_HEADER_STATUS]: this.statusCode, + }; + + // Convert traditional headers to HTTP/2 headers + for (const name in this._headers) { + headers[name.toLowerCase()] = this._headers[name]; + } + + stream.respond(headers); + this.headersSent = true; + } + + stream.end(data); + this.finished = true; + return this; + } + }; + + return res; +} + +/** + * Parse URL parts from request + * + * @param {Object} req Request object + * @private + */ +function parseUrl(req) { + const url = new URL(req.url, 'http://localhost'); + + req.path = url.pathname; + req.hostname = url.hostname; + + // Parse query string + req.query = {}; + for (const [key, value] of url.searchParams.entries()) { + req.query[key] = value; + } +} diff --git a/test/exports.js b/test/exports.js index fc7836c1594..ff989277454 100644 --- a/test/exports.js +++ b/test/exports.js @@ -34,6 +34,12 @@ describe('exports', function(){ assert.equal(express.urlencoded.length, 1) }) + it('should expose http2 module', function () { + assert.equal(typeof express.http2, 'object') + assert.equal(typeof express.http2.createServer, 'function') + assert.equal(typeof express.http2.createSecureServer, 'function') + }) + it('should expose the application prototype', function(){ assert.strictEqual(typeof express.application, 'object') assert.strictEqual(typeof express.application.set, 'function') From 4bf9acc9cb17ed628f7ecb892f00edd412f62ea9 Mon Sep 17 00:00:00 2001 From: Monaam Aouini Date: Mon, 28 Apr 2025 18:10:42 +0100 Subject: [PATCH 2/4] docs(http2): enhance README with detailed HTTP/2 information and usage instructions --- examples/http2/README.md | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/examples/http2/README.md b/examples/http2/README.md index afffe2c6d28..2f127344f47 100644 --- a/examples/http2/README.md +++ b/examples/http2/README.md @@ -1 +1,45 @@ -# HTTP/2 Support\n\nThis directory contains an example demonstrating HTTP/2 support in Express.\n\n## Running the example\n\n1. Generate self-signed certificates:\n\n```bash\nopenssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \\n -keyout private-key.pem -out certificate.pem\n```\n\n2. Run the example:\n\n```bash\nnode http2.js\n```\n\n3. Visit https://localhost:3000 in your browser\n\nNote: Your browser may show a security warning because of the self-signed certificate. +# HTTP/2 Support + +This directory contains an example demonstrating HTTP/2 support in Express. + +## What is HTTP/2? + +HTTP/2 is the second major version of the HTTP protocol. It offers several advantages over HTTP/1.1: + +- Multiplexing: Multiple requests can be sent over a single connection +- Header compression: Reduces overhead and improves performance +- Server push: Allows the server to proactively send resources to the client +- Binary format: More efficient to parse than HTTP/1.1's text format + +## Prerequisites + +- Node.js (version 8.4.0 or higher) +- OpenSSL (for generating certificates) + +## Running the example + +1. Generate self-signed certificates: + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + -keyout private-key.pem -out certificate.pem +``` + +2. Run the example: + +```bash +node http2.js +``` + +3. Visit https://localhost:3000 in your browser + +**Note:** Your browser may show a security warning because of the self-signed certificate. This is expected and you can proceed by accepting the risk. + +## How it works + +The `http2.js` file demonstrates: +- Creating an HTTP/2 server with Express +- Setting up secure connections with TLS certificates +- Serving content through the HTTP/2 protocol + +For more information about HTTP/2 support in Node.js, visit the [official documentation](https://nodejs.org/api/http2.html). From c23aea396f7c983137a12de4078a5c82bd6285ed Mon Sep 17 00:00:00 2001 From: Monaam Aouini Date: Mon, 19 May 2025 20:55:08 +0100 Subject: [PATCH 3/4] chore(http2): remove outdated license header from http2.js --- lib/http2.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/http2.js b/lib/http2.js index be2ee028e1b..58b68785ea3 100644 --- a/lib/http2.js +++ b/lib/http2.js @@ -1,11 +1,3 @@ -/*! - * express - * Copyright(c) 2009-2013 TJ Holowaychuk - * Copyright(c) 2013 Roman Shtylman - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - 'use strict'; /** From da8cdf8cf6bef3841f33bae5d36a96329df7f78d Mon Sep 17 00:00:00 2001 From: Monaam Aouini Date: Mon, 19 May 2025 21:12:33 +0100 Subject: [PATCH 4/4] test(http2): add comprehensive tests for HTTP/2 and HTTP/2 secure server functionality --- test/express.http2.js | 345 +++++++++++++++++++++++++++ test/http2.js | 537 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 882 insertions(+) create mode 100644 test/express.http2.js create mode 100644 test/http2.js diff --git a/test/express.http2.js b/test/express.http2.js new file mode 100644 index 00000000000..fe3bae45e10 --- /dev/null +++ b/test/express.http2.js @@ -0,0 +1,345 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const assert = require('node:assert'); +const http2 = require('node:http2'); +const fs = require('node:fs'); +const path = require('node:path'); +const express = require('..'); +const os = require('node:os'); + +/** + * Temporary certificate paths for tests + */ +const TEMP_KEY_PATH = path.join(os.tmpdir(), 'express-test-key.pem'); +const TEMP_CERT_PATH = path.join(os.tmpdir(), 'express-test-cert.pem'); + +describe('Express.js HTTP/2 Integration', function() { + // Increase test timeout + this.timeout(5000); + + // Track all servers and clients to ensure proper cleanup + let servers = []; + let clients = []; + + // Create a temporary self-signed certificate for tests + before(function(done) { + // Skip certificate generation in CI environments + if (process.env.CI) { + this.skip(); + return; + } + + const { execSync } = require('node:child_process'); + + try { + execSync(`openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + -keyout ${TEMP_KEY_PATH} \ + -out ${TEMP_CERT_PATH}`, { stdio: 'ignore' }); + done(); + } catch (err) { + done(new Error('Failed to generate test certificates. Skip these tests or install OpenSSL.')); + } + }); + + // Clean up certificates after tests + after(function(done) { + try { + fs.unlinkSync(TEMP_KEY_PATH); + fs.unlinkSync(TEMP_CERT_PATH); + } catch (err) { + // Ignore errors (files might not exist) + } + + // Ensure all servers and clients are closed + Promise.all([ + ...servers.map(server => new Promise(resolve => { + if (server.listening) { + server.close(resolve); + } else { + resolve(); + } + })), + ...clients.map(client => new Promise(resolve => { + if (!client.destroyed) { + client.close(resolve); + } else { + resolve(); + } + })) + ]).then(() => done()).catch(() => done()); + }); + + // Reset servers and clients arrays after each test + afterEach(function() { + servers = []; + clients = []; + }); + + describe('app.http2()', function() { + it('should expose HTTP/2 server creation method', function() { + const app = express(); + assert.strictEqual(typeof app.http2, 'function'); + }); + + it('should create an HTTP/2 server for an Express app', function() { + const app = express(); + const server = app.http2(app, {}); + servers.push(server); + + // Check for HTTP/2 server properties instead of using instanceof + assert.strictEqual(typeof server.on, 'function'); + assert.strictEqual(typeof server.listen, 'function'); + assert.strictEqual(typeof server.close, 'function'); + + server.close(); + }); + + it('should handle requests through the Express app', function(done) { + const app = express(); + + app.get('/', function(req, res) { + res.send('HTTP/2 test'); + }); + + const server = app.http2(app, {}); + servers.push(server); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + let data = ''; + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + assert.strictEqual(data, 'HTTP/2 test'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + + it('should correctly use Express middleware stack', function(done) { + const app = express(); + + // Add middleware + app.use(function(req, res, next) { + req.custom = 'middleware test'; + next(); + }); + + app.get('/middleware', function(req, res) { + res.send(req.custom); + }); + + const server = app.http2(app, {}); + servers.push(server); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + let data = ''; + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/middleware', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + assert.strictEqual(data, 'middleware test'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + }); + + describe('app.http2Secure()', function() { + before(function() { + // Skip secure server tests if certificates weren't created + if (!fs.existsSync(TEMP_KEY_PATH) || !fs.existsSync(TEMP_CERT_PATH)) { + this.skip(); + } + }); + + it('should expose HTTP/2 secure server creation method', function() { + const app = express(); + assert.strictEqual(typeof app.http2Secure, 'function'); + }); + + it('should create an HTTP/2 secure server for an Express app', function() { + const app = express(); + const options = { + key: fs.readFileSync(TEMP_KEY_PATH), + cert: fs.readFileSync(TEMP_CERT_PATH) + }; + + const server = app.http2Secure(app, options); + servers.push(server); + + // Check for HTTP/2 secure server properties instead of using instanceof + assert.strictEqual(typeof server.on, 'function'); + assert.strictEqual(typeof server.listen, 'function'); + assert.strictEqual(typeof server.close, 'function'); + + server.close(); + }); + + it('should handle secure requests through the Express app', function(done) { + const app = express(); + + app.get('/secure', function(req, res) { + res.send('HTTP/2 secure test'); + }); + + const options = { + key: fs.readFileSync(TEMP_KEY_PATH), + cert: fs.readFileSync(TEMP_CERT_PATH) + }; + + const server = app.http2Secure(app, options); + servers.push(server); + + server.listen(0, function() { + const port = server.address().port; + + // Use insecure client for tests (ignore certificate validation) + const client = http2.connect(`https://localhost:${port}`, { + ca: fs.readFileSync(TEMP_CERT_PATH), + rejectUnauthorized: false + }); + clients.push(client); + + let data = ''; + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/secure', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + assert.strictEqual(data, 'HTTP/2 secure test'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + + it('should correctly handle streaming responses', function(done) { + const app = express(); + + app.get('/stream', function(req, res) { + // HTTP/2 streaming response + res.write('part1,'); + + setTimeout(() => { + res.write('part2,'); + + setTimeout(() => { + res.write('part3'); + res.end(); + }, 50); + }, 50); + }); + + const options = { + key: fs.readFileSync(TEMP_KEY_PATH), + cert: fs.readFileSync(TEMP_CERT_PATH) + }; + + const server = app.http2Secure(app, options); + servers.push(server); + + server.listen(0, function() { + const port = server.address().port; + + // Use insecure client for tests (ignore certificate validation) + const client = http2.connect(`https://localhost:${port}`, { + ca: fs.readFileSync(TEMP_CERT_PATH), + rejectUnauthorized: false + }); + clients.push(client); + + let data = ''; + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/stream', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + assert.strictEqual(data, 'part1,part2,part3'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + }); + + describe('exports.http2', function() { + it('should expose HTTP/2 module as an export', function() { + assert.ok(express.http2); + assert.strictEqual(typeof express.http2.createServer, 'function'); + assert.strictEqual(typeof express.http2.createSecureServer, 'function'); + }); + }); +}); diff --git a/test/http2.js b/test/http2.js new file mode 100644 index 00000000000..a8a4c3f3dfc --- /dev/null +++ b/test/http2.js @@ -0,0 +1,537 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const assert = require('node:assert'); +const http2 = require('node:http2'); +const fs = require('node:fs'); +const path = require('node:path'); +const express = require('..'); +const httpModule = require('../lib/http2'); +const after = require('after'); +const os = require('node:os'); + +/** + * Temporary certificate paths for tests + */ +const TEMP_KEY_PATH = path.join(os.tmpdir(), 'express-test-key.pem'); +const TEMP_CERT_PATH = path.join(os.tmpdir(), 'express-test-cert.pem'); + +describe('HTTP/2', function() { + let app; + let servers = []; + let clients = []; + + // Increase test timeout + this.timeout(5000); + + // Create a temporary self-signed certificate for tests + before(function(done) { + // Skip certificate generation in CI environments + if (process.env.CI) { + this.skip(); + return; + } + + const { execSync } = require('node:child_process'); + + try { + execSync(`openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + -keyout ${TEMP_KEY_PATH} \ + -out ${TEMP_CERT_PATH}`, { stdio: 'ignore' }); + done(); + } catch (err) { + done(new Error('Failed to generate test certificates. Skip these tests or install OpenSSL.')); + } + }); + + // Clean up certificates after tests + after(function(done) { + try { + fs.unlinkSync(TEMP_KEY_PATH); + fs.unlinkSync(TEMP_CERT_PATH); + } catch (err) { + // Ignore errors (files might not exist) + } + + // Ensure all servers and clients are closed + Promise.all([ + ...servers.map(server => new Promise(resolve => { + if (server.listening) { + server.close(resolve); + } else { + resolve(); + } + })), + ...clients.map(client => new Promise(resolve => { + if (!client.destroyed) { + client.close(resolve); + } else { + resolve(); + } + })) + ]).then(() => done()).catch(() => done()); + }); + + beforeEach(function() { + app = express(); + }); + + afterEach(function() { + // Clear arrays for next test + servers = []; + clients = []; + }); + + describe('createServer()', function() { + it('should create an HTTP/2 server instance', function() { + const server = httpModule.createServer(app); + servers.push(server); + + // Check if server has expected HTTP/2 server methods instead of using instanceof + assert.strictEqual(typeof server.on, 'function'); + assert.strictEqual(typeof server.listen, 'function'); + assert.strictEqual(typeof server.close, 'function'); + server.close(); + }); + + it('should handle HTTP/2 stream events', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/', function(req, res) { + assert.strictEqual(req.httpVersion, '2.0'); + res.send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('response', (headers) => { + assert.strictEqual(headers[http2.constants.HTTP2_HEADER_STATUS], 200); + }); + + req.on('end', () => { + assert.strictEqual(data, 'ok'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + + it('should handle 404 errors correctly', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/not-found', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('response', (headers) => { + assert.strictEqual(headers[http2.constants.HTTP2_HEADER_STATUS], 404); + req.on('data', () => { + // Just consume the data + }); + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + + it('should handle errors in application routes', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/error', function(req, res) { + throw new Error('test error'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/error', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('response', (headers) => { + assert.strictEqual(headers[http2.constants.HTTP2_HEADER_STATUS], 500); + req.on('data', () => { + // Just consume the data + }); + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + }); + + describe('createSecureServer()', function() { + before(function() { + // Skip secure server tests if certificates weren't created + if (!fs.existsSync(TEMP_KEY_PATH) || !fs.existsSync(TEMP_CERT_PATH)) { + this.skip(); + } + }); + + it('should create an HTTP/2 secure server instance', function() { + const options = { + key: fs.readFileSync(TEMP_KEY_PATH), + cert: fs.readFileSync(TEMP_CERT_PATH) + }; + + const server = httpModule.createSecureServer(app, options); + servers.push(server); + + // Check if server has expected HTTP/2 secure server methods instead of using instanceof + assert.strictEqual(typeof server.on, 'function'); + assert.strictEqual(typeof server.listen, 'function'); + assert.strictEqual(typeof server.close, 'function'); + server.close(); + }); + + it('should require key and cert options', function() { + assert.throws(function() { + httpModule.createSecureServer(app, {}); + }, /HTTP\/2 secure server requires key and cert options/); + }); + + it('should handle HTTP/2 secure stream events', function(done) { + const options = { + key: fs.readFileSync(TEMP_KEY_PATH), + cert: fs.readFileSync(TEMP_CERT_PATH), + allowHTTP1: true + }; + + const server = httpModule.createSecureServer(app, options); + servers.push(server); + + app.get('/', function(req, res) { + assert.strictEqual(req.httpVersion, '2.0'); + res.send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + + // Use insecure client for tests (ignore certificate validation) + const client = http2.connect(`https://localhost:${port}`, { + ca: fs.readFileSync(TEMP_CERT_PATH), + rejectUnauthorized: false + }); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('response', (headers) => { + assert.strictEqual(headers[http2.constants.HTTP2_HEADER_STATUS], 200); + }); + + req.on('end', () => { + assert.strictEqual(data, 'ok'); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + }); + + describe('Request compatibility', function() { + it('should provide standard request properties', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/test-request', function(req, res) { + assert.strictEqual(req.httpVersion, '2.0'); + assert.strictEqual(req.httpVersionMajor, 2); + assert.strictEqual(req.httpVersionMinor, 0); + + // Standard properties + assert.strictEqual(typeof req.url, 'string'); + assert.strictEqual(typeof req.method, 'string'); + assert.strictEqual(typeof req.headers, 'object'); + + // Express extensions + assert.strictEqual(typeof req.query, 'object'); + assert.strictEqual(typeof req.params, 'object'); + assert.strictEqual(typeof req.body, 'object'); + + res.send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/test-request', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + // Just consume data without storing it + req.setEncoding('utf8'); + req.on('data', () => { + // Consume data but don't store it + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + + req.end(); + }); + }); + + it('should parse query parameters', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/query', function(req, res) { + assert.strictEqual(req.query.foo, 'bar'); + assert.strictEqual(req.query.baz, 'qux'); + res.send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/query?foo=bar&baz=qux', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + // Just consume data without storing it + req.setEncoding('utf8'); + req.on('data', () => { + // Consume data but don't store it + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + + req.end(); + }); + }); + }); + + describe('Response compatibility', function() { + it('should provide Express response methods', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/test-response', function(req, res) { + // Test available methods + assert.strictEqual(typeof res.status, 'function'); + assert.strictEqual(typeof res.send, 'function'); + assert.strictEqual(typeof res.json, 'function'); + assert.strictEqual(typeof res.sendStatus, 'function'); + + // Test chainable API + res.status(200).send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/test-response', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + // Just consume data without storing it + req.setEncoding('utf8'); + req.on('data', () => { + // Consume data but don't store it + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + + req.end(); + }); + }); + + it('should send JSON responses', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + let responseData = ''; + + app.get('/json', function(req, res) { + res.json({ hello: 'world' }); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/json', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + responseData += chunk; + }); + + req.on('end', () => { + assert.deepStrictEqual(JSON.parse(responseData), { hello: 'world' }); + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + + it('should set custom headers', function(done) { + const server = httpModule.createServer(app); + servers.push(server); + + app.get('/headers', function(req, res) { + res.setHeader('X-Custom-Header', 'test-value'); + res.send('ok'); + }); + + server.listen(0, function() { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + clients.push(client); + + const req = client.request({ + [http2.constants.HTTP2_HEADER_PATH]: '/headers', + [http2.constants.HTTP2_HEADER_METHOD]: 'GET' + }); + + // Just consume data without storing it + req.setEncoding('utf8'); + req.on('data', () => { + // Consume data but don't store it + }); + + req.on('response', (headers) => { + assert.strictEqual(headers['x-custom-header'], 'test-value'); + }); + + req.on('end', () => { + client.close(); + server.close(() => done()); + }); + + req.on('error', (err) => { + client.close(); + server.close(); + done(err); + }); + + req.end(); + }); + }); + }); +});