import { Injectable, Inject, ApplicationRef } from '@angular/core';
import { Subject } from 'rxjs';
import { RedisStateService } from './redis-state.service';
import { SettingsService } from './settings.service';
import { OverlayService } from './overlay.service';
import { I18nService } from './i18n.service';
declare const io: any;
/**
* Angular Socket.IO service — standalone, no AngularJS dependency.
* All callbacks run inside Angular's zone for automatic change detection.
*/
@Injectable({ providedIn: 'root' })
export class SocketService {
private ioClient: any;
private reconnect = false;
private connectErrorWas = false;
private disconnected = false;
readonly connections$ = new Subject<any>();
readonly redisDisconnected$ = new Subject<any>();
readonly redisStatus$ = new Subject<any>();
readonly configuration$ = new Subject<any>();
readonly socketError$ = new Subject<any>();
readonly stateChanged$ = new Subject<void>();
constructor(
@Inject(ApplicationRef) private appRef: ApplicationRef,
@Inject(RedisStateService) private state: RedisStateService,
@Inject(SettingsService) private settings: SettingsService,
@Inject(OverlayService) private overlay: OverlayService,
@Inject(I18nService) private i18n: I18nService,
) {
this.initConnection();
}
tick(): void {
setTimeout(() => {
this.appRef.tick();
});
}
private initConnection(): void {
const ioOptions: any = {
rejectUnauthorized: false,
path: '/socket.io',
secure: true,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
};
if ((globalThis as any).p3xrDevMode === true) {
ioOptions.transports = ['websocket'];
}
this.ioClient = io.connect(this.state.apiHost, ioOptions);
this.ioClient.on('connect', async () => {
if (this.disconnected || this.connectErrorWas) {
console.log('p3xr-socket RE-connected', this.ioClient.id);
this.disconnected = false;
this.connectErrorWas = false;
location.reload();
return;
}
if (this.reconnect) {
console.log('p3xr-socket RE-connected', this.ioClient.id);
} else {
console.log('p3xr-socket connected', this.ioClient.id);
}
this.reconnect = true;
});
this.ioClient.on('disconnect', () => {
this.disconnected = true;
try { this.overlay.show(); } catch {}
});
this.ioClient.on('error', (error: any) => {
this.handleSocketError(error);
});
this.ioClient.on('connect_error', (error: any) => {
this.handleSocketError(error);
});
this.ioClient.on('connections', (data: any) => {
if (data.status === 'error') {
this.state.resetConnections();
this.tick();
return;
}
this.state.connections.set(data.connections);
this.connections$.next(data);
this.tick();
});
this.ioClient.on('redis-disconnected', (data: any) => {
if (this.state.connection() !== undefined && this.state.connection().id === data.connectionId) {
this.state.monitor.set(false);
this.state.connection.set(undefined);
if (data.status === 'error') {
const strings = this.i18n.strings();
const msg = strings?.status?.redisDisconnected?.(data) ?? 'Redis disconnected';
this.showToast(msg);
} else if (data.status === 'code') {
const strings = this.i18n.strings();
const codes = strings?.code ?? {};
const msg = codes[data.code] ?? `unknown redis disconnect code: ${data.code}`;
this.showToast(msg);
}
this.redisDisconnected$.next(data);
this.tick();
this.request({ action: 'trigger-redis-disconnect', enableResponse: false }).catch(() => {});
}
});
this.ioClient.on('redis-status', (data: any) => {
this.state.redisConnections.set(data.redisConnections);
this.redisStatus$.next(data);
this.tick();
});
let receivedVersion = false;
this.ioClient.on('configuration', (data: any) => {
this.state.cfg.set(data);
if (data.snapshot === true) {
this.state.version.set('SNAPSHOT');
} else {
this.state.version.set('v' + data.version);
if (!receivedVersion) {
receivedVersion = true;
try {
(window as any).gtag?.('config', this.settings.googleAnalytics, {
page_path: '/version/' + this.state.version()
});
} catch { /* noop */ }
}
}
this.configuration$.next(data);
this.tick();
});
}
private handleSocketError(error: any): void {
try { this.overlay.show(); } catch {}
if (!this.connectErrorWas) {
this.connectErrorWas = true;
this.socketError$.next(error);
}
}
private showToast(message: string): void {
try {
const snackBar = (globalThis as any).__p3xr_snackbar;
if (snackBar) {
const ref = snackBar.open(message, 'x', {
duration: 5000,
horizontalPosition: 'right',
verticalPosition: 'bottom',
});
ref.onAction().subscribe(() => ref.dismiss());
}
} catch { /* noop */ }
}
// --- Request API ---
request(options: {
action: string;
payload?: any;
enableResponse?: boolean;
}): Promise<any> {
if (!this.ioClient) {
return Promise.reject(new Error('Socket.IO client unavailable'));
}
if (!options.payload) {
options.payload = {};
}
options.payload.maxKeys = parseInt(String(this.settings.maxKeys() ?? '10000'));
const enableResponse = options.enableResponse !== false;
if (!enableResponse) {
this.ioClient.emit('p3xr-request', options);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const requestId = this.settings.generateId();
(options as any).requestId = requestId;
const responseEvent = `p3xr-response-${requestId}`;
let timeout: any;
const response = (data: any) => {
clearTimeout(timeout);
this.ioClient.off(responseEvent);
if (data?.status === 'ok') {
resolve(data);
} else {
let errMsg = 'Unknown error';
try {
const err = data?.error;
if (typeof err === 'string') {
errMsg = err;
} else if (err?.message) {
errMsg = err.message;
} else if (err !== undefined && err !== null) {
errMsg = String(err);
}
} catch { /* noop */ }
reject(new Error(errMsg));
}
// Tick after await continuations settle (avoids NG0100 in dev mode)
this.tick();
};
timeout = setTimeout(() => {
this.ioClient.off(responseEvent, response);
const strings = this.i18n.strings();
const msg = strings?.label?.socketIoTimeout?.({ timeout: this.settings.socketTimeout })
?? `Socket.IO request timeout (${this.settings.socketTimeout}ms)`;
reject(new Error(msg));
this.tick();
}, this.settings.socketTimeout);
this.ioClient.on(responseEvent, response);
this.ioClient.emit('p3xr-request', options);
});
}
getClient(): any {
return this.ioClient;
}
}