import express from 'express' import fs from 'node:fs' import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' import { initStore, DATA_DIR, UPLOADS_DIR, getRegistry, canAccessDoc, docByShareToken, uploadProject } from './store.js' import { registerAuthRoutes, resolveUser, resolveShare, shareCookie, OAUTH_ENABLED, HOST, bootPayload } from './auth.js' import { apiRouter } from './api.js' import { hocuspocus } from './collab.js' import { stopDefaultAgents } from './default-agent.js' initStore() const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PUBLIC_DIR = path.join(__dirname, '..', 'public') const PORT = Number(process.env.PORT || 3000) const app = express() app.set('trust proxy', true) app.use(resolveUser()) app.use(resolveShare) // A share link is a bearer credential and documents link outwards; never let a // URL from this app travel in a Referer header to another origin. app.use((req, res, next) => { res.set('Referrer-Policy', 'no-referrer') next() }) app.get('/healthz', (req, res) => res.json({ ok: true, oauth: OAUTH_ENABLED })) registerAuthRoutes(app) app.use('/api', apiRouter()) // The HTML shell must never be cached: it carries the ?v= asset URLs, // and a stale shell means a stale bundle, which the collab build-check then // rejects — a tab that looks fine but silently saves nothing. The bundles // themselves are versioned, so they can be cached hard. // The shells are tiny; keep them in memory but re-read when a build replaces // them, so a rebuild without a restart cannot serve yesterday's asset URLs. const shells = new Map() const shell = name => { const file = path.join(PUBLIC_DIR, name) const stamp = fs.statSync(file).mtimeMs const hit = shells.get(name) if (hit?.stamp === stamp) return hit.body const body = fs.readFileSync(file, 'utf8') shells.set(name, { stamp, body }) return body } const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) // The header would otherwise show "…" for the whole load. The title is already // in the registry, so the shell can carry it — but only to someone who is // allowed to see the project, or the placeholder leaks private titles. // header = project name; tab = "page · project" (matching the client's painter) const titleFor = (req, id, slug) => { if (!id) return null if (!canAccessDoc(id, req.user?.username) && req.share?.docId !== id) return null const meta = getRegistry()[id] if (!meta) return null const project = meta.title || null const page = slug === '_structure' ? 'Structure' : slug && slug !== 'home' ? meta.pages?.[slug]?.title || null : null if (!project && !page) return null return { header: project || page, tab: page && project ? `${page} · ${project}` : page || project } } const sendShell = name => (req, res) => { const json = JSON.stringify(bootPayload(req)).replace(/', ``) const title = titleFor(req, req.params.id, req.params.slug) if (title) { body = body .replace('', `${escapeHtml(title.header)}`) .replace('Document', `${escapeHtml(title.tab)}`) } res.set('Cache-Control', 'no-cache, must-revalidate') res.type('html').send(body) } const sendDoc = sendShell('doc.html') // No index page: / means "the document I was last in". A signed-out visitor gets // the shell and its sign-in overlay; a signed-in one is sent to their most recent // document, and only sees the shell bare if they have none to send them to. The // redirect keeps GET / free of side effects — creating a document is a POST the // switcher makes, not something a page load does behind you. app.get('/', (req, res, next) => { if (!req.user) return sendDoc(req, res, next) const registry = getRegistry() const mine = Object.entries(registry) .filter(([id]) => canAccessDoc(id, req.user.username)) .sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0)) if (mine.length) return res.redirect(302, `/d/${mine[0][0]}`) sendDoc(req, res, next) }) // Bundles are immutable per build id, so a client that asks for ?v= can keep // the answer forever — that is what makes a second visit cost no bytes at all. app.use((req, res, next) => { if (req.query.v) res.set('Cache-Control', 'public, max-age=31536000, immutable') next() }) // Serve the build's pre-compressed twin when the client takes it. Saves ~480KB // on a cold editor load; without it express.static ships the raw bundle. const ENCODINGS = [ ['br', '.br'], ['gzip', '.gz'], ] app.get(/\.(?:js|css)$/, (req, res, next) => { const name = path.basename(req.path) if (name !== req.path.slice(1)) return next() // only flat asset names, no traversal const accepted = req.headers['accept-encoding'] || '' for (const [token, ext] of ENCODINGS) { if (!new RegExp(`\\b${token}\\b`).test(accepted)) continue const file = path.join(PUBLIC_DIR, name + ext) if (!fs.existsSync(file)) continue res.set('Content-Encoding', token) res.set('Vary', 'Accept-Encoding') res.type(path.extname(name)) return res.sendFile(file) } next() }) app.use(express.static(PUBLIC_DIR, { index: false, setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) res.set('Cache-Control', 'no-cache, must-revalidate') // the webfont files are content-addressed by name; a new face gets a new name else if (filePath.includes(`${path.sep}fonts${path.sep}`)) res.set('Cache-Control', 'public, max-age=31536000, immutable') } })) app.get('/d/:id', sendDoc) app.get('/d/:id/:slug', sendDoc) // Public share links. The token in the path is the whole credential, so it is // exchanged for a cookie on arrival: nothing else in the app has to carry it, // and the socket handshake and requests are covered by the same grant. // An unknown or revoked token is a 404 — it must not confirm that a project // with that id exists. const sendShared = (req, res) => { const docId = docByShareToken(req.params.token) if (!docId) return res.status(404).type('html').send(shell('doc.html')) res.setHeader('set-cookie', shareCookie(req.params.token)) // Redirect rather than render here. The token IS the credential, and a // rendered page leaves it in the address bar, in history, and — because a // document may carry external images and links — in the Referer header sent // to whatever host those point at. The cookie carries the grant from here on, // so /d/ works for this visitor and for nobody else. const slug = req.params.slug ? `/${encodeURIComponent(req.params.slug)}` : '' res.redirect(302, `/d/${docId}${slug}`) } app.get('/p/:token', sendShared) app.get('/p/:token/:slug', sendShared) // uploaded images (auth required — same session cookie the tags send) app.get('/files/:name', (req, res) => { if (!/^[a-f0-9]{16}\.(png|jpg|gif|webp|svg)$/.test(req.params.name)) return res.status(400).end() // A signed-in user may fetch any upload by name, as they always could. A // public-link visitor is confined to the project their link opens: uploads // are global on disk, so without this the cookie would be a key to every // figure on the instance for anyone holding any link. if (!req.user) { if (!req.share) return res.status(401).end() if (uploadProject(req.params.name) !== req.share.docId) return res.status(404).end() } // dotfiles:'allow' — DATA_DIR may itself be a dot-directory (e.g. ./.browser-data in tests) res.sendFile(path.join(UPLOADS_DIR, req.params.name), { maxAge: '365d', immutable: true, dotfiles: 'allow' }, err => { if (err) res.status(404).end() }) }) const server = http.createServer(app) const wss = new WebSocketServer({ noServer: true }) server.on('upgrade', (request, socket, head) => { if (!request.url?.startsWith('/collab')) { socket.destroy() return } wss.handleUpgrade(request, socket, head, ws => { // hocuspocus v4: the caller owns the socket events and feeds them in const connection = hocuspocus.handleConnection(ws, request) ws.on('message', data => connection.handleMessage(new Uint8Array(data))) ws.on('close', (code, reason) => connection.handleClose({ code, reason: reason?.toString() })) ws.on('error', () => connection.handleClose({ code: 1011, reason: 'socket error' })) }) }) server.listen(PORT, () => { console.log(`interactive-editor listening on :${PORT} (host: ${HOST}, oauth: ${OAUTH_ENABLED}, data: ${DATA_DIR})`) }) // heartbeat: visible in Space logs — if it stops ticking while the app is // unreachable, the event loop is blocked (e.g. a hung sync write on the // bucket mount); lag > ~1s means something synchronous is hogging the loop let hbLast = Date.now() setInterval(() => { const lag = Date.now() - hbLast - 60000 hbLast = Date.now() const mem = Math.round(process.memoryUsage().rss / 1e6) console.log(`[hb] rss=${mem}MB docs=${hocuspocus.documents?.size ?? '?'} lag=${lag}ms`) }, 60000) for (const signal of ['SIGTERM', 'SIGINT']) { process.on(signal, () => { stopDefaultAgents() try { hocuspocus.flushPendingStores() } catch {} setTimeout(() => process.exit(0), 500) }) }