@mandujs/cli 0.15.2 → 0.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/util/lockfile.ts +72 -10
- package/templates/realtime-chat/app/api/chat/stream/route.ts +51 -14
- package/templates/realtime-chat/src/client/features/chat/chat-api.ts +37 -5
- package/templates/realtime-chat/src/client/features/chat/use-realtime-chat.ts +3 -11
- package/templates/realtime-chat/src/server/application/chat-store.ts +73 -3
- package/templates/realtime-chat/tests/chat-api.sse.test.ts +41 -4
- package/templates/realtime-chat/tests/chat-starter.test.ts +51 -0
- package/templates/realtime-chat/tests/chat-store.concurrency.test.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/cli",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "Agent-Native Fullstack Framework - 에이전트가 코딩해도 아키텍처가 무너지지 않는 개발 OS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/main.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@mandujs/core": "^0.13.
|
|
35
|
+
"@mandujs/core": "^0.13.2",
|
|
36
36
|
"cfonts": "^3.3.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
package/src/util/lockfile.ts
CHANGED
|
@@ -8,6 +8,54 @@ import {
|
|
|
8
8
|
type LockfileValidationResult,
|
|
9
9
|
} from "@mandujs/core";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Lockfile command templates for consistent messaging
|
|
13
|
+
*/
|
|
14
|
+
export const LOCKFILE_COMMANDS = {
|
|
15
|
+
update: "mandu lock",
|
|
16
|
+
diff: "mandu lock --diff",
|
|
17
|
+
safeDev: "mandu lock && mandu dev --watch",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Formatted lockfile guidance lines with alternative commands
|
|
22
|
+
*/
|
|
23
|
+
export const LOCKFILE_GUIDE_LINES = {
|
|
24
|
+
update: `${LOCKFILE_COMMANDS.update} (or bunx mandu lock)`,
|
|
25
|
+
diff: `${LOCKFILE_COMMANDS.diff} (or bunx mandu lock --diff)`,
|
|
26
|
+
safeDev: `${LOCKFILE_COMMANDS.safeDev} (or bun run dev:safe)`,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns formatted lockfile guidance lines for display
|
|
31
|
+
*
|
|
32
|
+
* @returns Array of guidance messages with Korean labels
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const lines = getLockfileGuidanceLines();
|
|
37
|
+
* lines.forEach(line => console.log(` ↳ ${line}`));
|
|
38
|
+
* // Output:
|
|
39
|
+
* // ↳ lock 갱신: mandu lock (or bunx mandu lock)
|
|
40
|
+
* // ↳ 변경 확인: mandu lock --diff (or bunx mandu lock --diff)
|
|
41
|
+
* // ↳ 안정 실행: mandu lock && mandu dev --watch (or bun run dev:safe)
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function getLockfileGuidanceLines(): string[] {
|
|
45
|
+
return [
|
|
46
|
+
`lock 갱신: ${LOCKFILE_GUIDE_LINES.update}`,
|
|
47
|
+
`변경 확인: ${LOCKFILE_GUIDE_LINES.diff}`,
|
|
48
|
+
`안정 실행: ${LOCKFILE_GUIDE_LINES.safeDev}`,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates runtime lockfile against current config
|
|
54
|
+
*
|
|
55
|
+
* @param config - Mandu configuration object
|
|
56
|
+
* @param rootDir - Project root directory
|
|
57
|
+
* @returns Validation result with lockfile, action, and bypass status
|
|
58
|
+
*/
|
|
11
59
|
export async function validateRuntimeLockfile(config: Record<string, unknown>, rootDir: string) {
|
|
12
60
|
const lockfile = await readLockfile(rootDir);
|
|
13
61
|
|
|
@@ -30,17 +78,22 @@ export async function validateRuntimeLockfile(config: Record<string, unknown>, r
|
|
|
30
78
|
return { lockfile, lockResult, action, bypassed };
|
|
31
79
|
}
|
|
32
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Handles blocked server start due to lockfile mismatch
|
|
83
|
+
*
|
|
84
|
+
* Exits process with error code 1 if action is "block"
|
|
85
|
+
*
|
|
86
|
+
* @param action - Policy action from lockfile validation
|
|
87
|
+
* @param lockResult - Validation result with details
|
|
88
|
+
*/
|
|
33
89
|
export function handleBlockedLockfile(action: "pass" | "warn" | "error" | "block", lockResult: LockfileValidationResult | null): void {
|
|
34
90
|
if (action !== "block") return;
|
|
35
91
|
|
|
92
|
+
const guidance = getLockfileGuidanceLines();
|
|
36
93
|
console.error("🛑 서버 시작 차단: Lockfile 불일치");
|
|
37
|
-
console.error(" 설정이 변경되었습니다. 의도한 변경이라면
|
|
38
|
-
console.error(
|
|
39
|
-
console.error(
|
|
40
|
-
console.error("");
|
|
41
|
-
console.error(" 변경 사항 확인:");
|
|
42
|
-
console.error(" $ mandu lock --diff");
|
|
43
|
-
console.error(" $ bunx mandu lock --diff");
|
|
94
|
+
console.error(" 설정이 변경되었습니다. 의도한 변경이라면 아래를 실행하세요:");
|
|
95
|
+
console.error(` ↳ ${guidance[0]}`);
|
|
96
|
+
console.error(` ↳ ${guidance[1]}`);
|
|
44
97
|
if (lockResult) {
|
|
45
98
|
console.error("");
|
|
46
99
|
console.error(formatValidationResult(lockResult));
|
|
@@ -48,6 +101,14 @@ export function handleBlockedLockfile(action: "pass" | "warn" | "error" | "block
|
|
|
48
101
|
process.exit(1);
|
|
49
102
|
}
|
|
50
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Prints runtime lockfile validation status
|
|
106
|
+
*
|
|
107
|
+
* @param action - Policy action from lockfile validation
|
|
108
|
+
* @param bypassed - Whether validation was bypassed
|
|
109
|
+
* @param lockfile - Lockfile data (null if not found)
|
|
110
|
+
* @param lockResult - Validation result with hash and validity
|
|
111
|
+
*/
|
|
51
112
|
export function printRuntimeLockfileStatus(
|
|
52
113
|
action: "pass" | "warn" | "error" | "block",
|
|
53
114
|
bypassed: boolean,
|
|
@@ -56,11 +117,12 @@ export function printRuntimeLockfileStatus(
|
|
|
56
117
|
): void {
|
|
57
118
|
if (action === "warn") {
|
|
58
119
|
console.log(`⚠️ ${formatPolicyAction(action, bypassed)}`);
|
|
59
|
-
|
|
60
|
-
|
|
120
|
+
for (const line of getLockfileGuidanceLines()) {
|
|
121
|
+
console.log(` ↳ ${line}`);
|
|
122
|
+
}
|
|
61
123
|
} else if (lockfile && lockResult?.valid) {
|
|
62
124
|
console.log(`🔒 설정 무결성 확인됨 (${lockResult.currentHash?.slice(0, 8)})`);
|
|
63
125
|
} else if (!lockfile) {
|
|
64
|
-
console.log(`💡 Lockfile 없음 - '
|
|
126
|
+
console.log(`💡 Lockfile 없음 - '${LOCKFILE_COMMANDS.update}'으로 생성 권장`);
|
|
65
127
|
}
|
|
66
128
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
planResumeFrom,
|
|
3
|
+
subscribeWithSnapshot,
|
|
4
|
+
} from "@/server/application/chat-store";
|
|
2
5
|
import type { ChatMessage, ChatStreamEvent } from "@/shared/contracts/chat";
|
|
3
6
|
import { createSSEConnection } from "@mandujs/core";
|
|
4
7
|
import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
|
|
@@ -6,6 +9,17 @@ import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
|
|
|
6
9
|
// Rate limiter: 1분당 5개 연결로 제한 (SSE는 장시간 유지되므로 보수적으로 설정)
|
|
7
10
|
const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
|
|
8
11
|
|
|
12
|
+
function getLastEventId(request: Request): string | null {
|
|
13
|
+
const fromHeader = request.headers.get("last-event-id");
|
|
14
|
+
if (fromHeader && fromHeader.trim()) return fromHeader.trim();
|
|
15
|
+
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const fromQuery = url.searchParams.get("lastEventId");
|
|
18
|
+
if (fromQuery && fromQuery.trim()) return fromQuery.trim();
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export function GET(request: Request): Response {
|
|
10
24
|
// Rate limiting 체크
|
|
11
25
|
const decision = limiter.check(request, "chat-stream");
|
|
@@ -14,22 +28,46 @@ export function GET(request: Request): Response {
|
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
const sse = createSSEConnection(request.signal);
|
|
31
|
+
const lastEventId = getLastEventId(request);
|
|
17
32
|
|
|
18
|
-
const
|
|
19
|
-
const event
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
const subscribeToLiveMessages = () => {
|
|
34
|
+
const subscription = subscribeWithSnapshot((event) => {
|
|
35
|
+
const streamEvent: ChatStreamEvent = {
|
|
36
|
+
type: "message",
|
|
37
|
+
data: event.message,
|
|
38
|
+
};
|
|
39
|
+
sse.send(streamEvent, { id: event.eventId });
|
|
40
|
+
});
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
type: "snapshot",
|
|
28
|
-
data: subscription.snapshot,
|
|
42
|
+
return subscription;
|
|
29
43
|
};
|
|
30
|
-
sse.send(snapshot);
|
|
31
44
|
|
|
32
|
-
const
|
|
45
|
+
const resume = planResumeFrom(lastEventId);
|
|
46
|
+
|
|
47
|
+
if (resume.mode === "catch-up") {
|
|
48
|
+
for (const event of resume.events) {
|
|
49
|
+
const streamEvent: ChatStreamEvent = {
|
|
50
|
+
type: "message",
|
|
51
|
+
data: event.message,
|
|
52
|
+
};
|
|
53
|
+
sse.send(streamEvent, { id: event.eventId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const liveSubscription = subscribeToLiveMessages();
|
|
57
|
+
const unsubscribe = liveSubscription.commit();
|
|
58
|
+
sse.onClose(() => unsubscribe());
|
|
59
|
+
} else {
|
|
60
|
+
const snapshotSubscription = subscribeToLiveMessages();
|
|
61
|
+
|
|
62
|
+
const snapshot: ChatStreamEvent = {
|
|
63
|
+
type: "snapshot",
|
|
64
|
+
data: snapshotSubscription.snapshot,
|
|
65
|
+
};
|
|
66
|
+
sse.send(snapshot);
|
|
67
|
+
|
|
68
|
+
const unsubscribe = snapshotSubscription.commit();
|
|
69
|
+
sse.onClose(() => unsubscribe());
|
|
70
|
+
}
|
|
33
71
|
|
|
34
72
|
const interval = setInterval(() => {
|
|
35
73
|
const heartbeat: ChatStreamEvent = {
|
|
@@ -41,7 +79,6 @@ export function GET(request: Request): Response {
|
|
|
41
79
|
|
|
42
80
|
sse.onClose(() => {
|
|
43
81
|
clearInterval(interval);
|
|
44
|
-
unsubscribe();
|
|
45
82
|
});
|
|
46
83
|
|
|
47
84
|
return sse.response;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ChatHistoryResponse,
|
|
3
|
+
ChatMessage,
|
|
3
4
|
ChatMessagePayload,
|
|
4
5
|
ChatMessageResponse,
|
|
5
6
|
ChatStreamEvent,
|
|
@@ -24,7 +25,9 @@ interface ChatStreamOptions {
|
|
|
24
25
|
onConnectionStateChange?: (state: ChatStreamConnectionState) => void;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
type ReconnectOptions = Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>;
|
|
29
|
+
|
|
30
|
+
const DEFAULT_STREAM_OPTIONS: ReconnectOptions = {
|
|
28
31
|
maxRetries: 8,
|
|
29
32
|
baseDelayMs: 500,
|
|
30
33
|
maxDelayMs: 10_000,
|
|
@@ -55,18 +58,36 @@ export async function fetchChatHistory(): Promise<ChatHistoryResponse> {
|
|
|
55
58
|
return response.json() as Promise<ChatHistoryResponse>;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
function
|
|
61
|
+
export function mergeChatMessages(base: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
|
|
62
|
+
const merged = new Map<string, ChatMessage>();
|
|
63
|
+
|
|
64
|
+
for (const message of base) {
|
|
65
|
+
merged.set(message.id, message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const message of incoming) {
|
|
69
|
+
merged.set(message.id, message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [...merged.values()].sort((a, b) => {
|
|
73
|
+
const byTime = a.createdAt.localeCompare(b.createdAt);
|
|
74
|
+
if (byTime !== 0) return byTime;
|
|
75
|
+
return a.id.localeCompare(b.id);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toReconnectDelayMs(attempt: number, options: ReconnectOptions): number {
|
|
59
80
|
const exponentialDelay = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt);
|
|
60
81
|
const jitterRange = exponentialDelay * options.jitterRatio;
|
|
61
82
|
const jitter = (options.random() * 2 - 1) * jitterRange;
|
|
62
|
-
return Math.max(0, Math.round(exponentialDelay + jitter));
|
|
83
|
+
return Math.max(0, Math.min(options.maxDelayMs, Math.round(exponentialDelay + jitter)));
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
export function openChatStream(
|
|
66
87
|
onEvent: (event: ChatStreamEvent) => void,
|
|
67
88
|
streamOptions: ChatStreamOptions = {},
|
|
68
89
|
): () => void {
|
|
69
|
-
const options:
|
|
90
|
+
const options: ReconnectOptions = {
|
|
70
91
|
maxRetries: streamOptions.maxRetries ?? DEFAULT_STREAM_OPTIONS.maxRetries,
|
|
71
92
|
baseDelayMs: streamOptions.baseDelayMs ?? DEFAULT_STREAM_OPTIONS.baseDelayMs,
|
|
72
93
|
maxDelayMs: streamOptions.maxDelayMs ?? DEFAULT_STREAM_OPTIONS.maxDelayMs,
|
|
@@ -79,6 +100,7 @@ export function openChatStream(
|
|
|
79
100
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
80
101
|
let reconnectAttempts = 0;
|
|
81
102
|
let isDisposed = false;
|
|
103
|
+
let lastEventId: string | null = null;
|
|
82
104
|
|
|
83
105
|
const setConnectionState = (state: ChatStreamConnectionState) => {
|
|
84
106
|
streamOptions.onConnectionStateChange?.(state);
|
|
@@ -124,6 +146,11 @@ export function openChatStream(
|
|
|
124
146
|
}, delayMs);
|
|
125
147
|
};
|
|
126
148
|
|
|
149
|
+
const toStreamUrl = () => {
|
|
150
|
+
if (!lastEventId) return `${API_BASE}/stream`;
|
|
151
|
+
return `${API_BASE}/stream?lastEventId=${encodeURIComponent(lastEventId)}`;
|
|
152
|
+
};
|
|
153
|
+
|
|
127
154
|
const connect = () => {
|
|
128
155
|
if (isDisposed) {
|
|
129
156
|
return;
|
|
@@ -131,7 +158,7 @@ export function openChatStream(
|
|
|
131
158
|
|
|
132
159
|
setConnectionState("connecting");
|
|
133
160
|
closeSource();
|
|
134
|
-
const currentSource = createSource(
|
|
161
|
+
const currentSource = createSource(toStreamUrl());
|
|
135
162
|
source = currentSource;
|
|
136
163
|
|
|
137
164
|
currentSource.onopen = () => {
|
|
@@ -148,6 +175,11 @@ export function openChatStream(
|
|
|
148
175
|
return;
|
|
149
176
|
}
|
|
150
177
|
|
|
178
|
+
const maybeLastEventId = (event as MessageEvent).lastEventId;
|
|
179
|
+
if (typeof maybeLastEventId === "string" && maybeLastEventId.trim().length > 0) {
|
|
180
|
+
lastEventId = maybeLastEventId.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
151
183
|
try {
|
|
152
184
|
const parsed = JSON.parse(event.data) as ChatStreamEvent;
|
|
153
185
|
onEvent(parsed);
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
mergeChatMessages,
|
|
7
7
|
openChatStream,
|
|
8
8
|
sendChatMessage,
|
|
9
9
|
type ChatStreamConnectionState,
|
|
10
|
-
} from "
|
|
10
|
+
} from "@/client/features/chat/chat-api";
|
|
11
11
|
|
|
12
12
|
export function useRealtimeChat() {
|
|
13
13
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
@@ -17,14 +17,6 @@ export function useRealtimeChat() {
|
|
|
17
17
|
useEffect(() => {
|
|
18
18
|
let mounted = true;
|
|
19
19
|
|
|
20
|
-
fetchChatHistory()
|
|
21
|
-
.then((res) => {
|
|
22
|
-
if (mounted) setMessages(res.messages);
|
|
23
|
-
})
|
|
24
|
-
.catch(() => {
|
|
25
|
-
// starter template keeps errors simple
|
|
26
|
-
});
|
|
27
|
-
|
|
28
20
|
const close = openChatStream((event) => {
|
|
29
21
|
if (!mounted) return;
|
|
30
22
|
|
|
@@ -33,7 +25,7 @@ export function useRealtimeChat() {
|
|
|
33
25
|
}
|
|
34
26
|
|
|
35
27
|
if (event.type === "message" && !Array.isArray(event.data)) {
|
|
36
|
-
setMessages((prev) =>
|
|
28
|
+
setMessages((prev) => mergeChatMessages(prev, [event.data]));
|
|
37
29
|
}
|
|
38
30
|
}, {
|
|
39
31
|
onConnectionStateChange: (state) => {
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
2
2
|
|
|
3
|
-
type ChatListener = (
|
|
3
|
+
type ChatListener = (event: ChatMessageEvent) => void;
|
|
4
|
+
|
|
5
|
+
export interface ChatMessageEvent {
|
|
6
|
+
eventId: string;
|
|
7
|
+
message: ChatMessage;
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
type SubscriptionSnapshot = {
|
|
6
11
|
snapshot: ChatMessage[];
|
|
7
12
|
commit: () => () => void;
|
|
8
13
|
};
|
|
9
14
|
|
|
15
|
+
export interface ResumePlan {
|
|
16
|
+
mode: "catch-up" | "snapshot";
|
|
17
|
+
events: ChatMessageEvent[];
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
const listeners = new Set<ChatListener>();
|
|
11
21
|
const messages: ChatMessage[] = [];
|
|
12
22
|
const MAX_HISTORY_MESSAGES = 200;
|
|
23
|
+
const MAX_CATCH_UP_EVENTS = 500;
|
|
13
24
|
let storeVersion = 0;
|
|
25
|
+
let streamEventSeq = 0;
|
|
26
|
+
const catchUpEvents: ChatMessageEvent[] = [];
|
|
14
27
|
let testHookBeforeSubscribeCommit: (() => void) | undefined;
|
|
15
28
|
|
|
16
29
|
function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
@@ -22,6 +35,25 @@ function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
|
22
35
|
};
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
function createEventId(nextSeq: number): string {
|
|
39
|
+
return `msg-${nextSeq}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseEventSeq(eventId: string | null | undefined): number | null {
|
|
43
|
+
if (!eventId) return null;
|
|
44
|
+
const match = /^msg-(\d+)$/.exec(eventId);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const parsed = Number.parseInt(match[1]!, 10);
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pushCatchUpEvent(event: ChatMessageEvent): void {
|
|
51
|
+
catchUpEvents.push(event);
|
|
52
|
+
if (catchUpEvents.length > MAX_CATCH_UP_EVENTS) {
|
|
53
|
+
catchUpEvents.splice(0, catchUpEvents.length - MAX_CATCH_UP_EVENTS);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
export function getMessages(): ChatMessage[] {
|
|
26
58
|
return [...messages];
|
|
27
59
|
}
|
|
@@ -35,10 +67,18 @@ export function appendMessage(role: ChatMessage["role"], text: string): ChatMess
|
|
|
35
67
|
}
|
|
36
68
|
|
|
37
69
|
storeVersion += 1;
|
|
70
|
+
streamEventSeq += 1;
|
|
71
|
+
|
|
72
|
+
const event: ChatMessageEvent = {
|
|
73
|
+
eventId: createEventId(streamEventSeq),
|
|
74
|
+
message,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
pushCatchUpEvent(event);
|
|
38
78
|
|
|
39
79
|
for (const listener of listeners) {
|
|
40
80
|
try {
|
|
41
|
-
listener(
|
|
81
|
+
listener(event);
|
|
42
82
|
} catch {
|
|
43
83
|
// Ignore listener errors so one broken subscriber does not stop fan-out.
|
|
44
84
|
}
|
|
@@ -74,10 +114,40 @@ export function subscribeWithSnapshot(listener: ChatListener): SubscriptionSnaps
|
|
|
74
114
|
}
|
|
75
115
|
}
|
|
76
116
|
|
|
117
|
+
export function planResumeFrom(lastEventId: string | null | undefined): ResumePlan {
|
|
118
|
+
const parsedSeq = parseEventSeq(lastEventId);
|
|
119
|
+
if (parsedSeq === null) {
|
|
120
|
+
return { mode: "snapshot", events: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parsedSeq === streamEventSeq) {
|
|
124
|
+
return { mode: "catch-up", events: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const firstAvailable = catchUpEvents[0];
|
|
128
|
+
if (!firstAvailable) {
|
|
129
|
+
return { mode: "snapshot", events: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const firstSeq = parseEventSeq(firstAvailable.eventId);
|
|
133
|
+
if (firstSeq === null || parsedSeq < firstSeq - 1 || parsedSeq > streamEventSeq) {
|
|
134
|
+
return { mode: "snapshot", events: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const events = catchUpEvents.filter((event) => {
|
|
138
|
+
const seq = parseEventSeq(event.eventId);
|
|
139
|
+
return seq !== null && seq > parsedSeq;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { mode: "catch-up", events };
|
|
143
|
+
}
|
|
144
|
+
|
|
77
145
|
export function __resetChatStoreForTests(): void {
|
|
78
146
|
messages.length = 0;
|
|
79
147
|
listeners.clear();
|
|
80
148
|
storeVersion = 0;
|
|
149
|
+
streamEventSeq = 0;
|
|
150
|
+
catchUpEvents.length = 0;
|
|
81
151
|
testHookBeforeSubscribeCommit = undefined;
|
|
82
152
|
}
|
|
83
153
|
|
|
@@ -85,4 +155,4 @@ export function __setSubscribeCommitHookForTests(hook?: () => void): void {
|
|
|
85
155
|
testHookBeforeSubscribeCommit = hook;
|
|
86
156
|
}
|
|
87
157
|
|
|
88
|
-
export { MAX_HISTORY_MESSAGES };
|
|
158
|
+
export { MAX_HISTORY_MESSAGES, MAX_CATCH_UP_EVENTS };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { openChatStream } from "@/client/features/chat/chat-api";
|
|
2
|
+
import { mergeChatMessages, openChatStream } from "@/client/features/chat/chat-api";
|
|
3
3
|
import type { ChatStreamEvent } from "@/shared/contracts/chat";
|
|
4
4
|
|
|
5
|
-
type MessageEventLike = { data: string };
|
|
5
|
+
type MessageEventLike = { data: string; lastEventId?: string };
|
|
6
6
|
|
|
7
7
|
class FakeEventSource {
|
|
8
8
|
onopen: ((event: Event) => void) | null = null;
|
|
@@ -16,8 +16,8 @@ class FakeEventSource {
|
|
|
16
16
|
this.onopen?.(new Event("open"));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
emitMessage(data: string) {
|
|
20
|
-
this.onmessage?.({ data });
|
|
19
|
+
emitMessage(data: string, lastEventId?: string) {
|
|
20
|
+
this.onmessage?.({ data, lastEventId });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
emitError() {
|
|
@@ -66,6 +66,32 @@ describe("openChatStream", () => {
|
|
|
66
66
|
stop();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("adds lastEventId cursor on reconnect for resumable SSE", async () => {
|
|
70
|
+
const sources: FakeEventSource[] = [];
|
|
71
|
+
|
|
72
|
+
const stop = openChatStream(() => {}, {
|
|
73
|
+
baseDelayMs: 5,
|
|
74
|
+
maxDelayMs: 5,
|
|
75
|
+
jitterRatio: 0,
|
|
76
|
+
maxRetries: 1,
|
|
77
|
+
eventSourceFactory: (url) => {
|
|
78
|
+
const source = new FakeEventSource(url);
|
|
79
|
+
sources.push(source);
|
|
80
|
+
return source as unknown as EventSource;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(sources[0]?.url).toBe("/api/chat/stream");
|
|
85
|
+
|
|
86
|
+
sources[0]?.emitMessage(JSON.stringify({ type: "message" }), "msg-42");
|
|
87
|
+
sources[0]?.emitError();
|
|
88
|
+
|
|
89
|
+
await sleep(8);
|
|
90
|
+
expect(sources[1]?.url).toBe("/api/chat/stream?lastEventId=msg-42");
|
|
91
|
+
|
|
92
|
+
stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
69
95
|
it("notifies connection state changes and terminal failure", async () => {
|
|
70
96
|
const states: Array<"connecting" | "connected" | "reconnecting" | "failed" | "closed"> = [];
|
|
71
97
|
const sources: FakeEventSource[] = [];
|
|
@@ -149,3 +175,14 @@ describe("openChatStream", () => {
|
|
|
149
175
|
stop();
|
|
150
176
|
});
|
|
151
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("mergeChatMessages", () => {
|
|
180
|
+
it("idempotently merges duplicate messages by id", () => {
|
|
181
|
+
const a = { id: "1", role: "user", text: "hello", createdAt: "2026-02-13T00:00:00.000Z" } as const;
|
|
182
|
+
const b = { id: "2", role: "assistant", text: "world", createdAt: "2026-02-13T00:00:01.000Z" } as const;
|
|
183
|
+
|
|
184
|
+
const merged = mergeChatMessages([a, b], [b]);
|
|
185
|
+
expect(merged).toHaveLength(2);
|
|
186
|
+
expect(merged.map((m) => m.id)).toEqual(["1", "2"]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -133,6 +133,57 @@ describe("realtime chat starter template", () => {
|
|
|
133
133
|
}
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
it("supports catch-up resume from lastEventId without snapshot", async () => {
|
|
137
|
+
appendMessage("user", "m1");
|
|
138
|
+
appendMessage("assistant", "m2");
|
|
139
|
+
|
|
140
|
+
const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-1");
|
|
141
|
+
const response = getStream(request);
|
|
142
|
+
|
|
143
|
+
const reader = response.body?.getReader();
|
|
144
|
+
expect(reader).toBeDefined();
|
|
145
|
+
|
|
146
|
+
const firstChunk = await reader!.read();
|
|
147
|
+
expect(firstChunk.done).toBe(false);
|
|
148
|
+
|
|
149
|
+
const firstText = new TextDecoder().decode(firstChunk.value);
|
|
150
|
+
expect(firstText).toContain("id: msg-2");
|
|
151
|
+
expect(firstText).toContain('"type":"message"');
|
|
152
|
+
expect(firstText).toContain('"text":"m2"');
|
|
153
|
+
expect(firstText).not.toContain('"type":"snapshot"');
|
|
154
|
+
|
|
155
|
+
appendMessage("user", "m3-live");
|
|
156
|
+
|
|
157
|
+
const secondChunk = await reader!.read();
|
|
158
|
+
expect(secondChunk.done).toBe(false);
|
|
159
|
+
|
|
160
|
+
const secondText = new TextDecoder().decode(secondChunk.value);
|
|
161
|
+
expect(secondText).toContain("id: msg-3");
|
|
162
|
+
expect(secondText).toContain('"type":"message"');
|
|
163
|
+
expect(secondText).toContain('"text":"m3-live"');
|
|
164
|
+
|
|
165
|
+
await reader!.cancel();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to snapshot when lastEventId is out of catch-up range", async () => {
|
|
169
|
+
appendMessage("user", "hello");
|
|
170
|
+
|
|
171
|
+
const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-9999");
|
|
172
|
+
const response = getStream(request);
|
|
173
|
+
|
|
174
|
+
const reader = response.body?.getReader();
|
|
175
|
+
expect(reader).toBeDefined();
|
|
176
|
+
|
|
177
|
+
const chunk = await reader!.read();
|
|
178
|
+
expect(chunk.done).toBe(false);
|
|
179
|
+
|
|
180
|
+
const text = new TextDecoder().decode(chunk.value);
|
|
181
|
+
expect(text).toContain('"type":"snapshot"');
|
|
182
|
+
expect(text).toContain('"text":"hello"');
|
|
183
|
+
|
|
184
|
+
await reader!.cancel();
|
|
185
|
+
});
|
|
186
|
+
|
|
136
187
|
it("includes essential ARIA attributes in chat UI", async () => {
|
|
137
188
|
const source = await Bun.file(
|
|
138
189
|
new URL("../src/client/features/chat/realtime-chat-starter.client.tsx", import.meta.url),
|
|
@@ -22,8 +22,8 @@ describe("chat-store concurrency", () => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const seen: string[] = [];
|
|
25
|
-
const subscription = subscribeWithSnapshot((
|
|
26
|
-
seen.push(message.text);
|
|
25
|
+
const subscription = subscribeWithSnapshot((event) => {
|
|
26
|
+
seen.push(event.message.text);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
expect(subscription.snapshot.some((message) => message.text === "racing-message")).toBe(true);
|