feat: Add browser documentation

This commit is contained in:
2026-04-29 17:15:01 +02:00
parent d261bac0ed
commit 3d81c34159
29 changed files with 19981 additions and 40 deletions

View File

@@ -0,0 +1,104 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { HttpError } from './http-helpers';
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
function getDocsRootCandidates(): string[] {
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
const candidates: string[] = [];
if (processWithResources.resourcesPath) {
candidates.push(path.join(processWithResources.resourcesPath, 'docusaurus'));
}
candidates.push(path.join(process.cwd(), 'docs-site', 'build'));
return candidates;
}
async function getDocusaurusBuildRoot(): Promise<string | null> {
for (const candidate of getDocsRootCandidates()) {
try {
const stat = await fs.stat(candidate);
if (stat.isDirectory()) {
return candidate;
}
} catch {
// try next candidate
}
}
return null;
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function resolveAssetPath(root: string, pathname: string): string {
const withoutPrefix = pathname.replace(/^\/docusaurus\/?/u, '');
const decoded = decodeURIComponent(withoutPrefix);
const normalized = decoded.endsWith('/') || decoded === '' ? path.join(decoded, 'index.html') : decoded;
const absolutePath = path.resolve(root, normalized);
if (!isPathInside(root, absolutePath)) {
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
}
return absolutePath;
}
export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePath: string; contentType: string }> {
const root = await getDocusaurusBuildRoot();
if (!root) {
throw new HttpError(503, 'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.', 'DOCUSAURUS_BUILD_MISSING');
}
let filePath = resolveAssetPath(root, pathname);
try {
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
} catch {
const directoryIndexPath = path.join(filePath, 'index.html');
try {
await fs.access(directoryIndexPath);
filePath = directoryIndexPath;
} catch {
filePath = path.join(root, '404.html');
}
}
if (!isPathInside(root, filePath)) {
throw new HttpError(400, 'Invalid Docusaurus asset path', 'INVALID_DOCS_PATH');
}
const contentType = MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
return { filePath, contentType };
}

View File

