Lesson 10 in the flesh: the onApplicationBootstrap fix made the NATS-> WS bridge actually deliver events for the first time, which instantly crashed the API. esModuleInterop is off, so 'import WebSocket from ws' compiles to ws_1.default = undefined; WebSocket.OPEN threw 'Cannot read properties of undefined' and killed the process on the first heartbeat forward. All three WS guard sites (nats-bridge x2, console gateway) switched to the import-agnostic instance constant client.OPEN. Latent in every build — never hit because the bridge was dead-on-boot until today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import {
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
SubscribeMessage,
|
|
MessageBody,
|
|
ConnectedSocket,
|
|
} from '@nestjs/websockets';
|
|
import { Logger } from '@nestjs/common';
|
|
import { IncomingMessage } from 'http';
|
|
import WebSocket, { Server } from 'ws';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { NatsBridgeService } from '../services/nats-bridge.service';
|
|
import { NatsService } from '../services/nats.service';
|
|
|
|
interface ClientMeta {
|
|
userId: string;
|
|
licenseId: string;
|
|
email: string;
|
|
}
|
|
|
|
@WebSocketGateway({ path: '/api/ws' })
|
|
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
private readonly logger = new Logger(NatsBridgeGateway.name);
|
|
|
|
@WebSocketServer()
|
|
server!: Server;
|
|
|
|
// Client metadata and listener tracking (native WS has no .data or .join())
|
|
private clientMeta = new Map<WebSocket, ClientMeta>();
|
|
private licenseClients = new Map<string, Set<WebSocket>>();
|
|
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
|
|
|
|
constructor(
|
|
private jwtService: JwtService,
|
|
private configService: ConfigService,
|
|
private natsBridge: NatsBridgeService,
|
|
private natsService: NatsService,
|
|
) {}
|
|
|
|
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
|
try {
|
|
// Parse token from query string
|
|
const url = new URL(request.url || '/', `http://${request.headers.host}`);
|
|
const token = url.searchParams.get('token');
|
|
|
|
if (!token) {
|
|
client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
client.close(4001, 'Authentication required');
|
|
return;
|
|
}
|
|
|
|
const secret = this.configService.get<string>('jwt.secret');
|
|
const payload = this.jwtService.verify(token, { secret });
|
|
|
|
const meta: ClientMeta = {
|
|
userId: payload.sub,
|
|
licenseId: payload.license_id,
|
|
email: payload.email,
|
|
};
|
|
this.clientMeta.set(client, meta);
|
|
|
|
// Track client by license for broadcasting
|
|
if (payload.license_id) {
|
|
if (!this.licenseClients.has(payload.license_id)) {
|
|
this.licenseClients.set(payload.license_id, new Set());
|
|
}
|
|
this.licenseClients.get(payload.license_id)!.add(client);
|
|
|
|
// Subscribe to NATS events for this license
|
|
const listener = (event: string, data: unknown) => {
|
|
// client.OPEN (instance constant) — NOT WebSocket.OPEN: with
|
|
// esModuleInterop off, the default `ws` import is undefined at
|
|
// runtime, so the static crashes. The instance constant is safe.
|
|
if (client.readyState === client.OPEN) {
|
|
client.send(JSON.stringify({
|
|
type: 'event',
|
|
license_id: payload.license_id,
|
|
event,
|
|
data,
|
|
}));
|
|
}
|
|
};
|
|
this.natsBridge.addListener(payload.license_id, listener);
|
|
this.clientListeners.set(client, listener);
|
|
}
|
|
|
|
client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id }));
|
|
this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
|
|
} catch {
|
|
client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
|
client.close(4002, 'Invalid token');
|
|
}
|
|
}
|
|
|
|
handleDisconnect(client: WebSocket) {
|
|
const meta = this.clientMeta.get(client);
|
|
if (meta?.licenseId) {
|
|
// Remove NATS listener
|
|
const listener = this.clientListeners.get(client);
|
|
if (listener) {
|
|
this.natsBridge.removeListener(meta.licenseId, listener);
|
|
this.clientListeners.delete(client);
|
|
}
|
|
// Remove from license client set
|
|
this.licenseClients.get(meta.licenseId)?.delete(client);
|
|
if (this.licenseClients.get(meta.licenseId)?.size === 0) {
|
|
this.licenseClients.delete(meta.licenseId);
|
|
}
|
|
}
|
|
this.clientMeta.delete(client);
|
|
}
|
|
|
|
@SubscribeMessage('console_input')
|
|
async handleConsoleInput(
|
|
@ConnectedSocket() client: WebSocket,
|
|
@MessageBody() data: { command: string },
|
|
) {
|
|
const meta = this.clientMeta.get(client);
|
|
if (!meta?.licenseId) return;
|
|
await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command });
|
|
}
|
|
|
|
sendToLicense(licenseId: string, event: string, data: unknown): void {
|
|
const clients = this.licenseClients.get(licenseId);
|
|
if (!clients) return;
|
|
|
|
const message = JSON.stringify({
|
|
type: 'event',
|
|
license_id: licenseId,
|
|
event,
|
|
data,
|
|
});
|
|
|
|
for (const client of clients) {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(message);
|
|
}
|
|
}
|
|
}
|
|
}
|