mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-06 17:57:42 +02:00
459 lines
12 KiB
TypeScript
459 lines
12 KiB
TypeScript
//
|
|
// 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
|
|
|
|
interface GetFullResult<T> {
|
|
data: T
|
|
etag: string
|
|
expiresAt: number
|
|
}
|
|
|
|
type Command = 'sub' | 'unsub' | 'put' | 'get' | 'delete' | 'list' | 'sublist' | 'info'
|
|
|
|
interface SubscribedMessage {
|
|
message: 'Set' | 'Del' | 'Unlink' | 'Expired'
|
|
key: string
|
|
value?: string
|
|
}
|
|
|
|
interface CommandMessage {
|
|
command: Command
|
|
correlation: string
|
|
result: any
|
|
}
|
|
|
|
interface ErrorCommandMessage {
|
|
error: string
|
|
command: Command
|
|
correlation: string
|
|
}
|
|
|
|
type PulseIncomingMessage = SubscribedMessage | CommandMessage | ErrorCommandMessage
|
|
|
|
interface GetMessage {
|
|
type: 'get'
|
|
key: string
|
|
}
|
|
|
|
interface PutMessage {
|
|
type: 'put'
|
|
key: string
|
|
data: string
|
|
TTL?: number
|
|
expiresAt?: number
|
|
ifMatch?: string
|
|
ifNoneMatch?: string
|
|
}
|
|
|
|
interface DeleteMessage {
|
|
type: 'delete'
|
|
key: string
|
|
ifMatch?: string
|
|
}
|
|
|
|
interface SubscribeMessages {
|
|
type: 'sub'
|
|
key: string
|
|
}
|
|
|
|
interface UnsubscribeMessages {
|
|
type: 'unsub'
|
|
key: string
|
|
}
|
|
|
|
interface SubscribesList {
|
|
type: 'list'
|
|
}
|
|
|
|
interface InfoMessage {
|
|
type: 'info'
|
|
}
|
|
|
|
type ProtocolMessage =
|
|
| GetMessage
|
|
| PutMessage
|
|
| DeleteMessage
|
|
| SubscribeMessages
|
|
| UnsubscribeMessages
|
|
| SubscribesList
|
|
| InfoMessage
|
|
|
|
export class HulypulseClient implements Disposable {
|
|
private ws: WebSocket | null = null
|
|
private closed_manually = 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) => {
|
|
try {
|
|
if (event.data === 'ping') {
|
|
this.ws?.send('pong')
|
|
return
|
|
}
|
|
if (event.data === 'pong') {
|
|
return
|
|
}
|
|
|
|
const msg: PulseIncomingMessage = JSON.parse(event.data.toString())
|
|
|
|
// 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 {
|
|
const value = msg.message === 'Set' && msg.value !== undefined ? JSON.parse(msg.value) : undefined
|
|
cb(msg.key, value)
|
|
} 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] of this.subscribes) {
|
|
this.send({ type: 'sub', key }).catch((error) => {
|
|
console.error(`Resubscription failed for key=${key}:`, error)
|
|
// throw new Error(`Resubscription failed for key=${key}: ${error.message ?? error}`)
|
|
})
|
|
}
|
|
}
|
|
|
|
private startPing (): void {
|
|
this.stopPing()
|
|
this.pingInterval = setInterval(() => {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send('ping')
|
|
}
|
|
if (this.pingTimeout !== undefined) {
|
|
clearTimeout(this.pingTimeout)
|
|
}
|
|
this.pingTimeout = setTimeout(() => {
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
console.warn('WS-server not responding to ping, closing connection')
|
|
clearInterval(this.pingInterval)
|
|
this.ws?.close(WS_CLOSE_NORMAL)
|
|
}
|
|
}, this.PING_TIMEOUT_MS)
|
|
}, this.PING_INTERVAL_MS)
|
|
}
|
|
|
|
private stopPing (): void {
|
|
if (this.pingInterval !== undefined) {
|
|
clearInterval(this.pingInterval)
|
|
this.pingInterval = undefined
|
|
}
|
|
if (this.pingTimeout !== 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_manually) {
|
|
this.reconnectTimeout = setTimeout(() => {
|
|
void this.connect()
|
|
}, this.RECONNECT_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
public close (): void {
|
|
this.closed_manually = 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 !== undefined) {
|
|
throw new Error(reply.error)
|
|
}
|
|
return reply.result ?? ''
|
|
}
|
|
|
|
public async list (): Promise<string> {
|
|
const reply = await this.send({ type: 'list' })
|
|
if (reply.error !== undefined) {
|
|
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 === undefined) {
|
|
list = []
|
|
this.subscribes.set(key, list)
|
|
}
|
|
|
|
if (!list.includes(callback)) {
|
|
// Already subscribed?
|
|
list.push(callback)
|
|
if (list.length === 1) {
|
|
void (await this.send({ type: 'sub', key }))
|
|
}
|
|
}
|
|
|
|
// callback for every old item (ttl > 1 sec for atomicity)
|
|
const prevlist = await this.send({ type: 'list', key })
|
|
if (prevlist?.error === undefined && Array.isArray(prevlist?.result)) {
|
|
for (const item of prevlist.result) {
|
|
try {
|
|
const value = item.data !== undefined ? JSON.parse(item.data) : undefined
|
|
if (
|
|
(item.ttl ?? item.expires_at ?? 0) <= 1 || // TODO: remove expires_at after upgrade server
|
|
value === undefined
|
|
) {
|
|
continue
|
|
}
|
|
callback(item.key, value)
|
|
} catch (err) {
|
|
// throw new Error(err)
|
|
console.error('Error in initial callback', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 === undefined || !list.includes(callback)) {
|
|
return false
|
|
}
|
|
const newList = list.filter((cb) => cb !== callback)
|
|
if (newList.length === 0) {
|
|
this.subscribes.delete(key)
|
|
void (await this.send({ type: 'unsub', key }))
|
|
} 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: JSON.stringify(data),
|
|
...(typeof third === 'number' ? { TTL: third } : third)
|
|
}
|
|
void (await this.send(message))
|
|
}
|
|
|
|
public async put_full (key: string, data: any, ttl: number): Promise<any>
|
|
public async put_full (
|
|
key: string,
|
|
data: any,
|
|
options?: Pick<PutMessage, 'ifMatch' | 'ifNoneMatch' | 'TTL' | 'expiresAt'>
|
|
): Promise<void>
|
|
public async put_full (
|
|
key: string,
|
|
data: any,
|
|
third?: number | Pick<PutMessage, 'ifMatch' | 'ifNoneMatch' | 'TTL' | 'expiresAt'>
|
|
): Promise<void> {
|
|
const message: Omit<PutMessage, 'correlation'> = {
|
|
type: 'put',
|
|
key,
|
|
data: JSON.stringify(data),
|
|
...(typeof third === 'number' ? { TTL: third } : third)
|
|
}
|
|
const reply = await this.send(message)
|
|
if (reply.error !== undefined) {
|
|
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 !== undefined) {
|
|
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 !== undefined) {
|
|
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 !== undefined) {
|
|
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
|
|
|
|
// Reconnect if need before
|
|
if (this.closed_manually) {
|
|
if (msg.type === 'unsub') {
|
|
// don't need to do anything
|
|
return
|
|
}
|
|
this.closed_manually = false
|
|
if (this.ws?.readyState === WebSocket.CONNECTING || this.ws?.readyState === WebSocket.OPEN) {
|
|
return
|
|
}
|
|
await this.connect()
|
|
}
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
if (this.closed_manually || this.ws?.readyState !== WebSocket.OPEN) {
|
|
resolve({ error: 'WebSocket is not open.' })
|
|
return
|
|
}
|
|
const sendTimeout = setTimeout(() => {
|
|
const pending = this.pending.get(id)
|
|
if (pending !== undefined) {
|
|
pending.resolve({ 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))
|
|
this.startPing() // reset ping timer on any send
|
|
})
|
|
}
|
|
}
|
|
|
|
export function escapeString (str: string): string {
|
|
// eslint-disable-next-line no-control-regex, no-useless-escape
|
|
return str.replace(/[*?\[\]\\\x00-\x1F\x7F"']/g, '_')
|
|
}
|