code-workshop-kit
Version:
The future of remote code workshops & training
435 lines • 17.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.startServer = void 0;
const dev_server_1 = require("@web/dev-server");
const chalk_1 = __importDefault(require("chalk"));
const chokidar_1 = __importDefault(require("chokidar"));
const command_line_args_1 = __importDefault(require("command-line-args"));
const esm_1 = __importDefault(require("esm"));
const glob_1 = __importDefault(require("glob"));
const path_1 = __importDefault(require("path"));
const portfinder_1 = __importDefault(require("portfinder"));
const middleware_1 = require("./middleware/middleware");
const plugins_1 = require("./plugins/plugins");
const CwkStateSingleton_1 = require("./utils/CwkStateSingleton");
const runScript_1 = require("./runScript");
const logger = (str, opts) => {
if (opts.logStartup !== false) {
// eslint-disable-next-line no-console
console.log(str);
}
};
const getAdminUIDefaults = () => ({
enableCaching: false,
followMode: false,
});
const sendAdminConfig = (ws) => {
ws.send(JSON.stringify({
type: 'config-init',
config: CwkStateSingleton_1.cwkState.state.adminConfig,
}));
};
const setDefaultAdminConfig = () => {
CwkStateSingleton_1.cwkState.state = { adminConfig: getAdminUIDefaults() };
};
const getWsConnection = (participantName, feature, all = false) => {
let connection;
if (!CwkStateSingleton_1.cwkState.state.wsConnections || !CwkStateSingleton_1.cwkState.state.wsConnections[feature]) {
return null;
}
if (all) {
return Array.from(CwkStateSingleton_1.cwkState.state.wsConnections[feature].entries())
.filter((entry) => entry[0].endsWith(participantName))
.map((entry) => entry[1]);
}
if (CwkStateSingleton_1.cwkState.state.wsConnections && CwkStateSingleton_1.cwkState.state.wsConnections[feature]) {
connection = CwkStateSingleton_1.cwkState.state.wsConnections[feature].get(`${participantName}-${participantName}`);
}
return connection;
};
const runScriptForParticipant = async (participantName, cfg, opts) => {
const { state } = CwkStateSingleton_1.cwkState;
if (state.terminalScripts) {
const oldScript = state.terminalScripts.get(participantName);
if (oldScript && oldScript.script && oldScript.script.pid) {
try {
process.kill(-oldScript.script.pid);
await oldScript.hasClosed;
}
catch (e) {
logger(`
Error: problem killing a participant terminal script, this could be related to a bug on Windows where the wrong PID is given.
CWK is working on a fix or workaround.. In the meantime, we advise using WSL for windows users hosting CWK workshops,
or only doing participant scripts that are self-terminating.
`, opts);
}
}
}
if (!cfg || !cfg.targetOptions || !cfg.targetOptions.cmd) {
return;
}
const { processEmitter, script } = runScript_1.runScript({
cmd: cfg.targetOptions.cmd,
participant: participantName,
participantIndex: cfg.participants.indexOf(participantName),
dir: cfg.absoluteDir,
});
if (!state.terminalScripts) {
state.terminalScripts = new Map();
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
let closeResolve = () => { };
const hasClosed = new Promise((resolve) => {
if (!state.terminalScripts) {
return;
}
closeResolve = resolve;
});
// Save the current running script for participant
state.terminalScripts.set(participantName, { script, closeResolve, hasClosed });
// We will set a close listener for each participant, which can easily exceed 10.
script.setMaxListeners(0);
script.on('close', () => {
if (!state.terminalScripts) {
return;
}
const scriptData = state.terminalScripts.get(participantName);
if (scriptData) {
scriptData.closeResolve();
state.terminalScripts.delete(participantName);
}
});
// Open terminal input on the frontend
const connections = getWsConnection(participantName, 'terminal-process', true);
if (Array.isArray(connections)) {
connections.forEach((connection) => {
if (connection) {
connection.send(JSON.stringify({ type: 'terminal-input-enable' }));
}
// Close terminal input on the frontend
script.on('close', () => {
if (connection) {
connection.send(JSON.stringify({ type: 'terminal-input-disable' }));
}
});
});
}
const sendData = (data, type) => {
// Check connections again, as they may have changed since checking at the start of running the script
const _connections = getWsConnection(participantName, 'terminal-process', true);
if (Array.isArray(_connections)) {
_connections.forEach((connection) => connection.send(JSON.stringify({ type: `terminal-process-${type}`, data })));
}
};
processEmitter.on('out', (data) => {
sendData(data, 'output');
});
processEmitter.on('err', (data) => {
sendData(data, 'error');
});
CwkStateSingleton_1.cwkState.state = state;
};
const handleWsMessage = (message, ws, opts) => {
const parsedMessage = JSON.parse(message);
const { type } = parsedMessage;
switch (type) {
case 'config-init': {
sendAdminConfig(ws);
break;
}
case 'reset-state': {
setDefaultAdminConfig();
break;
}
case 'clear-state': {
CwkStateSingleton_1.cwkState.clear();
break;
}
case 'config-updated': {
const { config, key, byAdmin } = parsedMessage;
CwkStateSingleton_1.cwkState.state = { adminConfig: config };
if (key === 'followMode') {
CwkStateSingleton_1.cwkState.state = { followModeInitiatedBy: byAdmin };
}
ws.send(JSON.stringify({
type: 'config-update-completed',
config: CwkStateSingleton_1.cwkState.state.adminConfig,
}));
break;
}
case 'terminal-process-input': {
const { input, participantName } = parsedMessage;
if (CwkStateSingleton_1.cwkState.state.terminalScripts) {
const scriptData = CwkStateSingleton_1.cwkState.state.terminalScripts.get(participantName);
if (scriptData && scriptData.script) {
scriptData.script.stdin.write(`${input}\n`, (err) => {
if (err) {
throw new Error(`stdin error: ${err}`);
}
});
}
}
break;
}
case 'terminal-rerun': {
const { participantName } = parsedMessage;
runScriptForParticipant(participantName, CwkStateSingleton_1.cwkState.state.cwkConfig, opts);
break;
}
case 'authenticate': {
const { username, feature, participant } = parsedMessage;
const { state } = CwkStateSingleton_1.cwkState;
if (username) {
if (!state.wsConnections) {
state.wsConnections = {};
}
if (!state.wsConnections[feature]) {
state.wsConnections[feature] = new Map();
}
// Store the websocket connection for this user
state.wsConnections[feature].set(`${participant}-${username}`, ws);
CwkStateSingleton_1.cwkState.state = state;
ws.send(JSON.stringify({
type: 'authenticate-completed',
user: username,
}));
}
}
// no default
}
};
const addPluginsAndMiddleware = (wdsConfig, cwkConfig) => {
const newWdsConfig = wdsConfig;
newWdsConfig.middleware = [
...(wdsConfig.middleware || []),
middleware_1.changeParticipantUrlMiddleware(cwkConfig.absoluteDir),
middleware_1.jwtMiddleware(cwkConfig.absoluteDir),
middleware_1.noCacheMiddleware,
];
newWdsConfig.plugins = [
...(wdsConfig.plugins || []),
plugins_1.queryTimestampModulesPlugin(cwkConfig.absoluteDir),
plugins_1.missingIndexHtmlPlugin(cwkConfig),
plugins_1.wsPortPlugin(wdsConfig.port),
plugins_1.componentReplacersPlugin(cwkConfig),
plugins_1.followModePlugin(wdsConfig.port),
plugins_1.appShellPlugin(cwkConfig),
plugins_1.adminUIPlugin(cwkConfig.absoluteDir),
];
return newWdsConfig;
};
const getCwkConfig = (opts) => {
var _a;
// cwk defaults
let cwkConfig = {
participants: [],
admins: [],
adminPassword: '',
appKey: '',
absoluteDir: '/',
dir: '/',
title: '',
logStartup: true,
target: 'frontend',
argv: [],
_unknown: [],
...opts,
targetOptions: {
// terminal
cmd: '',
autoReload: true,
fromParticipantFolder: true,
excludeFromWatch: [],
// frontend
mode: 'iframe',
...(opts && opts.targetOptions),
},
templateData: {
...(opts && opts.templateData),
},
};
// If cli was used, read flags, both for cwk and wds flags
if (opts.argv) {
const cwkServerDefinitions = [
{
name: 'dir',
type: String,
description: 'The directory to read the cwk.config.js from, the index.html for the app shell and the template folder for scaffolding',
},
];
cwkConfig = {
...cwkConfig,
...command_line_args_1.default(cwkServerDefinitions, { argv: opts.argv, partial: true }),
};
}
if ((_a = cwkConfig.dir) === null || _a === void 0 ? void 0 : _a.startsWith('/')) {
// eslint-disable-next-line no-param-reassign
cwkConfig.dir = `.${cwkConfig.dir}`;
}
cwkConfig.absoluteDir = path_1.default.resolve(process.cwd(), cwkConfig.dir);
const esmRequire = esm_1.default(module);
const workshop = esmRequire(`${cwkConfig.absoluteDir}/cwk.config.js`).default;
// TODO: use deepmerge
cwkConfig = {
...cwkConfig,
...workshop,
targetOptions: {
...(cwkConfig && cwkConfig.targetOptions),
...(workshop && workshop.targetOptions),
},
};
return cwkConfig;
};
const getWdsConfig = (opts, cwkConfig, port) => {
// wds defaults & middleware
let wdsConfig = {
open: false,
nodeResolve: true,
plugins: [],
middleware: [],
...opts,
};
wdsConfig.port = port;
const wdsConfigWithPort = wdsConfig;
wdsConfig = addPluginsAndMiddleware(wdsConfigWithPort, cwkConfig);
return wdsConfig;
};
const setupParticipantWatcher = (absoluteDir) => chokidar_1.default.watch(path_1.default.resolve(absoluteDir, 'participants'));
const setupWatcherForTerminalProcess = (watcher, cfg, opts) => {
if (cfg.targetOptions && cfg.targetOptions.autoReload) {
watcher.on('change', (filePath) => {
var _a, _b;
// Get participant name from file path
// Cancel out the participant folder from the filepath (Bob/index.js or Bob/nested/style.css),
// and get the top most dir name
const participantFolder = path_1.default.join(cfg.absoluteDir, 'participants/');
const participantName = filePath.split(participantFolder)[1].split(path_1.default.sep).shift();
if (participantName && participantName !== '_cwk_disconnected') {
const excludeFilesArr = [
...new Set((_b = (_a = cfg.targetOptions) === null || _a === void 0 ? void 0 : _a.excludeFromWatch) === null || _b === void 0 ? void 0 : _b.flatMap((pattern) => glob_1.default.sync(pattern, {
cwd: path_1.default.resolve(cfg.absoluteDir, 'participants', participantName),
dot: true,
})).map((file) => path_1.default.resolve(cfg.absoluteDir, 'participants', participantName, file))),
];
// If the file that was changed is not within exclude-list
if (!excludeFilesArr.includes(filePath)) {
runScriptForParticipant(participantName, cfg, opts);
}
}
});
}
};
const setupHMR = (watcher, absoluteDir) => {
watcher.on('change', (filePath) => {
// Get participant name from file path
// Cancel out the participant folder from the filepath (Bob/index.js or Bob/nested/style.css),
// and get the top most dir name
const participantFolder = path_1.default.join(absoluteDir, 'participants/');
const participantName = filePath.split(participantFolder)[1].split(path_1.default.sep).shift();
if (participantName && participantName !== '_cwk_disconnected') {
const connections = getWsConnection(participantName, 'reload-module', true);
const queryTimestamp = Date.now();
const { state } = CwkStateSingleton_1.cwkState;
if (!state.queryTimestamps) {
state.queryTimestamps = {};
}
state.queryTimestamps[participantName] = queryTimestamp;
CwkStateSingleton_1.cwkState.state = state;
if (Array.isArray(connections)) {
connections.forEach((connection) => {
if (connection) {
// Send module-changed message to all participants for the folder that changed
connection.send(JSON.stringify({
type: 'reload-module',
name: participantName,
timestamp: queryTimestamp,
}));
}
});
}
}
});
};
const getPort = async (opts) => {
/**
* Pre-determine the port, since we have to pass it to our middleware and plugins before
* the dev server is instantiated. We either take the CLI flag port, the Node API's port
* property, or as a fallback, portfinder's default first found port.
*/
const defaultPort = await portfinder_1.default.getPortPromise();
const port = command_line_args_1.default([
{
alias: 'p',
name: 'port',
type: Number,
},
], { argv: opts.argv, partial: true }).port ||
opts.port ||
defaultPort;
return port;
};
const startServer = async (opts) => {
const port = await getPort(opts);
const cwkConfig = getCwkConfig(opts);
const wdsConfig = getWdsConfig(opts, cwkConfig, port);
const watcher = setupParticipantWatcher(cwkConfig.absoluteDir);
if (cwkConfig.target === 'frontend' && cwkConfig.targetOptions.mode === 'module') {
setupHMR(watcher, cwkConfig.absoluteDir);
}
else if (cwkConfig.target === 'terminal') {
setupWatcherForTerminalProcess(watcher, cwkConfig, opts);
}
const server = await dev_server_1.startDevServer({
config: {
...wdsConfig,
watch: false,
clearTerminalOnReload: false,
},
logStartMessage: false,
argv: cwkConfig._unknown, // pass those options that were unknown to CWK definitions
});
const wss = server.webSockets.webSocketServer;
wss.on('connection', (ws) => {
ws.on('message', (message) => handleWsMessage(message, ws, opts));
});
CwkStateSingleton_1.cwkState.state = { wss, cwkConfig };
setDefaultAdminConfig();
if (cwkConfig.logStartup !== false) {
logger(chalk_1.default.bold('code-workshop-kit server started...'), opts);
logger('', opts);
let url = `http://localhost:${server.config.port}/${path_1.default.relative(process.cwd(), cwkConfig.absoluteDir)}`;
if (!url.endsWith('/')) {
url += '/';
}
logger(`${chalk_1.default.white('Visit:')} ${chalk_1.default.cyanBright(url)}`, opts);
logger('', opts);
}
['exit', 'SIGINT'].forEach((event) => {
process.on(event, () => {
// ensure that when we close CWK, terminal script subchildren are killed off too
if (CwkStateSingleton_1.cwkState.state.terminalScripts) {
CwkStateSingleton_1.cwkState.state.terminalScripts.forEach((script) => {
if (script && script.script && script.script.pid) {
try {
process.kill(-script.script.pid);
}
catch (e) {
logger(`
Error: problem killing a participant terminal script, this could be related to a bug on Windows where the wrong PID is given.
CWK is working on a fix or workaround.. In the meantime, we advise using WSL for windows users hosting CWK workshops,
or only doing participant scripts that are self-terminating.
`, opts);
}
}
});
}
watcher.close();
});
});
return { server, wdsConfig, cwkConfig, wss, watcher };
};
exports.startServer = startServer;
//# sourceMappingURL=start-server.js.map