aui-mcp-server 0.0.1

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.
Files changed (62) hide show
  1. package/README.md +122 -0
  2. package/dist/cli.cjs +1088 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.d.cts +1 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +1076 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/client/index.cjs +619 -0
  9. package/dist/client/index.cjs.map +1 -0
  10. package/dist/client/index.d.cts +194 -0
  11. package/dist/client/index.d.ts +194 -0
  12. package/dist/client/index.js +584 -0
  13. package/dist/client/index.js.map +1 -0
  14. package/dist/index.cjs +1053 -0
  15. package/dist/index.cjs.map +1 -0
  16. package/dist/index.d.cts +163 -0
  17. package/dist/index.d.ts +163 -0
  18. package/dist/index.js +1036 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/rsbuild.cjs +1049 -0
  21. package/dist/rsbuild.cjs.map +1 -0
  22. package/dist/rsbuild.d.cts +12 -0
  23. package/dist/rsbuild.d.ts +12 -0
  24. package/dist/rsbuild.js +1038 -0
  25. package/dist/rsbuild.js.map +1 -0
  26. package/dist/rspack.cjs +1016 -0
  27. package/dist/rspack.cjs.map +1 -0
  28. package/dist/rspack.d.cts +40 -0
  29. package/dist/rspack.d.ts +40 -0
  30. package/dist/rspack.js +1005 -0
  31. package/dist/rspack.js.map +1 -0
  32. package/dist/server.cjs +304 -0
  33. package/dist/server.cjs.map +1 -0
  34. package/dist/server.d.cts +16 -0
  35. package/dist/server.d.ts +16 -0
  36. package/dist/server.js +297 -0
  37. package/dist/server.js.map +1 -0
  38. package/package.json +72 -0
  39. package/src/catalog/build.ts +89 -0
  40. package/src/catalog/entry.ts +183 -0
  41. package/src/catalog/parser.ts +173 -0
  42. package/src/catalog/tool_parser.ts +145 -0
  43. package/src/cli.ts +318 -0
  44. package/src/client/handshake.ts +166 -0
  45. package/src/client/index.ts +6 -0
  46. package/src/client/registry.tsx +184 -0
  47. package/src/client/types.ts +136 -0
  48. package/src/client/useA2UIStream.ts +378 -0
  49. package/src/client/useLogger.ts +147 -0
  50. package/src/generator.ts +100 -0
  51. package/src/index.ts +17 -0
  52. package/src/mcp-app-poc.html +69 -0
  53. package/src/poc.ts +88 -0
  54. package/src/rsbuild.ts +46 -0
  55. package/src/rspack.ts +282 -0
  56. package/src/server.ts +391 -0
  57. package/src/templates.ts +51 -0
  58. package/src/types.ts +195 -0
  59. package/src/utils.ts +29 -0
  60. package/test.js +16 -0
  61. package/tsconfig.json +19 -0
  62. package/tsup.config.ts +27 -0
