From 9a0b28fa91246b36ecb0fe23167f85f8f7f944a7 Mon Sep 17 00:00:00 2001 From: fredrikbernholm <35036205+fredrikbernholm@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:36:50 +0200 Subject: [PATCH] feat(dev-server): add https option to serve TLS over HTTP/1.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/dev-server-https-http1.md | 6 ++++ docs/docs/dev-server/cli-and-configuration.md | 2 ++ .../src/server/DevServerCoreConfig.ts | 4 +++ .../src/server/createServer.ts | 12 ++++--- packages/dev-server-core/src/test-helpers.ts | 2 +- .../test/server/DevServer.test.ts | 31 +++++++++++++++++++ packages/dev-server/src/config/parseConfig.ts | 2 +- .../dev-server/src/logger/logStartMessage.ts | 2 +- packages/dev-server/src/openBrowser.ts | 2 +- 9 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 .changeset/dev-server-https-http1.md diff --git a/.changeset/dev-server-https-http1.md b/.changeset/dev-server-https-http1.md new file mode 100644 index 0000000000..5e446f49ac --- /dev/null +++ b/.changeset/dev-server-https-http1.md @@ -0,0 +1,6 @@ +--- +'@web/dev-server-core': minor +'@web/dev-server': minor +--- + +Add an `https` option to serve over HTTPS with HTTP/1.1, without enabling HTTP/2. It uses `sslKey`/`sslCert`, falling back to the bundled self-signed certificate like `http2` does. diff --git a/docs/docs/dev-server/cli-and-configuration.md b/docs/docs/dev-server/cli-and-configuration.md index f34daff6e5..8752f5173e 100644 --- a/docs/docs/dev-server/cli-and-configuration.md +++ b/docs/docs/dev-server/cli-and-configuration.md @@ -120,6 +120,8 @@ interface DevServerConfig { // whether to run the server with HTTP2 http2?: boolean; + // whether to run the server with HTTPS over HTTP/1.1, ignored when http2 is set + https?: boolean; // path to SSL key sslKey?: string; // path to SSL certificate diff --git a/packages/dev-server-core/src/server/DevServerCoreConfig.ts b/packages/dev-server-core/src/server/DevServerCoreConfig.ts index 258e310a14..b7e6ca722d 100644 --- a/packages/dev-server-core/src/server/DevServerCoreConfig.ts +++ b/packages/dev-server-core/src/server/DevServerCoreConfig.ts @@ -48,6 +48,10 @@ export interface DevServerCoreConfig { * Whether to run the server with HTTP2 */ http2?: boolean; + /** + * Whether to run the server with HTTPS over HTTP/1.1. Ignored when http2 is set. + */ + https?: boolean; /** * Path to SSL key */ diff --git a/packages/dev-server-core/src/server/createServer.ts b/packages/dev-server-core/src/server/createServer.ts index 557e90d95c..962682c940 100644 --- a/packages/dev-server-core/src/server/createServer.ts +++ b/packages/dev-server-core/src/server/createServer.ts @@ -2,6 +2,7 @@ import { FSWatcher } from 'chokidar'; import fs from 'fs'; import httpServer, { IncomingMessage, ServerResponse } from 'http'; import http2Server from 'http2'; +import httpsServer from 'https'; import Koa from 'koa'; import net, { ListenOptions, Server, Socket } from 'net'; import path from 'path'; @@ -65,7 +66,7 @@ export function createServer( } let server: Server; - if (cfg.http2) { + if (cfg.http2 || cfg.https) { const dir = path.join(__dirname, '..'); const options = { key: fs.readFileSync( @@ -78,12 +79,15 @@ export function createServer( ? path.resolve(cfg.sslCert) : path.join(dir, '..', '.self-signed-dev-server-ssl.cert'), ), - allowHTTP1: true, - maxSessionMemory: 20, }; const httpsRedirectServer = httpServer.createServer(httpsRedirect); - server = http2Server.createSecureServer(options, app.callback()); + server = cfg.http2 + ? http2Server.createSecureServer( + { ...options, allowHTTP1: true, maxSessionMemory: 20 }, + app.callback(), + ) + : httpsServer.createServer(options, app.callback()); let appServerPort: number; let httpsRedirectServerPort: number; diff --git a/packages/dev-server-core/src/test-helpers.ts b/packages/dev-server-core/src/test-helpers.ts index fc34d83775..22d17d26b7 100644 --- a/packages/dev-server-core/src/test-helpers.ts +++ b/packages/dev-server-core/src/test-helpers.ts @@ -39,7 +39,7 @@ export async function createTestServer( await server.start(); const url = new URL('http://localhost'); - url.protocol = config.http2 ? 'https' : 'http'; + url.protocol = config.http2 || config.https ? 'https' : 'http'; url.port = port.toString(); return { server, port, host: url.toString().slice(0, -1) }; } diff --git a/packages/dev-server-core/test/server/DevServer.test.ts b/packages/dev-server-core/test/server/DevServer.test.ts index 02e5896085..95e2605556 100644 --- a/packages/dev-server-core/test/server/DevServer.test.ts +++ b/packages/dev-server-core/test/server/DevServer.test.ts @@ -1,10 +1,12 @@ import express from 'express'; import http from 'http'; +import https from 'https'; import Koa from 'koa'; import { Server } from 'net'; import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, describe, it, mock } from 'node:test'; import portfinder from 'portfinder'; +import type { TLSSocket } from 'tls'; import { assertIncludes } from '../../../../test-helpers/node.js'; import type { ServerStartParams } from '../../dist/plugins/Plugin.js'; import type { DevServer } from '../../dist/server/DevServer.js'; @@ -90,6 +92,35 @@ describe('http2', () => { }); }); +describe('https', () => { + it('serves a website over HTTP/1.1 with TLS', async () => { + const { server, host } = await createTestServer({ hostname: 'localhost', https: true }); + assert.ok(host.startsWith('https://')); + + const { res, body, encrypted } = await new Promise<{ + res: http.IncomingMessage; + body: string; + encrypted: boolean; + }>((resolve, reject) => { + https + .get(`${host}/index.html`, { rejectUnauthorized: false }, res => { + const { encrypted } = res.socket as TLSSocket; + let body = ''; + res.setEncoding('utf8'); + res.on('data', chunk => (body += chunk)); + res.on('end', () => resolve({ res, body, encrypted })); + }) + .on('error', reject); + }); + + assert.ok(encrypted); + assert.equal(res.httpVersion, '1.1'); + assert.equal(res.statusCode, 200); + assertIncludes(body, 'My app'); + server.stop(); + }); +}); + it('can run in middleware mode', async () => { const { server: wdsServer } = await createTestServer({ middlewareMode: true }); assert.equal(wdsServer.server, undefined); diff --git a/packages/dev-server/src/config/parseConfig.ts b/packages/dev-server/src/config/parseConfig.ts index d423ef3a9d..e4a09b1d27 100644 --- a/packages/dev-server/src/config/parseConfig.ts +++ b/packages/dev-server/src/config/parseConfig.ts @@ -31,7 +31,7 @@ function validate(config: Record, key: string, type: string) { const stringSettings = ['rootDir', 'hostname', 'basePath', 'appIndex', 'sslKey', 'sslCert']; const numberSettings = ['port']; -const booleanSettings = ['watch', 'preserveSymlinks', 'http2', 'eventStream']; +const booleanSettings = ['watch', 'preserveSymlinks', 'http2', 'https', 'eventStream']; export function validateConfig(config: Partial) { stringSettings.forEach(key => validate(config, key, 'string')); diff --git a/packages/dev-server/src/logger/logStartMessage.ts b/packages/dev-server/src/logger/logStartMessage.ts index 23765528e7..022caac384 100644 --- a/packages/dev-server/src/logger/logStartMessage.ts +++ b/packages/dev-server/src/logger/logStartMessage.ts @@ -4,7 +4,7 @@ import { bold, cyan, white } from 'nanocolors'; import { type DevServerConfig } from '../config/DevServerConfig'; const createAddress = (config: DevServerConfig, host: string, path: string) => - `http${config.http2 ? 's' : ''}://${host}:${config.port}${path}`; + `http${config.http2 || config.https ? 's' : ''}://${host}:${config.port}${path}`; function logNetworkAddress(config: DevServerConfig, logger: Logger, openPath: string) { try { diff --git a/packages/dev-server/src/openBrowser.ts b/packages/dev-server/src/openBrowser.ts index 7d793c6ca4..80406e88f8 100644 --- a/packages/dev-server/src/openBrowser.ts +++ b/packages/dev-server/src/openBrowser.ts @@ -41,7 +41,7 @@ export async function openBrowser(config: DevServerConfig) { // construct a full URL to open if the user didn't provide a full URL openPath = new URL( openPath, - `http${config.http2 ? 's' : ''}://${config.hostname}:${config.port}`, + `http${config.http2 || config.https ? 's' : ''}://${config.hostname}:${config.port}`, ).href; }