Spaces:
Sleeping
Sleeping
| 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() | |
| } | |
| }) | |