Ladies and gentlemen, introducing hulypulse-client and Typing (#9778)

Signed-off-by: Leonid Kaganov <lleo@lleo.me>
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
Co-authored-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Leonid Kaganov
2025-09-11 18:44:25 +07:00
committed by GitHub
co-authored by Alexander Onnikov
parent 7b87ca4047
commit 70ca4b1420
26 changed files with 965 additions and 46 deletions
+37 -2
View File
@@ -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
+3 -2
View File
@@ -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:
+1
View File
@@ -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"
}
+1
View File
@@ -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\"]"
}
+4 -1
View File
@@ -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']
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+152
View File
@@ -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<boolean>
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<any | false>
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<boolean>
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<boolean>
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<boolean>
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<any>
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()
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+2
View File
@@ -0,0 +1,2 @@
// Set up fetch mock
require('jest-fetch-mock').enableMocks()
+52
View File
@@ -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"
}
}
}
+521
View File
@@ -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<boolean>
export type Callback<T> = (key: string, data: T | undefined) => void
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
// type JSONObject = Record<string, JSONValue>
// 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<T> {
data: T
etag: string
expiresAt: number
}
// interface GetFullResultKey<T> {
// 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<JSONValue>
// }
// interface ListResponse {
// action: "list"
// correlation?: string
// result: GetFullResultKey<JSONValue>[]
// }
// 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<string, Callback<any>[]>()
private pingTimeout: ReturnType<typeof setTimeout> | undefined
private pingInterval: ReturnType<typeof setInterval> | 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<typeof setTimeout>
}
>()
private constructor (private readonly url: string | URL) {}
private async connect (): Promise<void> {
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<HulypulseClient> {
const client = new HulypulseClient(url)
await client.connect()
return client
}
public async info (): Promise<string> {
const reply = await this.send({ type: 'info' })
if (reply.error != null) {
throw new Error(reply.error)
}
return reply.result ?? ''
}
public async list (): Promise<string> {
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<any>): Promise<UnsubscribeCallback> {
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<any>): Promise<boolean> {
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<void>
public async put (
key: string,
data: any,
options?: Pick<PutMessage, 'ifMatch' | 'ifNoneMatch' | 'TTL' | 'expiresAt'>
): Promise<void>
public async put (
key: string,
data: any,
third?: number | Pick<PutMessage, 'ifMatch' | 'ifNoneMatch' | 'TTL' | 'expiresAt'>
): Promise<void> {
const message: Omit<PutMessage, 'correlation'> = {
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<T>(key: string): Promise<T | 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 reply.result?.data
}
public async get_full<T>(key: string): Promise<GetFullResult<T> | 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<DeleteMessage, 'ifMatch'>): Promise<boolean> {
const message: Omit<DeleteMessage, 'correlation'> = { 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<M extends Omit<ProtocolMessage, 'correlation'>>(msg: M): Promise<any> {
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, '\\$&')
}
+16
View File
@@ -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'
+12
View File
@@ -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"]
}
+2 -1
View File
@@ -179,7 +179,8 @@ export default plugin(presentationId, {
SessionId: '' as Metadata<string>,
StatsUrl: '' as Metadata<string>,
MailUrl: '' as Metadata<string>,
PreviewUrl: '' as Metadata<string>
PreviewUrl: '' as Metadata<string>,
PulseUrl: '' as Metadata<string>
},
status: {
FileTooLarge: '' as StatusCode
@@ -13,17 +13,15 @@
// limitations under the License.
-->
<script lang="ts">
import { onMount } from 'svelte'
import { onDestroy, onMount } from 'svelte'
import chunter from '@hcengineering/chunter'
import { getName, getCurrentEmployee } from '@hcengineering/contact'
import { getName, getCurrentEmployee, Person } from '@hcengineering/contact'
import { getPersonsByPersonRefs } from '@hcengineering/contact-resources'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import { PresenceTyping } from '../types'
import { subscribeTyping, TypingInfo } from '@hcengineering/presence-resources'
import { Doc, type Ref } from '@hcengineering/core'
export let typingInfo: PresenceTyping[] = []
const typingDelay = 2000
const maxTypingPersons = 3
const me = getCurrentEmployee()
const hierarchy = getClient().getHierarchy()
@@ -32,30 +30,48 @@
let typingPersonsCount = 0
let moreCount: number = 0
$: void updateTypingPersons(typingInfo)
$: void updateTypingPersons()
async function updateTypingPersons (typingInfo: PresenceTyping[]): Promise<void> {
const now = Date.now()
const personIds = new Set(
typingInfo.filter((info) => info.person !== me && now - info.lastTyping < typingDelay).map((info) => info.person)
)
const persons = await getPersonsByPersonRefs(Array.from(personIds))
export let typingInfo: Ref<Person>[] = []
export let object: Doc
async function updateTypingPersons (): Promise<void> {
const persons = await getPersonsByPersonRefs(typingInfo)
const names = Array.from(persons.values())
.map((person) => getName(hierarchy, person))
.sort((name1, name2) => name1.localeCompare(name2))
typingPersonsCount = names.length
typingPersonsLabel = names.slice(0, maxTypingPersons).join(', ')
moreCount = Math.max(names.length - maxTypingPersons, 0)
}
onMount(() => {
const interval = setInterval(() => {
void updateTypingPersons(typingInfo)
}, typingDelay)
return () => {
clearInterval(interval)
function handleTypingInfo (key: string, value: TypingInfo | undefined): void {
try {
const id: Ref<Person> = key.split('/').pop() as Ref<Person>
if (id === me) {
return
}
if (value !== undefined) {
if (!typingInfo.includes(id)) {
typingInfo.push(id)
}
} else {
typingInfo = typingInfo.filter((x) => x !== id)
}
void updateTypingPersons()
} catch (error) {
console.error('handleTypingInfo Error', error)
}
}
let unsubscribe: (() => Promise<boolean>) | undefined
onMount(async () => {
unsubscribe = await subscribeTyping(handleTypingInfo, object._id)
})
onDestroy(() => {
void unsubscribe?.()
})
</script>
@@ -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<ChatMessage, '_id' | 'message' | 'attachments'>
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<Person>[] = []
function clear (): void {
currentMessage = getDefault()
@@ -107,17 +104,14 @@
async function deleteTypingInfo (): Promise<void> {
if (!withTypingInfo) return
const room = { objectId: object._id, objectClass: object._class }
updateMyPresence(room, { typing: undefined })
clearTyping(me, object._id)
}
async function updateTypingInfo (): Promise<void> {
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}
<ChannelTypingInfo {typingInfo} />
<ChannelTypingInfo {typingInfo} {object} />
{/if}
+1
View File
@@ -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"
}
@@ -15,18 +15,19 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { type PresenceClient, connect } from '../client'
import { HulypulseClient } from '@hcengineering/hulypulse-client'
import { createPulseClient } from '../pulse'
let parentElement: HTMLDivElement
let client: PresenceClient | undefined
onMount(() => {
client = connect()
let pulseclient: HulypulseClient | undefined
onMount(async () => {
pulseclient = await createPulseClient()
})
onDestroy(() => {
client?.close()
pulseclient?.close()
})
</script>
+1
View File
@@ -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<Resources> => ({
+75
View File
@@ -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<HulypulseClient> {
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<Doc>): Promise<void> {
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<Doc>
}
export async function subscribeTyping (
callback: Callback<TypingInfo | undefined>,
objectId: Ref<Doc>
): Promise<UnsubscribeCallback> {
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
}
+2
View File
@@ -51,6 +51,8 @@ export const presenceByObjectId = derived<Readable<PersonPresenceMap>, Map<Ref<D
)
export function updateMyPresence (room: Room, presence: PresenceData): void {
// lleo console.log('@@@ key: ', room, presence)
myPresence.update((rooms) => {
const value = { room, presence, lastUpdated: Date.now() }
+1
View File
@@ -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
})
+5
View File
@@ -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",
+2
View File
@@ -278,6 +278,7 @@ export function start (
streamUrl?: string
mailUrl?: string
billingUrl?: string
pulseUrl?: string
},
port: number,
extraConfig?: Record<string, string | undefined>
@@ -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)
+7 -1
View File
@@ -109,6 +109,11 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
filesUrl = `${uploadUrl}/:workspace/:filename?file=:blobId&workspace=:workspace`
}
let pulseUrl = process.env.PULSE_URL
if (pulseUrl === undefined) {
pulseUrl = ''
}
const pushPublicKey = process.env.PUSH_PUBLIC_KEY
const brandingUrl = process.env.BRANDING_URL
@@ -155,7 +160,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
linkPreviewUrl,
streamUrl,
mailUrl,
billingUrl
billingUrl,
pulseUrl
}
console.log('Starting Front service with', config)
const shutdown = start(ctx, config, SERVER_PORT, extraConfig)