@spotfire/mods-dev-server
Version:
Development server for Spotfire® Mods
511 lines (435 loc) • 15.8 kB
JavaScript
//@ts-check
const http = require("http");
const fs = require("fs");
const path = require("path");
/**
* Chokidar is a library for watching file changes on disk.
*/
const chokidar = require("chokidar");
/**
* Connect is a small library for adding "middleware" support to http servers.
*/
const connect = require("connect");
/**
* Compression is a middleware to compress http responses.
*/
const compression = require("compression");
/**
* serve-static is a middle ware for responding with static files with correct content types.
*/
const serveStatic = require("serve-static");
/**
* Web socket server library. Used for setting up connections to each instance.
*/
const ws = require("ws");
/**
* Open is a library for opening applications. Here it is used to open the browser.
*/
const open = require("open");
/**
* Colors is a library to support colored console output.
*/
const colors = require("colors/safe");
/**
* Lodash is a utility library.
*/
const _ = require("lodash");
const package = require("./package.json");
const applicationJson = "application/json; charset=utf-8";
const injectHtml = fs.readFileSync(path.join(__dirname, "websocket.html"), { encoding: "utf8" });
const manifestName = "mod-manifest.json";
/** @type {import("./server").ServerSettings} */
const defaultSettings = {
port: 8090,
open: true,
root: ".",
path: "/" + manifestName,
allowProjectRoot: false
};
module.exports.start = start;
module.exports.settings = Object.freeze(defaultSettings);
/**
* Start the development server.
* @param {import("./server").ServerSettings} settings
* @returns {import("http").Server} the http server instance.
*/
function start(settings = {}) {
/** @type {string[]} */
let declaredExternalResourcesInManifest = [];
const allowedOrigins = new Set();
/** @type {string[]} */
let manifestFiles = [];
let serverUrl = "";
let wsServerUrl = "";
settings = Object.assign({}, defaultSettings, settings);
const rootDirectoryAbsolutePath = path.resolve(settings.root);
const manifestPath = path.join(rootDirectoryAbsolutePath, manifestName);
if (!fs.existsSync(rootDirectoryAbsolutePath)) {
throw `The path '${rootDirectoryAbsolutePath}' does not exist.`;
}
setupManifestListener();
const app = connect();
const serveStaticFiles = serveStatic(settings.root, { index: ["index.html", "index.htm"] });
app.use(compression());
app.use(cacheHeaders);
app.use(cspHeaders);
app.use(corsHeaders);
app.use(preflight);
if (settings.allowProjectRoot) {
// We need to be able to retrieve the absolute path to the project root to
// enable source maps when debugging scripts in action mods.
app.use("/modProjectRoot", (req, res, next) => {
res.setHeader("Content-Type", "text/plain; charset=UTF-8");
res.write(rootDirectoryAbsolutePath);
res.end();
});
}
// API endpoints for Spotfire
app.use("/@spotfire/api/snapshot", snapshot);
app.use("/@spotfire/api/info", info);
app.use(checkIfPartOfManifest);
app.use(onlyWhenOriginIsSet(injectWebSocketSnippet(settings)));
app.use(serveStaticFiles);
const server = http.createServer(app);
const wss = new ws.WebSocketServer({ noServer: true });
// Handle server startup errors
server.on("error", (e) => {
// @ts-ignore
if (e.code === "EADDRINUSE") {
let serverUrl = "http://" + "127.0.0.1" + ":" + settings.port;
console.log(colors.yellow("%s is already in use. Trying another port."), serverUrl);
setTimeout(function pickNewPort() {
server.close();
server.listen(0, "127.0.0.1");
}, 500);
} else {
console.error(colors.red(e.toString()));
server.close();
}
});
// Handle successful server
server.on("listening", function () {
let address = server.address();
// @ts-ignore
serverUrl = "http://" + address.address + ":" + address.port;
// @ts-ignore
wsServerUrl = "ws://" + address.address + ":" + address.port;
console.log(colors.green('Serving "%s" at %s'), settings.root, serverUrl);
// Launch the default browser
if (settings.open) {
let { path: serverPath = "/" } = settings;
serverPath = serverPath.startsWith("/") ? serverPath : "/" + serverPath;
open.openApp(serverUrl + serverPath);
}
});
// Upgrade event is used for setting up the web socket connection.
server.on("upgrade", (request, socket, head) => {
if (request.url != "/live-reload") {
return;
}
wss.handleUpgrade(request, socket, head, (socket) => {
wss.emit("connection", socket, request);
});
});
server.listen(settings.port, "127.0.0.1");
const reloadInstances = _.debounce(() => {
let openConnections = [...wss.clients].filter((client) => client.readyState == ws.OPEN);
if (!openConnections.length) {
console.log(colors.yellow("File change detected but no connected instances"));
return;
}
readExternalResources();
console.log(`Reloading ${openConnections.length} connected instance${openConnections.length > 1 ? "s" : ""}.`);
for (const client of openConnections) {
client.send("reload");
}
}, 500);
chokidar
.watch(settings.root, { ignored: /node_modules.*\.d\.ts/, persistent: true })
.on("add", reloadInstances)
.on("change", reloadInstances)
.on("unlink", reloadInstances)
.on("addDir", reloadInstances)
.on("addDir", reloadInstances)
.on("unlinkDir", reloadInstances)
.on("error", (error) => console.log(colors.red(`File watcher error: ${error}`)))
.on("ready", () => {
return console.log("Initial file scan is complete. Ready for changes");
});
return server;
/**
* Middleware to manage CSP headers.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function cspHeaders(req, res, next) {
if (req.method !== "GET") {
next();
return;
}
// Set same security headers in the development server as in the Spotfire runtime.
res.setHeader(
"content-security-policy",
`sandbox allow-scripts; default-src 'self' 'unsafe-eval' 'unsafe-inline' blob: data: ${[
...allowedOrigins.values(),
...declaredExternalResourcesInManifest
].join(" ")} ${wsServerUrl}`
);
// CSP header used by older browsers where the CSP policy is not fully supported.
res.setHeader("x-content-security-policy", "sandbox allow-scripts");
next();
}
/**
* Middleware to warn for files missing from manifest.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function checkIfPartOfManifest(req, res, next) {
let url = cleanUrl(req.url).slice(1);
// Do not report ugly error.
if (!settings.allowProjectRoot && url === "modProjectRoot") {
next();
}
if (manifestFiles.length && url != manifestName && !manifestFiles.includes(url)) {
console.log(colors.yellow(`Mod manifest warning: '${url}' is not listed in the files list.`));
}
next();
}
/**
* Read external resources from the mod manifest placed in the root directory.
*/
function setupManifestListener() {
if (!fs.existsSync(manifestPath)) {
console.log(
colors.yellow("Could not find a mod-manifest.json in the root directory"),
colors.yellow(rootDirectoryAbsolutePath)
);
return;
}
readExternalResources();
fs.watch(manifestPath, {}, readExternalResources);
}
function readExternalResources() {
if (!fs.existsSync(manifestPath)) {
return;
}
let content = fs.readFileSync(manifestPath, { encoding: "utf-8" });
try {
let json = JSON.parse(content);
declaredExternalResourcesInManifest = json.externalResources || [];
manifestFiles = [...(json.files || []), json.icon];
if (json.scripts) {
for (const script of json.scripts) {
if (script.file) {
manifestFiles.push(script.file);
}
if (script.icon) {
manifestFiles.push(script.icon);
}
}
}
} catch (err) { }
}
/**
* Middleware to manage CORS headers.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function corsHeaders(req, res, next) {
const isCorsRequest = req.headers.origin != undefined;
const requestFromOutsideSandbox = req.headers.origin != "null";
// Prevent CORS requests from the sandboxed iframe. E.g module loading will not work in embedded mode.
if (isCorsRequest && requestFromOutsideSandbox) {
allowedOrigins.add(req.headers.origin);
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Private-Network", "true");
}
next();
}
/**
* Middleware for handling preflight requests.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function preflight(req, res, next) {
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
} else {
next();
}
}
/**
* Middleware for taking a snapshot of the mod files.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function snapshot(req, res, next) {
if (
!req.method ||
req.method !== "POST" ||
(req.headers["content-type"] ?? "").toLowerCase() !== applicationJson
) {
res.statusCode = 400;
res.setHeader("Content-Type", applicationJson);
res.write(
JSON.stringify({
title: "Invalid request",
message: "Expected request with method 'POST' and Content-Type 'application/json; charset=UTF-8'."
})
);
res.end();
return;
}
let body = "";
req.setEncoding("utf-8");
req.on("data", (chunk) => {
if (typeof chunk === "string") {
body += chunk;
}
});
req.on("end", () => {
res.setHeader("Content-Type", applicationJson);
try {
/**
* @type {{filePaths?: string[]}}
*/
const manifestFiles = JSON.parse(body);
if (!manifestFiles.filePaths) {
throw new Error("Missing property 'filePaths'");
}
res.write(JSON.stringify(takeSnapshot(manifestFiles.filePaths)));
} catch (e) {
res.statusCode = 400;
res.write(
JSON.stringify({
title: "Invalid JSON payload",
message: "The JSON payload does not follow expected schema '{ filePaths: string[] }'.",
error: `${e}`
})
);
} finally {
res.end();
}
});
}
/**
* Middleware for querying info about the dev server.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function info(req, res, next) {
res.setHeader("Content-Type", applicationJson);
res.write(
JSON.stringify({
version: package.version,
allowProjectRoot: settings.allowProjectRoot
})
);
res.end();
}
}
/**
* Only use the middleware if no origin is set to prevent the middleware to be invoked in an ajax call.
* @param {any} middleware
*/
function onlyWhenOriginIsSet(middleware) {
/**
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
return function (req, res, next) {
if (req.method == "GET") {
console.log("GET", req.url);
}
if (req.headers.origin) {
next();
} else {
middleware(req, res, next);
}
};
}
/**
* Inject the web socket snippet in html pages.
* @param {any} settings
*
*/
function injectWebSocketSnippet(settings) {
/**
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
return function (req, res, next) {
let url = cleanUrl(req.url);
// Ignore files other than index.html and folder traversals.
if (!url.endsWith("/index.html") || url.includes("..")) {
next();
return;
}
let filePath = path.join(settings.root, url);
if (!fs.existsSync(filePath)) {
next();
return;
}
let file = fs.readFileSync(filePath, { encoding: "utf8" });
file = file.replace("</body>", injectHtml + "</body>");
res.setHeader("Content-Type", "text/html; charset=UTF-8");
res.write(file);
res.end();
};
}
function cleanUrl(url = "") {
// Remove any query params.
url = url.split("?")[0];
if (url.endsWith("/")) {
url += "index.html";
}
return url;
}
/**
* Middleware to manage caching.
* Turn off caching on everything to avoid stale CSP headers etc. in the browser.
* This also ensures that the dev server can inject its websocket snippet in .html pages it serves to the mod iframe.
*
* @param {connect.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {connect.NextFunction} next
*/
function cacheHeaders(req, res, next) {
res.setHeader("Cache-Control", "no-store");
next();
}
/**
* Records the modified time (ms since UNIX epoch) of the requested files.
* @param {string[]} filePaths
*/
function takeSnapshot(filePaths) {
/**
* @type {[string, number][]}
*/
const snapshot = [];
for (const filePath of filePaths) {
try {
const stats = fs.statSync(filePath);
snapshot.push([filePath, stats.mtimeMs]);
} catch (e) {
snapshot.push([filePath, 0]);
}
}
return { snapshot: snapshot };
}