// Hovermate Game Events SDK client for Node.js 18+: one ES module, no dependencies. // Protocol v1: https://docs.hovermate.gg/sdk/protocol/ // MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK. // // Usage: // import * as hovermate from './hovermate-events.mjs'; // hovermate.init('com.example.mygame', 'My Game', 'MyGame.exe'); // hovermate.hello(['kill', 'bossKill', hovermate.customOffer('parry', 'Parry')]); // hovermate.event('kill', { label: 'Double kill', tags: ['headshot'], context: 'Act 2' }); // hovermate.custom('parry', 'Parry'); // await hovermate.shutdown(); // // Calls return at once and never throw. Events go over HTTP one at a time in the background (250 ms timeouts, a // bounded queue that drops the oldest event, one retry). When HTTP is unreachable they are appended to the inbox. // When Hovermate is not installed or its SDK is off, the client stays silent. Nothing keeps the process alive. import { request } from 'node:http'; import { appendFile, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; const CAPACITY = 256; const TIMEOUT_MS = 250; const RELOAD_MS = 5000; const HTTP_PAUSE_MS = 5000; const GAME_ID = /^[a-z0-9._-]{3,64}$/; const CUSTOM_TYPE = /^[a-z0-9_.-]{1,32}$/; const TAG = /^[a-z0-9_-]{1,24}$/; /** A custom type for `hello(offers)`. */ export function customOffer(customType, customLabel) { return { customType, customLabel }; } /** Plain text: control characters become spaces, trimmed, at most `maximum` UTF-16 units; undefined when blank. */ function clean(text, maximum) { if (text === undefined || text === null) return undefined; let value = String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); if (value.length > maximum) { const cut = /[\ud800-\udbff]/.test(value[maximum - 1]) ? maximum - 1 : maximum; value = value.slice(0, cut).trimEnd(); } return value || undefined; } function connectionPath() { const forced = (process.env.HOVERMATE_SDK_CONNECTION ?? '').trim().replace(/^"|"$/g, ''); if (forced) return forced; return process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Hovermate', 'Sdk', 'connection.json') : undefined; } /** One game's connection to Hovermate. The module functions use a shared instance. */ export class HovermateClient { #game; #gameId; #queue = []; #running = null; #log; #connection = null; #stale = true; #nextLoad = 0; #httpPausedUntil = 0; #reportedMissing = false; /** Optional diagnostics (connection state, refused events): a function taking one string. */ setLog(log) { this.#log = log; } /** Starts the client. `gameId` matches [a-z0-9._-]{3,64} and stays stable across versions of the mod. */ init(gameId, gameName, exe) { try { if (typeof gameId !== 'string' || !GAME_ID.test(gameId)) return this.#report('game id must match [a-z0-9._-]{3,64}: the client stays off.'); const name = clean(gameName, 64); if (!name) return this.#report('game name is required: the client stays off.'); const game = { id: gameId, name }; const file = clean(exe, 128); if (file) game.exe = file.split(/[\\/]/).pop(); this.#game = game; this.#gameId = gameId; } catch (error) { this.#report(`init failed: ${error}`); } } /** Announces the types the game can send, a log file with [HOVERMATE] lines and mod-folder event files. */ hello(offers, { log, files } = {}) { try { const extra = {}; if (offers) { extra.offers = []; for (const offer of offers) { if (typeof offer === 'string') { if (offer) extra.offers.push(offer); continue; } const label = clean(offer?.customLabel, 40); if (typeof offer?.customType === 'string' && CUSTOM_TYPE.test(offer.customType) && label) extra.offers.push({ customType: offer.customType, customLabel: label }); else this.#report('skipped an invalid custom offer.'); } } const path = clean(log, 260); if (path) extra.log = path; const kept = (files ?? []).map(file => clean(file, 260)).filter(Boolean).slice(0, 16); if (kept.length) extra.files = kept; this.#enqueue('hello', extra); } catch (error) { this.#report(`hello failed: ${error}`); } } /** Sends a standard event type, for example 'kill', 'death' or 'bossKill'. */ event(type, options = {}) { this.#send(type, undefined, undefined, options); } /** Sends a custom event: customType matches [a-z0-9_.-]{1,32}; customLabel (at most 40) names its toggle. */ custom(customType, customLabel, options = {}) { this.#send('custom', customType, customLabel, options); } /** Resolves true once queued events are sent, false when the timeout passed first. */ async flush(timeoutMs = 1000) { const deadline = Date.now() + Math.max(0, timeoutMs); while (this.#running || this.#queue.length) { const left = deadline - Date.now(); if (left <= 0) return false; await Promise.race([this.#running ?? Promise.resolve(), new Promise(resolve => setTimeout(resolve, Math.min(left, 50)))]); } return true; } /** Sends what is queued (up to the timeout); later events are ignored until init is called again. */ async shutdown(timeoutMs = 1000) { try { await this.flush(timeoutMs); } catch { /* never throw */ } this.#queue.length = 0; this.#game = undefined; } #send(type, customType, customLabel, { label, tags, context } = {}) { try { if (typeof type !== 'string' || !type || type === 'hello') return this.#report('event type is required.'); const extra = {}; if (type === 'custom') { const toggle = clean(customLabel, 40); if (typeof customType !== 'string' || !CUSTOM_TYPE.test(customType) || !toggle) return this.#report('custom events need customType [a-z0-9_.-]{1,32} and customLabel.'); extra.customType = customType; extra.customLabel = toggle; } const text = clean(label, 80); if (text) extra.label = text; const kept = [...new Set((tags ?? []).filter(tag => typeof tag === 'string' && TAG.test(tag)))].slice(0, 8); if (kept.length) extra.tags = kept; const area = clean(context, 64); if (area) extra.context = area; this.#enqueue(type, extra); } catch (error) { this.#report(`event failed: ${error}`); } } #enqueue(type, extra) { if (!this.#game) return; const line = JSON.stringify({ v: 1, game: this.#game, type, ...extra, time: new Date().toISOString() }); if (this.#queue.length >= CAPACITY) this.#queue.shift(); this.#queue.push({ line, gameId: this.#gameId }); this.#kick(); } #kick() { if (this.#running) return; this.#running = this.#pump().finally(() => { this.#running = null; if (this.#queue.length) this.#kick(); }); } async #pump() { while (this.#queue.length) { const { line, gameId } = this.#queue.shift(); try { await this.#deliver(line, gameId); } catch (error) { this.#report(`delivery failed: ${error}`); } } } async #deliver(line, gameId) { let connection = await this.#current(false); if (!connection) return; if (Date.now() < this.#httpPausedUntil) return this.#appendToInbox(connection, gameId, line); const body = Buffer.from(line, 'utf8'); let status = 0; let reply = ''; for (let attempt = 0; attempt < 2; attempt++) { ({ status, reply } = await post(connection, body)); if (status === 202) return; if (attempt) break; if (status === 401) { // The token rotates when the SDK is switched off and on: read the file again, retry once. connection = await this.#current(true); if (!connection) return; continue; } if (status !== -1) break; } if (status === -1) { this.#httpPausedUntil = Date.now() + HTTP_PAUSE_MS; this.#stale = true; this.#report("Hovermate's HTTP receiver is unreachable; using the inbox for a few seconds."); return this.#appendToInbox(connection, gameId, line); } this.#report(`Hovermate refused an event: ${status} ${reply}`); } async #current(force) { const now = Date.now(); if (!force && this.#connection && !this.#stale) return this.#connection; if (!force && now < this.#nextLoad) return this.#connection; this.#nextLoad = now + RELOAD_MS; this.#stale = false; this.#connection = await load(); if (!this.#connection && !this.#reportedMissing) { this.#report('connection.json not found: Hovermate is not running or its SDK is off.'); this.#reportedMissing = true; } if (this.#connection) this.#reportedMissing = false; return this.#connection; } async #appendToInbox(connection, gameId, line) { if (!connection.inbox) return; try { if (!(await stat(connection.inbox)).isDirectory()) return; await appendFile(join(connection.inbox, `${gameId}.jsonl`), `${line}\n`, 'utf8'); } catch (error) { if (error?.code !== 'ENOENT') this.#report(`inbox write failed: ${error}`); } } #report(message) { try { this.#log?.(`[Hovermate] ${message}`); } catch { /* never throw */ } } } async function load() { const path = connectionPath(); if (!path) return null; try { const values = JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')); const url = new URL(values.url); if (url.protocol !== 'http:' || typeof values.token !== 'string') return null; return { host: url.hostname, port: Number(url.port || 80), path: url.pathname + url.search, token: values.token, inbox: typeof values.inbox === 'string' ? values.inbox : null }; } catch { return null; } } /** One POST without an agent or proxy; status -1 when Hovermate could not be reached in time. */ function post(connection, body) { return new Promise(resolve => { let settled = false; const finish = (status, reply = '') => { if (!settled) { settled = true; clearTimeout(timer); resolve({ status, reply }); } }; const req = request({ host: connection.host, port: connection.port, path: connection.path, method: 'POST', agent: false, timeout: TIMEOUT_MS, headers: { Authorization: `Bearer ${connection.token}`, 'Content-Type': 'application/json', 'Content-Length': body.length, Connection: 'close' }, }, response => { clearTimeout(timer); const chunks = []; response.on('data', chunk => { if (chunks.length < 8) chunks.push(chunk); }); response.on('end', () => finish(response.statusCode, Buffer.concat(chunks).toString('utf8'))); response.on('error', () => finish(response.statusCode)); }); const timer = setTimeout(() => { req.destroy(); finish(-1); }, TIMEOUT_MS); req.on('timeout', () => { req.destroy(); finish(-1); }); req.on('error', () => finish(-1)); req.end(body); }); } const shared = new HovermateClient(); export const init = (...args) => shared.init(...args); export const hello = (...args) => shared.hello(...args); export const event = (...args) => shared.event(...args); export const custom = (...args) => shared.custom(...args); export const flush = (...args) => shared.flush(...args); export const shutdown = (...args) => shared.shutdown(...args); export const setLog = log => shared.setLog(log);