mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-28 04:25:03 +02:00
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:
co-authored by
Alexander Onnikov
parent
7b87ca4047
commit
70ca4b1420
Generated
+37
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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\"]"
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Set up fetch mock
|
||||
require('jest-fetch-mock').enableMocks()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '\\$&')
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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> => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user