@mandujs/cli 0.15.2 → 0.15.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/cli",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
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.1",
35
+ "@mandujs/core": "^0.13.2",
36
36
  "cfonts": "^3.3.0"
37
37
  },
38
38
  "engines": {
@@ -1,4 +1,7 @@
1
- import { subscribeWithSnapshot } from "@/server/application/chat-store";
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 subscription = subscribeWithSnapshot((message: ChatMessage) => {
19
- const event: ChatStreamEvent = {
20
- type: "message",
21
- data: message,
22
- };
23
- sse.send(event);
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
- const snapshot: ChatStreamEvent = {
27
- type: "snapshot",
28
- data: subscription.snapshot,
42
+ return subscription;
29
43
  };
30
- sse.send(snapshot);
31
44
 
32
- const unsubscribe = subscription.commit();
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
- const DEFAULT_STREAM_OPTIONS: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
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 toReconnectDelayMs(attempt: number, options: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>): number {
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: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
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(`${API_BASE}/stream`);
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,7 +3,7 @@
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import type { ChatMessage } from "@/shared/contracts/chat";
5
5
  import {
6
- fetchChatHistory,
6
+ mergeChatMessages,
7
7
  openChatStream,
8
8
  sendChatMessage,
9
9
  type ChatStreamConnectionState,
@@ -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) => [...prev, event.data]);
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 = (message: ChatMessage) => void;
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(message);
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((message) => {
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);