import http from 'http'
import { gzipSync } from 'zlib'
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { renderPage } from './render.js'
import { buildIgnoreMatcher } from './ignore.js'
import { LiveStore } from './live-store.js'
export function startServer (tree, opts = {}) {
const port = opts.port || 9078
const prefix = opts.prefix || ''
const page = renderPage(tree, prefix)
const rootDir = tree.root
// Rebuild the ignore matcher so /api/file enforces the same hiding rules at runtime
// as the scan did at tree-build time. A file that wasn't in the tree won't have a
// request generated by the UI, but a crafted request could reach /api/file directly.
const ignore = buildIgnoreMatcher(rootDir, opts.ignore || [])
// Live store — subscribable event stream fed by fs.watch. opts.watch === false
// disables it (useful for one-shot CLI serve, or when the inotify limit
// would be exceeded by recursive watching of a huge tree).
// Built on first request to /api/sections, then reused. Building it walks
// every file's connectors, which is not worth doing at boot for a user who
// never opens the graph.
let sectionsCache = null
const live = opts.graph
? new LiveStore(opts.graph, { watch: opts.watch !== false })
: null
if (live) {
live.seedFromGraph()
live.start()
}
// The floor view ships as REAL FILES served statically, not as a string inlined
// into render.js. That file is a 4,000-line template literal with a no-backtick
// rule, and inlining has already cost a duplicate escapeHtml, a </script>
// breakout and an implicit global. A view with its own logic does not belong in
// a string.
const LIB = dirname(fileURLToPath(import.meta.url))
function sendFile (req, res, relPath, contentType) {
try {
send(req, res, 200, contentType, readFileSync(resolve(LIB, relPath), 'utf-8'))
} catch (err) {
send(req, res, 500, 'text/plain', 'cannot read ' + relPath + ': ' + (err.message || err))
}
}
// --- Response helpers ---
// Compress the body with gzip if the client sent Accept-Encoding: gzip.
// Only compresses when the payload is worth it (>1KB). Below that the
// gzip overhead isn't worth the CPU.
function send (req, res, status, contentType, body) {
const accept = (req.headers['accept-encoding'] || '').toLowerCase()
const acceptsGzip = accept.includes('gzip')
const buf = typeof body === 'string' ? Buffer.from(body) : body
if (acceptsGzip && buf.length > 1024) {
const compressed = gzipSync(buf)
res.writeHead(status, {
'content-type': contentType,
'content-encoding': 'gzip',
'vary': 'Accept-Encoding'
})
res.end(compressed)
} else {
res.writeHead(status, { 'content-type': contentType })
res.end(buf)
}
}
function sendJSON (req, res, status, obj) {
send(req, res, status, 'application/json', JSON.stringify(obj))
}
const server = http.createServer((req, res) => {
// Strip prefix from URL
let reqUrl = req.url
if (prefix && reqUrl.startsWith(prefix)) {
reqUrl = reqUrl.slice(prefix.length) || '/'
}
const [url, query] = reqUrl.split('?')
// SPA shell — every navigable view serves the same HTML. The client
// reads location.pathname and builds the initial graphStack from it.
// Deep links (`/module/:name`, `/module/:name/file/:path`, `/path/:fsPath`,
// `/system`) all land on the same page and the JS takes over.
if (
url === '/' ||
url === '/index.html' ||
url === '/system' ||
url.startsWith('/module/') ||
url.startsWith('/path/') ||
url === '/docs' ||
url.startsWith('/docs/') ||
url === '/validation' ||
url.startsWith('/validation/') ||
url.startsWith('/suggestions/')
) {
send(req, res, 200, 'text/html; charset=utf-8', page)
return
}
if (url === '/api/tree') {
sendJSON(req, res, 200, tree)
return
}
// Connector index. Served on demand rather than inlined into the page:
// the full tree is already ~77KB per module in the HTML, and folding every
// connector into it would multiply that. The UI fetches this once.
//
// GET /api/sections per-module misfit counts (small)
// GET /api/sections?full=1 every section with its connectors
if (url === '/api/sections') {
;(async () => {
try {
if (!sectionsCache) {
const { buildSections } = await import('./sections.js')
const index = buildSections(tree)
const m = index.misfits()
// Roll misfits up per module — that is all the graph needs to size
// and colour a node's stubs.
const perModule = {}
function bump (modName, key) {
if (!modName) return
if (!perModule[modName]) perModule[modName] = { dead: 0, unresolved: 0, leak: 0, unused: 0 }
perModule[modName][key]++
}
const germModule = new Map()
for (const s of index.sections) germModule.set(s.germ.path, s.germ.module)
for (const d of m.dead) bump(germModule.get(d.germ), 'dead')
for (const u of m.unresolved) bump(germModule.get(u.germ), 'unresolved')
for (const l of m.leak) bump(l.module, 'leak')
for (const u of m.unused) bump(u.module, 'unused')
sectionsCache = {
perModule,
totals: {
dead: m.dead.length,
unresolved: m.unresolved.length,
leak: m.leak.length,
unused: m.unused.length
},
index
}
}
const params = query ? new URLSearchParams(query) : null
// One module's file-scale sections, with every connector. This is
// what the socket view renders, and scoping it to a module keeps the
// payload bounded no matter how large the scan is.
const wantModule = params && params.get('module')
if (wantModule) {
const subset = sectionsCache.index.sections.filter(
s => s.scale === 'file' && s.germ.module === wantModule
)
sendJSON(req, res, 200, {
module: wantModule,
sections: subset,
misfits: sectionsCache.perModule[wantModule] || null
})
return
}
if (params && params.get('full')) {
sendJSON(req, res, 200, {
sections: sectionsCache.index.sections,
totals: sectionsCache.totals
})
} else {
sendJSON(req, res, 200, {
perModule: sectionsCache.perModule,
totals: sectionsCache.totals
})
}
} catch (err) {
sendJSON(req, res, 500, { error: err.message || String(err) })
}
})()
return
}
// Server-Sent Events stream: replay current state as events, then push
// live events (file changes detected by fs.watch) as they happen.
//
// Clients: `new EventSource('/codetree/api/events')` and attach
// onmessage / addEventListener handlers per event type.
if (url === '/api/events') {
if (!live) {
sendJSON(req, res, 503, { error: 'Live event stream not available (no graph attached)' })
return
}
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive',
'x-accel-buffering': 'no' // disable proxy buffering (nginx/apache)
})
const write = (ev) => {
// SSE frame: `event: <type>\ndata: <json>\n\n`
res.write('event: ' + ev.type + '\n')
res.write('data: ' + JSON.stringify(ev) + '\n\n')
}
const unsubscribe = live.subscribe(write)
// Flush on disconnect
req.on('close', unsubscribe)
req.on('error', unsubscribe)
return
}
// Lazy deep scan of a single module. Used by the UI when the user drills
// into a module that was originally captured shallow (only entry file
// parsed). Returns the fully-parsed module suitable for replacing the
// shallow placeholder client-side.
//
// GET /api/deep-scan?module=<name>
if (url === '/api/deep-scan' && query) {
const params = new URLSearchParams(query)
const name = params.get('module')
if (!name) {
sendJSON(req, res, 400, { error: 'module parameter required' })
return
}
const placeholder = tree.modules[name]
if (!placeholder) {
sendJSON(req, res, 404, { error: 'Module not found: ' + name })
return
}
// Resolve the module's filesystem path and security-check it.
const modAbsPath = resolve(rootDir, placeholder.path)
if (!modAbsPath.startsWith(rootDir)) {
sendJSON(req, res, 403, { error: 'Access denied' })
return
}
if (ignore(modAbsPath)) {
sendJSON(req, res, 404, { error: 'Module path is ignored' })
return
}
// Lazy-import scan() + buildTree() to avoid pulling them at server boot.
;(async () => {
try {
const { scan } = await import('./scanner.js')
const deep = await scan(modAbsPath, { ignore: opts.ignore || [], shallow: false })
// Return only the module entry (the new tree's root module). Strip
// big sub-trees the client doesn't need.
const deepMod = deep.modules[name] || deep.modules[Object.keys(deep.modules)[0]]
sendJSON(req, res, 200, { name, mod: deepMod })
} catch (err) {
sendJSON(req, res, 500, { error: err.message || String(err) })
}
})()
return
}
if (url.startsWith('/api/module/')) {
const name = decodeURIComponent(url.slice('/api/module/'.length))
const mod = tree.modules[name]
if (mod) {
sendJSON(req, res, 200, mod)
} else {
sendJSON(req, res, 404, { error: 'Module not found' })
}
return
}
// --- the floor: a level of the node ladder, drawn from the derivation.
// /floor and every place under it serve the same shell; the client reads
// location.pathname, exactly as the SPA does. One scheme, two surfaces.
// Every place under /floor serves the same shell, EXCEPT the modules it
// loads. Forgetting one here means a module request gets HTML back and the
// whole view fails to load with a blank page and a syntax error.
const FLOOR_MODULES = ['level.js', 'cards.js', 'layout.js', 'location.js', 'routing.js']
const isFloorModule = FLOOR_MODULES.some(m => url === '/floor/' + m)
if (url === '/floor' || url === '/floor/' || (url.startsWith('/floor/') && !isFloorModule)) {
sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8')
return
}
if (url === '/floor/level.js') {
sendFile(req, res, 'floor/level.js', 'application/javascript; charset=utf-8')
return
}
// The view imports the SAME layout module the tests run against, rather than
// a copy of it that can drift.
if (url === '/floor/routing.js') {
sendFile(req, res, 'floor/routing.js', 'application/javascript; charset=utf-8')
return
}
if (url === '/floor/location.js') {
sendFile(req, res, 'floor/location.js', 'application/javascript; charset=utf-8')
return
}
if (url === '/floor/cards.js') {
sendFile(req, res, 'floor/cards.js', 'application/javascript; charset=utf-8')
return
}
if (url === '/floor/layout.js') {
sendFile(req, res, 'layout.js', 'application/javascript; charset=utf-8')
return
}
// The expression-floor model for one file: /api/derive?path=relative/path.js
// Same traversal and ignore checks as /api/file — a model is a read of the
// file's contents by another name.
if (url === '/api/derive' && query) {
const params = new URLSearchParams(query)
const filePath = params.get('path')
if (!filePath) {
sendJSON(req, res, 400, { error: 'path parameter required' })
return
}
const absPath = resolve(rootDir, filePath)
if (!absPath.startsWith(rootDir)) {
sendJSON(req, res, 403, { error: 'Access denied' })
return
}
if (ignore(absPath)) {
sendJSON(req, res, 404, { error: 'File not found: ' + filePath })
return
}
;(async () => {
try {
const { deriveFile } = await import('./derive.js')
const source = readFileSync(absPath, 'utf-8')
const model = deriveFile(source, filePath)
sendJSON(req, res, 200, { model, source })
} catch (err) {
sendJSON(req, res, 500, { error: err.message || String(err) })
}
})()
return
}
// A list of the files the scan knows about, so the view has something to open.
if (url === '/api/files') {
const out = []
for (const [modName, mod] of Object.entries(tree.modules)) {
for (const rel of Object.keys(mod.files)) {
out.push({ module: modName, path: mod.path === '.' ? rel : mod.path + '/' + rel })
}
}
out.sort((a, b) => (a.path < b.path ? -1 : 1))
sendJSON(req, res, 200, { files: out })
return
}
// File contents API: /api/file?path=relative/path.js
if (url === '/api/file' && query) {
const params = new URLSearchParams(query)
const filePath = params.get('path')
if (!filePath) {
sendJSON(req, res, 400, { error: 'path parameter required' })
return
}
// Security: prevent directory traversal
const absPath = resolve(rootDir, filePath)
if (!absPath.startsWith(rootDir)) {
sendJSON(req, res, 403, { error: 'Access denied' })
return
}
// Security: honor the ignore matcher — hidden files are not readable.
if (ignore(absPath)) {
sendJSON(req, res, 404, { error: 'File not found: ' + filePath })
return
}
try {
const content = readFileSync(absPath, 'utf-8')
sendJSON(req, res, 200, { path: filePath, content, lines: content.split('\n').length })
} catch (err) {
sendJSON(req, res, 404, { error: 'File not found: ' + filePath })
}
return
}
res.writeHead(404, { 'content-type': 'text/plain' })
res.end('Not found')
})
server.listen(port, () => {
console.log(`codetree visualization: http://localhost:${port}`)
console.log(`floor (file level): http://localhost:${port}/floor`)
console.log(`API: http://localhost:${port}/api/tree`)
console.log(`${tree.system.modules.length} modules loaded`)
})
return server
}