import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' import { WebSocketServer } from 'ws' import { createCollabServer } from './collab' import { providers, authRequired, registerAuth, getSessionUser } from './auth' import { exportDeck, MIME, type ExportFormat } from './export' // Single process, single port: serves the built SPA, a small REST API, and the // Yjs/Hocuspocus realtime layer (WebSocket upgrade on /collab). const { hocuspocus, storage } = createCollabServer() const app = new Hono() app.get('/api/health', (c) => c.json({ ok: true, service: 'marp-share', milestone: 'M5', storage: storage.kind, providers: providers.map((p) => p.id), authRequired, }), ) registerAuth(app) const sanitizeUserId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) interface DeckMeta { id: string title: string createdAt: number } // The signed-in user's own presentations. app.get('/api/decks/mine', async (c) => { const user = await getSessionUser(c) if (!user) return c.json({ decks: [] as DeckMeta[] }) const raw = await storage.readText(`users/${sanitizeUserId(user.id)}.json`) const decks = raw ? (JSON.parse(raw) as DeckMeta[]) : [] return c.json({ decks }) }) // Create a new presentation (sign-in required). Records ownership in the user's index; // the deck content itself is seeded on first open via the realtime layer. app.post('/api/decks', async (c) => { const user = await getSessionUser(c) if (!user) return c.json({ error: 'sign in required' }, 401) let body: { title?: unknown } = {} try { body = await c.req.json() } catch { /* allow empty body */ } const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 120) : 'Untitled presentation' const id = crypto.randomUUID().replace(/-/g, '').slice(0, 12) const key = `users/${sanitizeUserId(user.id)}.json` const raw = await storage.readText(key) const list = raw ? (JSON.parse(raw) as DeckMeta[]) : [] const meta: DeckMeta = { id, title, createdAt: Date.now() } list.unshift(meta) await storage.writeText(key, JSON.stringify(list, null, 2)) return c.json(meta) }) // Export the current deck markdown to PDF / PPTX / HTML via marp-cli. app.post('/api/export', async (c) => { let body: { markdown?: unknown; format?: unknown } try { body = await c.req.json() } catch { return c.json({ error: 'invalid JSON' }, 400) } const format = (['pdf', 'pptx', 'html'].includes(body.format as string) ? body.format : 'pdf') as ExportFormat const markdown = typeof body.markdown === 'string' ? body.markdown : '' if (!markdown.trim()) return c.json({ error: 'no markdown provided' }, 400) try { const buf = await exportDeck(markdown, format) return c.body(new Uint8Array(buf), 200, { 'Content-Type': MIME[format], 'Content-Disposition': `attachment; filename="deck.${format}"`, }) } catch (e) { console.error('[export] failed:', (e as Error).message) return c.json({ error: 'export failed', detail: (e as Error).message }, 500) } }) // Static client assets (paths are relative to the repo root, the process cwd). app.use('/*', serveStatic({ root: './client/dist' })) // SPA fallback for client-side routes. app.get('*', serveStatic({ path: './client/dist/index.html' })) const port = Number(process.env.PORT) || 7860 const wss = new WebSocketServer({ noServer: true }) const httpServer = serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }, (info) => { console.log(`marp-share listening on http://0.0.0.0:${info.port}`) }) // Route WebSocket upgrades on /collab into Hocuspocus; reject everything else. httpServer.on('upgrade', (request, socket, head) => { if ((request.url || '').startsWith('/collab')) { wss.handleUpgrade(request, socket, head, (ws) => { hocuspocus.handleConnection(ws, request) }) } else { socket.destroy() } })