Skip to content

Clustered WebSockets + Run session refactor#1511

Open
Panzerhandschuh wants to merge 6 commits into
mainfrom
feat/websocket-connections
Open

Clustered WebSockets + Run session refactor#1511
Panzerhandschuh wants to merge 6 commits into
mainfrom
feat/websocket-connections

Conversation

@Panzerhandschuh

Copy link
Copy Markdown
Member

Checks

  • !! DONT IGNORE ME !! I have ran nx run db:create-migration <name> and committed the migration if I've made DB schema changes
  • I have included/updated tests where applicable (see Testing)
  • I have followed semantic commit messages e.g. feat: Add foo, chore: Update bar, etc...
  • My branch has a clear history of changes that can be easy to follow when being reviewed commit-by-commit
  • My branch is functionally complete; the only changes to be done will be those potentially requested in code review
  • All changes requested in review have been fixuped into my original commits

@Panzerhandschuh
Panzerhandschuh force-pushed the feat/websocket-connections branch 3 times, most recently from b0b65c7 to b439a6b Compare July 7, 2026 05:31
Comment thread apps/backend/src/app/modules/game-connection/game-connection.gateway.ts Outdated
Comment on lines +33 to +97
): Promise<WsResponse<unknown>> {
const userID = client.userId;
const leaderboardData = {
mapID: data.mapID,
gamemode: data.gamemode,
trackType: data.trackType,
trackNum: data.trackNum
};

if (!(await this.db.leaderboard.exists({ where: leaderboardData }))) {
this.logger.warn(
`session.create: leaderboard not found (userID=${userID}, mapID=${data.mapID}, gamemode=${data.gamemode}, trackType=${data.trackType}, trackNum=${data.trackNum})`
);
return {
event: 'session.create',
data: { error: 'Leaderboard does not exist' }
};
}

const sessionKey = idKey(userID);
const sessionIDs = await this.valkey.lrange(sessionKey, 0, -1);
for (const sessionID of sessionIDs) {
const session = await this.valkey.hgetall(dataKey(sessionID));
if (
session &&
Number(session.userID) === userID &&
Number(session.trackType) === data.trackType &&
Number(session.trackNum) === data.trackNum
) {
await Promise.all([
this.valkey.lrem(sessionKey, 0, sessionID),
this.valkey.del(dataKey(sessionID))
]);
}
}

const id = await this.valkey.incr(COUNTER_KEY);
const createdAt = Date.now();
const createdAtDate = new Date(createdAt);

await Promise.all([
this.valkey.lpush(sessionKey, id),
this.valkey.hset(dataKey(id), { userID, createdAt, ...leaderboardData }),
this.valkey.lpush(
timestampKey(id),
serializeTimestamp(1, 1, 0, createdAt)
)
]);

this.logger.log(
`session.create: created session (sessionID=${id}, userID=${userID}, mapID=${data.mapID})`
);
return {
event: 'session.create',
data: {
id,
userID,
createdAt: createdAtDate,
...leaderboardData,
timestamps: [
{ majorNum: 1, minorNum: 1, time: 0, createdAt: createdAtDate }
]
}
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bulk of these methods should be in a separate .service.ts file. Gateway files are effectively the same as controllers, they define the method names and DTOs for body and return time, DB/Valkey etc should be service logic.

Comment on lines +27 to +32
data: {
mapID: number;
gamemode: number;
trackType: number;
trackNum: number;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have class-validator setup for ws gateways, these can be annotated with DTOs like controllers do, and that'll perform validation. Otherwise people could set any garbage data and make us error.

trackType: number;
trackNum: number;
}
): Promise<WsResponse<unknown>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be typed, ideally with a DTO, like controllers do.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually okay, from discussion we're agreeing to avoid returning DTOs and just use JSON.stringify, but let's still write an explicit type for these.

Comment thread package.json Outdated
"@nestjs/schedule": "6.1.2",
"@nestjs/swagger": "11.2.0",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "^11.1.24",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please pin all deps (no ^)

@Panzerhandschuh
Panzerhandschuh force-pushed the feat/websocket-connections branch 2 times, most recently from dcc87e7 to 9c97df7 Compare July 14, 2026 19:34
Comment on lines +68 to +70
// These handle the live `runsession.*` messages from the game client. They
// return the response payload (a success DTO, an error DTO, or null for a
// bare ack); the GameConnectionGateway frames them into `{ event, data }`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just seems like LLM-babble to me. The message name isn't at all relevant to the service, that's for the controller. Then you're basically just summarising what the types already tell you.

Often when you ask them to some a specific change it'll insert comments like these sort of justifying the change, and it really isn't helpful.

Comment on lines +72 to +75
/**
* `runsession.create`: start a new session for the user on a leaderboard,
* clearing any existing session for the same track first.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, message name isn't relevant here. I guess the rest of the comment is okay. I know I'm nitpicking here, but I'm wary of filling the repo with LLM-generated stuff.

}
): Promise<WsResponse<unknown>> {
@MessageBody() data: CreateRunSessionDto
): Promise<WsResponse<RunSessionResponseDto | RunSessionErrorDto>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we spoke about this in voice the other day I agreed that we could skip using DTOs for responses here and just use plain types. But, if we're going to use DTOs, create a wrapper DTO like this:

export class WsResponseDto<T extends Record<string, unknown>> {
  @IsString()
  readonly event: string;

  readonly data: T | { error: string };

  constructor(c: { new (): T }, event: string, data: T | { error: string }) {
    this.event = event;
    this.data = 'error' in data ? data : DtoFactory(c, data);
  }
}

That way class-transformer will apply any transformations to T, also it actually erases types so you don't need to call .toISOString explicitly (Dates stringify to ISO strings anyway).

Writing this out made me realise that we should probably be using Nest's exception filters (https://docs.nestjs.com/websockets/exception-filters) since they let you throw an unhandled error to return an error response (esp helpful for e.g. unhandled Prisma errors), and sending stuff to Sentry. But let's leave that for now, I'll probably implement in the future.

@Panzerhandschuh
Panzerhandschuh force-pushed the feat/websocket-connections branch from 9c97df7 to 5cc9e47 Compare July 15, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants