diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 6bd69381d57..0d2e69c6e93 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -463,6 +463,9 @@ importers: '@rush-temp/huly-mail-resources': specifier: file:./projects/huly-mail-resources.tgz version: file:projects/huly-mail-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) + '@rush-temp/hulypulse-client': + specifier: file:./projects/hulypulse-client.tgz + version: file:projects/hulypulse-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/image-cropper': specifier: file:./projects/image-cropper.tgz version: file:projects/image-cropper.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) @@ -4678,7 +4681,7 @@ packages: version: 0.0.0 '@rush-temp/desktop@file:projects/desktop.tgz': - resolution: {integrity: sha512-mQn5uOzzloBnNW+COi9DK41N6OOvRyyDKMrBMbYnoyJK95eZjKRwdg3tsWs9LUHdy+lL2cDGjNfdq/N7XuFrhw==, tarball: file:projects/desktop.tgz} + resolution: {integrity: sha512-PXWgVzj8RCiUsgJ1VjZKZtPSz4oriYw9M8Op+z+pF53k9vsbfXVg+pY3h4kqHqEITKNOPZ67TJm50Axc+YCNYQ==, tarball: file:projects/desktop.tgz} version: 0.0.0 '@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz': @@ -4837,6 +4840,10 @@ packages: resolution: {integrity: sha512-gMpLPCEmi0tPlEjsE3OBYtpNVLu4Xx5BnegAamDxheXg78HQYfyZqzB6CEuOJXXTjoF0EgSHhN8wV03IW+51RQ==, tarball: file:projects/huly-mail.tgz} version: 0.0.0 + '@rush-temp/hulypulse-client@file:projects/hulypulse-client.tgz': + resolution: {integrity: sha512-5sJHgbSOB2n85z/ANFxNhqppxQO4N6CsaTyDDCZdwkqRQOSrw+lTj2ERBjwthY+QEXitsgaP3Or6Kcwnac/5Ow==, tarball: file:projects/hulypulse-client.tgz} + version: 0.0.0 + '@rush-temp/image-cropper-resources@file:projects/image-cropper-resources.tgz': resolution: {integrity: sha512-G2swqxbAhgQoP8dgRDz/FR4jPM8OJ8/YHgirUN4QkuCuuC1JHcuvhUDMPlFzxBf2kp2N80tPs4+BJmYjQMyV7Q==, tarball: file:projects/image-cropper-resources.tgz} version: 0.0.0 @@ -5530,7 +5537,7 @@ packages: version: 0.0.0 '@rush-temp/presence-resources@file:projects/presence-resources.tgz': - resolution: {integrity: sha512-ZuTyzvgesH2SHMqnwgwvC6X2gnGL29NnCXPGNkN5nYKW2JRj+gyMZd00+W5pXtBWhke4iL37QiyR+4oJ7AYVyw==, tarball: file:projects/presence-resources.tgz} + resolution: {integrity: sha512-Zx1AW6ssfHShMVY4/lKZHX/mD+QpkBbL4V33QdEBsp0vI0hqSrDm1QGZcDrgyF8qzTszZ5nYrOru12ALEKr9jg==, tarball: file:projects/presence-resources.tgz} version: 0.0.0 '@rush-temp/presence@file:projects/presence.tgz': @@ -21011,6 +21018,34 @@ snapshots: - supports-color - ts-node + '@rush-temp/hulypulse-client@file:projects/hulypulse-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))': + dependencies: + '@types/jest': 29.5.12 + '@types/node': 22.15.29 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) + cross-env: 7.0.3 + esbuild: 0.25.9 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)) + jest-fetch-mock: 3.0.3(encoding@0.1.13) + prettier: 3.2.5 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - encoding + - node-notifier + - supports-color + - ts-node + '@rush-temp/image-cropper-resources@file:projects/image-cropper-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.25.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.8.3))': dependencies: '@types/jest': 29.5.12 diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 69160302e99..1e1317f460d 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -285,6 +285,7 @@ services: - EXCLUDED_APPLICATIONS_FOR_ANONYMOUS=["chunter", "notification"] # - DISABLE_SIGNUP=true - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + - PULSE_URL=ws://huly.local:8099/ws restart: unless-stopped transactor_cockroach: image: hardcoreeng/transactor @@ -552,10 +553,10 @@ services: redis: condition: service_started ports: - - 8095:8095 + - 8099:8099 environment: - HULY_REDIS_URLS=redis://redis:6379 - - HULY_BIND_PORT=8095 + - HULY_BIND_PORT=8099 restart: unless-stopped process-service: diff --git a/dev/prod/public/config-dev.json b/dev/prod/public/config-dev.json index 9fea18e104d..d8e00391634 100644 --- a/dev/prod/public/config-dev.json +++ b/dev/prod/public/config-dev.json @@ -12,6 +12,7 @@ "PUBLIC_SCHEDULE_URL": "https://schedule.hc.engineering", "CALDAV_SERVER_URL": "https://caldav.hc.engineering", "BACKUP_URL": "https://front.hc.engineering/api/backup", + "PULSE_URL": "wss://pulse.hc.engineering/ws", "COMMUNICATION_API_ENABLED": "true", "FILES_URL": "https://datalake.hc.engineering/blob/:workspace/:blobId/:filename" } \ No newline at end of file diff --git a/dev/prod/public/config.json b/dev/prod/public/config.json index e07dbab80fa..eb8b80f1be5 100644 --- a/dev/prod/public/config.json +++ b/dev/prod/public/config.json @@ -27,5 +27,6 @@ "EXPORT_URL": "http://huly.local:4009", "COMMUNICATION_API_ENABLED": "true", "BACKUP_URL": "http://huly.local:4039/api/backup", + "PULSE_URL": "ws://huly.local:8099/ws", "EXCLUDED_APPLICATIONS_FOR_ANONYMOUS": "[\"chunter\", \"notification\"]" } diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 71075124f02..e88cd1471dd 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -202,7 +202,8 @@ export interface Config { MAIL_URL?: string, COMMUNICATION_API_ENABLED?: string BILLING_URL?: string, - EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string + EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string, + PULSE_URL?: string } export interface Branding { @@ -492,6 +493,8 @@ export async function configurePlatform() { setMetadata(billingPlugin.metadata.BillingURL, config.BILLING_URL ?? '') + setMetadata(presentation.metadata.PulseUrl, config.PULSE_URL ?? '') + const languages = myBranding.languages ? (myBranding.languages as string).split(',').map((l) => l.trim()) : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja'] diff --git a/packages/hulypulse-client/.eslintrc.js b/packages/hulypulse-client/.eslintrc.js new file mode 100644 index 00000000000..72235dc2833 --- /dev/null +++ b/packages/hulypulse-client/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/hulypulse-client/README.md b/packages/hulypulse-client/README.md new file mode 100644 index 00000000000..fa12f002810 --- /dev/null +++ b/packages/hulypulse-client/README.md @@ -0,0 +1,152 @@ +# HulypulseClient + +A TypeScript/Node.js client for the Hulypulse WebSocket server. +Supports automatic reconnection, request–response correlation, `get` / `put` / `delete`, and subscriptions. + +--- + +### Main Methods + +## put(key: string, data: string, TTL?: number): Promise + +Stores a value under a key. + + TTL (optional) — time-to-live in seconds. + + Resolves with true if the operation succeeded. + +await client.put("workspace/users/123", "Alice", 60) → true + +## get(key: string): Promise + +Retrieves the value for a key. + + Resolves with the value if found. + Resolves with false if the key does not exist. + +const value = await client.get("workspace/users/123") +if (value) { + console.log("User data:", value) +} else { + console.log("User not found") +} + +## get_full(key: string): Promise<{data, etag, expires_at} | false> + +Retrieves the full record: + + data — stored value, + etag — data identifier, + expires_at — expiration in seconds. + +const full = await client.get_full("workspace/users/123") +if (full) { + console.log(full.data, full.etag, full.expires_at) +} + +## delete(key: string): Promise + +Deletes a key. + + Resolves with true if the key was deleted. + Resolves with false if the key was not found. + +const deleted = await client.delete("workspace/users/123") +console.log(deleted ? "Deleted" : "Not found") + +## subscribe(key: string, callback: (msg, key, index) => void): Promise + +Subscribes to updates for a key (or prefix). + + The callback is invoked on every event: Set, Del, Expired + + Resolves with true if a new subscription was created. + Resolves with false if the callback was already subscribed. + +const cb = (msg, key, index) => { + if( msg.message === 'Expired' ) console.log(`${msg.key} was expired`) +} + +await client.subscribe("workspace/users/", cb) +// Now cb will be called when any key starting with "workspace/users/" changes + +## unsubscribe(key: string, callback: Callback): Promise + +Unsubscribes a specific callback. + + Resolves with true if the callback was removed (and if it was the last one, the server gets an unsub message). + Resolves with false if the callback was not found. + +await client.unsubscribe("workspace/users/", cb) + +## send(message: any): Promise + +Low-level method to send a raw message. + + Automatically attaches a correlation id. + Resolves when a response with the same correlation is received. + +const reply = await client.send({ type: "get", key: "workspace/users/123" }) +console.log("Raw reply:", reply) + +## Reconnection + + If the connection drops, the client automatically reconnects. + All active subscriptions are re-sent to the server after reconnect. + +## Closing + +The client supports both manual closing and the new using syntax (TypeScript 5.2+). + +client[Symbol.dispose]() // closes the connection + +or, if needed internally: + +(client as any).close() + +--- + +## Usage Example + +```ts +import { HulypulseClient } from "./hulypulse_client.js" + +async function main() { + // connect + const client = await HulypulseClient.connect("wss://hulypulse_mem.lleo.me/ws") + + // subscribe to updates + const cb = (msg, key, index) => { + console.log("Update for", key, ":", msg) + } + await client.subscribe("workspace/users/", cb) + + // put value + await client.put("workspace/users/123", JSON.stringify({ name: "Alice" }), 5) + + // get value + const value = await client.get("workspace/users/123") + console.log("Fetched:", value) + + // get full record + const full = await client.get_full("workspace/users/123") + if (full) { + console.log(full.data, full.etag, full.expires_at) + } + + // delete key + const deleted = await client.delete("workspace/users/123") + console.log(deleted ? "Deleted" : "Not found") + + // unsubscribe + await client.unsubscribe("workspace/users/", cb) + + // low-level send + const reply = await client.send({ type: "sublist" }) + console.log("My sublists:", reply) + + // dispose + client[Symbol.dispose]() +} + +main() diff --git a/packages/hulypulse-client/config/rig.json b/packages/hulypulse-client/config/rig.json new file mode 100644 index 00000000000..0110930f55e --- /dev/null +++ b/packages/hulypulse-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/packages/hulypulse-client/jest.config.js b/packages/hulypulse-client/jest.config.js new file mode 100644 index 00000000000..2cfd408b679 --- /dev/null +++ b/packages/hulypulse-client/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/hulypulse-client/jest.setup.js b/packages/hulypulse-client/jest.setup.js new file mode 100644 index 00000000000..78d57e3d235 --- /dev/null +++ b/packages/hulypulse-client/jest.setup.js @@ -0,0 +1,2 @@ +// Set up fetch mock +require('jest-fetch-mock').enableMocks() diff --git a/packages/hulypulse-client/package.json b/packages/hulypulse-client/package.json new file mode 100644 index 00000000000..eed69eab5c6 --- /dev/null +++ b/packages/hulypulse-client/package.json @@ -0,0 +1,52 @@ +{ + "name": "@hcengineering/hulypulse-client", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.6.0", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.25.9", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/packages/hulypulse-client/src/client.ts b/packages/hulypulse-client/src/client.ts new file mode 100644 index 00000000000..8071b32d09f --- /dev/null +++ b/packages/hulypulse-client/src/client.ts @@ -0,0 +1,521 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +const WS_CLOSE_NORMAL = 1000 + +export type UnsubscribeCallback = () => Promise + +export type Callback = (key: string, data: T | undefined) => void + +type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue } +// type JSONObject = Record + +// hulypulse API: incoming messages variants + +// interface Ping_Message { +// data: 'ping' | 'pong' +// } + +// interface Answer_Message { +// data: { answer: string } +// } + +// interface Error_Message { +// data: { error: string; reason?: string } +// } + +// interface Subscribe_Message { +// data: { key: string; result: { data: JSONValue; etag: string; expiresAt: number } } +// } + +// put: +// {action: "put", correlation, result:"OK" } +// {action: "put", correlation, error: "...error" } + +// get: +// {action: "get", correlation, "result":{ +// "data":"hello 1", +// "etag":"df0649bc4f1be901c85b6183091c1d83", +// "expires_at":3, +// "key":"00000000-0000-0000-0000-000000000001/foo/bar1" +// }} +// {action: "get", correlation, error: "...error" } + +// delete: +// {action: "delete", correlation, result:"OK" } +// {action: "delete", correlation, error: "...error" } + +// list: +// {action: "list", correlation, result:[ +// {"data":"hello 1","etag":"df0649bc4f1be901c85b6183091c1d83","expires_at":41,"key":"00000000-0000-0000-0000-000000000001/foo/bar1"}, +// {"data":"hello 2","etag":"bb21ec8394b75795622f61613a777a8b","expires_at":85,"key":"00000000-0000-0000-0000-000000000001/foo/bar2"} +// ] } +// {action: "list", correlation, error: "...error" } + +// sub: +// {action: "sub", correlation, result:"OK" } +// {action: "sub", correlation, error: "...error" } + +// unsub: +// {action: "unsub", correlation, result:"OK" } +// {action: "unsub", correlation, error: "...error" } + +// sublist: +// {action: "sublist", correlation, result:[keys] } +// {action: "sublist", correlation, error: "...error" } + +interface GetFullResult { + data: T + etag: string + expiresAt: number +} + +// interface GetFullResultKey { +// data: T +// etag: string +// expiresAt: number +// key: string +// } + +// hulypulse API: subscription messages variants + +// {"message":"Expired","key":"00000000-0000-0000-0000-000000000001/foo/bar1"} +// {"message":"Set","key":"00000000-0000-0000-0000-000000000001/foo/bar1","value":"hello 1"} + +type Command = 'sub' | 'unsub' | 'put' | 'get' | 'delete' | 'list' | 'sublist' | 'info' + +interface SubscribedMessage { + message: 'Set' | 'Del' | 'Unlink' | 'Expired' + key: string + value?: JSONValue +} + +interface CommandMessage { + command: Command + correlation: string + result: any +} + +interface ErrorCommandMessage { + error: string + command: Command + correlation: string +} + +type PulseIncomingMessage = SubscribedMessage | CommandMessage | ErrorCommandMessage + +// hulypulse API: answer messages variants + +// interface OkResponse { +// result: "OK" +// action: "put" | "delete" | "sub" | "unsub" +// correlation?: string +// } + +// interface ErrorResponse { +// error: string +// action: "put" | "get" | "delete" | "list" | "sub" | "unsub" | "sublist" | "info" +// correlation?: string +// } + +// interface InfoResponse { +// action: "info" +// correlation?: string +// result: string +// } + +// interface GetResponse { +// action: "get" +// correlation?: string +// result: GetFullResultKey +// } + +// interface ListResponse { +// action: "list" +// correlation?: string +// result: GetFullResultKey[] +// } + +// interface SublistResponse { +// action: "sublist" +// correlation?: string +// result: string[] +// } + +// hulypulse API: outcoming messages variants + +interface GetMessage { + type: 'get' + key: string + // correlation: string +} + +interface PutMessage { + type: 'put' + key: string + data: JSONValue + TTL?: number + expiresAt?: number + ifMatch?: string + ifNoneMatch?: string + // correlation: string +} + +interface DeleteMessage { + type: 'delete' + key: string + ifMatch?: string + // correlation: string +} + +interface SubscribeMessages { + type: 'sub' + key: string + // correlation: string +} + +interface UnsubscribeMessages { + type: 'unsub' + key: string + // correlation: string +} + +interface SubscribesList { + type: 'list' + // correlation: string +} + +interface InfoMessage { + type: 'info' + // correlation: string +} + +type ProtocolMessage = + | GetMessage + | PutMessage + | DeleteMessage + | SubscribeMessages + | UnsubscribeMessages + | SubscribesList + | InfoMessage + +export class HulypulseClient implements Disposable { + private ws: WebSocket | null = null + private closed = false + private reconnectTimeout: any | undefined + private readonly RECONNECT_INTERVAL_MS = 1000 + + private readonly subscribes = new Map[]>() + + private pingTimeout: ReturnType | undefined + private pingInterval: ReturnType | undefined + private readonly PING_INTERVAL_MS = 30 * 1000 + private readonly PING_TIMEOUT_MS = 5 * 60 * 1000 + private readonly SEND_TIMEOUT_MS = 3000 + + private correlationId = 1 + + private readonly pending = new Map< + string, + { + resolve: (v: any) => void + reject: (e: any) => void + send_timeout: ReturnType + } + >() + + private constructor (private readonly url: string | URL) {} + + private async connect (): Promise { + await new Promise((resolve, reject) => { + const ws = new WebSocket(this.url.toString()) + this.ws = ws + + ws.onopen = () => { + this.resubscribe() + this.startPing() + resolve(undefined) + } + + ws.onerror = (event) => { + this.reconnect() + } + + ws.onclose = (event) => { + this.reconnect() + } + + ws.onmessage = (event) => { + console.log('@@@@ WebSocket message received:', event.data) + try { + if (event.data === 'ping') { + this.ws?.send('pong') + return + } + + console.log('@@@ Received message:', event.data) + const msg: PulseIncomingMessage = JSON.parse(event.data.toString()) + console.log('@@@ Parsed message:', msg) + + // Handle incoming messages (Set, Expired, Del) + if ('message' in msg) { + for (const [key, callbacks] of this.subscribes) { + if (msg.key.startsWith(key)) { + callbacks.forEach((cb, index) => { + try { + cb(msg.key, msg.message === 'Set' ? msg.value : undefined) + } catch (err) { + console.error(`Error in callback #${index} with key "${key}":`, err) + } + }) + } + } + } else if ('correlation' in msg) { + const id = msg.correlation + if (id !== undefined && this.pending.has(id)) { + const pending = this.pending.get(id) + if (pending !== undefined) { + clearTimeout(pending.send_timeout) + this.pending.delete(id) + if ('error' in msg) { + pending.reject(new Error(msg.error)) + } else { + pending.resolve(msg) + } + } + } + } else { + console.warn('Unknown message format:', msg) + } + } catch (e) { + console.error('Failed to parse message', e) + } + } + }) + } + + private resubscribe (): void { + for (const key in this.subscribes) { + this.send({ type: 'sub', key }).catch((error) => { + throw new Error(`Resubscription failed for key=${key}: ${error.message ?? error}`) + }) + } + } + + private startPing (): void { + clearInterval(this.pingInterval) + this.pingInterval = setInterval(() => { + if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) { + this.ws.send('ping') + } + clearTimeout(this.pingTimeout) + this.pingTimeout = setTimeout(() => { + if (this.ws !== null) { + console.log('no response from server') + clearInterval(this.pingInterval) + this.ws.close(WS_CLOSE_NORMAL) + } + }, this.PING_TIMEOUT_MS) + }, this.PING_INTERVAL_MS) + } + + private stopPing (): void { + clearInterval(this.pingInterval) + this.pingInterval = undefined + + clearTimeout(this.pingTimeout) + this.pingTimeout = undefined + } + + [Symbol.dispose] (): void { + this.close() + } + + private reconnect (): void { + if (this.reconnectTimeout !== undefined) { + clearTimeout(this.reconnectTimeout) + } + this.reconnectTimeout = undefined + this.stopPing() + + if (!this.closed) { + this.reconnectTimeout = setTimeout(() => { + void this.connect() + }, this.RECONNECT_INTERVAL_MS) + } + } + + public close (): void { + this.closed = true + if (this.reconnectTimeout !== undefined) { + clearTimeout(this.reconnectTimeout) + } + this.reconnectTimeout = undefined + this.stopPing() + + this.ws?.close() + } + + static async connect (url: string | URL): Promise { + const client = new HulypulseClient(url) + await client.connect() + return client + } + + public async info (): Promise { + const reply = await this.send({ type: 'info' }) + if (reply.error != null) { + throw new Error(reply.error) + } + return reply.result ?? '' + } + + public async list (): Promise { + const reply = await this.send({ type: 'list' }) + if (reply.error != null) { + throw new Error(reply.error) + } + return reply.result ?? '' + } + + public async subscribe (key: string, callback: Callback): Promise { + let list = this.subscribes.get(key) + if (list == null) { + list = [] + this.subscribes.set(key, list) + } + + if (!list.includes(callback)) { + // Already subscribed? + list.push(callback) + if (list.length === 1) { + const reply = await this.send({ type: 'sub', key }) + if (reply.error != null) { + this.reconnect() + } + } + } + + return async () => { + return await this.unsubscribe(key, callback) + } + } + + public async unsubscribe (key: string, callback: Callback): Promise { + const list = this.subscribes.get(key) + if (list?.includes(callback) == null) { + return false + } + const newList = list.filter((cb) => cb !== callback) + if (newList.length === 0) { + this.subscribes.delete(key) + const reply = await this.send({ type: 'unsub', key }) + if (reply.error != null) { + this.reconnect() + return true + } + } else { + this.subscribes.set(key, newList) + } + return true + } + + public async put (key: string, data: any, ttl: number): Promise + public async put ( + key: string, + data: any, + options?: Pick + ): Promise + public async put ( + key: string, + data: any, + third?: number | Pick + ): Promise { + const message: Omit = { + type: 'put', + key, + data, + ...(typeof third === 'number' ? { TTL: third } : third) + } + const reply = await this.send(message) + if (reply.error != null) { + throw new Error(reply.error) + } + } + + public async get(key: string): Promise { + const reply = await this.send({ type: 'get', key }) + if (reply.error != null) { + if (reply.error === 'not found') { + return undefined + } + throw new Error(reply.error) + } + return reply.result?.data + } + + public async get_full(key: string): Promise | undefined> { + const reply = await this.send({ type: 'get', key }) + if (reply.error != null) { + if (reply.error === 'not found') { + return undefined + } + throw new Error(reply.error) + } + return { + data: reply.result.data, + etag: reply.result.etag, + expiresAt: reply.result.expiresAt + } + } + + public async delete (key: string, options?: Pick): Promise { + const message: Omit = { type: 'delete', key, ...options } + const reply = await this.send(message) + if (reply.error != null) { + if (reply.error === 'not found') { + return false + } + throw new Error(reply.error) + } + return true + } + + private async send>(msg: M): Promise { + const id = String(this.correlationId++) + const message = { ...msg, correlation: id.toString() } satisfies M + + return await new Promise((resolve, reject) => { + if (this.ws == null || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket is not open.')) + return + } + const sendTimeout = setTimeout(() => { + const pending = this.pending.get(id) + if (pending !== undefined) { + pending.reject(new Error('Timeout waiting for response')) + this.pending.delete(id) + } + }, this.SEND_TIMEOUT_MS) + this.pending.set(id, { resolve, reject, send_timeout: sendTimeout }) + this.ws.send(JSON.stringify(message)) + }) + } +} + +export function escapeString (str: string): string { + // Escape special characters to '*' | '?' | '[' | ']' | '\\' | '\0'..='\x1F' | '\x7F' | '"' | '\'' + return str.replace(/[\\'"]/g, '\\$&') +} diff --git a/packages/hulypulse-client/src/index.ts b/packages/hulypulse-client/src/index.ts new file mode 100644 index 00000000000..61878353962 --- /dev/null +++ b/packages/hulypulse-client/src/index.ts @@ -0,0 +1,16 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export * from './client' diff --git a/packages/hulypulse-client/tsconfig.json b/packages/hulypulse-client/tsconfig.json new file mode 100644 index 00000000000..b5ae22f6e46 --- /dev/null +++ b/packages/hulypulse-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 3539e83fbb6..295594569c8 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -179,7 +179,8 @@ export default plugin(presentationId, { SessionId: '' as Metadata, StatsUrl: '' as Metadata, MailUrl: '' as Metadata, - PreviewUrl: '' as Metadata + PreviewUrl: '' as Metadata, + PulseUrl: '' as Metadata }, status: { FileTooLarge: '' as StatusCode diff --git a/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte b/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte index 5244360d8f7..3bef3f82fb3 100644 --- a/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte +++ b/plugins/chunter-resources/src/components/ChannelTypingInfo.svelte @@ -13,17 +13,15 @@ // limitations under the License. --> diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index d1554041baf..a2349c3ce61 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -24,10 +24,7 @@ import { getObjectId } from '@hcengineering/view-resources' import { ThrottledCaller } from '@hcengineering/ui' import { getSpace } from '@hcengineering/activity-resources' - import { getCurrentEmployee } from '@hcengineering/contact' - import { presenceByObjectId, updateMyPresence } from '@hcengineering/presence-resources' - - import { type PresenceTyping } from '../../types' + import { getCurrentEmployee, Person } from '@hcengineering/contact' import { getChannelSpace } from '../../utils' import ChannelTypingInfo from '../ChannelTypingInfo.svelte' @@ -41,6 +38,8 @@ export let autofocus = false export let withTypingInfo = false + import { setTyping, clearTyping } from '@hcengineering/presence-resources' + type MessageDraft = Pick const dispatch = createEventDispatcher() @@ -77,9 +76,7 @@ createdMessageQuery.unsubscribe() } - let typingInfo: PresenceTyping[] = [] - $: presence = $presenceByObjectId.get(object._id) ?? [] - $: typingInfo = presence.map((p) => p.presence.typing).filter((p) => p !== undefined) + const typingInfo: Ref[] = [] function clear (): void { currentMessage = getDefault() @@ -107,17 +104,14 @@ async function deleteTypingInfo (): Promise { if (!withTypingInfo) return - const room = { objectId: object._id, objectClass: object._class } - updateMyPresence(room, { typing: undefined }) + clearTyping(me, object._id) } async function updateTypingInfo (): Promise { if (!withTypingInfo) return throttle.call(() => { - const room = { objectId: object._id, objectClass: object._class } - const typing = { person: me, lastTyping: Date.now() } - updateMyPresence(room, { typing }) + setTyping(me, object._id) }) } @@ -247,5 +241,5 @@ /> {#if withTypingInfo} - + {/if} diff --git a/plugins/presence-resources/package.json b/plugins/presence-resources/package.json index 4c731f797e7..e04ff471238 100644 --- a/plugins/presence-resources/package.json +++ b/plugins/presence-resources/package.json @@ -48,6 +48,7 @@ "@hcengineering/contact": "^0.6.24", "@hcengineering/contact-resources": "^0.6.0", "@hcengineering/presence": "^0.6.0", + "@hcengineering/hulypulse-client": "^0.6.0", "svelte": "^4.2.20", "fast-equals": "^5.2.2" } diff --git a/plugins/presence-resources/src/components/WorkbenchExtension.svelte b/plugins/presence-resources/src/components/WorkbenchExtension.svelte index 715edc2aa9a..21d05b794d2 100644 --- a/plugins/presence-resources/src/components/WorkbenchExtension.svelte +++ b/plugins/presence-resources/src/components/WorkbenchExtension.svelte @@ -15,18 +15,19 @@ diff --git a/plugins/presence-resources/src/index.ts b/plugins/presence-resources/src/index.ts index 59c0248ae94..becb54bdb3d 100644 --- a/plugins/presence-resources/src/index.ts +++ b/plugins/presence-resources/src/index.ts @@ -23,6 +23,7 @@ import { getFollowee, publishData, followeeDataSubscribe, followeeDataUnsubscrib export { Presence, PresenceAvatars } export { updateMyPresence, removeMyPresence, presenceByObjectId } from './store' +export * from './pulse' export * from './types' export default async (): Promise => ({ diff --git a/plugins/presence-resources/src/pulse.ts b/plugins/presence-resources/src/pulse.ts new file mode 100644 index 00000000000..0f957faa11a --- /dev/null +++ b/plugins/presence-resources/src/pulse.ts @@ -0,0 +1,75 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License + +import { HulypulseClient, type UnsubscribeCallback, type Callback } from '@hcengineering/hulypulse-client' +import { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' +import { type Doc, getCurrentAccount, type PersonId, type Ref } from '@hcengineering/core' +// export type { UnsubscribeCallback } + +const typingDelaySeconds = 2 + +let pulseclient: HulypulseClient | undefined + +function getWorkspace (): string | undefined { + return getMetadata(presentation.metadata.WorkspaceUuid) +} + +export function getPulseClient (): HulypulseClient | undefined { + return pulseclient +} + +export async function createPulseClient (): Promise { + if (pulseclient == null) { + const wsPulseUrl = getMetadata(presentation.metadata.PulseUrl) + const token = getMetadata(presentation.metadata.Token) + pulseclient = await HulypulseClient.connect(`${wsPulseUrl}?token=${token}`) + } + return pulseclient +} + +export async function setTyping (me: string, objectId: Ref): Promise { + const workspace = getWorkspace() + const id = getCurrentAccount().socialIds[0] + const typingInfo: TypingInfo = { + socialId: id, + objectId + } + const typingInfoStr = JSON.stringify(typingInfo) + await pulseclient?.put(`${workspace}/typing/${objectId}/${me}`, typingInfoStr, typingDelaySeconds) +} + +export interface TypingInfo { + socialId: PersonId + objectId: Ref +} + +export async function subscribeTyping ( + callback: Callback, + objectId: Ref +): Promise { + const workspace = getWorkspace() + return (await pulseclient?.subscribe(`${workspace}/typing/${objectId}/`, callback)) ?? (async () => false) +} + +export function clearTyping (me: string, objectId: string): void { + const workspace = getWorkspace() + void pulseclient?.delete(`${workspace}/typing/${objectId}/${me}`) +} + +// export function setOnline(): void { TODO } + +export function closePulseClient (): void { + pulseclient?.close() + pulseclient = undefined +} diff --git a/plugins/presence-resources/src/store.ts b/plugins/presence-resources/src/store.ts index 0234281ff01..eee9750b775 100644 --- a/plugins/presence-resources/src/store.ts +++ b/plugins/presence-resources/src/store.ts @@ -51,6 +51,8 @@ export const presenceByObjectId = derived, Map { const value = { room, presence, lastUpdated: Date.now() } diff --git a/pods/front/src/__start.ts b/pods/front/src/__start.ts index a64a52a309e..a359eb92066 100644 --- a/pods/front/src/__start.ts +++ b/pods/front/src/__start.ts @@ -54,6 +54,7 @@ startFront(metricsContext, { PUBLIC_SCHEDULE_URL: process.env.PUBLIC_SCHEDULE_URL, CALDAV_SERVER_URL: process.env.CALDAV_SERVER_URL, EXPORT_URL: process.env.EXPORT_URL, + PULSE_URL: process.env.PULSE_URL, COMMUNICATION_API_ENABLED: process.env.COMMUNICATION_API_ENABLED, EXCLUDED_APPLICATIONS_FOR_ANONYMOUS: process.env.EXCLUDED_APPLICATIONS_FOR_ANONYMOUS }) diff --git a/rush.json b/rush.json index 372ba7b7a48..8856788689e 100644 --- a/rush.json +++ b/rush.json @@ -2543,6 +2543,11 @@ "projectFolder": "packages/kvs-client", "shouldPublish": false }, + { + "packageName": "@hcengineering/hulypulse-client", + "projectFolder": "packages/hulypulse-client", + "shouldPublish": false + }, { "packageName": "@hcengineering/communication", "projectFolder": "plugins/communication", diff --git a/server/front/src/index.ts b/server/front/src/index.ts index 47bcf598ba8..e405d2f35b6 100644 --- a/server/front/src/index.ts +++ b/server/front/src/index.ts @@ -278,6 +278,7 @@ export function start ( streamUrl?: string mailUrl?: string billingUrl?: string + pulseUrl?: string }, port: number, extraConfig?: Record @@ -355,6 +356,7 @@ export function start ( HIDE_LOCAL_LOGIN: config.hideLocalLogin, MAIL_URL: config.mailUrl, BILLING_URL: config.billingUrl, + PULSE_URL: config.pulseUrl, ...(extraConfig ?? {}) } res.status(200) diff --git a/server/front/src/starter.ts b/server/front/src/starter.ts index c182c20bd11..705b774478b 100644 --- a/server/front/src/starter.ts +++ b/server/front/src/starter.ts @@ -109,6 +109,11 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record