@@ -0,0 +1,136 @@
1
+ import type { ServerToClientMessage } from '@a2ui/react';
2
+
3
+ /** A2UI extension URI (from the A2A protocol definition). */
4
+ export const A2UI_EXTENSION_URI = 'https://a2ui.org/a2a-extension/a2ui/v0.8';
5
+
6
+ /** A2UI DataPart mimeType. */
7
+ export const A2UI_MIME_TYPE = 'application/json+a2ui';
8
+
9
+ // ── Module Federation x-loader config ───────────────────────────────────────
10
+
11
+ /**
12
+ * Runtime config aligned with the `x-loader` field in the MCP Catalog.
13
+ */
14
+ export interface XLoaderConfig {
15
+ type: 'module-federation';
16
+ /** remoteEntry.js URL (passed to MF runtime as the remote entry). */
17
+ url: string;
18
+ /** MF remote name (`moduleFederation.name`). */
19
+ scope: string;
20
+ /** Exposed module path, e.g. "./ArtistProfile". */
21
+ module: string;
22
+ }
23
+
24
+ // ── A2A SSE response types ─────────────────────────────────────────────────-
25
+
26
+ export interface A2ATaskStatus {
27
+ state: 'submitted' | 'working' | 'input-required' | 'completed' | 'failed' | 'canceled';
28
+ /** Inline message from the agent, may contain text/data parts */
29
+ message?: {
30
+ kind: 'message';
31
+ role: 'agent';
32
+ messageId?: string;
33
+ contextId?: string;
34
+ // Narrowed to WirePart[] inside the hook
35
+ parts: unknown[];
36
+ };
37
+ timestamp?: string;
38
+ }
39
+
40
+ export interface A2AArtifact {
41
+ artifactId: string;
42
+ name?: string;
43
+ parts: unknown[];
44
+ index?: number;
45
+ lastChunk?: boolean;
46
+ metadata?: Record<string, unknown> | null;
47
+ }
48
+
49
+ /** Task status update event. */
50
+ export interface TaskStatusUpdateEvent {
51
+ id: string;
52
+ result: {
53
+ /** Task ID */
54
+ id: string;
55
+ contextId: string;
56
+ status: A2ATaskStatus;
57
+ final: boolean;
58
+ metadata?: Record<string, unknown> | null;
59
+ };
60
+ }
61
+
62
+ /** Task artifact update event (usually contains the final A2UI DataParts). */
63
+ export interface TaskArtifactUpdateEvent {
64
+ id: string;
65
+ result: {
66
+ taskId: string;
67
+ contextId: string;
68
+ artifact: A2AArtifact;
69
+ };
70
+ }
71
+
72
+ export type A2AStreamEvent = TaskStatusUpdateEvent | TaskArtifactUpdateEvent;
73
+
74
+ // ── Chat / Hook types ─────────────────────────────────────────────────------
75
+
76
+ export interface ChatMessage {
77
+ role: 'user' | 'assistant';
78
+ text: string;
79
+ /** True when this message triggers an A2UI surface render. */
80
+ hasSurface?: boolean;
81
+ }
82
+
83
+ /**
84
+ * `useA2UIStream` hook options.
85
+ */
86
+ export interface A2UIStreamOptions {
87
+ /**
88
+ * A2A Agent HTTP base URL. Defaults to `/api`.
89
+ *
90
+ * - `useA2UIStream({ agentUrl: '/api/a2a' })`
91
+ */
92
+ agentUrl?: string;
93
+
94
+ /**
95
+ * Whether `sendMessage` input is treated as an action payload (DataPart) by default.
96
+ * If true, you can pass a JSON string directly as a `userAction` payload.
97
+ */
98
+ defaultIsActionPayload?: boolean;
99
+ }
100
+
101
+ /**
102
+ * Logger creation options.
103
+ */
104
+ export interface CreateLoggerOptions {
105
+ /** Remote log endpoint. Defaults to `/api/log`. */
106
+ endpoint?: string;
107
+ /** Whether to hijack global console.*. Defaults to false. */
108
+ hijackConsole?: boolean;
109
+ }
110
+
111
+ /**
112
+ * MF Registry creation options.
113
+ */
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ export interface CreateMFRegistryOptions {
116
+ /**
117
+ * Additional shared deps (merged after the default react / react-dom / @a2ui/react config).
118
+ * Key is the package name; value is a shared config fragment for `@module-federation/enhanced/runtime`.
119
+ */
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ shared?: Record<string, any>;
122
+
123
+ /** Expose `window.__AUI_REGISTRY__` for debugging. Defaults to false. */
124
+ exposeGlobal?: boolean;
125
+ }
126
+
127
+ /**
128
+ * Payload shape for converting tool calls into A2UI messages.
129
+ */
130
+ export interface ToolCallPayload {
131
+ tool_name: string;
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ tool_input?: Record<string, any>;
134
+ }
135
+
136
+ export type { ServerToClientMessage };
@@ -0,0 +1,378 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import { useA2UIActions } from '@a2ui/react';
3
+ import type { ServerToClientMessage } from '@a2ui/react';
4
+ import {
5
+ A2UI_EXTENSION_URI,
6
+ A2UI_MIME_TYPE,
7
+ type A2AStreamEvent,
8
+ type TaskStatusUpdateEvent,
9
+ type TaskArtifactUpdateEvent,
10
+ type ChatMessage,
11
+ type A2UIStreamOptions,
12
+ } from './types';
13
+ import { logger } from './useLogger';
14
+
15
+ // ── A2A wire-format Part types (aligned with the Python SDK) ────────────────
16
+
17
+ interface WireTextPart {
18
+ kind: 'text';
19
+ text: string;
20
+ metadata?: Record<string, string> | null;
21
+ }
22
+
23
+ interface WireDataPart {
24
+ kind: 'data';
25
+ data: Record<string, unknown>;
26
+ metadata?: Record<string, string> | null;
27
+ }
28
+
29
+ type WirePart = WireTextPart | WireDataPart;
30
+
31
+ // ── Type guards ──────────────────────────────────────────────────────────────
32
+
33
+ function isTaskStatusUpdateEvent(e: A2AStreamEvent): e is TaskStatusUpdateEvent {
34
+ return 'result' in e && 'status' in (e as TaskStatusUpdateEvent).result;
35
+ }
36
+
37
+ function isTaskArtifactUpdateEvent(e: A2AStreamEvent): e is TaskArtifactUpdateEvent {
38
+ return 'result' in e && 'artifact' in (e as TaskArtifactUpdateEvent).result;
39
+ }
40
+
41
+ function isA2UIPart(part: WirePart): part is WireDataPart {
42
+ return (
43
+ part.kind === 'data' &&
44
+ (part.metadata?.mimeType === A2UI_MIME_TYPE ||
45
+ // snake_case compatibility
46
+ (part.metadata as any)?.mime_type === A2UI_MIME_TYPE)
47
+ );
48
+ }
49
+
50
+ // ── Helpers ──────────────────────────────────────────────────────────────────
51
+
52
+ function extractText(parts: WirePart[]): string {
53
+ return parts
54
+ .filter((p): p is WireTextPart => p.kind === 'text')
55
+ .map((p) => p.text)
56
+ .join('');
57
+ }
58
+
59
+ function extractA2UIData(parts: WirePart[]): Record<string, unknown>[] {
60
+ return parts.filter(isA2UIPart).map((p) => p.data);
61
+ }
62
+
63
+ // ── Hook ─────────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * A2A JSON-RPC 2.0 + SSE streaming hook.
67
+ *
68
+ * - Defaults to `/api` as the Agent HTTP entry
69
+ * - Supports `isActionPayload` mode to serialize user input as a DataPart
70
+ */
71
+ export interface A2UIStreamPerfSnapshot {
72
+ /** t0: request start timestamp (message/stream), in ms (performance.now) */
73
+ t0: number;
74
+ /** t1: timestamp when the first A2UI DataPart triggers render, in ms (performance.now) */
75
+ t1?: number;
76
+ /** t2: completion timestamp (SSE end or final status), in ms (performance.now) */
77
+ t2?: number;
78
+ /** TTFC = t1 - t0, in ms; undefined if no A2UI DataPart appears in this round */
79
+ ttfcMs?: number;
80
+ /** TTLC = t2 - t0, in ms */
81
+ ttlcMs?: number;
82
+ /** Completion reason for debugging (e.g. 'status-completed' | 'sse-end') */
83
+ reason?: string;
84
+ }
85
+
86
+ export function useA2UIStream(options?: A2UIStreamOptions) {
87
+ const actions = useA2UIActions();
88
+
89
+ const agentUrl = options?.agentUrl ?? '/api';
90
+ const defaultIsActionPayload = options?.defaultIsActionPayload ?? false;
91
+
92
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
93
+ const [isLoading, setIsLoading] = useState(false);
94
+ const [lastPerf, setLastPerf] = useState<A2UIStreamPerfSnapshot | null>(null);
95
+
96
+ // `contextId` stays stable across the session; `taskId` persists after the first response
97
+ const contextIdRef = useRef<string>(crypto.randomUUID());
98
+ const taskIdRef = useRef<string | undefined>(undefined);
99
+
100
+ // Perf timing state for the current round (use performance.now to avoid Date precision issues)
101
+ const perfStateRef = useRef<{
102
+ t0?: number;
103
+ t1?: number;
104
+ t2?: number;
105
+ hasFirstComponent?: boolean;
106
+ completed?: boolean;
107
+ }>({});
108
+
109
+ const now = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
110
+
111
+ const markFirstComponentRendered = () => {
112
+ const state = perfStateRef.current;
113
+ if (!state.t0 || state.t1 || state.completed) return;
114
+ state.t1 = now();
115
+ };
116
+
117
+ const finalizePerf = (reason: string) => {
118
+ const state = perfStateRef.current;
119
+ if (!state.t0 || state.completed) return;
120
+
121
+ state.completed = true;
122
+ state.t2 = state.t2 ?? now();
123
+
124
+ const t0 = state.t0;
125
+ const t1 = state.t1;
126
+ const t2 = state.t2;
127
+ const ttfcMs = t1 ? t1 - t0 : undefined;
128
+ const ttlcMs = t2 - t0;
129
+
130
+ const snapshot: A2UIStreamPerfSnapshot = {
131
+ t0,
132
+ t1,
133
+ t2,
134
+ ttfcMs,
135
+ ttlcMs,
136
+ reason,
137
+ };
138
+
139
+ setLastPerf(snapshot);
140
+
141
+ const ttfcLabel = ttfcMs !== undefined ? `${(ttfcMs / 1000).toFixed(2)}s` : 'N/A';
142
+ const totalLabel = `${(ttlcMs / 1000).toFixed(2)}s`;
143
+
144
+ // Print a visible perf snapshot to the console for quick inspection
145
+ // eslint-disable-next-line no-console
146
+ console.log('[AUI-X Perf] TTFC: %s, Total: %s', ttfcLabel, totalLabel, {
147
+ t0,
148
+ t1,
149
+ t2,
150
+ ttfcMs,
151
+ ttlcMs,
152
+ reason,
153
+ });
154
+ };
155
+
156
+ const sendMessage = useCallback(
157
+ async (userText: string, isActionPayload = defaultIsActionPayload) => {
158
+ if (!userText.trim() || isLoading) return;
159
+
160
+ setIsLoading(true);
161
+ // Reset timers per round and record t0
162
+ perfStateRef.current = {
163
+ t0: now(),
164
+ t1: undefined,
165
+ t2: undefined,
166
+ hasFirstComponent: false,
167
+ completed: false,
168
+ };
169
+ if (!isActionPayload) {
170
+ setMessages((prev) => [...prev, { role: 'user', text: userText }]);
171
+ }
172
+
173
+ let assistantText = '';
174
+ let renderedSurface = false;
175
+
176
+ try {
177
+ // ── Build message.parts ─────────────────────────────────────────
178
+ let parts: WirePart[];
179
+ if (isActionPayload) {
180
+ let actionData: Record<string, unknown>;
181
+ try {
182
+ actionData = JSON.parse(userText) as Record<string, unknown>;
183
+ } catch {
184
+ actionData = { userAction: { actionName: userText, context: {} } };
185
+ }
186
+ parts = [{ kind: 'data', data: actionData }];
187
+ } else {
188
+ parts = [{ kind: 'text', text: userText }];
189
+ }
190
+
191
+ // ── Build A2A JSON-RPC request ──────────────────────────────────
192
+ const rpcRequest = {
193
+ jsonrpc: '2.0' as const,
194
+ id: crypto.randomUUID(),
195
+ method: 'message/stream' as const,
196
+ params: {
197
+ message: {
198
+ kind: 'message' as const,
199
+ role: 'user' as const,
200
+ messageId: crypto.randomUUID(),
201
+ contextId: contextIdRef.current,
202
+ ...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
203
+ extensions: [A2UI_EXTENSION_URI],
204
+ parts,
205
+ },
206
+ },
207
+ };
208
+
209
+ const response = await fetch(agentUrl, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify(rpcRequest),
213
+ });
214
+
215
+ if (!response.ok) {
216
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
217
+ }
218
+ if (!response.body) {
219
+ throw new Error('No response body');
220
+ }
221
+
222
+ // ── Parse SSE stream ───────────────────────────────────────────
223
+ const reader = response.body.getReader();
224
+ const decoder = new TextDecoder();
225
+ let buffer = '';
226
+
227
+ // eslint-disable-next-line no-constant-condition
228
+ while (true) {
229
+ const { done, value } = await reader.read();
230
+ if (done) break;
231
+
232
+ buffer += decoder.decode(value, { stream: true });
233
+ const lines = buffer.split('\n');
234
+ buffer = lines.pop() ?? '';
235
+
236
+ for (const line of lines) {
237
+ if (!line.startsWith('data: ')) continue;
238
+ const jsonStr = line.slice(6).trim();
239
+ if (!jsonStr || jsonStr === '[DONE]') continue;
240
+
241
+ let event: A2AStreamEvent;
242
+ try {
243
+ event = JSON.parse(jsonStr) as A2AStreamEvent;
244
+ } catch {
245
+ continue;
246
+ }
247
+
248
+ // ── TaskStatusUpdateEvent ─────────────────────────────────
249
+ if (isTaskStatusUpdateEvent(event)) {
250
+ const { id: taskId, status } = event.result;
251
+
252
+ if (taskId) taskIdRef.current = taskId;
253
+
254
+ const rawParts = (status.message?.parts ?? []) as WirePart[];
255
+ const textChunk = extractText(rawParts);
256
+ if (textChunk) {
257
+ assistantText += textChunk;
258
+ setMessages((prev) => {
259
+ const last = prev[prev.length - 1];
260
+ if (last?.role === 'assistant') {
261
+ return [...prev.slice(0, -1), { ...last, text: assistantText }];
262
+ }
263
+ return [...prev, { role: 'assistant', text: assistantText }];
264
+ });
265
+ }
266
+
267
+ const a2uiData = extractA2UIData(rawParts);
268
+ if (a2uiData.length > 0) {
269
+ // Record t1 (TTFC) when the first A2UI DataPart triggers render
270
+ markFirstComponentRendered();
271
+ logger.info('[A2UI] Received UI update part', {
272
+ taskId,
273
+ componentCount: a2uiData.length,
274
+ });
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ actions.processMessages(a2uiData as any);
277
+ const surfaces = actions.getSurfaces();
278
+ logger.debug('[A2UI] Surfaces after process:', Array.from(surfaces.keys()));
279
+
280
+ renderedSurface = true;
281
+ setMessages((prev) => {
282
+ const last = prev[prev.length - 1];
283
+ if (last?.role === 'assistant') {
284
+ return [...prev.slice(0, -1), { ...last, hasSurface: true }];
285
+ }
286
+ return [...prev, { role: 'assistant', text: assistantText, hasSurface: true }];
287
+ });
288
+ }
289
+ }
290
+
291
+ // ── TaskArtifactUpdateEvent ───────────────────────────────
292
+ if (isTaskArtifactUpdateEvent(event)) {
293
+ const { artifact } = event.result;
294
+ const rawParts = (artifact.parts ?? []) as WirePart[];
295
+
296
+ const textChunk = extractText(rawParts);
297
+ if (textChunk) {
298
+ assistantText += textChunk;
299
+ setMessages((prev) => {
300
+ const last = prev[prev.length - 1];
301
+ if (last?.role === 'assistant') {
302
+ return [...prev.slice(0, -1), { ...last, text: assistantText }];
303
+ }
304
+ return [...prev, { role: 'assistant', text: assistantText }];
305
+ });
306
+ }
307
+
308
+ const a2uiData = extractA2UIData(rawParts);
309
+ if (a2uiData.length > 0) {
310
+ // Record t1 (TTFC) when the first A2UI DataPart triggers render
311
+ markFirstComponentRendered();
312
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
+ actions.processMessages(a2uiData as any);
314
+ renderedSurface = true;
315
+ setMessages((prev) => {
316
+ const last = prev[prev.length - 1];
317
+ if (last?.role === 'assistant') {
318
+ return [...prev.slice(0, -1), { ...last, hasSurface: true }];
319
+ }
320
+ return [...prev, { role: 'assistant', text: assistantText, hasSurface: true }];
321
+ });
322
+ }
323
+ }
324
+
325
+ // If the event is final, consider this round completed and record t2 (TTLC)
326
+ if (
327
+ isTaskStatusUpdateEvent(event) &&
328
+ (event.result.final || event.result.status.state === 'completed')
329
+ ) {
330
+ finalizePerf('status-completed');
331
+ }
332
+ }
333
+ }
334
+
335
+ // Fallback: ensure at least one assistant bubble
336
+ if (!assistantText && !renderedSurface) {
337
+ setMessages((prev) => [...prev, { role: 'assistant', text: '…' }]);
338
+ }
339
+ } catch (err) {
340
+ setMessages((prev) => [
341
+ ...prev,
342
+ { role: 'assistant', text: `Connection error: ${String(err)}` },
343
+ ]);
344
+ } finally {
345
+ setIsLoading(false);
346
+ // Even if we didn't receive a final status, record t2 when SSE ends
347
+ finalizePerf('sse-end');
348
+ }
349
+ },
350
+ [agentUrl, defaultIsActionPayload, isLoading, actions],
351
+ );
352
+
353
+ const processLocalMessages = useCallback(
354
+ (serverMessages: ServerToClientMessage[], label = 'Local tool call: rendered UI surface') => {
355
+ if (!serverMessages || serverMessages.length === 0) return;
356
+
357
+ logger.info('[A2UI] processLocalMessages:', label, `(${serverMessages.length} messages)`);
358
+ logger.debug('[A2UI] Surfaces BEFORE process:', Array.from(actions.getSurfaces().keys()));
359
+
360
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
+ actions.processMessages(serverMessages as any);
362
+
363
+ logger.debug('[A2UI] Surfaces AFTER process:', Array.from(actions.getSurfaces().keys()));
364
+
365
+ setMessages((prev) => [
366
+ ...prev,
367
+ {
368
+ role: 'assistant',
369
+ text: label,
370
+ hasSurface: true,
371
+ },
372
+ ]);
373
+ },
374
+ [actions],
375
+ );
376
+
377
+ return { messages, isLoading, sendMessage, processLocalMessages, lastPerf };
378
+ }
@@ -0,0 +1,147 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { CreateLoggerOptions } from './types';
3
+
4
+ type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
5
+
6
+ class RemoteLogger {
7
+ private queue: unknown[] = [];
8
+ private isProcessing = false;
9
+
10
+ constructor(private endpoint: string) {}
11
+
12
+ setEndpoint(endpoint: string) {
13
+ this.endpoint = endpoint;
14
+ }
15
+
16
+ private async flush() {
17
+ if (this.isProcessing || this.queue.length === 0) return;
18
+ this.isProcessing = true;
19
+
20
+ while (this.queue.length > 0) {
21
+ const entry = this.queue.shift();
22
+ try {
23
+ await fetch(this.endpoint, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify(entry),
27
+ });
28
+ } catch (e) {
29
+ // Use the original console to avoid recursion
30
+ // eslint-disable-next-line no-console
31
+ console.warn('[AUI Logger] Failed to sync log to backend', e);
32
+ // Abort the current flush to avoid an infinite loop when backend is unavailable
33
+ break;
34
+ }
35
+ }
36
+
37
+ this.isProcessing = false;
38
+ }
39
+
40
+ log(level: LogLevel, ...args: unknown[]) {
41
+ const time = new Date().toISOString();
42
+ const message = args
43
+ .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)))
44
+ .join(' ');
45
+
46
+ const consoleMethod = level.toLowerCase() as 'debug' | 'info' | 'warn' | 'error';
47
+
48
+ // Prefer the preserved original console methods (if present)
49
+ const original =
50
+ (typeof window !== 'undefined' && (window as any).__AUI_ORIGINAL_CONSOLE__?.[consoleMethod]) ||
51
+ // eslint-disable-next-line no-console
52
+ console[consoleMethod];
53
+
54
+ original(`[${level}]`, ...args);
55
+
56
+ this.queue.push({ time, level, message });
57
+ void this.flush();
58
+ }
59
+ }
60
+
61
+ export interface Logger {
62
+ debug: (...args: unknown[]) => void;
63
+ info: (...args: unknown[]) => void;
64
+ warn: (...args: unknown[]) => void;
65
+ error: (...args: unknown[]) => void;
66
+ }
67
+
68
+ let remoteLogger: RemoteLogger | null = null;
69
+ let globalLogger: Logger | null = null;
70
+
71
+ function ensureRemoteLogger(endpoint: string): RemoteLogger {
72
+ if (!remoteLogger) {
73
+ remoteLogger = new RemoteLogger(endpoint);
74
+ } else {
75
+ remoteLogger.setEndpoint(endpoint);
76
+ }
77
+ return remoteLogger;
78
+ }
79
+
80
+ function hijackConsoleIfNeeded(logger: Logger) {
81
+ if (typeof window === 'undefined') return;
82
+
83
+ const w = window as any;
84
+ if (w.__AUI_CONSOLE_HIJACKED__) return;
85
+
86
+ w.__AUI_ORIGINAL_CONSOLE__ = {
87
+ debug: console.debug,
88
+ info: console.info,
89
+ log: console.log,
90
+ warn: console.warn,
91
+ error: console.error,
92
+ };
93
+
94
+ console.debug = (...args) => logger.debug(...args);
95
+ console.info = (...args) => logger.info(...args);
96
+ console.log = (...args) => logger.info(...args);
97
+ console.warn = (...args) => logger.warn(...args);
98
+ console.error = (...args) => logger.error(...args);
99
+
100
+ w.__AUI_CONSOLE_HIJACKED__ = true;
101
+ }
102
+
103
+ /**
104
+ * Create (or reconfigure) the global logger.
105
+ *
106
+ * - `endpoint`: remote log endpoint (default: `/api/log`)
107
+ * - `hijackConsole`: whether to hijack console.* (default: false)
108
+ */
109
+ export function createLogger(options?: CreateLoggerOptions): Logger {
110
+ const endpoint = options?.endpoint ?? '/api/log';
111
+ const hijackConsole = options?.hijackConsole ?? false;
112
+
113
+ const remote = ensureRemoteLogger(endpoint);
114
+ const logger: Logger = {
115
+ debug: (...args: unknown[]) => remote.log('DEBUG', ...args),
116
+ info: (...args: unknown[]) => remote.log('INFO', ...args),
117
+ warn: (...args: unknown[]) => remote.log('WARN', ...args),
118
+ error: (...args: unknown[]) => remote.log('ERROR', ...args),
119
+ };
120
+
121
+ if (hijackConsole) {
122
+ hijackConsoleIfNeeded(logger);
123
+ }
124
+
125
+ globalLogger = logger;
126
+ return logger;
127
+ }
128
+
129
+ /**
130
+ * Default exported logger:
131
+ * - endpoint: `/api/log`
132
+ * - does not hijack console (configure on demand)
133
+ */
134
+ export const logger: Logger = createLogger();
135
+
136
+ /**
137
+ * React hook: initialize the logger on mount and optionally hijack console.*
138
+ */
139
+ export function useLogger(options?: CreateLoggerOptions): Logger {
140
+ const optionsRef = useRef(options);
141
+
142
+ useEffect(() => {
143
+ createLogger(optionsRef.current);
144
+ }, []);
145
+
146
+ return globalLogger ?? logger;
147
+ }