/** * Vite dev middleware exposing the local doocus docs-output tree to the app. * * Runs in the dev server process (Node), so it can reach the original source * file by the absolute path stored in each doc's manifest.json — needed for the * package builder, which bundles the original alongside content.md + meta.json. * All reads are local; nothing leaves the machine. * * Routes: * GET /api/docs list extracted docs * GET /api/docs/:id meta.json + content.md + thumb/original flags * GET /api/docs/:id/thumb stream thumb.jpg * GET /api/docs/:id/original stream the original source file */ import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' interface Options { outputDir: string } interface Manifest { source?: { name?: string; path?: string; sha256?: string } processed_at?: string outputs?: { content?: string; meta?: string; thumb?: string | null } } const ORIGINAL_MIME: Record = { '.pdf': 'application/pdf', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.csv': 'text/csv', '.html': 'text/html', '.htm': 'text/html', '.json': 'application/json', '.md': 'text/markdown', '.txt': 'text/plain', '.yaml': 'text/yaml', '.yml': 'text/yaml', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.mp4': 'video/mp4', } export function doocusApi(opts: Options): Plugin { const outputDir = path.resolve(opts.outputDir) const toPosix = (p: string) => p.split(path.sep).join('/') /** Resolve a doc id (possibly nested) to its directory, rejecting traversal. */ function docDir(id: string): string | null { const dir = path.resolve(outputDir, id) if (dir !== outputDir && !dir.startsWith(outputDir + path.sep)) return null try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* missing */ } return null } /** Find every doc run directory (one containing meta.json) under `base`. */ async function findDocDirs(base: string, maxDepth = 8): Promise { const found: string[] = [] const walk = async (dir: string, depth: number): Promise => { if (depth > maxDepth) return let ents try { ents = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } if (ents.some((e) => e.isFile() && e.name === 'meta.json')) { found.push(dir) return // a doc dir; don't descend into assets/ } for (const e of ents) { if (!e.isDirectory()) continue if (e.name === 'assets' || e.name === 'node_modules' || e.name.startsWith('.')) continue await walk(path.join(dir, e.name), depth + 1) } } await walk(base, 0) return found } function readJson(file: string): T | null { try { return JSON.parse(fs.readFileSync(file, 'utf-8')) as T } catch { return null } } return { name: 'doocus-api', configureServer(server) { server.config.logger.info(`[doocus-api] serving docs from: ${outputDir}`) server.middlewares.use('/api', (req, res, next) => { const url = new URL(req.url ?? '/', 'http://localhost') const parts = url.pathname.split('/').filter(Boolean) handle(req, res, parts).catch((err) => { server.config.logger.error(`[doocus-api] ${String(err)}`) sendJson(res, 500, { error: String(err) }) }).then((handled) => { if (!handled) next() }) }) }, } async function handle(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise { if (parts[0] !== 'docs') return false // GET /api/docs if (parts.length === 1 && req.method === 'GET') { await listDocs(res) return true } const id = parts[1] ? decodeURIComponent(parts[1]) : '' const dir = id ? docDir(id) : null if (!dir) { sendJson(res, 404, { error: 'doc not found' }); return true } // GET /api/docs/:id if (parts.length === 2 && req.method === 'GET') { await getDoc(res, id, dir) return true } // GET /api/docs/:id/thumb if (parts.length === 3 && parts[2] === 'thumb' && req.method === 'GET') { serveThumb(res, dir) return true } // GET /api/docs/:id/original if (parts.length === 3 && parts[2] === 'original' && req.method === 'GET') { serveOriginal(res, dir) return true } sendJson(res, 404, { error: 'not found' }) return true } async function listDocs(res: ServerResponse): Promise { const dirs = await findDocDirs(outputDir) const docs = [] for (const dir of dirs) { const meta = readJson>(path.join(dir, 'meta.json')) if (!meta) continue const id = toPosix(path.relative(outputDir, dir)) const source = (meta.source ?? {}) as { name?: string; bytes?: number } docs.push({ id, name: source.name ?? id, type: meta.type ?? null, family: meta.family ?? null, title: meta.title ?? null, extractedAt: meta.extracted_at ?? null, wordCount: meta.word_count ?? 0, bytes: source.bytes ?? 0, hasThumb: fs.existsSync(path.join(dir, 'thumb.jpg')), warnings: Array.isArray(meta.warnings) ? meta.warnings.length : 0, }) } docs.sort((a, b) => String(b.extractedAt ?? '').localeCompare(String(a.extractedAt ?? '')) || a.id.localeCompare(b.id)) sendJson(res, 200, { docs, outputDir }) } async function getDoc(res: ServerResponse, id: string, dir: string): Promise { const meta = readJson>(path.join(dir, 'meta.json')) if (!meta) { sendJson(res, 404, { error: 'meta missing' }); return } const manifest = readJson(path.join(dir, 'manifest.json')) let content = '' try { content = await fsp.readFile(path.join(dir, 'content.md'), 'utf-8') } catch { /* none */ } const hasThumb = fs.existsSync(path.join(dir, 'thumb.jpg')) const originalPath = manifest?.source?.path const hasOriginal = !!originalPath && isFile(originalPath) sendJson(res, 200, { id, meta, content, hasThumb, thumbUrl: hasThumb ? `/api/docs/${encodeURIComponent(id)}/thumb` : null, hasOriginal, originalUrl: hasOriginal ? `/api/docs/${encodeURIComponent(id)}/original` : null, }) } function serveThumb(res: ServerResponse, dir: string): void { const full = path.join(dir, 'thumb.jpg') if (!fs.existsSync(full)) { sendJson(res, 404, { error: 'no thumb' }); return } res.setHeader('Content-Type', 'image/jpeg') res.setHeader('Cache-Control', 'no-cache') fs.createReadStream(full).pipe(res) } function serveOriginal(res: ServerResponse, dir: string): void { const manifest = readJson(path.join(dir, 'manifest.json')) const original = manifest?.source?.path if (!original || !isFile(original)) { sendJson(res, 404, { error: 'original source not reachable on this machine' }) return } const mime = ORIGINAL_MIME[path.extname(original).toLowerCase()] ?? 'application/octet-stream' res.setHeader('Content-Type', mime) res.setHeader('Content-Disposition', `attachment; filename="${path.basename(original)}"`) fs.createReadStream(original).pipe(res) } } function sendJson(res: ServerResponse, status: number, obj: unknown): void { const body = JSON.stringify(obj) res.statusCode = status res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Length', Buffer.byteLength(body)) res.end(body) } function isFile(p: string): boolean { try { return fs.statSync(p).isFile() } catch { return false } }