@agentstage/bridge 0.1.0
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/counter/store.json +8 -0
- package/dist/browser/createBridgeStore.d.ts +5 -0
- package/dist/browser/createBridgeStore.d.ts.map +1 -0
- package/dist/browser/createBridgeStore.js +232 -0
- package/dist/browser/createBridgeStore.js.map +1 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/types.d.ts +36 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/gateway/apiHandler.d.ts +10 -0
- package/dist/gateway/apiHandler.d.ts.map +1 -0
- package/dist/gateway/apiHandler.js +91 -0
- package/dist/gateway/apiHandler.js.map +1 -0
- package/dist/gateway/createBridgeGateway.d.ts +3 -0
- package/dist/gateway/createBridgeGateway.d.ts.map +1 -0
- package/dist/gateway/createBridgeGateway.js +689 -0
- package/dist/gateway/createBridgeGateway.js.map +1 -0
- package/dist/gateway/fileStore.d.ts +39 -0
- package/dist/gateway/fileStore.d.ts.map +1 -0
- package/dist/gateway/fileStore.js +189 -0
- package/dist/gateway/fileStore.js.map +1 -0
- package/dist/gateway/index.d.ts +6 -0
- package/dist/gateway/index.d.ts.map +1 -0
- package/dist/gateway/index.js +5 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/gateway/registry.d.ts +21 -0
- package/dist/gateway/registry.d.ts.map +1 -0
- package/dist/gateway/registry.js +136 -0
- package/dist/gateway/registry.js.map +1 -0
- package/dist/gateway/types.d.ts +50 -0
- package/dist/gateway/types.d.ts.map +1 -0
- package/dist/gateway/types.js +2 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk/BridgeClient.d.ts +38 -0
- package/dist/sdk/BridgeClient.d.ts.map +1 -0
- package/dist/sdk/BridgeClient.js +163 -0
- package/dist/sdk/BridgeClient.js.map +1 -0
- package/dist/sdk/index.d.ts +3 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/shared/types.d.ts +154 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/utils/logger.d.ts +33 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +206 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/vite/index.d.ts +6 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +23 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +60 -0
- package/src/browser/createBridgeStore.ts +276 -0
- package/src/browser/index.ts +6 -0
- package/src/browser/types.ts +36 -0
- package/src/gateway/apiHandler.ts +107 -0
- package/src/gateway/createBridgeGateway.ts +854 -0
- package/src/gateway/fileStore.ts +244 -0
- package/src/gateway/index.ts +12 -0
- package/src/gateway/registry.ts +166 -0
- package/src/gateway/types.ts +65 -0
- package/src/index.ts +33 -0
- package/src/sdk/BridgeClient.ts +203 -0
- package/src/sdk/index.ts +2 -0
- package/src/shared/types.ts +117 -0
- package/src/utils/logger.ts +262 -0
- package/src/vite/index.ts +31 -0
- package/test/e2e/bridge.test.ts +386 -0
- package/test/integration/gateway.test.ts +485 -0
- package/test/mocks/mockWebSocket.ts +49 -0
- package/test/unit/browser.test.ts +267 -0
- package/test/unit/fileStore.test.ts +98 -0
- package/test/unit/registry.test.ts +345 -0
- package/test-page/store.json +8 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import type { BridgeEvent, SetStateOptions, StateSnapshot, StoreDescription, StoreSummary } from '../shared/types.js';
|
|
3
|
+
|
|
4
|
+
export type { BridgeEvent, StoreDescription, StoreSummary, StateSnapshot };
|
|
5
|
+
|
|
6
|
+
export class BridgeClient {
|
|
7
|
+
private ws: WebSocket | null = null;
|
|
8
|
+
private url: string;
|
|
9
|
+
private reconnectAttempts = 0;
|
|
10
|
+
private maxReconnectAttempts = 5;
|
|
11
|
+
private reconnectDelay = 1000;
|
|
12
|
+
private eventHandlers = new Set<(event: BridgeEvent) => void>();
|
|
13
|
+
private pendingRequests = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
|
|
14
|
+
private requestId = 0;
|
|
15
|
+
private subscribedStores = new Set<string>();
|
|
16
|
+
|
|
17
|
+
constructor(url: string) {
|
|
18
|
+
this.url = url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async connect(): Promise<void> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
this.ws = new WebSocket(this.url + '?type=client');
|
|
24
|
+
|
|
25
|
+
this.ws.on('open', () => {
|
|
26
|
+
this.reconnectAttempts = 0;
|
|
27
|
+
|
|
28
|
+
for (const storeId of this.subscribedStores) {
|
|
29
|
+
this.send({ type: 'subscribe', payload: { storeId } });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
resolve();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.ws.on('message', (data) => {
|
|
36
|
+
this.handleMessage(data.toString());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.ws.on('close', () => {
|
|
40
|
+
this.attemptReconnect();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.ws.on('error', (err) => {
|
|
44
|
+
reject(err);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private attemptReconnect(): void {
|
|
50
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.reconnectAttempts++;
|
|
55
|
+
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
this.connect().catch(() => {});
|
|
58
|
+
}, this.reconnectDelay * this.reconnectAttempts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private send(message: unknown): void {
|
|
62
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
63
|
+
this.ws.send(JSON.stringify(message));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private handleMessage(data: string): void {
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(data);
|
|
70
|
+
|
|
71
|
+
if (msg.id !== undefined && this.pendingRequests.has(msg.id)) {
|
|
72
|
+
const pending = this.pendingRequests.get(msg.id)!;
|
|
73
|
+
this.pendingRequests.delete(msg.id);
|
|
74
|
+
|
|
75
|
+
if (msg.error) {
|
|
76
|
+
pending.reject(new Error(msg.error.message || msg.error));
|
|
77
|
+
} else {
|
|
78
|
+
pending.resolve(msg.result);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (msg.type) {
|
|
84
|
+
switch (msg.type) {
|
|
85
|
+
case 'store.stateChanged':
|
|
86
|
+
this.emit({
|
|
87
|
+
type: 'stateChanged',
|
|
88
|
+
storeId: msg.payload.storeId,
|
|
89
|
+
state: msg.payload.state,
|
|
90
|
+
version: msg.payload.version,
|
|
91
|
+
source: msg.payload.source,
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case 'store.disconnected':
|
|
96
|
+
this.emit({
|
|
97
|
+
type: 'disconnected',
|
|
98
|
+
storeId: msg.payload.storeId,
|
|
99
|
+
reason: msg.payload.reason,
|
|
100
|
+
});
|
|
101
|
+
this.subscribedStores.delete(msg.payload.storeId);
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case 'store.registered':
|
|
105
|
+
this.emit({
|
|
106
|
+
type: 'connected',
|
|
107
|
+
storeId: msg.payload.storeId,
|
|
108
|
+
pageId: msg.payload.pageId,
|
|
109
|
+
storeKey: msg.payload.storeKey,
|
|
110
|
+
});
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore malformed messages from unknown clients.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private emit(event: BridgeEvent): void {
|
|
120
|
+
for (const handler of this.eventHandlers) {
|
|
121
|
+
handler(event);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
onEvent(handler: (event: BridgeEvent) => void): () => void {
|
|
126
|
+
this.eventHandlers.add(handler);
|
|
127
|
+
return () => this.eventHandlers.delete(handler);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async request<T>(method: string, params: unknown): Promise<T> {
|
|
131
|
+
const id = ++this.requestId;
|
|
132
|
+
|
|
133
|
+
return new Promise<T>((resolve, reject) => {
|
|
134
|
+
this.pendingRequests.set(id, {
|
|
135
|
+
resolve: (v) => resolve(v as T),
|
|
136
|
+
reject: (e) => reject(e),
|
|
137
|
+
});
|
|
138
|
+
this.send({ id, method, params });
|
|
139
|
+
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
if (this.pendingRequests.has(id)) {
|
|
142
|
+
this.pendingRequests.delete(id);
|
|
143
|
+
reject(new Error('Request timeout'));
|
|
144
|
+
}
|
|
145
|
+
}, 5000);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async listStores(): Promise<StoreSummary[]> {
|
|
150
|
+
return this.request<StoreSummary[]>('listStores', {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async describe(storeId: string): Promise<StoreDescription | undefined> {
|
|
154
|
+
const res = await this.request<StoreDescription | null>('describe', { storeId });
|
|
155
|
+
return res ?? undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getState(storeId: string): Promise<StateSnapshot | undefined> {
|
|
159
|
+
const res = await this.request<StateSnapshot | null>('getState', { storeId });
|
|
160
|
+
return res ?? undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async findStoreByKey(pageId: string, storeKey: string): Promise<StoreSummary | undefined> {
|
|
164
|
+
const stores = await this.listStores();
|
|
165
|
+
return stores.find((store) => store.pageId === pageId && store.storeKey === storeKey);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getStateByKey(pageId: string, storeKey = 'main'): Promise<StateSnapshot | undefined> {
|
|
169
|
+
const res = await this.request<StateSnapshot | null>('getState', { pageId, storeKey });
|
|
170
|
+
return res ?? undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
subscribe(storeId: string): void {
|
|
174
|
+
this.subscribedStores.add(storeId);
|
|
175
|
+
this.send({ type: 'subscribe', payload: { storeId } });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
unsubscribe(storeId: string): void {
|
|
179
|
+
this.subscribedStores.delete(storeId);
|
|
180
|
+
this.send({ type: 'unsubscribe', payload: { storeId } });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async setState(storeId: string, state: unknown, options?: SetStateOptions): Promise<void> {
|
|
184
|
+
await this.request<null>('setState', { storeId, state, ...options });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async setStateByKey(
|
|
188
|
+
pageId: string,
|
|
189
|
+
storeKey: string,
|
|
190
|
+
state: unknown,
|
|
191
|
+
options?: SetStateOptions
|
|
192
|
+
): Promise<{ version: number }> {
|
|
193
|
+
return this.request<{ version: number }>('setState', { pageId, storeKey, state, ...options });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async dispatch(storeId: string, action: { type: string; payload?: unknown }): Promise<void> {
|
|
197
|
+
await this.request<null>('dispatch', { storeId, action });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
disconnect(): void {
|
|
201
|
+
this.ws?.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/sdk/index.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Bridge
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type StoreId = string;
|
|
6
|
+
export type PageId = string;
|
|
7
|
+
export type StoreKey = string;
|
|
8
|
+
|
|
9
|
+
export interface ActionDefinition {
|
|
10
|
+
description: string;
|
|
11
|
+
payload?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EventDefinition {
|
|
15
|
+
description: string;
|
|
16
|
+
payload?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StoreDescription {
|
|
20
|
+
pageId: PageId;
|
|
21
|
+
storeKey: StoreKey;
|
|
22
|
+
schema: unknown;
|
|
23
|
+
actions: Record<string, ActionDefinition>;
|
|
24
|
+
events?: Record<string, EventDefinition>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StoreSummary {
|
|
28
|
+
id: StoreId;
|
|
29
|
+
pageId: PageId;
|
|
30
|
+
storeKey: StoreKey;
|
|
31
|
+
version: number;
|
|
32
|
+
connectedAt: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StateSnapshot {
|
|
36
|
+
state: unknown;
|
|
37
|
+
version: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StoreState<T = unknown> {
|
|
41
|
+
data: T;
|
|
42
|
+
version: number;
|
|
43
|
+
updatedAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SetStateOptions {
|
|
47
|
+
expectedVersion?: number;
|
|
48
|
+
waitForAck?: boolean;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RegisterPayload {
|
|
53
|
+
storeId: StoreId;
|
|
54
|
+
pageId: PageId;
|
|
55
|
+
storeKey: StoreKey;
|
|
56
|
+
description: StoreDescription;
|
|
57
|
+
initialState: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StateChangedPayload {
|
|
61
|
+
storeId: StoreId;
|
|
62
|
+
state: unknown;
|
|
63
|
+
version: number;
|
|
64
|
+
source: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StateAppliedPayload {
|
|
68
|
+
storeId: StoreId;
|
|
69
|
+
requestId: string;
|
|
70
|
+
status: 'applied' | 'version_mismatch' | 'failed';
|
|
71
|
+
version: number;
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Messages from Browser to Gateway
|
|
76
|
+
export type GatewayMessage =
|
|
77
|
+
| { type: 'store.register'; payload: RegisterPayload }
|
|
78
|
+
| { type: 'store.stateChanged'; payload: StateChangedPayload }
|
|
79
|
+
| { type: 'store.stateApplied'; payload: StateAppliedPayload }
|
|
80
|
+
| { type: 'store.heartbeat' }
|
|
81
|
+
| { type: 'store.disconnect' };
|
|
82
|
+
|
|
83
|
+
export interface ClientSetStatePayload {
|
|
84
|
+
storeId?: StoreId;
|
|
85
|
+
state: unknown;
|
|
86
|
+
expectedVersion?: number;
|
|
87
|
+
requestId?: string;
|
|
88
|
+
version?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Messages from Gateway to Browser
|
|
92
|
+
export type ServerMessage =
|
|
93
|
+
| { type: 'client.setState'; payload: ClientSetStatePayload }
|
|
94
|
+
| { type: 'client.dispatch'; payload: { action: { type: string; payload?: unknown } } }
|
|
95
|
+
| { type: 'client.ping' };
|
|
96
|
+
|
|
97
|
+
export type SubscriberMessage =
|
|
98
|
+
| { type: 'store.registered'; payload: { storeId: StoreId; pageId: PageId; storeKey: StoreKey } }
|
|
99
|
+
| { type: 'store.stateChanged'; payload: { storeId: StoreId; state: unknown; version: number; source: string } }
|
|
100
|
+
| { type: 'store.disconnected'; payload: { storeId: StoreId; reason: string } };
|
|
101
|
+
|
|
102
|
+
export type GatewayClientMessage =
|
|
103
|
+
| { type: 'subscribe'; payload: { storeId: StoreId } }
|
|
104
|
+
| { type: 'unsubscribe'; payload: { storeId: StoreId } };
|
|
105
|
+
|
|
106
|
+
export interface StoreChangeEvent {
|
|
107
|
+
type: 'stateChanged' | 'disconnected';
|
|
108
|
+
storeId: StoreId;
|
|
109
|
+
state?: unknown;
|
|
110
|
+
version?: number;
|
|
111
|
+
source?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type BridgeEvent =
|
|
115
|
+
| { type: 'stateChanged'; storeId: StoreId; state: unknown; version: number; source: string }
|
|
116
|
+
| { type: 'disconnected'; storeId: StoreId; reason: string }
|
|
117
|
+
| { type: 'connected'; storeId: StoreId; pageId: PageId; storeKey: StoreKey };
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { appendFile, mkdir, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
type SinkMode = 'console' | 'file' | 'both' | 'disabled';
|
|
8
|
+
|
|
9
|
+
interface LogEntry {
|
|
10
|
+
level: LogLevel;
|
|
11
|
+
message: string;
|
|
12
|
+
data?: unknown;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LoggerSink {
|
|
17
|
+
write(entry: LogEntry): Promise<void> | void;
|
|
18
|
+
clear?(): Promise<void>;
|
|
19
|
+
flush?(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface LoggerRuntimeOptions {
|
|
23
|
+
level: LogLevel;
|
|
24
|
+
mode: SinkMode;
|
|
25
|
+
logDir: string;
|
|
26
|
+
fileName: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
30
|
+
debug: 10,
|
|
31
|
+
info: 20,
|
|
32
|
+
warn: 30,
|
|
33
|
+
error: 40,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const TEST_DEFAULT_DIR = join(process.cwd(), '.agentstage-test-logs');
|
|
37
|
+
|
|
38
|
+
function normalizeLogLevel(value: string | undefined): LogLevel {
|
|
39
|
+
if (!value) {
|
|
40
|
+
return 'info';
|
|
41
|
+
}
|
|
42
|
+
const level = value.toLowerCase();
|
|
43
|
+
if (level === 'debug' || level === 'info' || level === 'warn' || level === 'error') {
|
|
44
|
+
return level;
|
|
45
|
+
}
|
|
46
|
+
return 'info';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeSinkMode(value: string | undefined): SinkMode {
|
|
50
|
+
if (!value) {
|
|
51
|
+
return process.env.NODE_ENV === 'test' ? 'disabled' : 'both';
|
|
52
|
+
}
|
|
53
|
+
const mode = value.toLowerCase();
|
|
54
|
+
if (mode === 'console' || mode === 'file' || mode === 'both' || mode === 'disabled') {
|
|
55
|
+
return mode;
|
|
56
|
+
}
|
|
57
|
+
return process.env.NODE_ENV === 'test' ? 'disabled' : 'both';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultOptions(): LoggerRuntimeOptions {
|
|
61
|
+
return {
|
|
62
|
+
level: normalizeLogLevel(process.env.BRIDGE_LOG_LEVEL),
|
|
63
|
+
mode: normalizeSinkMode(process.env.BRIDGE_LOG_SINK),
|
|
64
|
+
logDir: process.env.BRIDGE_LOG_DIR || (process.env.NODE_ENV === 'test' ? TEST_DEFAULT_DIR : join(homedir(), '.agentstage', 'logs')),
|
|
65
|
+
fileName: process.env.BRIDGE_LOG_FILE || 'bridge.log',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toLine(entry: LogEntry): string {
|
|
70
|
+
const dataStr = entry.data !== undefined ? ` ${JSON.stringify(entry.data)}` : '';
|
|
71
|
+
return `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}${dataStr}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class FileBatchSink implements LoggerSink {
|
|
75
|
+
private readonly path: string;
|
|
76
|
+
private queue: string[] = [];
|
|
77
|
+
private writing = false;
|
|
78
|
+
private flushScheduled = false;
|
|
79
|
+
private initialized = false;
|
|
80
|
+
|
|
81
|
+
constructor(path: string) {
|
|
82
|
+
this.path = path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async write(entry: LogEntry): Promise<void> {
|
|
86
|
+
this.queue.push(toLine(entry));
|
|
87
|
+
if (!this.flushScheduled) {
|
|
88
|
+
this.flushScheduled = true;
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
this.flushScheduled = false;
|
|
91
|
+
void this.flush();
|
|
92
|
+
}, 10);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async flush(): Promise<void> {
|
|
97
|
+
if (this.writing || this.queue.length === 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.writing = true;
|
|
102
|
+
try {
|
|
103
|
+
if (!this.initialized) {
|
|
104
|
+
await mkdir(join(this.path, '..'), { recursive: true });
|
|
105
|
+
this.initialized = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
while (this.queue.length > 0) {
|
|
109
|
+
const chunk = this.queue.splice(0, this.queue.length).join('');
|
|
110
|
+
await appendFile(this.path, chunk, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
this.writing = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async clear(): Promise<void> {
|
|
118
|
+
this.queue = [];
|
|
119
|
+
await mkdir(join(this.path, '..'), { recursive: true });
|
|
120
|
+
await writeFile(this.path, '', 'utf8');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class ConsoleSink implements LoggerSink {
|
|
125
|
+
write(entry: LogEntry): void {
|
|
126
|
+
if (entry.level === 'error') {
|
|
127
|
+
console.error(entry.message, entry.data ?? '');
|
|
128
|
+
} else if (entry.level === 'warn') {
|
|
129
|
+
console.warn(entry.message, entry.data ?? '');
|
|
130
|
+
} else {
|
|
131
|
+
console.log(entry.message, entry.data ?? '');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class DisabledSink implements LoggerSink {
|
|
137
|
+
write(): void {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class CompositeSink implements LoggerSink {
|
|
141
|
+
private sinks: LoggerSink[];
|
|
142
|
+
|
|
143
|
+
constructor(sinks: LoggerSink[]) {
|
|
144
|
+
this.sinks = sinks;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async write(entry: LogEntry): Promise<void> {
|
|
148
|
+
for (const sink of this.sinks) {
|
|
149
|
+
await sink.write(entry);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async clear(): Promise<void> {
|
|
154
|
+
for (const sink of this.sinks) {
|
|
155
|
+
if (sink.clear) {
|
|
156
|
+
await sink.clear();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async flush(): Promise<void> {
|
|
162
|
+
for (const sink of this.sinks) {
|
|
163
|
+
if (sink.flush) {
|
|
164
|
+
await sink.flush();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createBuiltinSink(options: LoggerRuntimeOptions): LoggerSink {
|
|
171
|
+
const logPath = join(options.logDir, options.fileName);
|
|
172
|
+
switch (options.mode) {
|
|
173
|
+
case 'disabled':
|
|
174
|
+
return new DisabledSink();
|
|
175
|
+
case 'console':
|
|
176
|
+
return new ConsoleSink();
|
|
177
|
+
case 'file':
|
|
178
|
+
return new FileBatchSink(logPath);
|
|
179
|
+
case 'both':
|
|
180
|
+
default:
|
|
181
|
+
return new CompositeSink([new ConsoleSink(), new FileBatchSink(logPath)]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let runtimeOptions = defaultOptions();
|
|
186
|
+
let activeSink: LoggerSink = createBuiltinSink(runtimeOptions);
|
|
187
|
+
|
|
188
|
+
function shouldLog(level: LogLevel): boolean {
|
|
189
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[runtimeOptions.level];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function write(level: LogLevel, message: string, data?: unknown): void {
|
|
193
|
+
if (!shouldLog(level)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const entry: LogEntry = {
|
|
198
|
+
level,
|
|
199
|
+
message,
|
|
200
|
+
data,
|
|
201
|
+
timestamp: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
void activeSink.write(entry);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const logger = {
|
|
208
|
+
configure(overrides: Partial<LoggerRuntimeOptions> = {}) {
|
|
209
|
+
runtimeOptions = {
|
|
210
|
+
...runtimeOptions,
|
|
211
|
+
...overrides,
|
|
212
|
+
};
|
|
213
|
+
activeSink = createBuiltinSink(runtimeOptions);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
setSink(sink: LoggerSink) {
|
|
217
|
+
activeSink = sink;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
debug(message: string, data?: unknown) {
|
|
221
|
+
write('debug', message, data);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
info(message: string, data?: unknown) {
|
|
225
|
+
write('info', message, data);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
warn(message: string, data?: unknown) {
|
|
229
|
+
write('warn', message, data);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
error(message: string, data?: unknown) {
|
|
233
|
+
write('error', message, data);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
wsMessage(direction: 'in' | 'out', clientType: string, data: string) {
|
|
237
|
+
const arrow = direction === 'in' ? '->' : '<-';
|
|
238
|
+
write('debug', `[WS] ${arrow} [${clientType}] ${data}`);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
getLogPath(): string {
|
|
242
|
+
return join(runtimeOptions.logDir, runtimeOptions.fileName);
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async clear(): Promise<void> {
|
|
246
|
+
if (activeSink.clear) {
|
|
247
|
+
await activeSink.clear();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const logPath = this.getLogPath();
|
|
252
|
+
if (existsSync(logPath)) {
|
|
253
|
+
await writeFile(logPath, '', 'utf8');
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async flush(): Promise<void> {
|
|
258
|
+
if (activeSink.flush) {
|
|
259
|
+
await activeSink.flush();
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
2
|
+
import type { Server } from 'http';
|
|
3
|
+
import type { Http2SecureServer } from 'http2';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { createBridgeGateway } from '../gateway/createBridgeGateway.js';
|
|
6
|
+
|
|
7
|
+
export interface BridgePluginOptions {
|
|
8
|
+
pagesDir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function bridgePlugin(options: BridgePluginOptions = {}): Plugin {
|
|
12
|
+
return {
|
|
13
|
+
name: 'agentstage-bridge',
|
|
14
|
+
configureServer(server: ViteDevServer) {
|
|
15
|
+
const pagesDir = options.pagesDir || join(process.cwd(), 'src', 'pages');
|
|
16
|
+
const gateway = createBridgeGateway({ pagesDir });
|
|
17
|
+
|
|
18
|
+
// 保存 gateway 引用供后续使用
|
|
19
|
+
(server as any).bridgeGateway = gateway;
|
|
20
|
+
|
|
21
|
+
// 同步检查 httpServer 是否可用
|
|
22
|
+
if (server.httpServer) {
|
|
23
|
+
gateway.attach(server.httpServer as Server | Http2SecureServer);
|
|
24
|
+
console.log('[Bridge] WebSocket mounted at /_bridge');
|
|
25
|
+
console.log('[Bridge] Pages directory:', pagesDir);
|
|
26
|
+
} else {
|
|
27
|
+
console.warn('[Bridge] httpServer not available during configureServer');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|