385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
/**
|
|
* 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<OutgoingMessage["setHeader"]>
|
|
[name: string]: number | string | ReadonlyArray<string>
|
|
}
|
|
|
|
/**
|
|
* 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((<OutgoingHttpHeaders>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 = (<OutputChunk>file).code || (<OutputAsset>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 ((<ErrorCodeException>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 = (<any>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 ((<ErrorCodeException>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<void>;
|
|
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<TestOptions> = {
|
|
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<TestOptions>, hostUrl: string){
|
|
const options : TestOptions = (<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((<any>filterOutput.console)?.includes?.(<any>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;
|
|
}
|