/** * Puppeteer + from-memory devServer rollup plugin to open the result in a webpage en output the result * (after an optional series of commands to the puppeteer Page) */ import {puppeteerRunTest, PageTestCallback, TestOutput} from "./puppeteer-run-test.ts"; import {isInDebugMode} from "./debug-mode.ts"; import {resolve, posix} from "node:path"; import fs from "node:fs/promises"; import type {Stats} from "node:fs"; import { createServer as createHttpsServer } from 'https' import { createServer} from 'http' import { Mime } from 'mime/lite' import standardTypes from 'mime/types/standard.js' import otherTypes from 'mime/types/other.js' import type {NormalizedOutputOptions, OutputAsset, OutputBundle, OutputChunk, Plugin} from 'rollup'; import type { IncomingHttpHeaders, OutgoingHttpHeaders, IncomingMessage, ServerResponse, Server } from 'http' import type { ServerOptions } from 'https' type TypeMap = { [key: string]: string[]; }; type ErrorCodeException = Error & {code: string}; export type TestResultCallback = (output: TestOutput)=>void; export type LogCallback = (...args: string[])=>void; export interface ServeTestOptions { /** * Change the path to be opened when the test is started * Remember to start with a slash, e.g. `'/different/page'` */ path?: string /** * Fallback to serving from a specified srcDir, this allows setting breakpoints on sourcecode and test the sourcemaps */ srcDir?: string|boolean; /** * A callback to manually take control of the page and simulate user interactions */ cb?: PageTestCallback; /** * Set to `true` to return index.html (200) instead of error page (404) * or path to fallback page */ historyApiFallback?: boolean | string /** * Change the host of the server (default: `'localhost'`) */ host?: string /** * Change the port that the server will listen on (default: `10001`) */ port?: number | string /** * By default server will be served over HTTP (https: `false`). It can optionally be served over HTTPS. */ https?: ServerOptions | false /** * Set custom response headers */ headers?: | IncomingHttpHeaders | OutgoingHttpHeaders | { // i.e. Parameters [name: string]: number | string | ReadonlyArray } /** * Set custom mime types, usage https://github.com/broofa/mime#mimedefinetypemap-force--false */ mimeTypes?: TypeMap /** * Execute function after server has begun listening */ onListening?: (server: Server) => void } export interface RollupServeTestOptions extends ServeTestOptions{ /** * A callback to run when a test has been run */ onResult?: TestResultCallback; /** * Callback to log messages */ log?: LogCallback; } /** * Serve your rolled up bundle like webpack-dev-server * @param {import('..').RollupServeOptions} options */ export default function serveTest (options: RollupServeTestOptions ): Plugin { const mime = new Mime(standardTypes, otherTypes) const testOptions = { port: 0, headers: {}, historyApiFallback: true, srcDir: '', // Serve source dir as fallback (for sourcemaps / debugging) onListening: function noop (){}, ...options||{}, https: options.https??false, mimeTypes: options.mimeTypes? mime.define(options.mimeTypes, true): false } let server : Server; let bundle : OutputBundle = {}; const closeServer = async ()=>new Promise((resolve, reject)=>{ server.close((err)=>err?reject(err):resolve(undefined)); server = null as unknown as Server; // unset }); const logTest = (msg: string, mode: 'info'|'warn' = 'info')=>{ if(isInDebugMode()){ console.log(msg); } const modeColor = { green: 32, info: 34, warn: 33, }[mode]; testOptions.log?.(`\u001b[${modeColor}m${msg}\u001b[0m`); } const requestListener = async (request: IncomingMessage, response: ServerResponse) => { // Remove querystring const unsafePath = decodeURI(request.url!.split('?')[0]) // Don't allow path traversal const urlPath = posix.normalize(unsafePath) for(const [key, value] of Object.entries((testOptions.headers))){ response.setHeader(key, value!); } function urlToFilePath(url:string){ return url[0]==='/'?url.slice(1):url; } let filePath = urlToFilePath(urlPath); let absPath: string | undefined = undefined; let stats: Stats | undefined = undefined; if(!bundle[filePath]){ if(testOptions.srcDir || testOptions.srcDir===''){ try{ absPath = resolve(testOptions.srcDir||'',filePath); stats = await fs.stat(absPath); }catch(err){ // File not found } } if(!(stats?.isFile()) && testOptions.historyApiFallback) { const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html'; if(bundle[urlToFilePath(fallbackPath)]){ filePath = urlToFilePath(fallbackPath); } } } const mimeType = mime.getType(filePath!); if(bundle[filePath]) { let file: OutputChunk | OutputAsset = bundle[filePath]; const content = (file).code || (file).source; // Todo might need to read a source file; response.writeHead(200, {'Content-Type': mimeType || 'text/plain'}); response.end(content, 'utf-8'); logTest(`[200] ${request.url}`); return; }else if(stats?.isFile()){ response.writeHead(200, { 'Content-Type': mimeType || 'text/plain', 'Content-Length': stats.size, 'Last-Modified': stats.mtime.toUTCString() }); const content = await fs.readFile(absPath!); response.end(content); response.end(); logTest(`[200] ${request.url} (src)`); }else{ response.writeHead(404) response.end( '404 Not Found' + '\n\n' + filePath, 'utf-8' ) logTest(`[404] ${request.url}`, "warn"); return; } } function closeServerOnTermination () { const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'] terminationSignals.forEach(signal => { process.on(signal, async () => { if (server) { await closeServer(); process.exit() } }) }) } // release previous server instance if rollup is reloading configuration in watch mode // @ts-ignore if (server) { closeServer() } else { closeServerOnTermination() } // If HTTPS options are available, create an HTTPS server server = testOptions.https ? createHttpsServer(testOptions.https, requestListener) : createServer(requestListener) server.listen( typeof(testOptions.port)==='string'? Number.parseInt(testOptions.port):testOptions.port, testOptions.host, undefined, () => testOptions.onListening?.(server) ) testOptions.port = (server.address())?.port ?? testOptions.port; // Assemble url for error and info messages const url = (testOptions.https ? 'https' : 'http') + '://' + (testOptions.host || 'localhost') + ':' + testOptions.port // Handle common server errors server.on('error', e => { if ((e).code === 'EADDRINUSE') { console.error(url + ' is in use, either stop the other server or use a different port.') process.exit() } else { throw e } }) let first = true return { name: 'serve', generateBundle: { order: 'post', async handler(options, output){ bundle = output; if (first) { first = false const testOutput = await puppeteerRunTest({ ...testOptions }, url); testOptions.onResult?.(testOutput); } } }, async closeBundle(){ // Done with the bundle await closeServer(); } } }