1e4925007d
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html → WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и делает guarded reload ДО битого чанка. closes #481 - Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз → и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл тождественны by construction. Сервер читает client/dist/version.json при старте (client-version.ts: resolveClientDistPath вынесен из static.module — единый источник пути; readClientBuildVersion → версия или '' на любой ошибке, fail-safe). НИКАКИХ Docker/ENV-изменений. - WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent — член без operation сломал бы union). ws.gateway читает версию в onModuleInit + boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join). ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd reload флота; естественный реконнект покрывает и single-container, и кластер). - Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить». - Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над существующим chunk-reload-attempted); chunk-load-error-boundary переведён на него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled storage → после первого reload только баннер, петли нет (проверено трассировкой). Проверка: server jest client-version 7/7; client vitest version-coherence+ reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника: APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл. Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
import {
|
|
MessageBody,
|
|
OnGatewayConnection,
|
|
OnGatewayInit,
|
|
SubscribeMessage,
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
} from '@nestjs/websockets';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { TokenService } from '../core/auth/services/token.service';
|
|
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
|
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
import { WsService } from './ws.service';
|
|
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
|
import {
|
|
readClientBuildVersion,
|
|
resolveClientDistPath,
|
|
} from '../common/helpers/client-version';
|
|
import * as cookie from 'cookie';
|
|
|
|
@WebSocketGateway({
|
|
cors: { origin: '*' },
|
|
transports: ['websocket'],
|
|
})
|
|
export class WsGateway
|
|
implements
|
|
OnGatewayConnection,
|
|
OnGatewayInit,
|
|
OnModuleInit,
|
|
OnModuleDestroy
|
|
{
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
private readonly logger = new Logger(WsGateway.name);
|
|
|
|
// The build version of the client bundle shipped in this image, read once at
|
|
// startup from client/dist/version.json (single source of truth, same value
|
|
// baked into the client's APP_VERSION). Empty string => version.json missing
|
|
// or empty => the proactive version-coherence reload feature stays inert.
|
|
private appVersion = '';
|
|
|
|
constructor(
|
|
private tokenService: TokenService,
|
|
private spaceMemberRepo: SpaceMemberRepo,
|
|
private wsService: WsService,
|
|
) {}
|
|
|
|
onModuleInit(): void {
|
|
this.appVersion = readClientBuildVersion(resolveClientDistPath());
|
|
if (this.appVersion) {
|
|
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
|
|
} else {
|
|
this.logger.log(
|
|
'app-version reload: DISABLED (version.json missing/empty)',
|
|
);
|
|
}
|
|
}
|
|
|
|
afterInit(server: Server): void {
|
|
this.wsService.setServer(server);
|
|
}
|
|
|
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
|
try {
|
|
const cookies = cookie.parse(client.handshake.headers.cookie);
|
|
const token: JwtPayload = await this.tokenService.verifyJwt(
|
|
cookies['authToken'],
|
|
JwtType.ACCESS,
|
|
);
|
|
|
|
const userId = token.sub;
|
|
const workspaceId = token.workspaceId;
|
|
|
|
client.data.userId = userId;
|
|
|
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
|
|
|
const userRoom = getUserRoomName(userId);
|
|
const workspaceRoom = `workspace-${workspaceId}`;
|
|
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
|
|
|
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
|
|
|
// Announce this container's client build version to the freshly
|
|
// authenticated socket. On a redeploy the client reconnects to the new
|
|
// container and receives the new version here, letting it guard-reload
|
|
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
|
|
// natural reconnect covers both single-container and cluster without a
|
|
// thundering-herd fleet reload.
|
|
client.emit('app-version', { version: this.appVersion });
|
|
} catch (err) {
|
|
client.emit('Unauthorized');
|
|
client.disconnect();
|
|
}
|
|
}
|
|
|
|
@SubscribeMessage('message')
|
|
handleMessage(_client: Socket, _data: any): void {
|
|
// Inbound tree events from clients are no longer accepted: tree updates are
|
|
// now server-authoritative (broadcast by PageWsListener from domain events).
|
|
// The old client-relay path was removed to close that attack surface.
|
|
}
|
|
|
|
/*
|
|
@SubscribeMessage('join-room')
|
|
handleJoinRoom(client: Socket, @MessageBody() roomName: string): void {
|
|
// if room is a space, check if user has permissions
|
|
//client.join(roomName);
|
|
}
|
|
|
|
@SubscribeMessage('leave-room')
|
|
handleLeaveRoom(client: Socket, @MessageBody() roomName: string): void {
|
|
client.leave(roomName);
|
|
}
|
|
*/
|
|
|
|
onModuleDestroy() {
|
|
if (this.server) {
|
|
this.server.close();
|
|
}
|
|
}
|
|
}
|