refactor(bridge): type the bridge and harden the websocket and file server

Type every module under bridge/src against a shared types.ts vocabulary and the
protocol payload types; narrow Wand IPC input as unknown instead of any.
Keep the Electron port types out of the CommonJS runtime files, where an export
statement makes esbuild treat the module as ESM and drop module.exports.

Reject unmasked client frames, enforce control-frame length and fragmentation
limits, and echo the peer close code.
Resolve static requests inside the panel root and serve the whole root, not
only assets/.
Ignore value-changed events that name a different trainer.
Validate envelopes through the shared protocol validator.
Bind the renderer destroyed listener once per sender.
Handle https response errors while fetching trainer strings.
Compare installed-app snapshots structurally so no field is missed.
This commit is contained in:
kitbyte
2026-08-29 16:56:45 +03:00
parent e04e313fa3
commit 5d1aa69a97
22 changed files with 758 additions and 502 deletions
+49 -22
View File
@@ -1,3 +1,6 @@
import type { BridgeClient, LogFn, ServerInfo } from './types';
import type { GameStatusPayload, InstalledAppsPayload, TrainerMetaPayload, TrainerValuesPayload, IncomingMessage } from '../../protocol/messages';
const {
gameStatusSignature,
installedAppsSignature,
@@ -6,18 +9,40 @@ const {
normalizeSnapshot,
normalizeTrainerValue,
summarizeInstalledAppsSource,
} = require('./normalizers');
const { cloneValue, isRecord, safeString } = require('./utils');
const { sendJson } = require('./websocket-codec');
} = require('./normalizers') as {
gameStatusSignature: (snapshot: GameStatusPayload) => string;
installedAppsSignature: (snapshot: InstalledAppsPayload) => string;
normalizeGameStatusSnapshot: (snapshot: unknown) => GameStatusPayload | null;
normalizeInstalledAppsSnapshot: (snapshot: unknown) => InstalledAppsPayload | null;
normalizeSnapshot: (snapshot: unknown) => BridgeStateSnapshot | null;
normalizeTrainerValue: (snapshot: BridgeStateSnapshot, target: string, value: unknown) => unknown;
summarizeInstalledAppsSource: (snapshot: unknown) => string;
};
const { cloneValue, isRecord, safeString } = require('./utils') as {
cloneValue: (value: unknown) => unknown;
isRecord: (value: unknown) => value is Record<string, unknown>;
safeString: (value: unknown, fallback?: string) => string;
};
const { sendJson } = require('./websocket-codec') as {
sendJson: (client: BridgeClient, type: string, payload: unknown, requestId?: string | number | null) => void;
};
function createBridgeState({ clients, log, getServerInfo }) {
let currentSnapshot: any = null;
let currentInstalledApps: any = null;
type BridgeStateSnapshot = { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload };
type BridgeStateOptions = {
clients: Iterable<BridgeClient>;
log: LogFn;
getServerInfo: () => ServerInfo & { listening: boolean; remoteUrl: string | null };
};
function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) {
let currentSnapshot: BridgeStateSnapshot | null = null;
let currentInstalledApps: InstalledAppsPayload | null = null;
let currentInstalledAppsSignature: string | null = null;
let currentGameStatus: any = null;
let currentGameStatus: GameStatusPayload | null = null;
let currentGameStatusSignature: string | null = null;
function broadcast(type, payload, requestId = null) {
function broadcast(type: IncomingMessage['type'], payload: unknown, requestId: string | null = null) {
for (const client of clients) {
if (client.handshaken) {
sendJson(client, type, payload, requestId);
@@ -25,7 +50,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
}
}
function sendSnapshot(client) {
function sendSnapshot(client: BridgeClient) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' });
} else {
@@ -36,7 +61,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps);
}
function sync(rawSnapshot) {
function sync(rawSnapshot: unknown) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
@@ -51,10 +76,9 @@ function createBridgeState({ clients, log, getServerInfo }) {
}
}
function syncTrainerMeta(rawSnapshot) {
function syncTrainerMeta(rawSnapshot: unknown) {
const localizedSnapshot = normalizeSnapshot(rawSnapshot);
const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId;
if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) {
if (!currentSnapshot || !localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== currentSnapshot.trainerMeta.trainer.trainerId) {
return;
}
@@ -62,15 +86,18 @@ function createBridgeState({ clients, log, getServerInfo }) {
broadcast('trainer_meta', currentSnapshot.trainerMeta);
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) return;
function valueChanged(change: unknown) {
const snapshot = currentSnapshot;
if (!snapshot || !isRecord(change)) return;
const target = safeString(change.target);
if (!target) return;
if (safeString(change.trainerId) !== snapshot.trainerMeta.trainer.trainerId) return;
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
currentSnapshot.trainerValues.values[target] = value;
const value = normalizeTrainerValue(snapshot, target, change.value);
snapshot.trainerValues.values[target] = value;
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
trainerId: snapshot.trainerMeta.trainer.trainerId,
target,
value,
oldValue: cloneValue(change.oldValue),
@@ -79,7 +106,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
});
}
function syncInstalledApps(rawInstalledApps) {
function syncInstalledApps(rawInstalledApps: unknown) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
@@ -90,11 +117,11 @@ function createBridgeState({ clients, log, getServerInfo }) {
if (nextSignature === currentInstalledAppsSignature) return;
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
log('info', `Installed apps snapshot accepted (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
function syncGameStatus(rawGameStatus: unknown) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
@@ -104,7 +131,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
if (nextSignature === currentGameStatusSignature) return;
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
log('info', `Game status snapshot accepted (${nextGameStatus.session.state}/${nextGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
+3 -5
View File
@@ -1,6 +1,8 @@
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
const { ECheatType } = require('../../protocol/messages');
const WEB_CONTRACT = require('../../protocol/web-contract.json');
const KNOWN_CHEAT_TYPES = new Set(Object.values(ECheatType));
const WS_OPCODE = Object.freeze({
TEXT: 1,
BINARY: 2,
@@ -34,12 +36,8 @@ module.exports = {
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
RENDERER_SCRIPT_API_VERSION: 1,
+2 -1
View File
@@ -1,6 +1,7 @@
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
const { installWandRuntime: installRuntime } = require('./wand/runtime');
import type { BridgeOptions, ElectronPort } from './types';
import type { BridgeOptions } from './types';
import type { ElectronPort } from './types';
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
if (options.panelRoot) {
+7 -6
View File
@@ -3,30 +3,31 @@ const os = require('node:os');
const path = require('node:path');
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
import type { BridgeOptions } from './types';
import type { BridgeLogger, BridgeOptions, LogLevel } from './types';
function writeLogLine(logFile, level, message, error) {
function writeLogLine(logFile: string, level: LogLevel, message: string, error?: unknown) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
const tag = `[wand-remote-bridge] ${message}`;
// Logging runs inside Wand's own process; a failure here must never take the app down.
try {
console[method](tag, error || '');
} catch { }
try {
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
const detail = error ? ` :: ${error && typeof error === 'object' && 'stack' in error ? String(error.stack) : String(error)}` : '';
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
} catch { }
}
function createBridgeLogger(options: BridgeOptions = {}) {
function createBridgeLogger(options: BridgeOptions = {}): BridgeLogger {
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
const log = ((level: LogLevel, message: string, error?: unknown) => writeLogLine(logFile, level, message, error)) as BridgeLogger;
log.file = logFile;
return log;
}
function writeInstallLog(level, message, error) {
function writeInstallLog(level: LogLevel, message: string, error?: unknown) {
writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error);
}
+15 -10
View File
@@ -1,17 +1,22 @@
import type { RemoteCommandAction, RemoteCommandResultPayload } from '../../../protocol/messages';
import type { UnknownRecord } from '../types';
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeRemoteCommandAction(value) {
return value === 'launch' || value === 'stop' ? value : null;
function normalizeRemoteCommandAction(value: unknown): RemoteCommandAction | null {
return value === 'launch' || value === 'stop' ? (value as RemoteCommandAction) : null;
}
function normalizeRemoteCommandResult(rawResult, fallback) {
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
function normalizeRemoteCommandResult(rawResult: unknown, fallback: { action: RemoteCommandAction, gameId?: string | null, titleId?: string | null }): RemoteCommandResultPayload {
const raw = isRecord(rawResult) ? (rawResult as UnknownRecord) : null;
const action = normalizeRemoteCommandAction(raw ? raw.action : null) || fallback.action;
const gameId = raw ? toStringId(raw.gameId) || fallback.gameId || null : fallback.gameId || null;
const titleId = raw ? toStringId(raw.titleId) || fallback.titleId || null : fallback.titleId || null;
const ok = rawResult === true || Boolean(raw && raw.ok === true);
const payload = { ok, action, gameId, titleId };
if (ok) return payload;
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
const errorRaw = raw && isRecord(raw.error) ? (raw.error as UnknownRecord) : null;
if (!errorRaw) {
return {
...payload,
error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' },
@@ -20,8 +25,8 @@ function normalizeRemoteCommandResult(rawResult, fallback) {
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
code: safeString(errorRaw.code, 'command_rejected'),
message: safeString(errorRaw.message, 'The renderer rejected the remote command.'),
},
};
}
+9 -6
View File
@@ -1,12 +1,15 @@
import type { GameStatusPayload } from '../../../protocol/messages';
import type { UnknownRecord } from '../types';
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeGameStatusSnapshot(rawSnapshot) {
function normalizeGameStatusSnapshot(rawSnapshot: unknown): GameStatusPayload | null {
if (!isRecord(rawSnapshot)) return null;
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
const snap = rawSnapshot as UnknownRecord;
const rawSession = isRecord(snap.session) ? (snap.session as UnknownRecord) : {};
const rawTrainer = isRecord(snap.trainer) ? (snap.trainer as UnknownRecord) : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
instanceId: safeString(snap.instanceId, 'wand-game-status'),
updatedAt: typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
@@ -29,7 +32,7 @@ function normalizeGameStatusSnapshot(rawSnapshot) {
};
}
function gameStatusSignature(snapshot) {
function gameStatusSignature(snapshot: GameStatusPayload): string {
return [
snapshot.session.state,
snapshot.session.event,
+110 -104
View File
@@ -1,10 +1,12 @@
import type { CheatArgs, CheatOption, CheatSchema, InstalledAppsPayload, InstalledAppSummary, TrainerMetaPayload, TrainerValuesPayload } from '../../../protocol/messages';
import type { UnknownRecord } from '../types';
const { KNOWN_CHEAT_TYPES } = require('../constants');
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils');
const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results');
const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status');
const { normalizeTrainerValue } = require('./trainer');
function normalizeOption(option) {
function normalizeOption(option: unknown): CheatOption | null {
if (typeof option === 'string' || typeof option === 'number') {
return {
label: String(option),
@@ -16,77 +18,80 @@ function normalizeOption(option) {
return null;
}
const value = option.value;
const opt = option as UnknownRecord;
const value = opt.value;
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
return {
label: safeString(option.label, String(value)),
label: safeString(opt.label, String(value)),
value,
};
}
function normalizeArgs(args) {
function normalizeArgs(args: unknown): CheatArgs {
if (!isRecord(args)) {
return {};
}
const next: Record<string, unknown> = {};
if (typeof args.min === 'number') next.min = args.min;
if (typeof args.max === 'number') next.max = args.max;
if (typeof args.step === 'number') next.step = args.step;
if (typeof args.postfix === 'string') next.postfix = args.postfix;
if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') {
next.default = args.default;
const a = args as UnknownRecord;
const next: CheatArgs = {};
if (typeof a.min === 'number') next.min = a.min;
if (typeof a.max === 'number') next.max = a.max;
if (typeof a.step === 'number') next.step = a.step;
if (typeof a.postfix === 'string') next.postfix = a.postfix;
if (typeof a.default === 'string' || typeof a.default === 'number' || typeof a.default === 'boolean') {
next.default = a.default;
}
if (Array.isArray(args.options)) {
next.options = args.options.map(normalizeOption).filter(Boolean);
if (Array.isArray(a.options)) {
next.options = a.options.map(normalizeOption).filter(Boolean) as CheatOption[];
}
if (typeof args.button === 'string' || typeof args.button === 'boolean') {
next.button = args.button;
if (typeof a.button === 'string' || typeof a.button === 'boolean') {
next.button = a.button;
}
return next;
}
function normalizeCheat(cheat, index) {
function normalizeCheat(cheat: unknown, index: number): CheatSchema | null {
if (!isRecord(cheat)) {
return null;
}
const target = safeString(cheat.target);
const type = safeString(cheat.type);
const c = cheat as UnknownRecord;
const target = safeString(c.target);
const type = safeString(c.type) as CheatSchema['type'];
if (!target || !KNOWN_CHEAT_TYPES.has(type)) {
return null;
}
const normalized: Record<string, unknown> = {
uuid: safeString(cheat.uuid, `${target}-${index}`),
const normalized: CheatSchema = {
uuid: safeString(c.uuid, `${target}-${index}`),
target,
type,
name: safeString(cheat.name, target),
description: typeof cheat.description === 'string' ? cheat.description : null,
instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null,
category: safeString(cheat.category, 'general'),
parent: typeof cheat.parent === 'string' ? cheat.parent : null,
args: normalizeArgs(cheat.args),
name: safeString(c.name, target),
description: typeof c.description === 'string' ? c.description : null,
instructions: typeof c.instructions === 'string' ? c.instructions : null,
category: safeString(c.category, 'general'),
parent: typeof c.parent === 'string' ? c.parent : null,
args: normalizeArgs(c.args),
};
if (typeof cheat.flags === 'number') {
normalized.flags = cheat.flags;
if (typeof c.flags === 'number') {
normalized.flags = c.flags;
}
if (Array.isArray(cheat.hotkeys)) {
normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item)));
if (Array.isArray(c.hotkeys)) {
normalized.hotkeys = c.hotkeys.filter(Array.isArray).map((group: unknown[]) => group.map((item: unknown) => String(item)));
}
return normalized;
}
function normalizeImageUrl(...values) {
function normalizeImageUrl(...values: unknown[]): string | null {
const value = firstString(...values);
if (!value) {
return null;
@@ -100,76 +105,78 @@ function normalizeImageUrl(...values) {
}
}
function getRawInstalledApps(rawSnapshot) {
function getRawInstalledApps(rawSnapshot: unknown): unknown[] | null {
if (Array.isArray(rawSnapshot)) {
return rawSnapshot;
}
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) {
return rawSnapshot.apps;
}
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) {
return rawSnapshot.installedApps;
if (isRecord(rawSnapshot)) {
const snap = rawSnapshot as UnknownRecord;
if (Array.isArray(snap.apps)) return snap.apps;
if (Array.isArray(snap.installedApps)) return snap.installedApps;
}
return null;
}
function normalizeInstalledApp(app) {
function normalizeInstalledApp(app: unknown): InstalledAppSummary | null {
if (!isRecord(app)) {
return null;
}
const a = app as UnknownRecord;
const platform = safeString(app.platform);
const sku = safeString(app.sku);
const platform = safeString(a.platform);
const sku = safeString(a.sku);
if (!platform || !sku) {
return null;
}
const location = typeof app.location === 'string' ? app.location : '';
const location = typeof a.location === 'string' ? a.location : '';
return {
platform,
sku,
correlationId: `${platform}:${sku}`,
displayName: firstString(
app.displayName,
app.titleName,
app.gameName,
app.name,
a.displayName,
a.titleName,
a.gameName,
a.name,
location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '',
`${platform}:${sku}`
),
gameId: toStringId(app.gameId),
titleId: toStringId(app.titleId),
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
gameId: toStringId(a.gameId),
titleId: toStringId(a.titleId),
imageUrl: normalizeImageUrl(a.imageUrl, a.iconUrl, a.coverUrl, a.thumbnailUrl, a.logoUrl, a.headerImageUrl),
platformLastPlayedTimestamp: typeof a.platformLastPlayedTimestamp === 'number' ? a.platformLastPlayedTimestamp : null,
platformTotalPlaytimeMinutes: typeof a.platformTotalPlaytimeMinutes === 'number' ? a.platformTotalPlaytimeMinutes : null,
};
}
function normalizeInstalledAppsSnapshot(rawSnapshot) {
function normalizeInstalledAppsSnapshot(rawSnapshot: unknown): InstalledAppsPayload | null {
const rawApps = getRawInstalledApps(rawSnapshot);
if (!rawApps) {
return null;
}
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean) as InstalledAppSummary[];
apps.sort(compareInstalledApps);
const snap = isRecord(rawSnapshot) ? (rawSnapshot as UnknownRecord) : null;
return {
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
instanceId: snap ? safeString(snap.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
updatedAt: snap && typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(),
apps,
};
}
function summarizeInstalledAppsSource(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
return '';
}
function summarizeInstalledAppsSource(rawSnapshot: unknown): string {
if (!isRecord(rawSnapshot)) return '';
const snap = rawSnapshot as UnknownRecord;
if (!isRecord(snap.diagnostics)) return '';
const diag = snap.diagnostics as UnknownRecord;
const parts: string[] = [];
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
const value = rawSnapshot.diagnostics[key];
const value = diag[key];
if (typeof value === 'number') {
parts.push(`${key}=${value}`);
}
@@ -178,69 +185,68 @@ function summarizeInstalledAppsSource(rawSnapshot) {
return parts.join(', ');
}
function installedAppsSignature(snapshot) {
return snapshot.apps
.map((app) => [
app.platform,
app.sku,
app.displayName,
app.gameId || '',
app.titleId || '',
app.imageUrl || '',
app.platformLastPlayedTimestamp || '',
app.platformTotalPlaytimeMinutes || '',
].join('|'))
.join('\n');
/**
* Structural, not field-by-field: an explicit field list silently stops detecting
* whatever it forgets. The apps are already normalized here, so key order is stable.
*/
function installedAppsSignature(snapshot: InstalledAppsPayload): string {
return JSON.stringify(snapshot.apps);
}
function normalizeSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
return null;
}
function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload } | null {
if (!isRecord(rawSnapshot)) return null;
const snap = rawSnapshot as UnknownRecord;
if (!isRecord(snap.metadata)) return null;
const meta = snap.metadata as UnknownRecord;
if (!isRecord(meta.info)) return null;
const info = meta.info as UnknownRecord;
const info = rawSnapshot.metadata.info;
const blueprint = isRecord(info.blueprint) ? info.blueprint : {};
const blueprint = isRecord(info.blueprint) ? (info.blueprint as UnknownRecord) : {};
const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : [];
const cheats = rawCheats.map(normalizeCheat).filter(Boolean);
const cheats = rawCheats.map(normalizeCheat).filter(Boolean) as CheatSchema[];
const categories = Array.from(new Set(cheats.map((entry) => entry.category)));
const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId);
const trainerInfo = isRecord(snap.trainerInfo) ? (snap.trainerInfo as UnknownRecord) : null;
const infoGame = isRecord(info.game) ? (info.game as UnknownRecord) : null;
const trainerId = safeString(snap.trainerId || trainerInfo?.trainerId);
const displayName = firstString(
rawSnapshot.trainerInfo?.displayName,
rawSnapshot.trainerInfo?.gameName,
rawSnapshot.trainerInfo?.titleName,
rawSnapshot.trainerInfo?.title,
rawSnapshot.trainerInfo?.name,
trainerInfo?.displayName,
trainerInfo?.gameName,
trainerInfo?.titleName,
trainerInfo?.title,
trainerInfo?.name,
info.displayName,
info.gameName,
info.titleName,
info.title,
info.name,
info.game?.displayName,
info.game?.name,
info.game?.title
infoGame?.displayName,
infoGame?.name,
infoGame?.title
);
if (!trainerId) {
return null;
}
const trainerMeta = {
const trainerMeta: TrainerMetaPayload = {
session: {
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
instanceId: safeString(snap.instanceId, 'wand-session'),
},
trainer: {
trainerId,
gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId),
displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId),
gameId: safeString(trainerInfo?.gameId || info.gameId),
displayName: displayName || safeString(trainerInfo?.gameId || info.gameId, trainerId),
titleId: typeof info.titleId === 'string' ? info.titleId : null,
gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null,
trainerLoading: rawSnapshot.trainerLoading === true,
gameInstalled: rawSnapshot.gameInstalled !== false,
needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true,
language: safeString(rawSnapshot.language, 'en-US'),
themeId: safeString(rawSnapshot.themeId, 'default'),
isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true,
notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null,
gameVersion: typeof snap.gameVersion === 'string' ? snap.gameVersion : null,
trainerLoading: snap.trainerLoading === true,
gameInstalled: snap.gameInstalled !== false,
needsCompatibilityWarning: snap.needsCompatibilityWarning === true,
language: safeString(snap.language, 'en-US'),
themeId: safeString(snap.themeId, 'default'),
isTimeLimitExpired: snap.isTimeLimitExpired === true,
notesReadHash: typeof snap.notesReadHash === 'string' ? snap.notesReadHash : null,
},
schema: {
categories,
@@ -248,9 +254,9 @@ function normalizeSnapshot(rawSnapshot) {
},
};
const trainerValues = {
const trainerValues: TrainerValuesPayload = {
trainerId,
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
values: isRecord(snap.values) ? (cloneValue(snap.values) as Record<string, unknown>) : {},
};
for (const cheat of cheats) {
if (cheat.target in trainerValues.values) {
@@ -264,7 +270,7 @@ function normalizeSnapshot(rawSnapshot) {
};
}
function compareInstalledApps(left, right) {
function compareInstalledApps(left: InstalledAppSummary, right: InstalledAppSummary): number {
const displayNameDiff = left.displayName.localeCompare(right.displayName);
if (displayNameDiff !== 0) {
return displayNameDiff;
+15 -7
View File
@@ -1,10 +1,18 @@
export function normalizeTrainerValue(snapshot, target, value) {
import { cloneValue } from '../utils';
type SnapshotShape = {
trainerMeta?: {
schema?: {
cheats?: Array<{ target: string; type?: string }>;
};
};
};
export function normalizeTrainerValue(
snapshot: SnapshotShape | null | undefined,
target: string,
value: unknown
): unknown {
const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target);
return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value);
}
function cloneValue(value) {
if (Array.isArray(value)) return value.map(cloneValue);
if (typeof value !== 'object' || value === null) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]));
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest';
import { ECheatType } from '../../protocol/messages';
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
const snapshot = {
trainerMeta: {
trainer: { trainerId: 'active' },
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
schema: { cheats: [{ target: 'god', type: ECheatType.Toggle }] },
},
trainerValues: { values: { god: false } },
};
+27 -30
View File
@@ -1,8 +1,11 @@
import webContract from '../../protocol/web-contract.json';
import { isRecord, safeString } from './utils';
import { isOutgoingMessage } from '../../protocol/validation';
import type { CheatSchema, SetValueMessage, TrainerMetaPayload, TrainerValuesPayload } from '../../protocol/messages';
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
export function validateClientMessage(message, handshaken) {
export function validateClientMessage(message: unknown, handshaken: boolean) {
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
}
@@ -15,35 +18,37 @@ export function validateClientMessage(message, handshaken) {
return invalid('invalid_request_id', 'requestId must be a string or null.');
}
if (message.type === 'hello') {
if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) {
if (!isOutgoingMessage(message)) {
const type = (message as Record<string, unknown>).type;
if (type === 'hello') {
return invalid('invalid_hello', 'The hello payload is incomplete.');
}
return { ok: true };
if (type === 'set_value') {
return invalid('invalid_set_value', 'trainerId, target and value are required.');
}
if (type === 'remote_command') {
return invalid('invalid_command', 'Unknown remote command.');
}
return invalid('unknown_message', 'Unknown protocol message type.');
}
if (!handshaken) {
if (message.type !== 'hello' && !handshaken) {
return invalid('handshake_required', 'Send a compatible hello message before commands.');
}
if (message.type === 'set_value') {
if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) {
return invalid('invalid_set_value', 'trainerId, target and value are required.');
}
return { ok: true };
}
if (message.type === 'remote_command') {
if (message.payload.action !== 'launch' && message.payload.action !== 'stop') {
return invalid('invalid_command', 'Unknown remote command.');
}
return { ok: true };
}
return invalid('unknown_message', 'Unknown protocol message type.');
return { ok: true };
}
export function validateSetValueTarget(message, snapshot) {
/** Only the parts this validator actually reads, so callers need not build a full payload. */
type ValidationSnapshot = {
trainerMeta: {
trainer: Pick<TrainerMetaPayload['trainer'], 'trainerId'>;
schema: { cheats: Pick<CheatSchema, 'target' | 'type'>[] };
};
trainerValues: Pick<TrainerValuesPayload, 'values'>;
};
export function validateSetValueTarget(message: Pick<SetValueMessage, 'payload'>, snapshot: ValidationSnapshot | null) {
const target = safeString(message.payload?.target);
const requestedTrainerId = safeString(message.payload?.trainerId);
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
@@ -65,14 +70,6 @@ export function validateSetValueTarget(message, snapshot) {
};
}
function invalid(code, message) {
function invalid(code: string, message: string) {
return { ok: false, error: { code, message } };
}
function isRecord(value) {
return typeof value === 'object' && value !== null;
}
function safeString(value) {
return typeof value === 'string' && value.length > 0 ? value : '';
}
+6 -1
View File
@@ -1,12 +1,17 @@
const { createBridgeServer } = require('./server');
import type { BridgeOptions } from './types';
declare global {
var __wandRemoteBridgeRuntime: ReturnType<typeof createBridgeRuntime> | undefined;
}
function createBridgeRuntime(options: BridgeOptions = {}) {
return createBridgeServer(options);
}
function ensureBridge(options: BridgeOptions = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
// A closed instance must not be handed out again: its server is gone and its state cleared.
if (!globalThis.__wandRemoteBridgeRuntime || globalThis.__wandRemoteBridgeRuntime.closed) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
+51 -13
View File
@@ -18,7 +18,10 @@ const VIRTUAL_MAC_PREFIXES = new Set([
'52:54:00',
]);
function contentTypeFor(filePath) {
import type { NetworkInterfaceInfo } from 'node:os';
import type { ServerResponse } from 'node:http';
function contentTypeFor(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
case '.html':
@@ -37,12 +40,12 @@ function contentTypeFor(filePath) {
}
}
function getAdvertisedUrls(port) {
const candidates: any[] = [];
function getAdvertisedUrls(port: number) {
const candidates: { index: number, score: number, url: string }[] = [];
const interfaces = os.networkInterfaces();
let index = 0;
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
for (const [name, entries] of Object.entries(interfaces) as [string, NetworkInterfaceInfo[] | undefined][]) {
if (!entries) {
continue;
}
@@ -69,15 +72,15 @@ function getAdvertisedUrls(port) {
return Array.from(new Set(urls));
}
function isUsableIpv4Entry(entry) {
function isUsableIpv4Entry(entry: NetworkInterfaceInfo) {
return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address));
}
function isIpv4Family(family) {
function isIpv4Family(family: string | number) {
return family === 'IPv4' || family === 4;
}
function scoreIpv4Entry(name, entry) {
function scoreIpv4Entry(name: string, entry: NetworkInterfaceInfo) {
const octets = parseIpv4(entry.address) as number[];
let score = 0;
@@ -116,7 +119,7 @@ function scoreIpv4Entry(name, entry) {
return score;
}
function parseIpv4(address): number[] | null {
function parseIpv4(address: unknown): number[] | null {
if (typeof address !== 'string') {
return null;
}
@@ -130,15 +133,15 @@ function parseIpv4(address): number[] | null {
return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
}
function isPrivateIpv4(octets) {
function isPrivateIpv4(octets: number[]) {
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
}
function isLinkLocalIpv4(octets) {
function isLinkLocalIpv4(octets: number[]) {
return octets[0] === 169 && octets[1] === 254;
}
function isVirtualMac(mac) {
function isVirtualMac(mac: unknown) {
if (typeof mac !== 'string') {
return false;
}
@@ -146,9 +149,43 @@ function isVirtualMac(mac) {
return VIRTUAL_MAC_PREFIXES.has(mac.toLowerCase().slice(0, 8));
}
function serveFile(response, filePath) {
// Async on purpose: this runs on the same event loop as every live WebSocket client,
// so a blocking read would stall trainer updates for everyone.
/**
* Resolves a request path inside `root`, or null when it escapes.
* Today's routing happens to be safe only because pathnames are never percent-decoded;
* decoding without this check would turn `%2e%2e%2f` into a real traversal.
*/
function resolveInsideRoot(root: string, relativePath: string): string | null {
const decoded = safeDecode(relativePath);
if (decoded === null || decoded.indexOf('\0') >= 0) {
return null;
}
const resolvedRoot = path.resolve(root);
const candidate = path.resolve(resolvedRoot, `.${path.sep}${decoded}`);
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
return candidate === resolvedRoot || candidate.startsWith(prefix) ? candidate : null;
}
function safeDecode(value: string): string | null {
try {
const content = fs.readFileSync(filePath);
return decodeURIComponent(value);
} catch {
return null;
}
}
async function serveFile(response: ServerResponse, filePath: string | null) {
if (filePath === null) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
try {
const content = await fs.promises.readFile(filePath);
response.writeHead(200, {
'Content-Type': contentTypeFor(filePath),
'Cache-Control': 'no-store',
@@ -162,5 +199,6 @@ function serveFile(response, filePath) {
module.exports = {
getAdvertisedUrls,
resolveInsideRoot,
serveFile,
};
+173 -141
View File
@@ -8,7 +8,6 @@ const {
DEFAULT_REMOTE_PORT,
DEV_SERVER_PORTS,
PORT_SCAN_RANGE,
REMOTE_ASSETS_PREFIX,
REMOTE_BASE_PATH,
REMOTE_HEALTH_PATH,
REMOTE_WS_PATH,
@@ -21,17 +20,43 @@ const {
} = require('./normalizers');
const { createBridgeState } = require('./bridge-state');
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
const { getAdvertisedUrls, serveFile } = require('./server-files');
const { cloneValue, isValidPort, safeString } = require('./utils');
const { getAdvertisedUrls, resolveInsideRoot, serveFile } = require('./server-files');
const { cloneValue, isValidPort, safeString, toStringId } = require('./utils');
const {
closeClient,
createAcceptKey,
FRAME_TOO_LARGE_ERROR,
WS_PROTOCOL_ERROR,
makeFrame,
parseFrame,
sendJson,
} = require('./websocket-codec');
import type { BridgeOptions } from './types';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Socket } from 'node:net';
import type { BridgeOptions, BridgeClient } from './types';
const HTTP_BAD_REQUEST = 400;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const WS_CLOSE_NORMAL = 1000;
const WS_CLOSE_PROTOCOL_ERROR = 1002;
const WS_CLOSE_UNSUPPORTED = 1003;
const WS_CLOSE_TOO_LARGE = 1009;
declare global {
var __wandRemoteBridgeUrl: string | undefined;
var __wandRemoteBridgeLogFile: string | undefined;
}
type SetValueHandler = (args: { trainerId: string; target: string; value: unknown; cheatId?: string }) => boolean | Promise<boolean>;
type CommandHandler = (args: { action: string; gameId: string; titleId: string }) => unknown | Promise<unknown>;
type ClientMessage = {
type?: string;
requestId?: string | number | null;
payload?: Record<string, unknown>;
};
function createBridgeServer(options: BridgeOptions = {}) {
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
@@ -39,12 +64,13 @@ function createBridgeServer(options: BridgeOptions = {}) {
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
const panelRoot = options.panelRoot || path.dirname(__dirname);
const clients = new Set<any>();
const clients = new Set<BridgeClient>();
const log = createBridgeLogger(options);
let advertisedUrls: string[] = [];
let setValueHandler: any = null;
let commandHandler: any = null;
let setValueHandler: SetValueHandler | null = null;
let commandHandler: CommandHandler | null = null;
let listening = false;
let closed = false;
const bridgeState = createBridgeState({
clients,
log,
@@ -55,24 +81,24 @@ function createBridgeServer(options: BridgeOptions = {}) {
}),
});
function setAdvertisedPort(nextPort) {
function setAdvertisedPort(nextPort: number) {
port = nextPort;
advertisedUrls = getAdvertisedUrls(port);
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry: string) => !entry.includes('localhost')) || advertisedUrls[0];
}
function setHandler(handler) {
function setHandler(handler: SetValueHandler | null) {
setValueHandler = typeof handler === 'function' ? handler : null;
}
function setCommandHandler(handler) {
function setCommandHandler(handler: CommandHandler | null) {
commandHandler = typeof handler === 'function' ? handler : null;
}
function handleRequest(request, response) {
function handleRequest(request: IncomingMessage, response: ServerResponse) {
const url = parseRequestUrl(request.url);
if (!url) {
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
response.writeHead(HTTP_BAD_REQUEST, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad Request');
return;
}
@@ -100,23 +126,21 @@ function createBridgeServer(options: BridgeOptions = {}) {
return;
}
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
// Any file under the panel root, not just assets/: a Vite build also emits
// icons and a manifest at the root, and /remote/index.html must resolve too.
if (url.pathname.startsWith(REMOTE_BASE_PATH)) {
serveFile(response, resolveInsideRoot(panelRoot, url.pathname.slice(REMOTE_BASE_PATH.length)));
return;
}
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.writeHead(HTTP_NOT_FOUND, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
}
async function handleRemoteCommandMessage(client, message) {
async function handleRemoteCommandMessage(client: BridgeClient, message: ClientMessage) {
const action = normalizeRemoteCommandAction(message.payload?.action);
const gameId = typeof message.payload?.gameId === 'string' || typeof message.payload?.gameId === 'number'
? String(message.payload.gameId)
: null;
const titleId = typeof message.payload?.titleId === 'string' || typeof message.payload?.titleId === 'number'
? String(message.payload.titleId)
: null;
const gameId = toStringId(message.payload?.gameId);
const titleId = toStringId(message.payload?.titleId);
if (!action) {
sendJson(client, 'error', {
@@ -164,76 +188,52 @@ function createBridgeServer(options: BridgeOptions = {}) {
}
}
async function handleSetValueMessage(client, message) {
async function handleSetValueMessage(client: BridgeClient, message: ClientMessage) {
const currentSnapshot = bridgeState.snapshot;
const validation = validateSetValueTarget(message, currentSnapshot);
const trainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId || '';
const target = validation.ok ? validation.target : safeString(message.payload?.target);
const reply = (error?: { code: string; message: string }) => sendJson(
client,
'set_value_result',
{ ok: !error, trainerId, target, error },
message.requestId ?? null,
);
if (!validation.ok) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
target: safeString(message.payload?.target),
error: validation.error,
}, message.requestId ?? null);
reply(validation.error);
return;
}
const { target } = validation;
if (!setValueHandler) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'bridge_not_ready',
message: 'The local bridge is not ready to write trainer values yet.',
},
}, message.requestId ?? null);
reply({
code: 'bridge_not_ready',
message: 'The local bridge is not ready to write trainer values yet.',
});
return;
}
let result = false;
let accepted = false;
try {
result = await Promise.resolve(setValueHandler({
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
accepted = await Promise.resolve(setValueHandler({
trainerId,
target,
value: cloneValue(validation.value),
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
}));
} catch (error) {
log('warn', 'Set-value handler failed.', error);
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'set_failed',
message: 'Failed to set trainer value.',
},
}, message.requestId ?? null);
reply({ code: 'set_failed', message: 'Failed to set trainer value.' });
return;
}
if (!result) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'set_rejected',
message: 'The trainer rejected the requested value.',
},
}, message.requestId ?? null);
return;
}
sendJson(client, 'set_value_result', {
ok: true,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
}, message.requestId ?? null);
reply(accepted ? undefined : {
code: 'set_rejected',
message: 'The trainer rejected the requested value.',
});
}
async function handleClientMessage(client, message) {
async function handleClientMessage(client: BridgeClient, message: ClientMessage) {
const validation = validateClientMessage(message, client.handshaken);
if (!validation.ok) {
sendJson(client, 'error', validation.error, message?.requestId ?? null);
@@ -264,91 +264,118 @@ function createBridgeServer(options: BridgeOptions = {}) {
}
}
function bindSocket(socket) {
const client = {
// Handling a message can await a renderer round-trip, so frames are drained by a single
// loop per client. Appending on 'data' must never interleave with the loop mutating the
// buffer, or frames are duplicated or lost.
async function drainFrames(client: BridgeClient) {
if (client.draining) {
return;
}
client.draining = true;
try {
while (!client.closed && client.buffer.length > 0) {
const frame = parseFrame(client.buffer);
if (!frame) {
return;
}
client.buffer = client.buffer.subarray(frame.bytesConsumed);
if (!frame.fin) {
closeClient(client, WS_CLOSE_UNSUPPORTED, 'Fragmented frames are not supported.');
return;
}
if (frame.opcode === WS_OPCODE.CLOSE) {
let code = WS_CLOSE_NORMAL;
if (frame.payload.length >= 2) {
code = frame.payload.readUInt16BE(0);
}
closeClient(client, code, 'Closing');
return;
}
if (frame.opcode === WS_OPCODE.PING) {
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
continue;
}
if (frame.opcode !== WS_OPCODE.TEXT) {
continue;
}
try {
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
} catch (error) {
// The frame is already consumed, so the ones behind it stay drainable.
sendJson(client, 'error', {
code: 'invalid_message',
message: error instanceof Error ? error.message : 'Failed to process client message.',
});
}
}
} catch (error) {
if (error instanceof Error && 'code' in error) {
if (error.code === FRAME_TOO_LARGE_ERROR) {
closeClient(client, WS_CLOSE_TOO_LARGE, error.message);
return;
}
if (error.code === WS_PROTOCOL_ERROR) {
closeClient(client, WS_CLOSE_PROTOCOL_ERROR, error.message);
return;
}
}
log('warn', 'Dropping client after an unreadable frame.', error);
closeClient(client, WS_CLOSE_PROTOCOL_ERROR, 'Protocol error.');
} finally {
client.draining = false;
}
}
function bindSocket(socket: Socket) {
const client: BridgeClient = {
socket,
buffer: Buffer.alloc(0),
closed: false,
draining: false,
handshaken: false,
};
clients.add(client);
socket.on('data', async (chunk) => {
try {
client.buffer = Buffer.concat([client.buffer, chunk]);
while (client.buffer.length > 0) {
const frame = parseFrame(client.buffer);
if (!frame) {
return;
}
client.buffer = client.buffer.subarray(frame.bytesConsumed);
if (!frame.fin) {
closeClient(client, 1003, 'Fragmented frames are not supported.');
return;
}
if (frame.opcode === WS_OPCODE.CLOSE) {
closeClient(client, 1000, 'Closing');
return;
}
if (frame.opcode === WS_OPCODE.PING) {
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
continue;
}
if (frame.opcode !== WS_OPCODE.TEXT) {
continue;
}
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === FRAME_TOO_LARGE_ERROR) {
closeClient(client, 1009, error.message);
return;
}
sendJson(client, 'error', {
code: 'invalid_message',
message: error instanceof Error ? error.message : 'Failed to process client message.',
});
const dropClient = (error?: unknown) => {
client.closed = true;
clients.delete(client);
if (error) {
log('warn', 'WebSocket client error.', error);
}
};
socket.on('data', (chunk: Buffer) => {
client.buffer = Buffer.concat([client.buffer, chunk]);
void drainFrames(client);
});
socket.on('close', () => {
client.closed = true;
clients.delete(client);
});
socket.on('end', () => {
client.closed = true;
clients.delete(client);
});
socket.on('error', (error) => {
client.closed = true;
clients.delete(client);
log('warn', 'WebSocket client error.', error);
});
socket.on('close', () => dropClient());
socket.on('end', () => dropClient());
socket.on('error', dropClient);
}
function handleUpgrade(request, socket) {
function handleUpgrade(request: IncomingMessage, socket: Socket) {
const url = parseRequestUrl(request.url);
if (!url) {
rejectUpgrade(socket, 400, 'Bad Request');
rejectUpgrade(socket, HTTP_BAD_REQUEST, 'Bad Request');
return;
}
if (url.pathname !== REMOTE_WS_PATH) {
rejectUpgrade(socket, 404, 'Not Found');
rejectUpgrade(socket, HTTP_NOT_FOUND, 'Not Found');
return;
}
if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) {
rejectUpgrade(socket, 403, 'Forbidden');
rejectUpgrade(socket, HTTP_FORBIDDEN, 'Forbidden');
return;
}
@@ -370,7 +397,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
bindSocket(socket);
}
function listen(nextPort) {
function listen(nextPort: number) {
setAdvertisedPort(nextPort);
server.listen(port, host);
}
@@ -381,7 +408,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
const server = http.createServer(handleRequest);
server.on('upgrade', handleUpgrade);
server.on('error', (error) => {
server.on('error', (error: Error & { code?: string }) => {
if (!listening && error && error.code === 'EADDRINUSE' && port < maxPort) {
const nextPort = port + 1;
log('warn', `Port ${port} is busy, trying ${nextPort}.`);
@@ -405,6 +432,9 @@ function createBridgeServer(options: BridgeOptions = {}) {
get listening() {
return listening;
},
get closed() {
return closed;
},
get remoteUrl() {
return globalThis.__wandRemoteBridgeUrl;
},
@@ -415,6 +445,8 @@ function createBridgeServer(options: BridgeOptions = {}) {
clients.clear();
bridgeState.clear();
listening = false;
closed = true;
globalThis.__wandRemoteBridgeUrl = undefined;
server.close();
},
setCommandHandler,
@@ -427,7 +459,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
};
}
function parseRequestUrl(requestUrl) {
function parseRequestUrl(requestUrl: string | undefined) {
try {
return new URL(requestUrl || '/', 'http://localhost');
} catch {
@@ -435,7 +467,7 @@ function parseRequestUrl(requestUrl) {
}
}
function isAllowedWebSocketOrigin(origin, host) {
function isAllowedWebSocketOrigin(origin: string | undefined, host: string | undefined) {
if (origin === undefined) {
return true;
}
@@ -453,17 +485,17 @@ function isAllowedWebSocketOrigin(origin, host) {
const sameHostname = parsed.hostname.toLowerCase() === requested.hostname.toLowerCase();
const compatibleLoopback = isLoopback(parsed.hostname) && isLoopback(requested.hostname);
return parsed.host.toLowerCase() === host.toLowerCase()
|| DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback);
|| (DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback));
} catch {
return false;
}
}
function isLoopback(hostname) {
function isLoopback(hostname: string) {
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase());
}
function rejectUpgrade(socket, statusCode, statusText) {
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
socket.end([
`HTTP/1.1 ${statusCode} ${statusText}`,
'Connection: close',
+71 -16
View File
@@ -1,24 +1,79 @@
export type BridgeOptions = {
host?: string;
logFile?: string;
maxPort?: number | string;
panelRoot?: string;
port?: number | string;
scriptsRoot?: string;
import type { Socket } from 'node:net';
/**
* Shared vocabulary for the bridge runtime. Payloads crossing the Wand renderer
* IPC boundary are genuinely unknown until a normalizer validates them, so they
* are typed `unknown` and narrowed there - not `any`.
*/
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type UnknownRecord = Record<string, unknown>;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogFn = (level: LogLevel, message: string, error?: unknown) => void;
/** One connected websocket peer. */
export type BridgeClient = {
socket: Socket;
buffer: Buffer;
closed: boolean;
draining: boolean;
handshaken: boolean;
};
export type BridgeOptions = {
logFile?: string;
port?: number;
maxPort?: number;
host?: string;
panelRoot?: string;
};
export type ServerInfo = {
port: number;
advertisedUrls: string[];
};
/** A decoded websocket frame. */
export type WsFrame = {
opcode: number;
payload: Buffer;
rest: Buffer;
};
export interface BridgeLogger extends LogFn {
file: string;
}
/**
* Minimal structural views of the Electron objects the bridge touches. Declared here
* rather than in each consumer: `@types/electron` is not a dependency, and the runtime
* modules use `module.exports`, which esbuild disables in any file carrying an `export`.
*/
export type WebContentsPort = {
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
isDestroyed(): boolean;
on(event: string, listener: () => void): void;
send(channel: string, payload: unknown): void;
send(channel: string, ...args: unknown[]): void;
executeJavaScript(code: string, userGesture?: boolean): Promise<unknown>;
on(event: 'dom-ready' | 'did-finish-load', listener: () => void): void;
/** Optional in Electron's older typings; guarded at every call site. */
once?(event: 'destroyed', listener: () => void): void;
};
export type IpcMainEventPort = {
sender?: WebContentsPort;
};
export type IpcMainPort = {
handle(channel: string, listener: (event: IpcMainEventPort, payload?: unknown) => unknown): void;
};
export type AppPort = {
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
};
export type ElectronPort = {
app: {
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
};
ipcMain: {
handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void;
};
app: AppPort;
ipcMain: IpcMainPort;
};
+10 -17
View File
@@ -1,12 +1,14 @@
function isRecord(value) {
import type { UnknownRecord } from './types';
export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
function safeString(value, fallback = '') {
export function safeString(value: unknown, fallback = '') {
return typeof value === 'string' && value.length ? value : fallback;
}
function firstString(...values) {
export function firstString(...values: unknown[]) {
for (const value of values) {
if (typeof value !== 'string') {
continue;
@@ -21,7 +23,7 @@ function firstString(...values) {
return '';
}
function cloneValue(value) {
export function cloneValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
@@ -30,7 +32,7 @@ function cloneValue(value) {
return value;
}
const result = {};
const result: UnknownRecord = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = cloneValue(entry);
}
@@ -38,11 +40,11 @@ function cloneValue(value) {
return result;
}
function isValidPort(value) {
return Number.isFinite(value) && value > 0 && value < 65536;
export function isValidPort(value: unknown) {
return Number.isFinite(value) && (value as number) > 0 && (value as number) < 65536;
}
function toStringId(value) {
export function toStringId(value: unknown) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
@@ -53,12 +55,3 @@ function toStringId(value) {
return null;
}
module.exports = {
cloneValue,
firstString,
isRecord,
isValidPort,
safeString,
toStringId,
};
+14 -10
View File
@@ -3,24 +3,28 @@ const path = require('node:path');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
const { writeInstallLog } = require('../logger');
import type { BridgeOptions, ElectronPort } from '../types';
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
function loadRendererScripts(panelRoot, scriptsRoot) {
declare global {
var __wandRemoteBridgeRendererScriptsInstalled: boolean | undefined;
}
function loadRendererScripts(panelRoot: string, scriptsRoot?: string) {
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
if (!fs.existsSync(root)) {
return [];
}
return fs.readdirSync(root)
.filter((name) => name.endsWith('.js'))
.sort((left, right) => left.localeCompare(right))
.map((name) => ({
.filter((name: string) => name.endsWith('.js'))
.sort((left: string, right: string) => left.localeCompare(right))
.map((name: string) => ({
name,
source: fs.readFileSync(path.join(root, name), 'utf8'),
}));
}
function buildRendererBootstrap(remoteUrl, scripts) {
function buildRendererBootstrap(remoteUrl: string, scripts: { name: string; source: string }[]) {
const header = `
globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)};
if (!globalThis.WandEnhancer) {
@@ -52,7 +56,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
return `(() => {\n${header}\n${body}\n})();`;
}
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
function installRendererScripts(electron: ElectronPort, runtime: { remoteUrl: string }, options: BridgeOptions & { scriptsRoot?: string } = {}) {
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
return;
}
@@ -64,14 +68,14 @@ function installRendererScripts(electron: ElectronPort, runtime, options: Bridge
return;
}
electron.app.on('web-contents-created', (_event, contents) => {
electron.app.on('web-contents-created', (_event: unknown, contents: WebContentsPort) => {
const inject = () => {
if (!contents || contents.isDestroyed()) {
return;
}
contents.executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true)
.catch((error) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error));
.catch((error: unknown) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error));
};
contents.on('dom-ready', inject);
@@ -81,7 +85,7 @@ function installRendererScripts(electron: ElectronPort, runtime, options: Bridge
}
});
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => script.name).join(', ')}).`);
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script: { name: string }) => script.name).join(', ')}).`);
}
module.exports = {
+89 -69
View File
@@ -1,20 +1,31 @@
const crypto = require('node:crypto');
const path = require('node:path');
const {
IPC_CHANNEL,
REMOTE_COMMAND_REQUEST_CHANNEL,
REMOTE_COMMAND_RESPONSE_CHANNEL,
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
REMOTE_GAME_STATUS_CHANNEL,
REMOTE_INSTALLED_APPS_CHANNEL,
} = require('../constants');
const { IPC_CHANNEL, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS } = require('../constants');
const { writeInstallLog } = require('../logger');
const { ensureBridge } = require('../runtime');
const { installRendererScripts } = require('./renderer-scripts');
const { localizeTrainerSnapshot } = require('./trainer-localization');
const { safeString } = require('../utils');
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
import type { BridgeOptions, ElectronPort, IpcMainEventPort, WebContentsPort } from '../types';
type RuntimePort = {
remoteUrl: string;
setHandler(handler: (request: unknown) => boolean): void;
setCommandHandler(handler: (request: unknown) => Promise<unknown>): void;
sync(snapshot: unknown): void;
syncTrainerMeta(snapshot: unknown): void;
syncInstalledApps(snapshot: unknown): void;
syncGameStatus(snapshot: unknown): void;
valueChanged(change: unknown): void;
};
declare global {
var __wandRemoteBridgeBoundRenderers: Set<WebContentsPort> | undefined;
var __wandRemoteBridgePendingCommandResponses: Map<string, (response: unknown) => void> | undefined;
var __wandRemoteBridgeActiveRuntime: RuntimePort | undefined;
var __wandRemoteBridgeIpcInstalled: boolean | undefined;
}
const WEMOD_ACCESS_TOKEN_SCRIPT =
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
@@ -30,15 +41,10 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {})
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
runtime.setHandler((request) => {
runtime.setHandler((request: unknown) => {
let delivered = false;
for (const sender of Array.from(boundRenderers)) {
for (const sender of liveRenderers(boundRenderers)) {
try {
if (!sender || sender.isDestroyed()) {
boundRenderers.delete(sender);
continue;
}
sender.send(IPC_CHANNEL.SET_VALUE, request);
delivered = true;
} catch (error) {
@@ -50,21 +56,18 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {})
return delivered;
});
runtime.setCommandHandler(async (request) => {
for (const sender of Array.from(boundRenderers)) {
try {
if (!sender || sender.isDestroyed()) {
boundRenderers.delete(sender);
continue;
}
return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses);
} catch (error) {
writeInstallLog('warn', 'Failed to execute remote command in renderer.', error);
}
runtime.setCommandHandler(async (request: unknown) => {
const [sender] = liveRenderers(boundRenderers);
if (!sender) {
return buildRendererBridgeMissingResponse(request);
}
return buildRendererBridgeMissingResponse(request);
try {
return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses);
} catch (error) {
writeInstallLog('warn', 'Failed to execute remote command in renderer.', error);
return buildRendererBridgeMissingResponse(request);
}
});
installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses);
@@ -76,63 +79,86 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {})
return runtime;
}
function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses) {
function installIpcHandlers(electron: ElectronPort, activeRuntime: RuntimePort, boundRenderers: Set<WebContentsPort>, pendingCommandResponses: Map<string, (response: unknown) => void>) {
// Handlers can only be registered once per channel, so they read the runtime through a
// mutable global: a reinstall must retarget them instead of leaving them on the old one.
globalThis.__wandRemoteBridgeActiveRuntime = activeRuntime;
if (globalThis.__wandRemoteBridgeIpcInstalled) {
return;
}
globalThis.__wandRemoteBridgeIpcInstalled = true;
const runtime = () => globalThis.__wandRemoteBridgeActiveRuntime as RuntimePort;
let trainerSnapshotRevision = 0;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event: IpcMainEventPort, snapshot: unknown) => {
const revision = ++trainerSnapshotRevision;
runtime.sync(snapshot);
runtime().sync(snapshot);
void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => {
if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) {
runtime.syncTrainerMeta(localizedSnapshot);
runtime().syncTrainerMeta(localizedSnapshot);
}
}).catch((error) => {
}).catch((error: unknown) => {
writeInstallLog('warn', 'Failed to localize trainer metadata.', error);
});
return true;
});
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
runtime.syncInstalledApps(snapshot);
electron.ipcMain.handle(IPC_CHANNEL.INSTALLED_APPS, (_event: IpcMainEventPort, snapshot: unknown) => {
runtime().syncInstalledApps(snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_GAME_STATUS_CHANNEL, (_event, snapshot) => {
runtime.syncGameStatus(snapshot);
electron.ipcMain.handle(IPC_CHANNEL.GAME_STATUS, (_event: IpcMainEventPort, snapshot: unknown) => {
runtime().syncGameStatus(snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_COMMAND_RESPONSE_CHANNEL, (_event, response) => {
const requestId = safeString(response?.requestId);
const pending = requestId ? pendingCommandResponses.get(requestId) : null;
if (!pending) {
electron.ipcMain.handle(IPC_CHANNEL.COMMAND_RESPONSE, (_event: IpcMainEventPort, response: unknown) => {
const requestId = safeString((response as Record<string, unknown>)?.requestId);
const resolvePending = requestId ? pendingCommandResponses.get(requestId) : null;
if (!resolvePending) {
return false;
}
pending.resolve(response);
resolvePending(response);
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event, change) => {
runtime.valueChanged(change);
electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event: IpcMainEventPort, change: unknown) => {
runtime().valueChanged(change);
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event) => {
if (event && event.sender) {
boundRenderers.add(event.sender);
electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event: IpcMainEventPort) => {
const sender = event?.sender;
if (sender) {
if (!boundRenderers.has(sender)) {
boundRenderers.add(sender);
// Without this the set grows for the lifetime of the app: entries are otherwise
// only dropped when a later send happens to fail.
sender.once?.('destroyed', () => boundRenderers.delete(sender));
}
}
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime().remoteUrl);
}
async function localizeSnapshot(sender, snapshot) {
function liveRenderers(boundRenderers: Set<WebContentsPort>) {
const live: WebContentsPort[] = [];
for (const sender of Array.from(boundRenderers) as WebContentsPort[]) {
if (sender && !sender.isDestroyed()) {
live.push(sender);
} else {
boundRenderers.delete(sender);
}
}
return live;
}
async function localizeSnapshot(sender: WebContentsPort | undefined, snapshot: unknown) {
const accessToken = await readWemodAccessToken(sender);
return localizeTrainerSnapshot(snapshot, accessToken);
}
async function readWemodAccessToken(sender) {
async function readWemodAccessToken(sender: WebContentsPort | undefined) {
if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) {
return null;
}
@@ -146,7 +172,7 @@ async function readWemodAccessToken(sender) {
}
}
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
function dispatchRemoteCommandToRenderer(sender: WebContentsPort, request: unknown, pendingCommandResponses: Map<string, (response: unknown) => void>) {
return new Promise((resolve, reject) => {
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
const timer = setTimeout(() => {
@@ -154,22 +180,15 @@ function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponse
reject(new Error('Renderer remote command timed out.'));
}, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS);
pendingCommandResponses.set(requestId, {
resolve: (response) => {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
resolve(response);
},
reject: (error) => {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
reject(error instanceof Error ? error : new Error(String(error)));
},
pendingCommandResponses.set(requestId, (response) => {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
resolve(response);
});
try {
sender.send(REMOTE_COMMAND_REQUEST_CHANNEL, {
...request,
sender.send(IPC_CHANNEL.COMMAND_REQUEST, {
...(request as object),
requestId,
});
} catch (error) {
@@ -180,12 +199,13 @@ function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponse
});
}
function buildRendererBridgeMissingResponse(request) {
function buildRendererBridgeMissingResponse(request: unknown) {
const req = request as Record<string, unknown> | undefined;
return {
ok: false,
action: request?.action === 'stop' ? 'stop' : 'launch',
gameId: typeof request?.gameId === 'string' ? request.gameId : null,
titleId: typeof request?.titleId === 'string' ? request.titleId : null,
action: req?.action === 'stop' ? 'stop' : 'launch',
gameId: typeof req?.gameId === 'string' ? req.gameId : null,
titleId: typeof req?.titleId === 'string' ? req.titleId : null,
error: {
code: 'bridge_not_ready',
message: 'The renderer command bridge is not ready yet.',
+10 -3
View File
@@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import { localizeTrainerSnapshot } from "./trainer-localization"
// The function takes an unknown snapshot and returns it unchanged when it cannot be
// localized, so tests narrow the result to read into it.
type LocalizedSnapshot = {
metadata: { info: { blueprint: { cheats: { name: string; description: string }[] } } }
}
const asLocalized = (value: unknown) => value as LocalizedSnapshot
afterEach(() => vi.restoreAllMocks())
describe("trainer localization", () => {
@@ -25,7 +32,7 @@ describe("trainer localization", () => {
)
expect(localized).not.toBe(snapshot)
expect(localized.metadata.info.blueprint.cheats[0]).toMatchObject({
expect(asLocalized(localized).metadata.info.blueprint.cheats[0]).toMatchObject({
name: "Unverwundbar",
description: "Kein Schaden",
})
@@ -68,8 +75,8 @@ describe("trainer localization", () => {
const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token")
expect(get).toHaveBeenCalledTimes(1)
expect(localized[0].metadata.info.blueprint.cheats[0].name).toBe("Cached name")
expect(cached.metadata.info.blueprint.cheats[0].name).toBe("Cached name")
expect(asLocalized(localized[0]).metadata.info.blueprint.cheats[0].name).toBe("Cached name")
expect(asLocalized(cached).metadata.info.blueprint.cheats[0].name).toBe("Cached name")
})
})
+62 -29
View File
@@ -8,9 +8,31 @@ let cachedStrings: Record<string, string> | null = null
let inFlightRequestKey = ""
let inFlightRequest: Promise<Record<string, string> | null> | null = null
type FetchTrainerStringsRequest = {
accessToken: string;
gameId: string;
gameVersion: string;
language: string;
}
type MinimalResponse = {
statusCode?: number;
resume(): void;
setEncoding(encoding: string): void;
on(event: 'data', listener: (chunk: Buffer | string) => void): MinimalResponse;
on(event: 'end', listener: () => void): MinimalResponse;
on(event: 'error', listener: (error: unknown) => void): MinimalResponse;
};
type MinimalRequest = {
destroy(): void;
setTimeout(timeout: number, callback: () => void): MinimalRequest;
on(event: 'error', listener: (error: unknown) => void): MinimalRequest;
};
export async function localizeTrainerSnapshot(
rawSnapshot,
accessToken,
rawSnapshot: unknown,
accessToken: string | null,
loadStrings = fetchTrainerStrings
) {
const request = buildTrainerRequest(rawSnapshot, accessToken)
@@ -18,7 +40,7 @@ export async function localizeTrainerSnapshot(
return rawSnapshot
}
let strings
let strings: Record<string, string> | null = null
try {
strings = await loadStrings(request)
} catch {
@@ -28,22 +50,24 @@ export async function localizeTrainerSnapshot(
return rawSnapshot
}
const info = rawSnapshot.metadata.info
const blueprint = info.blueprint
const snap = rawSnapshot as Record<string, unknown>
const metadata = snap.metadata as Record<string, unknown> | undefined
const info = metadata?.info as Record<string, unknown> | undefined
const blueprint = info?.blueprint as Record<string, unknown> | undefined
if (!Array.isArray(blueprint?.cheats)) {
return rawSnapshot
}
return {
...rawSnapshot,
...snap,
metadata: {
...rawSnapshot.metadata,
...metadata,
info: {
...info,
blueprint: {
...blueprint,
cheats: blueprint.cheats.map((cheat) =>
localizeCheat(cheat, strings)
cheats: blueprint.cheats.map((cheat: unknown) =>
localizeCheat(cheat, strings as Record<string, string>)
),
},
},
@@ -51,13 +75,18 @@ export async function localizeTrainerSnapshot(
}
}
function buildTrainerRequest(rawSnapshot, accessToken) {
function buildTrainerRequest(rawSnapshot: unknown, accessToken: string | null): FetchTrainerStringsRequest | null {
if (!accessToken || !rawSnapshot || typeof rawSnapshot !== "object") {
return null
}
const snap = rawSnapshot as Record<string, unknown>
const trainerInfo = snap.trainerInfo as Record<string, unknown> | undefined
const metadata = snap.metadata as Record<string, unknown> | undefined
const info = metadata?.info as Record<string, unknown> | undefined
const gameId = stringValue(
rawSnapshot.trainerInfo?.gameId || rawSnapshot.metadata?.info?.gameId
trainerInfo?.gameId || info?.gameId
)
if (!gameId) {
return null
@@ -66,12 +95,12 @@ function buildTrainerRequest(rawSnapshot, accessToken) {
return {
accessToken,
gameId,
gameVersion: stringValue(rawSnapshot.gameVersion),
language: stringValue(rawSnapshot.language),
gameVersion: stringValue(snap.gameVersion),
language: stringValue(snap.language),
}
}
function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }: FetchTrainerStringsRequest) {
const requestKey = [accessToken, gameId, gameVersion, language].join("\0")
if (requestKey === cachedRequestKey) {
return Promise.resolve(cachedStrings)
@@ -87,7 +116,7 @@ function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
if (language) url.searchParams.set("locale", language)
const request = requestJson(url, accessToken)
.then((payload) => normalizeStrings(payload?.i18n?.strings))
.then((payload: unknown) => normalizeStrings((payload as { i18n?: { strings?: unknown } })?.i18n?.strings))
.then((strings) => {
if (strings) {
cachedRequestKey = requestKey
@@ -107,10 +136,10 @@ function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
return request
}
function requestJson(url, accessToken): Promise<any> {
return new Promise<any>((resolve) => {
function requestJson(url: URL, accessToken: string): Promise<unknown> {
return new Promise<unknown>((resolve) => {
let settled = false
const finish = (value) => {
const finish = (value: unknown) => {
if (settled) return
settled = true
resolve(value)
@@ -124,7 +153,7 @@ function requestJson(url, accessToken): Promise<any> {
Authorization: `Bearer ${accessToken}`,
},
},
(response) => {
(response: MinimalResponse) => {
if (response.statusCode !== 200) {
response.resume()
finish(null)
@@ -134,7 +163,7 @@ function requestJson(url, accessToken): Promise<any> {
let body = ""
let receivedBytes = 0
response.setEncoding("utf8")
response.on("data", (chunk) => {
response.on("data", (chunk: Buffer | string) => {
receivedBytes += Buffer.byteLength(chunk)
if (receivedBytes > RESPONSE_LIMIT_BYTES) {
request.destroy()
@@ -150,15 +179,18 @@ function requestJson(url, accessToken): Promise<any> {
finish(null)
}
})
response.on("error", () => {
finish(null)
})
}
)
) as MinimalRequest
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy())
request.on("error", () => finish(null))
})
}
function normalizeStrings(value): Record<string, string> | null {
function normalizeStrings(value: unknown): Record<string, string> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null
}
@@ -169,23 +201,24 @@ function normalizeStrings(value): Record<string, string> | null {
return Object.keys(strings).length > 0 ? strings : null
}
function localizeCheat(cheat, strings) {
function localizeCheat(cheat: unknown, strings: Record<string, string>) {
if (!cheat || typeof cheat !== "object") {
return cheat
}
const c = cheat as Record<string, unknown>
return {
...cheat,
name: translate(cheat.name, strings),
description: translate(cheat.description, strings),
instructions: translate(cheat.instructions, strings),
...c,
name: translate(c.name, strings),
description: translate(c.description, strings),
instructions: translate(c.instructions, strings),
}
}
function translate(value, strings) {
function translate(value: unknown, strings: Record<string, string>) {
return typeof value === "string" ? (strings[value] ?? value) : value
}
function stringValue(value) {
function stringValue(value: unknown) {
return typeof value === "string" && value ? value : ""
}
+30 -8
View File
@@ -4,14 +4,24 @@ const { BRIDGE_PROTOCOL_VERSION, MAX_WS_FRAME_BYTES, WS_OPCODE } = require('./co
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const FRAME_TOO_LARGE_ERROR = 'WS_FRAME_TOO_LARGE';
const WS_PROTOCOL_ERROR = 'WS_PROTOCOL_ERROR';
const WS_CLOSE_NORMAL = 1000;
import type { BridgeClient } from './types';
function frameTooLarge() {
const error: any = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`);
const error = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`) as RangeError & { code: string };
error.code = FRAME_TOO_LARGE_ERROR;
return error;
}
function jsonMessage(type, payload, requestId = null) {
function protocolError(message: string) {
const error = new Error(message) as Error & { code: string };
error.code = WS_PROTOCOL_ERROR;
return error;
}
function jsonMessage(type: string, payload: unknown, requestId: string | number | null = null) {
return JSON.stringify({
type,
version: BRIDGE_PROTOCOL_VERSION,
@@ -20,7 +30,7 @@ function jsonMessage(type, payload, requestId = null) {
});
}
function makeFrame(opcode, payload) {
function makeFrame(opcode: number, payload: Buffer | string) {
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
const header: number[] = [];
header.push(0x80 | (opcode & 0x0f));
@@ -43,17 +53,17 @@ function makeFrame(opcode, payload) {
return Buffer.concat([prefix, source]);
}
function sendText(client, text) {
function sendText(client: BridgeClient, text: string) {
if (!client.closed) {
client.socket.write(makeFrame(WS_OPCODE.TEXT, Buffer.from(text, 'utf8')));
}
}
function sendJson(client, type, payload, requestId = null) {
function sendJson(client: BridgeClient, type: string, payload: unknown, requestId: string | number | null = null) {
sendText(client, jsonMessage(type, payload, requestId));
}
function closeClient(client, code = 1000, reason = 'Closing') {
function closeClient(client: BridgeClient, code = WS_CLOSE_NORMAL, reason = 'Closing') {
if (client.closed) {
return;
}
@@ -67,7 +77,7 @@ function closeClient(client, code = 1000, reason = 'Closing') {
client.socket.end();
}
function parseFrame(buffer) {
function parseFrame(buffer: Buffer) {
if (buffer.length < 2) {
return null;
}
@@ -114,6 +124,17 @@ function parseFrame(buffer) {
mask = buffer.subarray(offset, offset + 4);
offset += 4;
} else {
throw protocolError('Client frames must be masked');
}
if (opcode >= 8) {
if (!fin) {
throw protocolError('Control frames must not be fragmented');
}
if (length > 125) {
throw protocolError('Control frames must have a payload of 125 bytes or less');
}
}
if (buffer.length < offset + length) {
@@ -135,7 +156,7 @@ function parseFrame(buffer) {
};
}
function createAcceptKey(key) {
function createAcceptKey(key: string) {
return crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
}
@@ -143,6 +164,7 @@ module.exports = {
closeClient,
createAcceptKey,
FRAME_TOO_LARGE_ERROR,
WS_PROTOCOL_ERROR,
jsonMessage,
makeFrame,
parseFrame,
+1 -1
View File
@@ -1,4 +1,4 @@
export { PROTOCOL_VERSION } from './contract';
export { PROTOCOL_VERSION } from './contract.js';
// String values mirror the wire protocol; do not rename the right-hand side.
export enum ECheatType {
+2 -2
View File
@@ -1,5 +1,5 @@
import { PROTOCOL_VERSION } from './contract';
import type { IncomingMessage, OutgoingMessage } from './messages';
import { PROTOCOL_VERSION } from './contract.js';
import type { IncomingMessage, OutgoingMessage } from './messages.js';
const INCOMING_TYPES = new Set<IncomingMessage['type']>([
'hello_ack',