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

@@ -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
};
}