@@ -1,4 +1,9 @@
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import {
createServer,
IncomingMessage,
Server,
ServerResponse
} from 'http';
import { createReadStream } from 'fs';
import { AddressInfo } from 'net';
import { pipeline } from 'stream/promises';
@@ -26,6 +31,7 @@ export interface LocalApiSnapshot {
error: string | null;
exposeOnLan: boolean;
scalarEnabled: boolean;
docusaurusEnabled: boolean;
}
let server: Server | null = null;
@@ -41,6 +47,7 @@ function pickBindHost(settings: LocalApiSettings): string {
function buildBaseUrl(host: string, port: number): string {
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
return `http://${safeHost}:${port}`;
}
@@ -64,7 +71,8 @@ export function getLocalApiSnapshot(): LocalApiSnapshot {
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
error: currentError,
exposeOnLan: settings.exposeOnLan,
scalarEnabled: settings.scalarEnabled
scalarEnabled: settings.scalarEnabled,
docusaurusEnabled: settings.docusaurusEnabled
};
}
@@ -101,7 +109,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
remoteAddress: req.socket.remoteAddress ?? '',
bearerToken: getBearerToken(req.headers)
};
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
if (!match) {
@@ -110,6 +117,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
} else {
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
}
return;
}
@@ -122,9 +130,9 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
}
}
const dataSource = getDataSource();
const dataSource = getDataSource() ?? null;
if (!dataSource || !dataSource.isInitialized) {
if (match.requiresDatabase && (!dataSource || !dataSource.isInitialized)) {
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
return;
}
@@ -142,6 +150,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
if (bodyCache === undefined) {
bodyCache = await readJsonBody<unknown>(req);
}
return bodyCache;
}
});
@@ -167,6 +176,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, settings
if (!(error instanceof HttpError)) {
console.error('[LocalApi] Request handler error:', error);
}
sendError(res, error);
}
}
@@ -190,6 +200,7 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
const httpServer = createServer((req, res) => {
void handleRequest(req, res, activeSettings!).catch((error) => {
console.error('[LocalApi] Unhandled request error:', error);
try {
sendError(res, error);
} catch {

View File

@@ -6,6 +6,7 @@ import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store
import { buildOpenApiDocument } from './openapi';
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
import { resolveDocusaurusRoute } from './docusaurus-static';
import { LocalApiSettings } from '../desktop-settings';
export interface RouteResponse {
@@ -20,7 +21,7 @@ export interface RouteContext {
request: RequestContext;
settings: LocalApiSettings;
baseUrl: string;
dataSource: DataSource;
dataSource: DataSource | null;
bodyBuffer: () => Promise<unknown>;
}
@@ -30,6 +31,7 @@ interface RouteMatch {
handler: RouteHandler;
params: Record<string, string>;
requiresAuth: boolean;
requiresDatabase: boolean;
}
interface RouteDefinition {
@@ -38,6 +40,7 @@ interface RouteDefinition {
paramKeys: string[];
handler: RouteHandler;
requiresAuth: boolean;
requiresDatabase: boolean;
}
function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } {
@@ -56,10 +59,10 @@ function compilePattern(template: string): { pattern: RegExp; paramKeys: string[
return { pattern: new RegExp(`^${source}$`), paramKeys };
}
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean): RouteDefinition {
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean, requiresDatabase = true): RouteDefinition {
const compiled = compilePattern(template);
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth };
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth, requiresDatabase };
}
function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
@@ -73,6 +76,14 @@ function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
return handler(query) as Promise<T>;
}
function requireDataSource(dataSource: DataSource | null): DataSource {
if (!dataSource) {
throw new HttpError(503, 'Database not initialised', 'DB_UNAVAILABLE');
}
return dataSource;
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const parsed = typeof value === 'string' ? Number(value) : NaN;
@@ -86,12 +97,12 @@ const ROUTES: RouteDefinition[] = [
defineRoute('GET', '/api/health', async (ctx): Promise<RouteResponse> => ({
status: 200,
body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan }
}), false),
}), false, false),
defineRoute('GET', '/api/openapi.json', async (ctx): Promise<RouteResponse> => ({
status: 200,
body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() })
}), false),
}), false, false),
defineRoute('GET', '/docs', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.scalarEnabled) {
@@ -109,7 +120,27 @@ const ROUTES: RouteDefinition[] = [
contentType: 'text/html; charset=utf-8',
rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`)
};
}, false),
}, false, false),
defineRoute('GET', '/docusaurus(?:/.*)?', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.docusaurusEnabled) {
return {
status: 404,
body: null,
contentType: 'text/plain; charset=utf-8',
rawBody: 'Docusaurus documentation is disabled. Open documentation from the desktop app to activate it.'
};
}
const docsRoute = await resolveDocusaurusRoute(ctx.request.pathname);
return {
status: 200,
body: null,
contentType: docsRoute.contentType,
filePath: docsRoute.filePath
};
}, false, false),
defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise<RouteResponse> => {
if (!ctx.settings.scalarEnabled) {
@@ -133,7 +164,7 @@ const ROUTES: RouteDefinition[] = [
contentType: 'application/javascript; charset=utf-8',
filePath: bundlePath
};
}, false),
}, false, false),
defineRoute('POST', '/api/auth/login', async (ctx): Promise<RouteResponse> => {
const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown };
@@ -213,7 +244,7 @@ const ROUTES: RouteDefinition[] = [
}, true),
defineRoute('GET', '/api/profile', async (ctx): Promise<RouteResponse> => {
const user = await runQuery<unknown>(ctx.dataSource, {
const user = await runQuery<unknown>(requireDataSource(ctx.dataSource), {
type: QueryType.GetCurrentUser,
payload: {}
});
@@ -226,7 +257,7 @@ const ROUTES: RouteDefinition[] = [
}, true),
defineRoute('GET', '/api/rooms', async (ctx): Promise<RouteResponse> => {
const rooms = await runQuery<unknown[]>(ctx.dataSource, {
const rooms = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetAllRooms,
payload: {}
});
@@ -243,7 +274,7 @@ const ROUTES: RouteDefinition[] = [
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
const messages = await runQuery<unknown[]>(ctx.dataSource, {
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
type: QueryType.GetMessages,
payload: { roomId: decodeURIComponent(roomId), limit, offset }
});
@@ -278,7 +309,7 @@ export function matchRoute(method: string, pathname: string): RoutingResult {
}
return {
match: { handler: route.handler, params, requiresAuth: route.requiresAuth },
match: { handler: route.handler, params, requiresAuth: route.requiresAuth, requiresDatabase: route.requiresDatabase },
methodNotAllowed: false
};
}