You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-03 23:50:33 +02:00
240 lines
6.2 KiB
TypeScript
240 lines
6.2 KiB
TypeScript
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
import fetch from 'node-fetch';
|
|
import { Server } from 'http';
|
|
import Logger from './Logger';
|
|
import { pathExists } from 'fs-extra';
|
|
import { readFile, writeFile } from 'fs/promises';
|
|
import { getSecureRandomString } from './crypto';
|
|
|
|
const tcpPortUsed = require('tcp-port-used');
|
|
const maxPorts = 10;
|
|
|
|
const findAvailablePort = async (startPort: number) => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const port = startPort + i;
|
|
const inUse = await tcpPortUsed.check(port, 'localhost');
|
|
if (!inUse) return port;
|
|
}
|
|
|
|
throw new Error(`All potential ports are in use or not available. Starting from port: ${startPort}`);
|
|
};
|
|
|
|
const findListenerPorts = async (startPort: number) => {
|
|
const output: number[] = [];
|
|
for (let i = 0; i < maxPorts; i++) {
|
|
const port = startPort + i;
|
|
const inUse = await tcpPortUsed.check(port, 'localhost');
|
|
if (inUse) output.push(port);
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
const parseJson = (req: IncomingMessage): Promise<unknown> => {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', chunk => {
|
|
body += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
interface HttpError extends Error {
|
|
httpCode: number;
|
|
}
|
|
|
|
export interface Message {
|
|
action: string;
|
|
data: object|number|string|null;
|
|
sourcePort?: number;
|
|
secretKey?: string;
|
|
}
|
|
|
|
type Response = string|number|object|boolean;
|
|
|
|
export const newHttpError = (httpCode: number, message = '') => {
|
|
const error = (new Error(message) as HttpError);
|
|
error.httpCode = httpCode;
|
|
return error;
|
|
};
|
|
|
|
export type IpcMessageHandler = (message: Message)=> Promise<Response|void>;
|
|
|
|
export interface IpcServer {
|
|
port: number;
|
|
httpServer: Server;
|
|
secretKey: string;
|
|
}
|
|
|
|
interface StartServerOptions {
|
|
logger?: Logger;
|
|
}
|
|
|
|
const getSecretKey = async (filePath: string) => {
|
|
try {
|
|
const keyLength = 64;
|
|
|
|
const writeKeyToFile = async () => {
|
|
const key = getSecureRandomString(keyLength);
|
|
await writeFile(filePath, key, 'utf-8');
|
|
return key;
|
|
};
|
|
|
|
if (!(await pathExists(filePath))) {
|
|
return await writeKeyToFile();
|
|
}
|
|
|
|
const key = await readFile(filePath, 'utf-8');
|
|
if (key.length !== keyLength) return await writeKeyToFile();
|
|
|
|
return key;
|
|
} catch (error) {
|
|
const e = error as NodeJS.ErrnoException;
|
|
e.message = `Could not get secret key from file: ${filePath}`;
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
// `secretKeyFilePath` must be the same for all the instances that can communicate with each others
|
|
export const startServer = async (startPort: number, secretKeyFilePath: string, messageHandler: IpcMessageHandler, options: StartServerOptions|null = null): Promise<IpcServer> => {
|
|
const logger = options && options.logger ? options.logger : new Logger();
|
|
|
|
let port: number;
|
|
try {
|
|
port = await findAvailablePort(startPort);
|
|
} catch (error) {
|
|
logger.error(`Could not find available - using default: ${startPort}`, error);
|
|
port = startPort;
|
|
}
|
|
|
|
const secretKey = await getSecretKey(secretKeyFilePath);
|
|
|
|
return new Promise<IpcServer>((resolve, reject) => {
|
|
let promiseFulfilled = false;
|
|
|
|
try {
|
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
let message: Message|null = null;
|
|
try {
|
|
message = await parseJson(req) as Message;
|
|
if (message.secretKey !== secretKey) throw newHttpError(401, 'Invalid secret key');
|
|
if (!message.action) throw newHttpError(400, 'Missing "action" property in message');
|
|
const response = await messageHandler(message);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(response));
|
|
} catch (error) {
|
|
const httpError = error as HttpError;
|
|
const httpCode = httpError.httpCode || 500;
|
|
logger.error('Could not response to request:', message, 'Error', httpCode, httpError.message);
|
|
res.writeHead(httpCode, { 'Content-Type': 'text/plain' });
|
|
res.end(`Error ${httpCode}: ${httpError.message}`);
|
|
}
|
|
});
|
|
|
|
server.on('error', error => {
|
|
logger.error('Server error:', error);
|
|
if (!promiseFulfilled) {
|
|
promiseFulfilled = true;
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
server.listen(port, 'localhost', () => {
|
|
if (!promiseFulfilled) {
|
|
promiseFulfilled = true;
|
|
resolve({
|
|
httpServer: server,
|
|
port,
|
|
secretKey,
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (!promiseFulfilled) {
|
|
promiseFulfilled = true;
|
|
reject(error);
|
|
} else {
|
|
logger.error('Server initialization error:', error);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
export const stopServer = async (server: IpcServer|null): Promise<void> => {
|
|
if (!server) return;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
server.httpServer.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
export interface SendMessageOutput {
|
|
port: number;
|
|
response: Response;
|
|
}
|
|
|
|
export interface SendMessageOptions {
|
|
logger?: Logger;
|
|
sendToSpecificPortOnly?: boolean;
|
|
}
|
|
|
|
export const sendMessage = async (startPort: number, message: Message, options: SendMessageOptions|null = null) => {
|
|
const logger = options && options.logger ? options.logger : new Logger();
|
|
|
|
const output: SendMessageOutput[] = [];
|
|
|
|
let ports: number[] = [];
|
|
try {
|
|
ports = await findListenerPorts(startPort);
|
|
} catch (error) {
|
|
logger.error(`Could not find listener ports - using default only: ${startPort}`, error);
|
|
ports.push(startPort);
|
|
}
|
|
|
|
const sendToSpecificPortOnly = !!options && !!options.sendToSpecificPortOnly;
|
|
|
|
for (const port of ports) {
|
|
if (sendToSpecificPortOnly && port !== startPort) continue;
|
|
if (message.sourcePort === port) continue;
|
|
|
|
try {
|
|
const response = await fetch(`http://localhost:${port}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(message),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// It means the server doesn't support this particular message - so just skip it
|
|
if (response.status === 404) continue;
|
|
const text = await response.text();
|
|
throw new Error(`Request failed: on port ${port}: ${text}`);
|
|
}
|
|
|
|
output.push({
|
|
port,
|
|
response: await response.json(),
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Could not send message on port ${port}:`, error);
|
|
}
|
|
}
|
|
|
|
return output;
|
|
};
|