From bcd4baff3d69533aaa67378756eaa7a25ce4b5b2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 22 Mar 2026 19:11:08 +0200 Subject: [PATCH] feat(standalone): basic WS functionality --- .../src/lightweight/messaging_provider.ts | 32 ++- .../src/local-server-worker.ts | 3 +- apps/server/src/services/ws.ts | 256 +----------------- .../src/services/ws_messaging_provider.ts | 106 ++++++++ apps/server/src/www.ts | 25 +- packages/trilium-core/src/index.ts | 1 + .../src/services/messaging/types.ts | 14 +- packages/trilium-core/src/services/ws.ts | 224 +++++++++++++-- 8 files changed, 376 insertions(+), 285 deletions(-) create mode 100644 apps/server/src/services/ws_messaging_provider.ts diff --git a/apps/client-standalone/src/lightweight/messaging_provider.ts b/apps/client-standalone/src/lightweight/messaging_provider.ts index 07f233c1d0..8cfc5b3c4a 100644 --- a/apps/client-standalone/src/lightweight/messaging_provider.ts +++ b/apps/client-standalone/src/lightweight/messaging_provider.ts @@ -1,5 +1,5 @@ import type { WebSocketMessage } from "@triliumnext/commons"; -import type { MessagingProvider, MessageHandler } from "@triliumnext/core"; +import type { ClientMessageHandler, MessageHandler,MessagingProvider } from "@triliumnext/core"; /** * Messaging provider for browser Worker environments. @@ -14,6 +14,7 @@ import type { MessagingProvider, MessageHandler } from "@triliumnext/core"; */ export default class WorkerMessagingProvider implements MessagingProvider { private messageHandlers: MessageHandler[] = []; + private clientMessageHandler?: ClientMessageHandler; private isDisposed = false; constructor() { @@ -27,6 +28,15 @@ export default class WorkerMessagingProvider implements MessagingProvider { const { type, message } = event.data || {}; if (type === "WS_MESSAGE" && message) { + // Dispatch to the client message handler (used by ws.ts for log-error, log-info, ping) + if (this.clientMessageHandler) { + try { + this.clientMessageHandler("main-thread", message); + } catch (e) { + console.error("[WorkerMessagingProvider] Error in client message handler:", e); + } + } + // Dispatch to all registered handlers for (const handler of this.messageHandlers) { try { @@ -58,6 +68,26 @@ export default class WorkerMessagingProvider implements MessagingProvider { } } + /** + * Send a message to a specific client. + * In worker context, there's only one client (the main thread), so clientId is ignored. + */ + sendMessageToClient(_clientId: string, message: WebSocketMessage): boolean { + if (this.isDisposed) { + return false; + } + + this.sendMessageToAllClients(message); + return true; + } + + /** + * Register a handler for incoming client messages. + */ + setClientMessageHandler(handler: ClientMessageHandler): void { + this.clientMessageHandler = handler; + } + /** * Subscribe to incoming messages from the main thread. */ diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 98964f6dd3..a98cad3293 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -157,13 +157,14 @@ async function initialize(): Promise { provider: sqlProvider!, isReadOnly: false, onTransactionCommit: () => { - // No-op for now + coreModule?.ws.sendTransactionEntityChangesToAllClients(); }, onTransactionRollback: () => { // No-op for now } } }); + coreModule.ws.init(); console.log("[Worker] Supported routes", Object.keys(coreModule.routes)); diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index 0f1d214dd4..690cf3a3d6 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -1,254 +1,2 @@ -import { type EntityChange,WebSocketMessage } from "@triliumnext/commons"; -import { AbstractBeccaEntity } from "@triliumnext/core"; -import type { IncomingMessage, Server as HttpServer } from "http"; -import { WebSocket,WebSocketServer } from "ws"; - -import becca from "../becca/becca.js"; -import cls from "./cls.js"; -import config from "./config.js"; -import log from "./log.js"; -import protectedSessionService from "./protected_session.js"; -import sql from "./sql.js"; -import syncMutexService from "./sync_mutex.js"; -import { isElectron, randomString } from "./utils.js"; - -let webSocketServer!: WebSocketServer; -let lastSyncedPush: number; - -type SessionParser = (req: IncomingMessage, params: {}, cb: () => void) => void; -function init(httpServer: HttpServer, sessionParser: SessionParser) { - webSocketServer = new WebSocketServer({ - verifyClient: (info, done) => { - sessionParser(info.req, {}, () => { - const allowed = isElectron || (info.req as any).session.loggedIn || (config.General && config.General.noAuthentication); - - if (!allowed) { - log.error("WebSocket connection not allowed because session is neither electron nor logged in."); - } - - done(allowed); - }); - }, - server: httpServer - }); - - webSocketServer.on("connection", (ws, req) => { - (ws as any).id = randomString(10); - - console.log(`websocket client connected`); - - ws.on("message", async (messageJson) => { - const message = JSON.parse(messageJson as any); - - if (message.type === "log-error") { - log.info(`JS Error: ${message.error}\r -Stack: ${message.stack}`); - } else if (message.type === "log-info") { - log.info(`JS Info: ${message.info}`); - } else if (message.type === "ping") { - await syncMutexService.doExclusively(() => sendPing(ws)); - } else { - log.error("Unrecognized message: "); - log.error(message); - } - }); - }); - - webSocketServer.on("error", (error) => { - // https://github.com/zadam/trilium/issues/3374#issuecomment-1341053765 - console.log(error); - }); -} - -function sendMessage(client: WebSocket, message: WebSocketMessage) { - const jsonStr = JSON.stringify(message); - - if (client.readyState === WebSocket.OPEN) { - client.send(jsonStr); - } -} - -function sendMessageToAllClients(message: WebSocketMessage) { - const jsonStr = JSON.stringify(message); - - if (webSocketServer) { - if (message.type !== "sync-failed" && message.type !== "api-log-messages") { - log.info(`Sending message to all clients: ${jsonStr}`); - } - - webSocketServer.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(jsonStr); - } - }); - } -} - -function fillInAdditionalProperties(entityChange: EntityChange) { - if (entityChange.isErased) { - return; - } - - // fill in some extra data needed by the frontend - // first try to use becca, which works for non-deleted entities - // only when that fails, try to load from the database - if (entityChange.entityName === "attributes") { - entityChange.entity = becca.getAttribute(entityChange.entityId); - - if (!entityChange.entity) { - entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM attributes WHERE attributeId = ?`, [entityChange.entityId]); - } - } else if (entityChange.entityName === "branches") { - entityChange.entity = becca.getBranch(entityChange.entityId); - - if (!entityChange.entity) { - entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM branches WHERE branchId = ?`, [entityChange.entityId]); - } - } else if (entityChange.entityName === "notes") { - entityChange.entity = becca.getNote(entityChange.entityId); - - if (!entityChange.entity) { - entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM notes WHERE noteId = ?`, [entityChange.entityId]); - - if (entityChange.entity?.isProtected) { - entityChange.entity.title = protectedSessionService.decryptString(entityChange.entity.title || ""); - } - } - } else if (entityChange.entityName === "revisions") { - entityChange.noteId = sql.getValue( - /*sql*/`SELECT noteId - FROM revisions - WHERE revisionId = ?`, - [entityChange.entityId] - ); - } else if (entityChange.entityName === "note_reordering") { - entityChange.positions = {}; - - const parentNote = becca.getNote(entityChange.entityId); - - if (parentNote) { - for (const childBranch of parentNote.getChildBranches()) { - if (childBranch?.branchId) { - entityChange.positions[childBranch.branchId] = childBranch.notePosition; - } - } - } - } else if (entityChange.entityName === "options") { - entityChange.entity = becca.getOption(entityChange.entityId); - - if (!entityChange.entity) { - entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM options WHERE name = ?`, [entityChange.entityId]); - } - } else if (entityChange.entityName === "attachments") { - entityChange.entity = becca.getAttachment(entityChange.entityId); - - if (!entityChange.entity) { - entityChange.entity = sql.getRow( - /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength - FROM attachments - JOIN blobs USING (blobId) - WHERE attachmentId = ?`, - [entityChange.entityId] - ); - - if (entityChange.entity?.isProtected) { - entityChange.entity.title = protectedSessionService.decryptString(entityChange.entity.title || ""); - } - } - } - - if (entityChange.entity instanceof AbstractBeccaEntity) { - entityChange.entity = entityChange.entity.getPojo(); - } -} - -// entities with higher number can reference the entities with lower number -const ORDERING: Record = { - etapi_tokens: 0, - attributes: 2, - branches: 2, - blobs: 0, - note_reordering: 2, - revisions: 2, - attachments: 3, - notes: 1, - options: 0, -}; - -function sendPing(client: WebSocket, entityChangeIds = []) { - if (entityChangeIds.length === 0) { - sendMessage(client, { type: "ping" }); - - return; - } - - const entityChanges = sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE id IN (???)`, entityChangeIds); - if (!entityChanges) { - return; - } - - // sort entity changes since froca expects "referential order", i.e. referenced entities should already exist - // in froca. - // Froca needs this since it is an incomplete copy, it can't create "skeletons" like becca. - entityChanges.sort((a, b) => ORDERING[a.entityName] - ORDERING[b.entityName]); - - for (const entityChange of entityChanges) { - try { - fillInAdditionalProperties(entityChange); - } catch (e: any) { - log.error(`Could not fill additional properties for entity change ${JSON.stringify(entityChange)} because of error: ${e.message}: ${e.stack}`); - } - } - - sendMessage(client, { - type: "frontend-update", - data: { - lastSyncedPush, - entityChanges - } - }); -} - -function sendTransactionEntityChangesToAllClients() { - if (webSocketServer) { - const entityChangeIds = cls.getAndClearEntityChangeIds(); - - webSocketServer.clients.forEach((client) => sendPing(client, entityChangeIds)); - } -} - -function syncPullInProgress() { - sendMessageToAllClients({ type: "sync-pull-in-progress", lastSyncedPush }); -} - -function syncPushInProgress() { - sendMessageToAllClients({ type: "sync-push-in-progress", lastSyncedPush }); -} - -function syncFinished() { - sendMessageToAllClients({ type: "sync-finished", lastSyncedPush }); -} - -function syncFailed() { - sendMessageToAllClients({ type: "sync-failed", lastSyncedPush }); -} - -function reloadFrontend(reason: string) { - sendMessageToAllClients({ type: "reload-frontend", reason }); -} - -function setLastSyncedPush(entityChangeId: number) { - lastSyncedPush = entityChangeId; -} - -export default { - init, - sendMessageToAllClients, - syncPushInProgress, - syncPullInProgress, - syncFinished, - syncFailed, - sendTransactionEntityChangesToAllClients, - setLastSyncedPush, - reloadFrontend -}; +import { ws } from "@triliumnext/core"; +export default ws; diff --git a/apps/server/src/services/ws_messaging_provider.ts b/apps/server/src/services/ws_messaging_provider.ts new file mode 100644 index 0000000000..545bc749e2 --- /dev/null +++ b/apps/server/src/services/ws_messaging_provider.ts @@ -0,0 +1,106 @@ +import type { WebSocketMessage } from "@triliumnext/commons"; +import type { ClientMessageHandler, MessagingProvider } from "@triliumnext/core"; +import type { IncomingMessage, Server as HttpServer } from "http"; +import { WebSocket, WebSocketServer } from "ws"; + +import config from "./config.js"; +import log from "./log.js"; +import { isElectron, randomString } from "./utils.js"; + +type SessionParser = (req: IncomingMessage, params: {}, cb: () => void) => void; + +/** + * WebSocket-based implementation of MessagingProvider. + * + * Handles the raw WebSocket transport: server setup, connection management, + * message serialization, and client tracking. + */ +export default class WebSocketMessagingProvider implements MessagingProvider { + private webSocketServer: WebSocketServer; + private clientMap = new Map(); + private clientMessageHandler?: ClientMessageHandler; + + constructor(httpServer: HttpServer, sessionParser: SessionParser) { + this.webSocketServer = new WebSocketServer({ + verifyClient: (info, done) => { + sessionParser(info.req, {}, () => { + const allowed = isElectron || (info.req as any).session.loggedIn || (config.General && config.General.noAuthentication); + + if (!allowed) { + log.error("WebSocket connection not allowed because session is neither electron nor logged in."); + } + + done(allowed); + }); + }, + server: httpServer + }); + + this.webSocketServer.on("connection", (ws, req) => { + const id = randomString(10); + (ws as any).id = id; + this.clientMap.set(id, ws); + + console.log(`websocket client connected`); + + ws.on("message", async (messageJson) => { + const message = JSON.parse(messageJson as any); + + if (this.clientMessageHandler) { + await this.clientMessageHandler(id, message); + } + }); + + ws.on("close", () => { + this.clientMap.delete(id); + }); + }); + + this.webSocketServer.on("error", (error) => { + // https://github.com/zadam/trilium/issues/3374#issuecomment-1341053765 + console.log(error); + }); + } + + /** + * Register a handler for incoming client messages. + */ + setClientMessageHandler(handler: ClientMessageHandler) { + this.clientMessageHandler = handler; + } + + sendMessageToAllClients(message: WebSocketMessage): void { + const jsonStr = JSON.stringify(message); + + if (this.webSocketServer) { + if (message.type !== "sync-failed" && message.type !== "api-log-messages") { + log.info(`Sending message to all clients: ${jsonStr}`); + } + + this.webSocketServer.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(jsonStr); + } + }); + } + } + + sendMessageToClient(clientId: string, message: WebSocketMessage): boolean { + const client = this.clientMap.get(clientId); + if (!client || client.readyState !== WebSocket.OPEN) { + return false; + } + + client.send(JSON.stringify(message)); + return true; + } + + getClientCount(): number { + return this.webSocketServer?.clients?.size ?? 0; + } + + dispose(): void { + this.webSocketServer?.close(); + this.clientMap.clear(); + } +} diff --git a/apps/server/src/www.ts b/apps/server/src/www.ts index 130d3a380a..3860444cdb 100644 --- a/apps/server/src/www.ts +++ b/apps/server/src/www.ts @@ -1,17 +1,19 @@ +import type { Express } from "express"; import fs from "fs"; import http from "http"; import https from "https"; import tmp from "tmp"; -import config from "./services/config.js"; -import log from "./services/log.js"; -import appInfo from "./services/app_info.js"; -import ws from "./services/ws.js"; -import utils, { formatSize, formatUtcTime } from "./services/utils.js"; -import port from "./services/port.js"; -import host from "./services/host.js"; + import buildApp from "./app.js"; -import type { Express } from "express"; +import appInfo from "./services/app_info.js"; +import config from "./services/config.js"; +import host from "./services/host.js"; +import log from "./services/log.js"; +import port from "./services/port.js"; import { getDbSize } from "./services/sql_init.js"; +import utils, { formatSize, formatUtcTime } from "./services/utils.js"; +import ws from "./services/ws.js"; +import WebSocketMessagingProvider from "./services/ws_messaging_provider.js"; const MINIMUM_NODE_VERSION = "20.0.0"; @@ -58,7 +60,8 @@ export default async function startTriliumServer() { const httpServer = startHttpServer(app); const sessionParser = (await import("./routes/session_parser.js")).default; - ws.init(httpServer, sessionParser as any); // TODO: Not sure why session parser is incompatible. + const messagingProvider = new WebSocketMessagingProvider(httpServer, sessionParser); + ws.init(messagingProvider); // TODO: Not sure why session parser is incompatible. if (utils.isElectron) { const electronRouting = await import("./routes/electron.js"); @@ -67,8 +70,8 @@ export default async function startTriliumServer() { } async function displayStartupMessage() { - log.info("\n" + LOGO.replace("[version]", appInfo.appVersion)); - log.info(`📦 Versions: app=${appInfo.appVersion} db=${appInfo.dbVersion} sync=${appInfo.syncVersion} clipper=${appInfo.clipperProtocolVersion}`) + log.info(`\n${LOGO.replace("[version]", appInfo.appVersion)}`); + log.info(`📦 Versions: app=${appInfo.appVersion} db=${appInfo.dbVersion} sync=${appInfo.syncVersion} clipper=${appInfo.clipperProtocolVersion}`); log.info(`🔧 Build: ${formatUtcTime(appInfo.buildDate)} (${appInfo.buildRevision.substring(0, 10)})`); log.info(`📂 Data dir: ${appInfo.dataDirectory}`); log.info(`⏰ UTC time: ${formatUtcTime(appInfo.utcDateTime)}`); diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index dc836c372a..e42bba571f 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -73,6 +73,7 @@ export { default as note_service } from "./services/notes"; export type { NoteParams } from "./services/notes"; export * as sanitize from "./services/sanitizer"; export * as routes from "./routes"; +export { default as ws } from "./services/ws"; export async function initializeCore({ dbConfig, executionContext, crypto, translations, messaging, extraAppInfo }: { dbConfig: SqlServiceParams, diff --git a/packages/trilium-core/src/services/messaging/types.ts b/packages/trilium-core/src/services/messaging/types.ts index 655df6736a..ab36020263 100644 --- a/packages/trilium-core/src/services/messaging/types.ts +++ b/packages/trilium-core/src/services/messaging/types.ts @@ -25,6 +25,13 @@ export interface MessageClient { * - Worker postMessage for browser environments * - Mock implementations for testing */ +/** + * Handler for incoming client messages. Receives the client ID and the raw parsed message. + * The message is typed as `any` because clients may send message types (e.g. "log-error", + * "log-info") that aren't part of the server's WebSocketMessage union. + */ +export type ClientMessageHandler = (clientId: string, message: any) => void | Promise; + export interface MessagingProvider { /** * Send a message to all connected clients. @@ -36,7 +43,12 @@ export interface MessagingProvider { * Send a message to a specific client by ID. * Returns false if the client is not found or disconnected. */ - sendMessageToClient?(clientId: string, message: WebSocketMessage): boolean; + sendMessageToClient(clientId: string, message: WebSocketMessage): boolean; + + /** + * Register a handler for incoming client messages. + */ + setClientMessageHandler(handler: ClientMessageHandler): void; /** * Subscribe to incoming messages from clients. diff --git a/packages/trilium-core/src/services/ws.ts b/packages/trilium-core/src/services/ws.ts index 01e2efb6d9..b28b1d4048 100644 --- a/packages/trilium-core/src/services/ws.ts +++ b/packages/trilium-core/src/services/ws.ts @@ -1,20 +1,210 @@ -import type { WebSocketMessage } from "@triliumnext/commons"; -import { sendMessageToAllClients as sendMessage } from "./messaging/index.js"; +import { type EntityChange, WebSocketMessage } from "@triliumnext/commons"; -/** - * WebSocket service abstraction for core. - * - * This module provides a simple interface for sending messages to clients. - * The actual transport mechanism is provided by the messaging provider - * configured during initialization. - * - * @deprecated Use the messaging module directly instead. - */ -export default { - /** - * Send a message to all connected clients. - */ - sendMessageToAllClients(message: WebSocketMessage) { - sendMessage(message); +import becca from "../becca/becca.js"; +import * as cls from "./context.js"; +import { getLog } from "./log.js"; +import protectedSessionService from "./protected_session.js"; +import syncMutexService from "./sync_mutex.js"; +import { getSql } from "./sql/index.js"; +import { getMessagingProvider, MessagingProvider } from "./messaging/index.js"; +import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; + +let messagingProvider!: MessagingProvider; +let lastSyncedPush: number; + +function init() { + messagingProvider = getMessagingProvider(); + + messagingProvider.setClientMessageHandler(async (clientId, message: any) => { + const log = getLog(); + if (message.type === "log-error") { + log.info(`JS Error: ${message.error}\r\nStack: ${message.stack}`); + } else if (message.type === "log-info") { + log.info(`JS Info: ${message.info}`); + } else if (message.type === "ping") { + await syncMutexService.doExclusively(() => { + messagingProvider.sendMessageToClient(clientId, { type: "ping" }); + }); + } else { + log.error("Unrecognized message: "); + log.error(message); + } + }); +} + +function sendMessageToAllClients(message: WebSocketMessage) { + if (messagingProvider) { + messagingProvider.sendMessageToAllClients(message); } } + +function fillInAdditionalProperties(entityChange: EntityChange) { + if (entityChange.isErased) { + return; + } + + // fill in some extra data needed by the frontend + // first try to use becca, which works for non-deleted entities + // only when that fails, try to load from the database + const sql = getSql(); + if (entityChange.entityName === "attributes") { + entityChange.entity = becca.getAttribute(entityChange.entityId); + + if (!entityChange.entity) { + entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM attributes WHERE attributeId = ?`, [entityChange.entityId]); + } + } else if (entityChange.entityName === "branches") { + entityChange.entity = becca.getBranch(entityChange.entityId); + + if (!entityChange.entity) { + entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM branches WHERE branchId = ?`, [entityChange.entityId]); + } + } else if (entityChange.entityName === "notes") { + entityChange.entity = becca.getNote(entityChange.entityId); + + if (!entityChange.entity) { + entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM notes WHERE noteId = ?`, [entityChange.entityId]); + + if (entityChange.entity?.isProtected) { + entityChange.entity.title = protectedSessionService.decryptString(entityChange.entity.title || ""); + } + } + } else if (entityChange.entityName === "revisions") { + entityChange.noteId = sql.getValue( + /*sql*/`SELECT noteId + FROM revisions + WHERE revisionId = ?`, + [entityChange.entityId] + ); + } else if (entityChange.entityName === "note_reordering") { + entityChange.positions = {}; + + const parentNote = becca.getNote(entityChange.entityId); + + if (parentNote) { + for (const childBranch of parentNote.getChildBranches()) { + if (childBranch?.branchId) { + entityChange.positions[childBranch.branchId] = childBranch.notePosition; + } + } + } + } else if (entityChange.entityName === "options") { + entityChange.entity = becca.getOption(entityChange.entityId); + + if (!entityChange.entity) { + entityChange.entity = sql.getRow(/*sql*/`SELECT * FROM options WHERE name = ?`, [entityChange.entityId]); + } + } else if (entityChange.entityName === "attachments") { + entityChange.entity = becca.getAttachment(entityChange.entityId); + + if (!entityChange.entity) { + entityChange.entity = sql.getRow( + /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + FROM attachments + JOIN blobs USING (blobId) + WHERE attachmentId = ?`, + [entityChange.entityId] + ); + + if (entityChange.entity?.isProtected) { + entityChange.entity.title = protectedSessionService.decryptString(entityChange.entity.title || ""); + } + } + } + + if (entityChange.entity instanceof AbstractBeccaEntity) { + entityChange.entity = entityChange.entity.getPojo(); + } +} + +// entities with higher number can reference the entities with lower number +const ORDERING: Record = { + etapi_tokens: 0, + attributes: 2, + branches: 2, + blobs: 0, + note_reordering: 2, + revisions: 2, + attachments: 3, + notes: 1, + options: 0, +}; + +function buildFrontendUpdateMessage(entityChangeIds: number[]): WebSocketMessage | null { + if (entityChangeIds.length === 0) { + return { type: "ping" }; + } + + const entityChanges = getSql().getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE id IN (???)`, entityChangeIds); + if (!entityChanges) { + return null; + } + + // sort entity changes since froca expects "referential order", i.e. referenced entities should already exist + // in froca. + // Froca needs this since it is an incomplete copy, it can't create "skeletons" like becca. + entityChanges.sort((a, b) => ORDERING[a.entityName] - ORDERING[b.entityName]); + + for (const entityChange of entityChanges) { + try { + fillInAdditionalProperties(entityChange); + } catch (e: any) { + getLog().error(`Could not fill additional properties for entity change ${JSON.stringify(entityChange)} because of error: ${e.message}: ${e.stack}`); + } + } + + return { + type: "frontend-update", + data: { + lastSyncedPush, + entityChanges + } + }; +} + +function sendTransactionEntityChangesToAllClients() { + if (messagingProvider) { + const entityChangeIds = cls.getAndClearEntityChangeIds(); + const message = buildFrontendUpdateMessage(entityChangeIds); + + if (message) { + messagingProvider.sendMessageToAllClients(message); + } + } +} + +function syncPullInProgress() { + sendMessageToAllClients({ type: "sync-pull-in-progress", lastSyncedPush }); +} + +function syncPushInProgress() { + sendMessageToAllClients({ type: "sync-push-in-progress", lastSyncedPush }); +} + +function syncFinished() { + sendMessageToAllClients({ type: "sync-finished", lastSyncedPush }); +} + +function syncFailed() { + sendMessageToAllClients({ type: "sync-failed", lastSyncedPush }); +} + +function reloadFrontend(reason: string) { + sendMessageToAllClients({ type: "reload-frontend", reason }); +} + +function setLastSyncedPush(entityChangeId: number) { + lastSyncedPush = entityChangeId; +} + +export default { + init, + sendMessageToAllClients, + syncPushInProgress, + syncPullInProgress, + syncFinished, + syncFailed, + sendTransactionEntityChangesToAllClients, + setLastSyncedPush, + reloadFrontend +};