Skip to content

Commit d73700f

Browse files
committed
fix(client): send dev-panel WebSocket responses to the requesting client only
Dev-panel replies were sent via server.ws.send(), which Vite broadcasts to every connected client. Route each response through the WebSocketClient passed to the event handler so info-data, template:sync:*, action-required, and config:update reach only the tab that initiated the request. Add regression tests covering targeted info-data and template:sync responses.
1 parent 5fd28b9 commit d73700f

2 files changed

Lines changed: 126 additions & 52 deletions

File tree

src/lib/client.service.ts

Lines changed: 60 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { colors, getTokenErrorInfo, logTokenError } from './helpers/index.js';
44
import { isUnavailableJsonApiError } from '../api/unavailable-json-api.js';
55
import { DistService, TEMPLATE_VARIABLES_FILE_NAME } from './dist.service.js';
66
import { MiAPI } from './pp.middleware.js';
7-
import { Logger, ViteDevServer } from 'vite';
7+
import { Logger, ViteDevServer, WebSocketClient } from 'vite';
88
import { isAxiosError } from 'axios';
99
import { randomUUID } from 'crypto';
1010

@@ -93,12 +93,8 @@ export class ClientService {
9393
}
9494
}
9595

96-
onInfoDataRequest() {
97-
this.server.ws.send({
98-
type: 'custom',
99-
event: 'info-data:response',
100-
data: {},
101-
});
96+
onInfoDataRequest(_data: unknown, client: WebSocketClient) {
97+
client.send('info-data:response', {});
10298
}
10399

104100
onTemplateSyncActionResponse(payload?: SyncActionResponsePayload) {
@@ -109,7 +105,7 @@ export class ClientService {
109105
this.resolveSyncAction(payload.requestId, payload.approved);
110106
}
111107

112-
async requestSyncAction(payload: Omit<SyncActionRequestPayload, 'requestId'>) {
108+
async requestSyncAction(payload: Omit<SyncActionRequestPayload, 'requestId'>, client: WebSocketClient) {
113109
const requestId = randomUUID();
114110
const timeoutMs = this.opts.syncActionTimeoutMs ?? 120_000;
115111

@@ -120,21 +116,21 @@ export class ClientService {
120116

121117
this.syncActionResolvers.set(requestId, { resolve, timeoutId });
122118

123-
this.server.ws.send('template:sync:action-required', {
119+
client.send('template:sync:action-required', {
124120
...payload,
125121
requestId,
126122
} satisfies SyncActionRequestPayload);
127123
});
128124
}
129125

130-
async onTemplateSync() {
126+
async onTemplateSync(_data: unknown, client: WebSocketClient) {
131127
if (this.opts.distService && this.opts.miAPI) {
132128
const { distService, miAPI } = this.opts;
133129

134130
try {
135131
if (this.server.config.clientInjectionPlugin?.v7Features) {
136132
if (!miAPI?.isV710OrHigher) {
137-
this.server.ws.send('template:sync:response', {
133+
client.send('template:sync:response', {
138134
error: 'This feature is available only for MI v7.1.0 or higher',
139135
config: {
140136
canSync: false,
@@ -143,7 +139,7 @@ export class ClientService {
143139

144140
return;
145141
} else {
146-
this.server.ws.send('client:config:update', {
142+
client.send('client:config:update', {
147143
config: {
148144
canSync: true,
149145
},
@@ -165,22 +161,22 @@ export class ClientService {
165161

166162
if (validation && !validation.isValid) {
167163
this.logger.error(colors.red(`Authentication error: ${validation.error}`));
168-
this.server.ws.send('template:sync:response', {
164+
client.send('template:sync:response', {
169165
error: validation.error,
170166
code: validation.code,
171167
refresh: true,
172168
});
173169
} else {
174170
this.logger.info(colors.yellow('Session expired'));
175-
this.server.ws.send('template:sync:response', {
171+
client.send('template:sync:response', {
176172
error: 'Session expired',
177173
code: 'SESSION_EXPIRED',
178174
refresh: true,
179175
});
180176
}
181177
} catch (validationError) {
182178
this.logger.info(colors.yellow('Session expired'));
183-
this.server.ws.send('template:sync:response', {
179+
client.send('template:sync:response', {
184180
error: 'Session expired',
185181
code: 'SESSION_EXPIRED',
186182
refresh: true,
@@ -200,7 +196,7 @@ export class ClientService {
200196
colors.yellow('Server in maintenance mode, VPN connection is needed or no internet connection'),
201197
);
202198

203-
this.server.ws.send('template:sync:response', {
199+
client.send('template:sync:response', {
204200
error: 'Server in maintenance mode, VPN connection is needed or no internet connection',
205201
code: 'CONNECTION_ERROR',
206202
});
@@ -215,7 +211,7 @@ export class ClientService {
215211
colors.yellow(`Server in maintenance mode or unreachable (HTTP ${httpStatus}); VPN may be required`),
216212
);
217213

218-
this.server.ws.send('template:sync:response', {
214+
client.send('template:sync:response', {
219215
error: 'Server in maintenance mode, VPN connection is needed or no internet connection',
220216
code: 'CONNECTION_ERROR',
221217
});
@@ -232,7 +228,7 @@ export class ClientService {
232228
),
233229
);
234230

235-
this.server.ws.send('template:sync:response', {
231+
client.send('template:sync:response', {
236232
error: 'Server in maintenance mode, VPN connection is needed or no internet connection',
237233
code: 'CONNECTION_ERROR',
238234
});
@@ -298,13 +294,16 @@ export class ClientService {
298294
const serverPreferredHash = templateVariables.actualHash;
299295

300296
if (localHash !== serverPreferredHash) {
301-
const replaceFromServer = await this.requestSyncAction({
302-
title: 'Template variables (server backup)',
303-
content:
304-
'The server backup includes __template_variables.json. It differs from your local public/__template_variables.json (or that file is missing). Replace your project copy with the server backup?',
305-
confirmText: 'Replace from server',
306-
cancelText: 'Keep local',
307-
});
297+
const replaceFromServer = await this.requestSyncAction(
298+
{
299+
title: 'Template variables (server backup)',
300+
content:
301+
'The server backup includes __template_variables.json. It differs from your local public/__template_variables.json (or that file is missing). Replace your project copy with the server backup?',
302+
confirmText: 'Replace from server',
303+
cancelText: 'Keep local',
304+
},
305+
client,
306+
);
308307

309308
if (replaceFromServer) {
310309
await distService.saveTemplateVariablesFile(templateVariables.content);
@@ -313,15 +312,18 @@ export class ClientService {
313312
};
314313

315314
if (backupAnalysis.unknownFiles.length > 0) {
316-
const shouldContinueSync = await this.requestSyncAction({
317-
title: 'Unknown files found in backup',
318-
content: `Backup contains files not listed in VERSION: ${backupAnalysis.unknownFiles.join(', ')}. Continue sync or cancel?`,
319-
confirmText: 'Continue sync',
320-
cancelText: 'Cancel sync',
321-
});
315+
const shouldContinueSync = await this.requestSyncAction(
316+
{
317+
title: 'Unknown files found in backup',
318+
content: `Backup contains files not listed in VERSION: ${backupAnalysis.unknownFiles.join(', ')}. Continue sync or cancel?`,
319+
confirmText: 'Continue sync',
320+
cancelText: 'Cancel sync',
321+
},
322+
client,
323+
);
322324

323325
if (!shouldContinueSync) {
324-
this.server.ws.send('template:sync:response', {
326+
client.send('template:sync:response', {
325327
cancelled: true,
326328
message: 'Sync cancelled by user. Backup was saved.',
327329
});
@@ -344,15 +346,18 @@ export class ClientService {
344346
const fileList = listed.map((p) => `• ${p}`).join('\n');
345347
const suffix = remainder > 0 ? `\n... and ${remainder} more file${remainder === 1 ? '' : 's'}` : '';
346348

347-
const shouldContinueAfterVersionMismatch = await this.requestSyncAction({
348-
title: 'VERSION manifest out of date',
349-
content: `These files no longer match the hashes recorded in the VERSION manifest:\n\n${fileList}${suffix}\n\nCancel sync, or override and continue using the files on disk?`,
350-
confirmText: 'Override and continue',
351-
cancelText: 'Cancel sync',
352-
});
349+
const shouldContinueAfterVersionMismatch = await this.requestSyncAction(
350+
{
351+
title: 'VERSION manifest out of date',
352+
content: `These files no longer match the hashes recorded in the VERSION manifest:\n\n${fileList}${suffix}\n\nCancel sync, or override and continue using the files on disk?`,
353+
confirmText: 'Override and continue',
354+
cancelText: 'Cancel sync',
355+
},
356+
client,
357+
);
353358

354359
if (!shouldContinueAfterVersionMismatch) {
355-
this.server.ws.send('template:sync:response', {
360+
client.send('template:sync:response', {
356361
cancelled: true,
357362
message: 'Sync cancelled by user. Backup was saved.',
358363
});
@@ -371,15 +376,18 @@ export class ClientService {
371376
) {
372377
const { expected, actual } = backupAnalysis.buildManifestMismatch;
373378

374-
const shouldContinueAfterMismatch = await this.requestSyncAction({
375-
title: 'Build manifest fingerprint mismatch',
376-
content: `The recomputed backup fingerprint does not match BUILD-MANIFEST.json.\nManifest: ${expected.slice(0, 12)}...\nComputed: ${actual.slice(0, 12)}...\nContinue sync anyway?`,
377-
confirmText: 'Continue sync',
378-
cancelText: 'Cancel sync',
379-
});
379+
const shouldContinueAfterMismatch = await this.requestSyncAction(
380+
{
381+
title: 'Build manifest fingerprint mismatch',
382+
content: `The recomputed backup fingerprint does not match BUILD-MANIFEST.json.\nManifest: ${expected.slice(0, 12)}...\nComputed: ${actual.slice(0, 12)}...\nContinue sync anyway?`,
383+
confirmText: 'Continue sync',
384+
cancelText: 'Cancel sync',
385+
},
386+
client,
387+
);
380388

381389
if (!shouldContinueAfterMismatch) {
382-
this.server.ws.send('template:sync:response', {
390+
client.send('template:sync:response', {
383391
cancelled: true,
384392
message: 'Sync cancelled by user. Backup was saved.',
385393
});
@@ -416,23 +424,23 @@ export class ClientService {
416424
lastBackupDate: new Date().toISOString(),
417425
};
418426

419-
this.server.ws.send('template:sync:response', {
427+
client.send('template:sync:response', {
420428
syncedAt: new Date(backupDate),
421429
currentHash,
422430
backupFilename,
423431
});
424432

425433
this.logger.info(colors.green('Template synced'));
426434
} else {
427-
this.server.ws.send('template:sync:response', {
435+
client.send('template:sync:response', {
428436
error: 'Failed to update assets',
429437
});
430438

431439
this.logger.error(colors.red('Failed to update assets'));
432440
}
433441
} else {
434442
if (newAssets instanceof Error) {
435-
this.server.ws.send('template:sync:response', {
443+
client.send('template:sync:response', {
436444
error: newAssets.message,
437445
});
438446

@@ -441,7 +449,7 @@ export class ClientService {
441449
return;
442450
}
443451

444-
this.server.ws.send('template:sync:response', {
452+
client.send('template:sync:response', {
445453
error: 'Failed to build new assets',
446454
});
447455

@@ -454,15 +462,15 @@ export class ClientService {
454462

455463
this.logger.error(colors.red(`Template sync failed: ${message}`));
456464

457-
this.server.ws.send('template:sync:response', {
465+
client.send('template:sync:response', {
458466
error: message,
459467
code: 'SYNC_FAILED',
460468
});
461469

462470
return;
463471
}
464472
} else {
465-
this.server.ws.send('template:sync:response', {
473+
client.send('template:sync:response', {
466474
error: 'Dist service or MiAPI is not defined',
467475
});
468476

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import type { ViteDevServer, WebSocketClient } from 'vite';
3+
import { ClientService } from '../../../src/lib/client.service.js';
4+
5+
/**
6+
* Regression tests for the WebSocket "broadcast to all clients" bug.
7+
*
8+
* The dev panel previously responded via `server.ws.send()`, which Vite
9+
* broadcasts to every connected client. Each response must instead be sent
10+
* only to the `WebSocketClient` that triggered the request.
11+
*/
12+
describe('ClientService — targeted WebSocket responses', () => {
13+
const handlers = new Map<string, (...args: any[]) => void>();
14+
let server: ViteDevServer;
15+
16+
function makeClient(): WebSocketClient {
17+
return { send: vi.fn() } as unknown as WebSocketClient;
18+
}
19+
20+
beforeEach(() => {
21+
handlers.clear();
22+
23+
server = {
24+
ws: {
25+
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
26+
handlers.set(event, handler);
27+
}),
28+
send: vi.fn(),
29+
},
30+
config: { clientInjectionPlugin: { v7Features: false } },
31+
} as unknown as ViteDevServer;
32+
33+
// Registers handlers on server.ws via init()
34+
new ClientService(server);
35+
});
36+
37+
it('replies to info-data:request on the requesting client only', () => {
38+
const sender = makeClient();
39+
const other = makeClient();
40+
41+
handlers.get('info-data:request')!({}, sender);
42+
43+
expect(sender.send).toHaveBeenCalledWith('info-data:response', {});
44+
expect(other.send).not.toHaveBeenCalled();
45+
});
46+
47+
it('never broadcasts via server.ws.send for info-data:request', () => {
48+
handlers.get('info-data:request')!({}, makeClient());
49+
50+
expect(server.ws.send).not.toHaveBeenCalled();
51+
});
52+
53+
it('sends template:sync error to the requesting client only (no dist service)', async () => {
54+
const sender = makeClient();
55+
const other = makeClient();
56+
57+
// No distService / miAPI configured → error path
58+
await handlers.get('template:sync')!({}, sender);
59+
60+
expect(sender.send).toHaveBeenCalledWith('template:sync:response', {
61+
error: 'Dist service or MiAPI is not defined',
62+
});
63+
expect(other.send).not.toHaveBeenCalled();
64+
expect(server.ws.send).not.toHaveBeenCalled();
65+
});
66+
});

0 commit comments

Comments
 (0)