feat: signal server tag
This commit is contained in:
@@ -19,7 +19,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
||||
- The server loads the repository-root `.env` file on startup.
|
||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||
- `DB_PATH` can override the SQLite database file location.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, `serverTag`, and `linkPreview`. When `serverTag` is empty, `GET /api/health` falls back to the server's public URL.
|
||||
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
|
||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||
|
||||
33
server/src/config/signal-server-tag.ts
Normal file
33
server/src/config/signal-server-tag.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ServerHttpProtocol } from './variables';
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
return `[${host}]`;
|
||||
}
|
||||
|
||||
function getDisplayHost(serverHost: string | undefined): string {
|
||||
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
return serverHost;
|
||||
}
|
||||
|
||||
export function buildSignalServerPublicUrl(
|
||||
serverProtocol: ServerHttpProtocol,
|
||||
serverHost: string | undefined,
|
||||
serverPort: number
|
||||
): string {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
|
||||
return `${serverProtocol}://${displayHost}:${serverPort}`;
|
||||
}
|
||||
|
||||
export function resolveSignalServerTag(configuredTag: string | undefined, publicUrl: string): string {
|
||||
const normalizedTag = configuredTag?.trim();
|
||||
|
||||
return normalizedTag || publicUrl;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveRuntimePath } from '../runtime-paths';
|
||||
import { buildSignalServerPublicUrl, resolveSignalServerTag } from './signal-server-tag';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
@@ -21,6 +22,7 @@ export interface ServerVariablesConfig {
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
serverTag: string;
|
||||
linkPreview: LinkPreviewConfig;
|
||||
openApiDocs: OpenApiDocsConfig;
|
||||
}
|
||||
@@ -49,6 +51,10 @@ function normalizeServerHost(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerTag(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerProtocol(
|
||||
value: unknown,
|
||||
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||
@@ -162,6 +168,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
serverTag: normalizeServerTag(remainingParsed.serverTag),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||
};
|
||||
@@ -178,6 +185,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost,
|
||||
serverTag: normalized.serverTag,
|
||||
linkPreview: normalized.linkPreview,
|
||||
openApiDocs: normalized.openApiDocs
|
||||
};
|
||||
@@ -229,6 +237,18 @@ export function getServerHost(): string | undefined {
|
||||
return serverHost || undefined;
|
||||
}
|
||||
|
||||
export function getSignalServerPublicUrl(): string {
|
||||
const config = getVariablesConfig();
|
||||
|
||||
return buildSignalServerPublicUrl(config.serverProtocol, config.serverHost, config.serverPort);
|
||||
}
|
||||
|
||||
export function getServerTag(): string {
|
||||
const config = getVariablesConfig();
|
||||
|
||||
return resolveSignalServerTag(config.serverTag, getSignalServerPublicUrl());
|
||||
}
|
||||
|
||||
export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getAllPublicServers } from '../cqrs';
|
||||
import { getReleaseManifestUrl } from '../config/variables';
|
||||
import { getReleaseManifestUrl, getServerTag } from '../config/variables';
|
||||
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
||||
import { connectedUsers } from '../websocket/state';
|
||||
|
||||
@@ -27,7 +27,8 @@ router.get('/health', async (_req, res) => {
|
||||
connectedUsers: connectedUsers.size,
|
||||
serverInstanceId: SERVER_INSTANCE_ID,
|
||||
serverVersion: getServerProjectVersion(),
|
||||
releaseManifestUrl: getReleaseManifestUrl()
|
||||
releaseManifestUrl: getReleaseManifestUrl(),
|
||||
serverTag: getServerTag()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,11 +68,19 @@ describe('server websocket handler - status_update', () => {
|
||||
});
|
||||
|
||||
it('treats signaling keepalive messages as connection liveness', async () => {
|
||||
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||
const user = createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
|
||||
const sentMessages = (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
|
||||
const ack = JSON.parse(sentMessages[0]) as { type: string; serverTime: number };
|
||||
|
||||
expect(ack.type).toBe('keepalive_ack');
|
||||
expect(ack.serverTime).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('updates user status on valid status_update message', async () => {
|
||||
@@ -276,6 +284,34 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
||||
expect(aliceInList?.profileUpdatedAt).toBe(123);
|
||||
});
|
||||
|
||||
it('includes homeSignalServerUrl in server_users responses', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'user-1');
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
homeSignalServerUrl: 'http://signal.example.com:3001/'
|
||||
});
|
||||
|
||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-2', {
|
||||
type: 'view_server',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||
|
||||
expect(aliceInList?.homeSignalServerUrl).toBe('http://signal.example.com:3001');
|
||||
});
|
||||
|
||||
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
|
||||
@@ -39,6 +39,34 @@ function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeHomeSignalServerUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/\/+$/, '');
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function buildPresenceUserPayload(user: ConnectedUser): {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
status: 'online' | 'away' | 'busy' | 'offline';
|
||||
} {
|
||||
return {
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl,
|
||||
status: user.status ?? 'online'
|
||||
};
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -82,13 +110,7 @@ function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => ({
|
||||
oderId: cu.oderId,
|
||||
displayName: normalizeDisplayName(cu.displayName),
|
||||
description: cu.description,
|
||||
profileUpdatedAt: cu.profileUpdatedAt,
|
||||
status: cu.status ?? 'online'
|
||||
}));
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => buildPresenceUserPayload(cu));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
@@ -115,6 +137,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
@@ -127,11 +150,20 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'homeSignalServerUrl')) {
|
||||
user.homeSignalServerUrl = normalizeHomeSignalServerUrl(message['homeSignalServerUrl']);
|
||||
}
|
||||
|
||||
user.connectionScope = newScope;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
if (user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt) {
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||
&& user.homeSignalServerUrl === previousHomeSignalServerUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,11 +172,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
serverId,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId
|
||||
},
|
||||
user.oderId
|
||||
@@ -191,11 +219,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
sid,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId: sid
|
||||
},
|
||||
user.oderId
|
||||
@@ -460,6 +484,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
|
||||
switch (message.type) {
|
||||
case 'keepalive':
|
||||
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
|
||||
break;
|
||||
|
||||
case 'identify':
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ConnectedUser {
|
||||
* URLs routing to the same server coexist without an eviction loop.
|
||||
*/
|
||||
connectionScope?: string;
|
||||
/** Public signal-server URL the user registered on. */
|
||||
homeSignalServerUrl?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
|
||||
Reference in New Issue
Block a user