UNPKG

myst-cli

Version:
198 lines (197 loc) 8.01 kB
import chalk from 'chalk'; import cors from 'cors'; import express from 'express'; import getPort, { portNumbers } from 'get-port'; import { makeExecutable, killProcessTree } from 'myst-cli-utils'; import { nanoid } from 'nanoid'; import { join } from 'node:path'; import { WebSocketServer } from 'ws'; import version from '../../version.js'; import { createServerLogger } from './logger.js'; import { buildSite } from './prepare.js'; import { installSiteTemplate, getSiteTemplate } from './template.js'; import { watchContent } from './watch.js'; /* Find a free port close to the preferred port and bind to it. If that port was taken by the time we tried to bind, probe again and retry. */ async function listenOnPreferredPort(app, host, preferredPort) { const tryListen = (port) => new Promise((resolve, reject) => { const s = app.listen(port, host, () => resolve(s)); s.once('error', (err) => { s.close(); reject(err); }); }); const first = preferredPort ?? (await getPort({ port: portNumbers(3100, 3200) })); try { return { server: await tryListen(first), port: first }; } catch (err) { if (err.code !== 'EADDRINUSE') throw err; const second = await getPort({ port: portNumbers(3100, 3200) }); return { server: await tryListen(second), port: second }; } } const DEFAULT_HOST = 'localhost'; const DEFAULT_START_COMMAND = 'npm run start'; /** * Creates a content server and a websocket that can reload and log messages to the client. */ export async function startContentServer(session, opts) { const host = opts?.serverHost || DEFAULT_HOST; let port = 0; const app = express(); app.use(cors()); app.get('/', (req, res) => { res.json({ version, links: { site: `http://${host}:${port}/config.json`, }, }); }); app.use('/', express.static(session.publicPath())); app.use('/content', express.static(session.contentPath())); app.use('/config.json', express.static(join(session.sitePath(), 'config.json'))); app.use('/objects.inv', express.static(join(session.sitePath(), 'objects.inv'))); app.use('/myst.xref.json', express.static(join(session.sitePath(), 'myst.xref.json'))); app.use('/myst.search.json', express.static(join(session.sitePath(), 'myst.search.json'))); const { server, port: boundPort } = await listenOnPreferredPort(app, host, opts?.serverPort); port = boundPort; session.log.debug(`Content server listening on port ${port}`); const wss = new WebSocketServer({ noServer: true, path: '/socket', }); const connections = {}; wss.on('connection', function connection(ws) { const id = nanoid(); session.log.debug(`Content server websocket connected ${id}`); connections[id] = ws; ws.on('close', () => { session.log.debug(`Content server websocket disconnected ${id}`); delete connections[id]; }); }); /** * Send a message to all connections. */ const sendJson = (data) => { Object.entries(connections).forEach(([, ws]) => { ws.send(JSON.stringify(data)); }); }; /** * @deprecated Use the websocket server exposed via `sockets.wss` to broadcast * messages directly instead. */ const sendReload = () => sendJson({ type: 'RELOAD' }); server.on('upgrade', (request, socket, head) => { wss.handleUpgrade(request, socket, head, (websocket) => { wss.emit('connection', websocket, request); }); }); const stop = () => { server.close(); wss.close(); }; return { host, port, stop, sendReload, sendJson, wss, connections }; } export function warnOnHostEnvironmentVariable(session, opts) { // Check if we're running in ReadTheDocs environment if (process.env.READTHEDOCS === 'True') { session.log.info('Detected ReadTheDocs environment, setting HOST to 127.0.0.1'); process.env.HOST = '127.0.0.1'; return '127.0.0.1'; } if (!process.env.HOST || process.env.HOST === 'localhost' || process.env.HOST === '127.0.0.1') { return process.env.HOST || DEFAULT_HOST; } if (opts?.keepHost) { session.log.warn(`\nThe HOST environment variable is set to "${process.env.HOST}", this may cause issues for the web server.\n`); return process.env.HOST; } session.log.warn(`\nThe HOST environment variable is set to "${process.env.HOST}", we are overwriting this to "${DEFAULT_HOST}".\nTo keep this value use the \`--keep-host\` flag.\n`); process.env.HOST = DEFAULT_HOST; return DEFAULT_HOST; } /* Start the app server (npm start) on the given port. Rejects if the process exits before emitting the ready signal. */ async function tryStartAppServer(mystTemplate, session, host, contentServer, opts, port) { const appServer = { port, contentServer }; let started = false; await new Promise((resolve, reject) => { const start = makeExecutable(mystTemplate.getValidatedTemplateYml().build?.start ?? DEFAULT_START_COMMAND, createServerLogger(session, { host, ready: () => { started = true; resolve(); }, }), { cwd: mystTemplate.templatePath, env: { ...process.env, HOST: host, CONTENT_CDN_PORT: String(contentServer.port), PORT: String(port), MODE: opts.buildStatic ? 'static' : 'app', BASE_URL: opts.baseurl || undefined, }, getProcess(proc) { appServer.process = proc; proc.on('exit', (code) => { if (!started) reject(new Error(`App server exited (code ${code}) before becoming ready`)); }); }, }); start().catch((e) => { if (!started) reject(e); }); }); appServer.stop = async () => { if (appServer.process) await killProcessTree(appServer.process); contentServer.stop(); }; return appServer; } export async function startServer(session, opts) { // Ensure we are on the latest version of the configs await session.reload(); const host = warnOnHostEnvironmentVariable(session, opts); const mystTemplate = await getSiteTemplate(session, opts); if (!opts.headless && !opts.template) await installSiteTemplate(session, mystTemplate); await buildSite(session, opts); const contentServer = await startContentServer(session, { ...opts, serverHost: host }); if (!opts.buildStatic) { watchContent(session, contentServer.sendReload, opts); } if (opts.headless) { const local = chalk.green(`http://${host}:${contentServer.port}`); session.log.info(`\n🔌 Content server started on port ${contentServer.port}! 🥳 🎉\n\n\n\t👉 ${local} 👈\n\n`); // Return a headless AppServer so callers (e.g. curvenote-cli's // startServerWithLoggers) can still reach contentServer.sendJson to wire // up websocket loggers. `port` and `process` are intentionally omitted // since no template server is spawned. return { contentServer, stop: async () => contentServer.stop(), }; } session.log.info(`\n\n\t✨✨✨ Starting ${mystTemplate.getValidatedTemplateYml().title} ✨✨✨\n\n`); const first = opts?.port ?? (await getPort({ port: portNumbers(3000, 3100) })); try { return await tryStartAppServer(mystTemplate, session, host, contentServer, opts, first); } catch { const second = await getPort({ port: portNumbers(3000, 3100) }); return await tryStartAppServer(mystTemplate, session, host, contentServer, opts, second); } }