I made a few more changes. Still need to pass body / url / search params.
- Added a 3-second timeout for all evaluations (rate limiting still needs to be added though)
- The browser is launched on server startup and instance re-used
- A new page is launched for each request
- Alternative setup could re-use page for all requests if you dedicate your server to specific notebook app for better perf
- Return type processing adjusted
- More work can be done here to refine what properties of request are set; Tom emulates the look and feel of express, whereas my approach is simplified but restricted.
- I wanted this to feel more like ObservableHQ, so if my cell returns “html
<p>Hello!” then I’ll get an html response back, just like I would on the site. - You could still adjust my approach below to check the returned object for other properties, for example perhaps you check for a “result.res” object and copy status / headers / content to send for more control over the response.
const express = require('express');
const cors = require('cors');
const puppeteer = require("puppeteer");
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 8080;
herokuChromeOptions = {
args: [
'--incognito',
'--no-sandbox',
'--single-process',
'--no-zygote',
],
};
const app = express();
const browser = puppeteer.launch(herokuChromeOptions);
const x = '([\-0-9@A-Z_a-z]+)';
app.get(`/api/:user${x}/:notebook${x}/:cell${x}?`, cors(), async (req, res) => {
const start = new Date();
const { method, url, params: { user, notebook, cell = 'app' } } = req;
try {
const content = getRunNoteBookScript({ user, notebook, cell });
const page = await (await browser).newPage();
await page.addScriptTag({ type: 'module', content });
const handle = await page.waitForFunction(
async (req, cell) => {
const func = window[cell];
if (!func) return false;
let result;
try { result = await func(req); }
catch (error) { return { error: error.message }; }
if (!result) return {};
if (typeof result === 'string') return { html: result };
if (result.outerHTML) return { html: result.outerHTML };
return { json: result };
},
{ timeout: 3000 },
{ url, method },
cell);
const result = await handle.jsonValue();
page.close();
if (result.error) {
log('error', result.error);
res.status(500).json({ error: result.error });
} else if (result.html) {
log('html');
res.send(result.html);
} else if (result.json) {
log('json');
res.json(result.json);
} else {
log('empty');
res.status(204).end();
}
} catch (error) {
log('error', error.message);
res.status(500).json({ error: error.message });
}
function log(resultType, resultData) {
const end = new Date();
const duration = ((new Date() - start) / 1000).toPrecision(3);
console.log(`(+${duration}s) ${method} [${resultType}] ${url}\n${resultData || ''}`.trim());
}
});
function getRunNoteBookScript({ user, notebook, cell } = {}) {
return `
import { Runtime } from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js";
import define from "https://api.observablehq.com/${user}/${notebook}.js?v=3";
new Runtime().module(define, name => {
if (name === '${cell}') return {
fulfilled(value) {
window['${cell}'] = value;
},
rejected(error) {
window['${cell}'] = () => { throw error; };
}
};
});`
}
app.listen(port, host);