-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
157 lines (146 loc) · 5.8 KB
/
Copy pathindex.ts
File metadata and controls
157 lines (146 loc) · 5.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import { chatRoutes } from './routes/chat.js'
import { bookRoutes } from './routes/books.js'
import { settingsRoutes } from './routes/settings.js'
import { profileRoutes } from './routes/profile.js'
import { taskRoutes } from './routes/tasks.js'
import { coverRoutes } from './routes/covers.js'
import { importRoutes } from './routes/import.js'
import { modelsRoutes } from './routes/models.js'
import { audiobookRoutes } from './routes/audiobook.js'
import { recoverFromCrash } from './services/book-store.js'
const ALLOWED_ORIGINS = [
'http://localhost:5173',
'http://localhost:3147',
]
function isAllowedOrigin(origin: string): boolean {
if (ALLOWED_ORIGINS.includes(origin)) return true
// Allow null origin (file:// protocol in Electron)
if (origin === 'null') return true
// Allow any localhost/127.0.0.1 origin (single-user app, server is localhost-only)
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true
return false
}
export async function startServer(port = 3147, host = '127.0.0.1') {
const fastify = Fastify({
logger: {
level: 'info',
redact: {
paths: ['req.body.apiKey'],
censor: '[REDACTED]',
},
serializers: {
req(request) {
const traceId = request.headers['x-trace-id']
return {
method: request.method,
url: request.url,
...(typeof traceId === 'string' && traceId ? { traceId } : {}),
}
},
},
},
})
// Manual CORS via onRequest hook — sets headers on reply.raw so they
// survive streaming routes that use reply.raw.writeHead().
// @fastify/cors uses reply.header() which only applies during reply.send(),
// so streaming routes that bypass send() would lose CORS headers.
fastify.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin
if (origin && !isAllowedOrigin(origin)) {
reply.status(403).send({ error: 'Not allowed by CORS' })
return
}
if (origin) {
reply.raw.setHeader('Access-Control-Allow-Origin', origin)
reply.raw.setHeader('Vary', 'Origin')
}
const traceId = request.headers['x-trace-id']
if (typeof traceId === 'string' && traceId) {
reply.raw.setHeader('X-Trace-Id', traceId)
}
if (request.method === 'OPTIONS') {
reply.raw.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Trace-Id')
reply.status(204).send()
return
}
})
// Mermaid renderer — Electron sets this to a BrowserWindow-based renderer.
// Falls back to kroki.io API for standalone/dev server mode.
// Returns PNG as <img> tags with file:// URLs (epub-gen-memory doesn't support data: URLs).
fastify.decorate('mermaidRenderer', (async (charts: string[]) => {
const { writeFile: writeFileAsync } = await import('node:fs/promises')
const { randomUUID } = await import('node:crypto')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { pathToFileURL } = await import('node:url')
const results: string[] = []
for (const chart of charts) {
try {
const res = await fetch('https://kroki.io/mermaid/png', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: chart,
signal: AbortSignal.timeout(30_000),
})
if (res.ok) {
const buf = Buffer.from(await res.arrayBuffer())
const tmpFile = join(tmpdir(), `tutor-mermaid-${randomUUID()}.png`)
await writeFileAsync(tmpFile, buf)
results.push(`<img src="${pathToFileURL(tmpFile).href}" alt="diagram" style="max-width:100%"/>`)
} else {
console.warn(`[mermaid-renderer] kroki.io returned ${res.status}: ${await res.text().catch(() => '')}`)
results.push('')
}
} catch (err) {
console.warn('[mermaid-renderer] kroki.io fallback failed:', err)
results.push('')
}
}
return results
}) as (charts: string[]) => Promise<string[]>)
await fastify.register(rateLimit, { global: false })
await fastify.register(chatRoutes)
await fastify.register(bookRoutes)
await fastify.register(settingsRoutes)
await fastify.register(profileRoutes)
await fastify.register(taskRoutes)
await fastify.register(coverRoutes)
await fastify.register(importRoutes)
await fastify.register(modelsRoutes)
await fastify.register(audiobookRoutes)
// Global error handler — clean 404 for ENOENT, no path leak
fastify.setErrorHandler((error: Error & { code?: string; statusCode?: number }, request, reply) => {
if (error.code === 'ENOENT') {
return reply.status(404).send({ error: 'Not found' })
}
const statusCode = error.statusCode ?? 500
if (statusCode >= 500) {
fastify.log.error({ err: error, req: { method: request.method, url: request.url } }, 'Unhandled server error')
return reply.status(500).send({ error: 'Internal server error' })
}
reply.status(statusCode).send({
error: error.message || 'Internal server error',
})
})
fastify.get('/api/health', async () => ({ status: 'ok' }))
const recovery = await recoverFromCrash()
if (recovery.booksReset.length > 0 || recovery.artifactsRemoved.length > 0) {
fastify.log.info(
{ booksReset: recovery.booksReset.length, artifactsRemoved: recovery.artifactsRemoved.length },
'Crash recovery completed',
)
}
await fastify.listen({ port, host })
return fastify
}
// Allow standalone usage: pnpm dev:server
const isDirectRun = process.argv[1] && (
process.argv[1].endsWith('/server/index.ts') ||
process.argv[1].endsWith('/server/index.js')
)
if (isDirectRun) {
startServer(3147, '127.0.0.1')
}