OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / server.js

402 lines · javascript ref:
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
}