feat: plugins v1.7

This commit is contained in:
2026-04-29 15:24:56 +02:00
parent eabbc08896
commit d261bac0ed
45 changed files with 5621 additions and 867 deletions

View File

@@ -0,0 +1,276 @@
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { createReadStream } from 'fs';
import { AddressInfo } from 'net';
import { pipeline } from 'stream/promises';
import { getDataSource } from '../db/database';
import { LocalApiSettings, readDesktopSettings } from '../desktop-settings';
import { authenticate, matchRoute } from './router';
import { clearAllTokens } from './auth-store';
import {
HttpError,
RequestContext,
getBearerToken,
readJsonBody,
sendError,
sendJson,
sendText
} from './http-helpers';
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
export interface LocalApiSnapshot {
status: LocalApiStatus;
host: string | null;
port: number | null;
baseUrl: string | null;
error: string | null;
exposeOnLan: boolean;
scalarEnabled: boolean;
}
let server: Server | null = null;
let currentStatus: LocalApiStatus = 'stopped';
let currentBindHost: string | null = null;
let currentBindPort: number | null = null;
let currentError: string | null = null;
let activeSettings: LocalApiSettings | null = null;
function pickBindHost(settings: LocalApiSettings): string {
return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1';
}
function buildBaseUrl(host: string, port: number): string {
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
return `http://${safeHost}:${port}`;
}
async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise<void> {
if (!res.headersSent) {
res.statusCode = status;
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
}
await pipeline(createReadStream(filePath), res);
}
export function getLocalApiSnapshot(): LocalApiSnapshot {
const settings = activeSettings ?? readDesktopSettings().localApi;
return {
status: currentStatus,
host: currentBindHost,
port: currentBindPort,
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
error: currentError,
exposeOnLan: settings.exposeOnLan,
scalarEnabled: settings.scalarEnabled
};
}
async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise<void> {
// CORS for loopback origin only. Local-first; not a public API.
const origin = req.headers.origin;
const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null';
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
let urlObj: URL;
try {
urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
} catch {
sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' });
return;
}
const requestContext: RequestContext = {
method: (req.method ?? 'GET').toUpperCase(),
url: urlObj,
pathname: urlObj.pathname,
headers: req.headers,
remoteAddress: req.socket.remoteAddress ?? '',
bearerToken: getBearerToken(req.headers)
};
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
if (!match) {
if (methodNotAllowed) {
sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' });
} else {
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
}
return;
}
if (match.requiresAuth) {
const issued = authenticate(requestContext.bearerToken);
if (!issued) {
sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' });
return;
}
}
const dataSource = getDataSource();
if (!dataSource || !dataSource.isInitialized) {
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
return;
}
let bodyCache: unknown | undefined;
try {
const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port);
const result = await match.handler({
request: requestContext,
settings,
baseUrl,
dataSource,
bodyBuffer: async () => {
if (bodyCache === undefined) {
bodyCache = await readJsonBody<unknown>(req);
}
return bodyCache;
}
});
if (result.status === 204) {
res.statusCode = 204;
res.end();
return;
}
if (result.filePath) {
await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream');
return;
}
if (result.rawBody !== undefined) {
sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8');
return;
}
sendJson(res, result.status, result.body);
} catch (error) {
if (!(error instanceof HttpError)) {
console.error('[LocalApi] Request handler error:', error);
}
sendError(res, error);
}
}
export interface StartResult {
ok: boolean;
snapshot: LocalApiSnapshot;
}
export async function startLocalApiServer(settings: LocalApiSettings): Promise<StartResult> {
if (server) {
await stopLocalApiServer();
}
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
currentStatus = 'starting';
currentError = null;
currentBindHost = pickBindHost(settings);
currentBindPort = settings.port;
const httpServer = createServer((req, res) => {
void handleRequest(req, res, activeSettings!).catch((error) => {
console.error('[LocalApi] Unhandled request error:', error);
try {
sendError(res, error);
} catch {
// ignore
}
});
});
return await new Promise<StartResult>((resolve) => {
httpServer.once('error', (error) => {
currentStatus = 'error';
currentError = (error as Error).message;
currentBindPort = null;
server = null;
activeSettings = null;
console.error('[LocalApi] Failed to start:', error);
resolve({ ok: false, snapshot: getLocalApiSnapshot() });
});
httpServer.listen(settings.port, pickBindHost(settings), () => {
const address = httpServer.address() as AddressInfo | null;
server = httpServer;
currentStatus = 'running';
currentBindPort = address?.port ?? settings.port;
currentError = null;
console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`);
resolve({ ok: true, snapshot: getLocalApiSnapshot() });
});
});
}
export async function stopLocalApiServer(): Promise<LocalApiSnapshot> {
const httpServer = server;
if (!httpServer) {
currentStatus = 'stopped';
currentBindHost = null;
currentBindPort = null;
activeSettings = null;
return getLocalApiSnapshot();
}
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
// close() waits for connections; force-close keep-alives so it returns promptly.
httpServer.closeAllConnections?.();
});
server = null;
currentStatus = 'stopped';
currentBindHost = null;
currentBindPort = null;
currentError = null;
activeSettings = null;
clearAllTokens();
console.log('[LocalApi] Stopped');
return getLocalApiSnapshot();
}
export async function applyLocalApiSettings(): Promise<LocalApiSnapshot> {
const settings = readDesktopSettings().localApi;
if (!settings.enabled) {
return await stopLocalApiServer();
}
// If already running with the same bind config, no-op (settings like
// scalarEnabled / allowedSignalingServers are read on every request).
if (
server
&& activeSettings
&& currentStatus === 'running'
&& activeSettings.port === settings.port
&& activeSettings.exposeOnLan === settings.exposeOnLan
) {
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
return getLocalApiSnapshot();
}
const result = await startLocalApiServer(settings);
return result.snapshot;
}