fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -1,17 +1,7 @@
|
||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||
// @mosaicstack/types' source.
|
||||
//
|
||||
// `apps/web` does not declare `@mosaicstack/types` as a package dependency (P3 scope
|
||||
// forbids editing package manifests/lockfiles), so these are type-only imports
|
||||
// resolved directly against the package source. `import type` is erased at compile
|
||||
// time, so no runtime dependency is introduced — this only reuses the exact payload
|
||||
// shapes instead of redeclaring them.
|
||||
//
|
||||
// This deliberately imports the narrow `chat/events` and `commands/index` entry
|
||||
// points rather than the package's full `src/index` barrel: the barrel also re-exports
|
||||
// class-validator/class-transformer decorated DTOs (chat.dto.ts, reflection.dto.ts,
|
||||
// connector-lease.dto.ts) that require compiler options apps/web's tsconfig does not
|
||||
// set, which breaks `tsc --noEmit` here even though only types are imported.
|
||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||
// payload shapes instead of redeclaring them.
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type {
|
||||
AbortPayload,
|
||||
@@ -21,6 +11,9 @@ import type {
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
@@ -28,19 +21,14 @@ import type {
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
} from '../../../../packages/types/src/chat/events';
|
||||
import type {
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
} from '../../../../packages/types/src/commands/index';
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export type {
|
||||
AbortPayload,
|
||||
|
||||
@@ -10,30 +10,32 @@ vi.mock('socket.io-client', () => ({
|
||||
|
||||
import { destroySocket, getSocket } from './socket';
|
||||
|
||||
function createMockSocket(): {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
offAny: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const mockSocket = {
|
||||
on: vi.fn(() => mockSocket),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
};
|
||||
return mockSocket;
|
||||
}
|
||||
|
||||
describe('chat socket', () => {
|
||||
let disconnectHandler: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
disconnectHandler = undefined;
|
||||
ioMock.mockReset();
|
||||
|
||||
const mockSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'disconnect') disconnectHandler = handler;
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
};
|
||||
|
||||
ioMock.mockReturnValue(mockSocket);
|
||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||
// a genuinely new instance) are meaningful.
|
||||
ioMock.mockImplementation(() => createMockSocket());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroySocket();
|
||||
});
|
||||
|
||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
||||
it('creates one same-origin /chat namespace socket', () => {
|
||||
const first = getSocket();
|
||||
const second = getSocket();
|
||||
|
||||
@@ -44,9 +46,28 @@ describe('chat socket', () => {
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
});
|
||||
|
||||
disconnectHandler?.();
|
||||
getSocket();
|
||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||
const first = getSocket();
|
||||
|
||||
// socket.ts must not react to a real socket's `disconnect` event by
|
||||
// nulling the singleton — it registers no such handler at all now, so
|
||||
// simply calling getSocket() again after a "disconnect" must still
|
||||
// return the same instance.
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||
const first = getSocket();
|
||||
|
||||
destroySocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getSocket(): ChatSocket {
|
||||
transports: ['websocket', 'polling'],
|
||||
}) as unknown as ChatSocket;
|
||||
|
||||
// Reset singleton reference when socket is fully closed so the next
|
||||
// getSocket() call creates a fresh instance instead of returning a
|
||||
// closed/dead socket.
|
||||
socket.on('disconnect', () => {
|
||||
socket = null;
|
||||
});
|
||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||
// and its listeners stay registered across that reconnect. Nulling here
|
||||
// previously orphaned those listeners on the next getSocket() call by
|
||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||
// (an explicit, intentional teardown) may reset the singleton.
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user