288 lines
8.2 KiB
TypeScript
288 lines
8.2 KiB
TypeScript
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;
|
|
docusaurusEnabled: 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,
|
|
docusaurusEnabled: settings.docusaurusEnabled
|
|
};
|
|
}
|
|
|
|
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() ?? null;
|
|
|
|
if (match.requiresDatabase && (!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;
|
|
}
|