feat: socket.io redis->postgres socket.io, add broadcastchannel option

This commit is contained in:
midzelis
2026-02-13 21:31:32 +00:00
parent 02d356f5dd
commit cbdac440fd
23 changed files with 875 additions and 143 deletions

View File

@@ -0,0 +1,80 @@
import {
ClusterAdapterWithHeartbeat,
type ClusterAdapterOptions,
type ClusterMessage,
type ClusterResponse,
type ServerId,
} from 'socket.io-adapter';
const BC_CHANNEL_NAME = 'immich:socketio';
interface BroadcastChannelPayload {
type: 'message' | 'response';
sourceUid: string;
targetUid?: string;
data: unknown;
}
/**
* Socket.IO adapter using Node.js BroadcastChannel
*
* Relays messages between worker_threads within a single OS process.
* Zero external dependencies. Does NOT work across containers — use
* the Postgres adapter for multi-replica deployments.
*/
class BroadcastChannelAdapter extends ClusterAdapterWithHeartbeat {
private readonly channel: BroadcastChannel;
constructor(nsp: any, opts?: Partial<ClusterAdapterOptions>) {
super(nsp, opts ?? {});
this.channel = new BroadcastChannel(BC_CHANNEL_NAME);
this.channel.addEventListener('message', (event: MessageEvent<BroadcastChannelPayload>) => {
const msg = event.data;
if (msg.sourceUid === this.uid) {
return;
}
if (msg.type === 'message') {
this.onMessage(msg.data as ClusterMessage);
} else if (msg.type === 'response' && msg.targetUid === this.uid) {
this.onResponse(msg.data as ClusterResponse);
}
});
this.init();
}
override doPublish(message: ClusterMessage): Promise<string> {
this.channel.postMessage({
type: 'message',
sourceUid: this.uid,
data: message,
});
return Promise.resolve('');
}
override doPublishResponse(requesterUid: ServerId, response: ClusterResponse): Promise<void> {
this.channel.postMessage({
type: 'response',
sourceUid: this.uid,
targetUid: requesterUid,
data: response,
});
return Promise.resolve();
}
override close(): void {
super.close();
this.channel.close();
}
}
export function createBroadcastChannelAdapter(opts?: Partial<ClusterAdapterOptions>) {
const options: Partial<ClusterAdapterOptions> = {
...opts,
};
return function (nsp: any) {
return new BroadcastChannelAdapter(nsp, options);
};
}

View File

@@ -1,21 +1,103 @@
import { INestApplicationContext } from '@nestjs/common';
import { INestApplication, Logger } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { Pool, PoolConfig } from 'pg';
import type { ServerOptions } from 'socket.io';
import { SocketIoAdapter } from 'src/enum';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { asPostgresConnectionConfig } from 'src/utils/database';
export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
export function asPgPoolSsl(ssl?: Ssl): PoolConfig['ssl'] {
if (ssl === undefined || ssl === false || ssl === 'allow') {
return false;
}
if (ssl === true || ssl === 'prefer' || ssl === 'require') {
return { rejectUnauthorized: false };
}
if (ssl === 'verify-full') {
return { rejectUnauthorized: true };
}
return ssl;
}
class BroadcastChannelSocketAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createBroadcastChannelAdapter>;
constructor(app: INestApplication) {
super(app);
this.adapterConstructor = createBroadcastChannelAdapter();
}
createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get(ConfigRepository).getEnv();
const server = super.createIOServer(port, options);
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
server.adapter(this.adapterConstructor);
return server;
}
}
class PostgresSocketAdapter extends IoAdapter {
private adapterConstructor: any;
constructor(app: INestApplication, adapterConstructor: any) {
super(app);
this.adapterConstructor = adapterConstructor;
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
export async function createWebSocketAdapter(
app: INestApplication,
adapterOverride?: SocketIoAdapter,
): Promise<IoAdapter> {
const logger = new Logger('WebSocketAdapter');
const config = new ConfigRepository();
const { database, socketIo } = config.getEnv();
const adapter = adapterOverride ?? socketIo.adapter;
switch (adapter) {
case SocketIoAdapter.Postgres: {
logger.log('Using Postgres Socket.IO adapter');
const { createAdapter } = await import('@socket.io/postgres-adapter');
const config = asPostgresConnectionConfig(database.config);
const pool = new Pool({
host: config.host,
port: config.port,
user: config.username,
password: config.password,
database: config.database,
ssl: asPgPoolSsl(config.ssl),
max: 2,
});
await pool.query(`
CREATE TABLE IF NOT EXISTS socket_io_attachments (
id bigserial UNIQUE,
created_at timestamptz DEFAULT NOW(),
payload bytea
);
`);
pool.on('error', (error) => {
logger.error(' Postgres pool error', error);
});
const adapterConstructor = createAdapter(pool);
return new PostgresSocketAdapter(app, adapterConstructor);
}
case SocketIoAdapter.BroadcastChannel: {
logger.log('Using BroadcastChannel Socket.IO adapter');
return new BroadcastChannelSocketAdapter(app);
}
}
}