marp-share / server /src /index.ts
Nipun's picture
Multi-deck: unique presentation IDs + per-user list + sharing
2cd1b66 verified
Raw
History Blame Contribute Delete
4.02 kB
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()
}
})