index.ts (9084B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import assert from 'node:assert'; 8 import {readFile, readFileSync} from 'node:fs'; 9 import { 10 createServer as createHttpServer, 11 type IncomingMessage, 12 type RequestListener, 13 type Server as HttpServer, 14 type ServerResponse, 15 } from 'node:http'; 16 import { 17 createServer as createHttpsServer, 18 type Server as HttpsServer, 19 type ServerOptions as HttpsServerOptions, 20 } from 'node:https'; 21 import type {AddressInfo} from 'node:net'; 22 import {join} from 'node:path'; 23 import type {Duplex} from 'node:stream'; 24 import {gzip} from 'zlib'; 25 26 import {getType as getMimeType} from 'mime'; 27 import {Server as WebSocketServer, type WebSocket} from 'ws'; 28 29 interface Subscriber { 30 resolve: (msg: IncomingMessage) => void; 31 reject: (err?: Error) => void; 32 promise: Promise<IncomingMessage>; 33 } 34 35 type TestIncomingMessage = IncomingMessage & {postBody?: Promise<string>}; 36 37 export class TestServer { 38 PORT!: number; 39 PREFIX!: string; 40 CROSS_PROCESS_PREFIX!: string; 41 EMPTY_PAGE!: string; 42 43 #dirPath: string; 44 #server: HttpsServer | HttpServer; 45 #wsServer: WebSocketServer; 46 47 #startTime = new Date(); 48 #cachedPathPrefix?: string; 49 50 #connections = new Set<Duplex>(); 51 #routes = new Map< 52 string, 53 (msg: IncomingMessage, res: ServerResponse) => void 54 >(); 55 #auths = new Map<string, {username: string; password: string}>(); 56 #csp = new Map<string, string>(); 57 #gzipRoutes = new Set<string>(); 58 #requestSubscribers = new Map<string, Subscriber>(); 59 #requests = new Set<ServerResponse>(); 60 61 static async create(dirPath: string): Promise<TestServer> { 62 let res!: (value: unknown) => void; 63 const promise = new Promise(resolve => { 64 res = resolve; 65 }); 66 const server = new TestServer(dirPath); 67 server.#server.once('listening', res); 68 server.#server.listen(0); 69 await promise; 70 return server; 71 } 72 73 static async createHTTPS(dirPath: string): Promise<TestServer> { 74 let res!: (value: unknown) => void; 75 const promise = new Promise(resolve => { 76 res = resolve; 77 }); 78 const server = new TestServer(dirPath, { 79 key: readFileSync(join(__dirname, '..', 'key.pem')), 80 cert: readFileSync(join(__dirname, '..', 'cert.pem')), 81 passphrase: 'aaaa', 82 }); 83 server.#server.once('listening', res); 84 server.#server.listen(0); 85 await promise; 86 return server; 87 } 88 89 constructor(dirPath: string, sslOptions?: HttpsServerOptions) { 90 this.#dirPath = dirPath; 91 92 if (sslOptions) { 93 this.#server = createHttpsServer(sslOptions, this.#onRequest); 94 } else { 95 this.#server = createHttpServer(this.#onRequest); 96 } 97 this.#server.on('connection', this.#onServerConnection); 98 // Disable this as sometimes the socket will timeout 99 // We rely on the fact that we will close the server at the end 100 this.#server.keepAliveTimeout = 0; 101 this.#server.on('clientError', err => { 102 if ( 103 'code' in err && 104 err.code === 'ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN' 105 ) { 106 return; 107 } 108 console.error('test-server client error', err); 109 }); 110 this.#wsServer = new WebSocketServer({server: this.#server}); 111 this.#wsServer.on('connection', this.#onWebSocketConnection); 112 } 113 114 #onServerConnection = (connection: Duplex): void => { 115 this.#connections.add(connection); 116 // ECONNRESET is a legit error given 117 // that tab closing simply kills process. 118 connection.on('error', error => { 119 if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') { 120 throw error; 121 } 122 }); 123 connection.once('close', () => { 124 return this.#connections.delete(connection); 125 }); 126 }; 127 128 get port(): number { 129 return (this.#server.address() as AddressInfo).port; 130 } 131 132 enableHTTPCache(pathPrefix: string): void { 133 this.#cachedPathPrefix = pathPrefix; 134 } 135 136 setAuth(path: string, username: string, password: string): void { 137 this.#auths.set(path, {username, password}); 138 } 139 140 enableGzip(path: string): void { 141 this.#gzipRoutes.add(path); 142 } 143 144 setCSP(path: string, csp: string): void { 145 this.#csp.set(path, csp); 146 } 147 148 async stop(): Promise<void> { 149 this.reset(); 150 for (const socket of this.#connections) { 151 socket.destroy(); 152 } 153 this.#connections.clear(); 154 await new Promise(x => { 155 return this.#server.close(x); 156 }); 157 } 158 159 setRoute( 160 path: string, 161 handler: (req: IncomingMessage, res: ServerResponse) => void, 162 ): void { 163 this.#routes.set(path, handler); 164 } 165 166 setRedirect(from: string, to: string): void { 167 this.setRoute(from, (_, res) => { 168 res.writeHead(302, {location: to}); 169 res.end(); 170 }); 171 } 172 173 waitForRequest(path: string): Promise<TestIncomingMessage> { 174 const subscriber = this.#requestSubscribers.get(path); 175 if (subscriber) { 176 return subscriber.promise; 177 } 178 let resolve!: (value: IncomingMessage) => void; 179 let reject!: (reason?: Error) => void; 180 const promise = new Promise<IncomingMessage>((res, rej) => { 181 resolve = res; 182 reject = rej; 183 }); 184 this.#requestSubscribers.set(path, {resolve, reject, promise}); 185 return promise; 186 } 187 188 reset(): void { 189 this.#routes.clear(); 190 this.#auths.clear(); 191 this.#csp.clear(); 192 this.#gzipRoutes.clear(); 193 const error = new Error('Static Server has been reset'); 194 for (const subscriber of this.#requestSubscribers.values()) { 195 subscriber.reject.call(undefined, error); 196 } 197 this.#requestSubscribers.clear(); 198 for (const request of this.#requests.values()) { 199 if (!request.writableEnded) { 200 request.destroy(); 201 } 202 } 203 this.#requests.clear(); 204 } 205 206 #onRequest: RequestListener = ( 207 request: TestIncomingMessage, 208 response, 209 ): void => { 210 this.#requests.add(response); 211 212 request.on('error', (error: {code: string}) => { 213 if (error.code === 'ECONNRESET') { 214 response.end(); 215 } else { 216 throw error; 217 } 218 }); 219 request.postBody = new Promise(resolve => { 220 let body = ''; 221 request.on('data', (chunk: string) => { 222 return (body += chunk); 223 }); 224 request.on('end', () => { 225 return resolve(body); 226 }); 227 }); 228 assert(request.url); 229 const url = new URL(request.url, `https://${request.headers.host}`); 230 const path = url.pathname; 231 const auth = this.#auths.get(path); 232 if (auth) { 233 const credentials = Buffer.from( 234 (request.headers.authorization || '').split(' ')[1] || '', 235 'base64', 236 ).toString(); 237 if (credentials !== `${auth.username}:${auth.password}`) { 238 response.writeHead(401, { 239 'WWW-Authenticate': 'Basic realm="Secure Area"', 240 }); 241 response.end('HTTP Error 401 Unauthorized: Access is denied'); 242 return; 243 } 244 } 245 const subscriber = this.#requestSubscribers.get(path); 246 if (subscriber) { 247 subscriber.resolve.call(undefined, request); 248 this.#requestSubscribers.delete(path); 249 } 250 const handler = this.#routes.get(path); 251 if (handler) { 252 handler.call(undefined, request, response); 253 } else { 254 this.serveFile(request, response, path); 255 } 256 }; 257 258 serveFile( 259 request: IncomingMessage, 260 response: ServerResponse, 261 pathName: string, 262 ): void { 263 pathName = decodeURIComponent(pathName); 264 if (pathName === '/') { 265 pathName = '/index.html'; 266 } 267 const filePath = join(this.#dirPath, pathName.substring(1)); 268 269 if (this.#cachedPathPrefix && filePath.startsWith(this.#cachedPathPrefix)) { 270 if (request.headers['if-modified-since']) { 271 response.statusCode = 304; // not modified 272 response.end(); 273 return; 274 } 275 response.setHeader('Cache-Control', 'public, max-age=31536000'); 276 response.setHeader('Last-Modified', this.#startTime.toISOString()); 277 } else { 278 response.setHeader('Cache-Control', 'no-cache, no-store'); 279 } 280 const csp = this.#csp.get(pathName); 281 if (csp) { 282 response.setHeader('Content-Security-Policy', csp); 283 } 284 285 readFile(filePath, (err, data) => { 286 // This can happen if the request is not awaited but started 287 // in the test and get clean via `reset()` 288 if (response.writableEnded) { 289 return; 290 } 291 292 if (err) { 293 response.statusCode = 404; 294 response.end(`File not found: ${filePath}`); 295 return; 296 } 297 const mimeType = getMimeType(filePath); 298 if (mimeType) { 299 const isTextEncoding = /^text\/|^application\/(javascript|json)/.test( 300 mimeType, 301 ); 302 const contentType = isTextEncoding 303 ? `${mimeType}; charset=utf-8` 304 : mimeType; 305 response.setHeader('Content-Type', contentType); 306 } 307 if (this.#gzipRoutes.has(pathName)) { 308 response.setHeader('Content-Encoding', 'gzip'); 309 gzip(data, (_, result) => { 310 response.end(result); 311 }); 312 } else { 313 response.end(data); 314 } 315 }); 316 } 317 318 #onWebSocketConnection = (socket: WebSocket): void => { 319 socket.send('opened'); 320 }; 321 }