express-dom
Version:
Prepare, render web pages - express middleware
407 lines (360 loc) • 8.86 kB
JavaScript
const { Pool } = require('lightning-pool');
const playwright = require('playwright-core');
const debug = require('debug')('express-dom');
const clone = require('clone');
const { randomUUID } = require('node:crypto');
const which = require('which');
const { IncomingMessage } = require('node:http');
const Phase = require('./phase');
const plugins = require('./plugins');
const routers = require('./routers');
const RequestTracker = require('./request-tracker');
const asyncTracker = require('./async-tracker');
const customTracker = require('./custom-tracker');
const asyncEmitter = require('./async-emitter');
const { ManualRequest, ManualResponse } = require('./manual');
class PoolFactory {
#opts;
#browser;
constructor(browser, opts) {
this.#browser = browser;
this.#opts = opts;
}
async create() {
const context = await this.#browser.newContext(this.#opts);
return context.newPage();
}
async destroy(page) {
await page.context().close();
}
async validate(page) {
// each used page must be thrown
throw new Error();
}
}
module.exports = class Handler {
static executable = null;
static browser = 'chromium';
static debug = process.env.PWDEBUG == 1;
static header = 'Sec-Purpose';
static #browser;
static plugins = plugins;
static routers = routers;
static defaults = {
cookies: new Set(),
log: process.env.NODE_ENV != "production" ? "info" : "error",
timeout: process.env.PWDEBUG == 1 ? 0 : 10000,
page: {
ignoreHTTPSErrors: true,
serviceWorkers: 'block'
},
pool: {
max: 10,
min: 2,
minIdle: 2,
maxQueue: 100,
acquireTimeoutMillis: 15000,
validation: true
}
};
static pools = [{
visible: false,
pool: {
...this.defaults.pool
},
page: {
...this.defaults.page,
deviceScaleFactor: 1
}
}, {
visible: true,
pool: {
...this.defaults.pool
},
page: {
...this.defaults.page,
deviceScaleFactor: 1
}
}, {
visible: true,
pool: {
...this.defaults.pool
},
page: {
...this.defaults.page,
deviceScaleFactor: 2
}
}, {
visible: true,
pool: {
...this.defaults.pool
},
page: {
...this.defaults.page,
deviceScaleFactor: 4
}
}];
static offline = {
header: 'prepare',
policies: {
default: "'none'"
},
enabled: false,
track: false,
styles: [],
scripts: [],
plugins: new Set([
'console',
'hidden',
'cookies',
'html'
])
};
static online = {
header: 'prefetch; prerender',
policies: {
default: "'none'",
script: "'self' 'unsafe-inline'",
connect: "'self'"
},
enabled: !process.env.DEVELOP,
track: true,
styles: [],
scripts: [],
plugins: new Set([
'console',
'hidden',
'cookies',
'media',
'redirect',
'referrer',
'html'
])
};
static visible = {
policies: {}
};
#router;
constructor(conf = {}) {
this.plugins = clone(Handler.plugins);
for (const phase of ['offline', 'online', 'visible']) {
this[phase] = Object.assign(
clone(Handler.defaults),
clone(Handler[phase]),
conf[phase]
);
}
if (typeof conf == "function") {
conf(this);
}
this.chain = (...args) => this.middleware(...args);
}
#init() {
if (!Handler.#browser) {
Handler.#browser = this.#initBrowser();
}
}
route(fn) {
this.#router = fn;
return this.chain;
}
static async destroy() {
for (const def of Handler.pools) {
if (def.instance) {
await def.instance.close(true);
delete def.instance;
}
}
await Handler.#browser.close();
Handler.#browser = null;
}
async #initBrowser() {
const expath =
(Handler.executable && await which(Handler.executable, { nothrow: true }))
?? await which(Handler.browser, { nothrow: true })
?? await which('google-chrome', { nothrow: true });
const opts = {
executablePath: expath,
devtools: false, //Handler.debug,
timeout: Handler.defaults.timeout / 2,
args: [
'--force-color-profile=srgb',
'--deterministic-mode',
'--disable-gpu',
'--headless=new'
]
};
Handler.#browser = await playwright[Handler.browser].launch(opts);
}
async acquire(scale = 1, visible) {
const def = Handler.pools.find(
conf => conf.visible == visible && conf.page.deviceScaleFactor == scale
);
if (!def) throw new Error("No pool has scale: " + scale);
if (!Handler.#browser) {
this.#initBrowser();
}
await Handler.#browser;
const pool = def.instance ??= new Pool(
new PoolFactory(Handler.#browser, def.page), def.pool
);
const page = await pool.acquire();
page.pool = pool;
return page;
}
async release(page) {
const { pool } = page;
delete page.pool;
await pool.release(page);
}
async middleware(req, res, next) {
this.#init();
if (typeof req == "string" || !(req instanceof IncomingMessage)) {
req = new ManualRequest(req);
}
const { res: initialRes } = req;
if (!next && !res) {
res = req.res = new ManualResponse(res);
}
try {
await this.runMiddleware(req, res, next);
if (res instanceof ManualResponse) {
if (initialRes) req.res = initialRes;
return res;
}
} catch (err) {
if (next) next(err);
else throw err;
}
}
async runMiddleware(req, res, next) {
const phase = new Phase(this, req);
phase.settings = clone(phase.settings);
phase.policies = clone(phase.policies);
if (this.#router) {
await this.#router(phase, req, res);
}
res.set(phase.headers());
res.vary(Handler.header);
if (phase.settings?.enabled) {
if (Handler.debug) phase.settings.timeout = 0;
await this.runMethod(phase, req, res);
} else {
next();
}
}
async runMethod(phase, req, res) {
const { location, settings } = phase;
const page = await this.acquire(settings.scale, phase.visible);
if (Array.isArray(settings.plugins)) {
settings.plugins = new Set(settings.plugins);
}
page.location = location;
settings.headers = {};
for (const plugin of settings.plugins) {
const fn = this.plugins[plugin];
if (!fn) {
throw new Error(`plugin not found: ${plugin}`);
}
await fn(page, settings, req, res);
}
// plugins might change any of these values
const {
scripts, styles, timeout, referer, track
} = settings;
const url = page.location.toString();
const errListener = err => console.error(err);
page.on('crash', errListener);
page.on('pageerror', errListener);
page.once('response', response => {
const code = response.status();
if (code != 200 && res.statusCode == 200) {
res.status(code);
}
});
await page.route(str => str == url, route => {
if (req instanceof ManualRequest && req.body) {
return route.fulfill({
status: req.status,
body: req.body,
headers: req.headers
});
} else {
const headers = {
...route.request().headers(),
[Handler.header]: settings.header,
...settings.headers
};
return route.continue({ headers });
}
}, { times: 1 });
const reqTrack = new RequestTracker(page);
const id = randomUUID();
const inits = [];
if (typeof track == "function") {
inits.push([`window['track_${id}'] = ${track.toString()}`]);
inits.push([customTracker, { id }]);
} else if (track) {
inits.push([asyncTracker, { id, timeout }]);
}
if (styles.length) {
inits.push([initStyles, styles.join('\n')]);
}
await initScripts(page, inits.concat(scripts));
let closeListener;
try {
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout, referer
});
debug("page loaded");
const event = await Promise.race([
Promise.all([
track ? page.evaluate(id => window[id], `signal_${id}`) : null,
reqTrack
]).then(() => 'idle'),
new Promise(resolve => {
if (timeout) setTimeout(() => resolve('timeout'), timeout + 1000);
}),
new Promise(resolve => {
closeListener = () => resolve('close');
page.on('close', closeListener);
})
]);
if (event == "timeout") {
debug("page timeout", url);
throw new Error('Page timeout');
} else if (event == "close") {
debug("page closed", url);
} else if (event == "idle") {
debug("page idle", url);
await asyncEmitter(page, 'idle');
} else {
debug("page stale", url);
throw new Error('Page stale');
}
} finally {
page.off('crash', errListener);
page.off('pageerror', errListener);
if (closeListener) page.off('close', closeListener);
Promise.resolve().then(async () => {
await page.context().close();
this.release(page);
}).catch(err => {
console.error(err);
});
}
}
};
function initScripts(page, list) {
return Promise.all(list.map(args => {
if (!args) return;
if (typeof args == "function") args = [args];
return page.addInitScript(...args);
}));
}
function initStyles(css) {
const sheet = new CSSStyleSheet();
document.adoptedStyleSheets.push(sheet);
return sheet.replace(css);
}