/** * 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 puppeteer, {Page} from "puppeteer"; import http from 'http'; import {resolve, posix} from "node:path"; import {URL} from "node:url"; import { readFile } from '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 interface RollupServeOptions { /** * Change the path to be opened when the test is started * Remember to start with a slash, e.g. `'/different/page'` */ path?: string cb?: PageTestCallback t?: any /** * 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 } /** * Serve your rolled up bundle like webpack-dev-server * @param {import('..').RollupServeOptions} options */ export default function serveTest (options: RollupServeOptions ): Plugin { const mime = new Mime(standardTypes, otherTypes) const testOptions = { port: 0, headers: {}, historyApiFallback: true, onListening: function noop (){}, ...options||{}, https: options.https??false, mimeTypes: options.mimeTypes? mime.define(options.mimeTypes, true): false } let server : Server; let bundle : OutputBundle = {}; const requestListener = (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); // Todo check if we need to strip '/' let file: OutputChunk|OutputAsset; if(!bundle[filePath] && testOptions.historyApiFallback) { const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html'; if(bundle[urlToFilePath(fallbackPath)]){ filePath = urlToFilePath(fallbackPath); } } file = bundle[filePath]; if(!file){ return notFound(response, filePath); }else{ const content = (file).code || (file).source; // Todo might need to read a source file; return found(response, mime.getType(filePath!), content); } // // if(bundle[urlPath]){ // const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html' // } // // readFileFromContentBase(contentBase, urlPath, function (error, content, filePath) { // if (!error) { // return found(response, mime.getType(filePath!), content) // } // if ((error).code !== 'ENOENT') { // response.writeHead(500) // response.end('500 Internal Server Error' + // '\n\n' + filePath + // '\n\n' + Object.values(error).join('\n') + // '\n\n(rollup-plugin-serve)', 'utf-8') // return // } // if (testOptions.historyApiFallback) { // const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html' // readFileFromContentBase(contentBase, fallbackPath, function (error, content, filePath) { // if (error) { // notFound(response, filePath) // } else { // found(response, mime.getType(filePath), content) // } // }) // } else { // notFound(response, filePath) // } // }) } function closeServerOnTermination () { const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'] terminationSignals.forEach(signal => { process.on(signal, () => { if (server) { server.close() process.exit() } }) }) } // release previous server instance if rollup is reloading configuration in watch mode // @ts-ignore if (server) { server.close() } 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 runTest({ page: testOptions.path!, cb: testOptions.cb, }, url) testOptions.t?.snapshot?.(testOutput); } } }, closeBundle (){ // Done with the bundle } } } function notFound (response: ServerResponse, filePath: string) { response.writeHead(404) response.end( '404 Not Found' + '\n\n' + filePath, 'utf-8' ) } function found (response: ServerResponse, mimeType: string|null, content: any) { response.writeHead(200, { 'Content-Type': mimeType || 'text/plain' }) response.end(content, 'utf-8') } function green (text: string) { return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m' } export type PageTestCallback = (page: Page)=>Promise; export interface TestFilterOptions{ html?: boolean console?: ('log'|'error'|'warn')[] | true errors?: boolean, // again don't know possible values responses?: boolean, // interesting to see what other values were requested requestsFailed?: boolean, // will probably also be replicated into console errors, but helpful to have if imports werent found } export interface TestOptions { page: string cb: PageTestCallback filterOutput: TestFilterOptions replaceHost: boolean replaceHostWith?: string } const defaultOptions: Partial = { page: 'index.html', cb: async (page: Page)=>{ await page.waitForNetworkIdle({}); }, replaceHost: true, replaceHostWith: `http://localhost`, filterOutput:{ html: true, console: ['log','error','warn'],// TODO: or warning? need to check what possible values are errors: true, // again don't know possible values responses: true, // interesting to see what other values were requested requestsFailed: true, // will probably also be replicated into console errors, but helpful to have if imports werent found } } export interface TestOutput{ html?: string, console?: string[], errors?: string[], responses?: string[], requestsFailed?: string[], } export async function runTest(opts: Partial, hostUrl: string){ const options : TestOptions = ({ ...defaultOptions, ...opts, filterOutput: { ...defaultOptions.filterOutput, ...(opts?.filterOutput), }, }); const { page: path, cb, replaceHost, replaceHostWith, filterOutput } = options; const browser = await puppeteer.launch({ headless: 'new', }); const page = await browser.newPage(); let output : TestOutput = { console: [], errors: [], responses: [], requestsFailed: [] }; try{ // Track requests, errors and console page.on('console', message => { let [type, text] = [message.type(), message.text()]; if(replaceHost){ text = text.replaceAll(hostUrl, replaceHostWith!); } if((filterOutput.console)?.includes?.(type) ?? (filterOutput.console === true)){// TODO: add callback option output.console?.push(`[${type}] ${text}`); } }).on('pageerror', ({ message }) => { let text = message; if(replaceHost){ text = text.replaceAll(hostUrl, replaceHostWith!); } if(filterOutput.errors === true) {// TODO add callback option output.errors?.push(text) } }).on('response', response => { let [status, url] = [response.status(), response.url()] if(replaceHost){ url = url.replaceAll(hostUrl, replaceHostWith!); } if(filterOutput.responses === true) {// TODO add callback option output.responses?.push(`${status} ${url}`) } }).on('requestfailed', request => { let [failure, url] = [request.failure()?.errorText, request.url()]; if(replaceHost){ failure = failure?.replaceAll(hostUrl, replaceHostWith!); url = url.replaceAll(hostUrl, replaceHostWith!); } if(filterOutput.requestsFailed === true) {// TODO add callback option output.requestsFailed?.push(`${failure} ${url}`) } }); const url = new URL(`${hostUrl}/${path??''}`); await page.goto(url.href); if(!cb) { await page.waitForNetworkIdle({}); }else{ await cb(page); } const htmlHandle = await page.$('html'); const html = await page.evaluate(html => html?.outerHTML??html?.innerHTML, htmlHandle); // Add the final html output.html = html; }finally{ await page.close(); await browser.close(); } return output; }