ctxpkg 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.
- package/LICENSE +661 -0
- package/README.md +282 -0
- package/bin/cli.js +8 -0
- package/bin/daemon.js +7 -0
- package/package.json +70 -0
- package/src/agent/AGENTS.md +249 -0
- package/src/agent/agent.prompts.ts +66 -0
- package/src/agent/agent.test-runner.schemas.ts +158 -0
- package/src/agent/agent.test-runner.ts +436 -0
- package/src/agent/agent.ts +371 -0
- package/src/agent/agent.types.ts +94 -0
- package/src/backend/AGENTS.md +112 -0
- package/src/backend/backend.protocol.ts +95 -0
- package/src/backend/backend.schemas.ts +123 -0
- package/src/backend/backend.services.ts +151 -0
- package/src/backend/backend.ts +111 -0
- package/src/backend/backend.types.ts +34 -0
- package/src/cli/AGENTS.md +213 -0
- package/src/cli/cli.agent.ts +197 -0
- package/src/cli/cli.chat.ts +369 -0
- package/src/cli/cli.client.ts +55 -0
- package/src/cli/cli.collections.ts +491 -0
- package/src/cli/cli.config.ts +252 -0
- package/src/cli/cli.daemon.ts +160 -0
- package/src/cli/cli.documents.ts +413 -0
- package/src/cli/cli.mcp.ts +177 -0
- package/src/cli/cli.ts +28 -0
- package/src/cli/cli.utils.ts +122 -0
- package/src/client/AGENTS.md +135 -0
- package/src/client/client.adapters.ts +279 -0
- package/src/client/client.ts +86 -0
- package/src/client/client.types.ts +17 -0
- package/src/collections/AGENTS.md +185 -0
- package/src/collections/collections.schemas.ts +195 -0
- package/src/collections/collections.ts +1160 -0
- package/src/config/config.ts +118 -0
- package/src/daemon/AGENTS.md +168 -0
- package/src/daemon/daemon.config.ts +23 -0
- package/src/daemon/daemon.manager.ts +215 -0
- package/src/daemon/daemon.schemas.ts +22 -0
- package/src/daemon/daemon.ts +205 -0
- package/src/database/AGENTS.md +211 -0
- package/src/database/database.ts +64 -0
- package/src/database/migrations/migrations.001-init.ts +56 -0
- package/src/database/migrations/migrations.002-fts5.ts +32 -0
- package/src/database/migrations/migrations.ts +20 -0
- package/src/database/migrations/migrations.types.ts +9 -0
- package/src/documents/AGENTS.md +301 -0
- package/src/documents/documents.schemas.ts +190 -0
- package/src/documents/documents.ts +734 -0
- package/src/embedder/embedder.ts +53 -0
- package/src/exports.ts +0 -0
- package/src/mcp/AGENTS.md +264 -0
- package/src/mcp/mcp.ts +105 -0
- package/src/tools/AGENTS.md +228 -0
- package/src/tools/agent/agent.ts +45 -0
- package/src/tools/documents/documents.ts +401 -0
- package/src/tools/tools.langchain.ts +37 -0
- package/src/tools/tools.mcp.ts +46 -0
- package/src/tools/tools.types.ts +35 -0
- package/src/utils/utils.services.ts +46 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a header with decorative borders
|
|
5
|
+
*/
|
|
6
|
+
const formatHeader = (text: string) => {
|
|
7
|
+
console.log();
|
|
8
|
+
console.log(chalk.bold.cyan(`━━━ ${text} ━━━`));
|
|
9
|
+
console.log();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a success message with checkmark
|
|
14
|
+
*/
|
|
15
|
+
const formatSuccess = (text: string) => {
|
|
16
|
+
console.log(chalk.green('✔'), text);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Format an error message with X mark
|
|
21
|
+
*/
|
|
22
|
+
const formatError = (text: string) => {
|
|
23
|
+
console.log(chalk.red('✖'), text);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format an info message with info icon
|
|
28
|
+
*/
|
|
29
|
+
const formatInfo = (text: string) => {
|
|
30
|
+
console.log(chalk.blue('ℹ'), text);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format a warning message
|
|
35
|
+
*/
|
|
36
|
+
const formatWarning = (text: string) => {
|
|
37
|
+
console.log(chalk.yellow('⚠'), text);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format a key-value pair
|
|
42
|
+
*/
|
|
43
|
+
const formatKeyValue = (key: string, value: unknown, keyWidth?: number) => {
|
|
44
|
+
const keyStr = keyWidth ? key.padEnd(keyWidth) : key;
|
|
45
|
+
console.log(chalk.dim(' ') + chalk.white(keyStr) + chalk.dim(' │ ') + chalk.cyan(String(value)));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format a table header
|
|
50
|
+
*/
|
|
51
|
+
const formatTableHeader = (columns: { name: string; width: number }[]) => {
|
|
52
|
+
const header = columns.map((col) => chalk.bold(col.name.padEnd(col.width))).join(chalk.dim(' │ '));
|
|
53
|
+
const separator = columns.map((col) => '─'.repeat(col.width)).join(chalk.dim('─┼─'));
|
|
54
|
+
|
|
55
|
+
console.log(chalk.dim(' ') + header);
|
|
56
|
+
console.log(chalk.dim(' ') + separator);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a table row
|
|
61
|
+
*/
|
|
62
|
+
const formatTableRow = (values: { value: string; width: number; color?: typeof chalk }[]) => {
|
|
63
|
+
const row = values
|
|
64
|
+
.map((val) => {
|
|
65
|
+
const color = val.color || chalk.white;
|
|
66
|
+
return color(val.value.padEnd(val.width));
|
|
67
|
+
})
|
|
68
|
+
.join(chalk.dim(' │ '));
|
|
69
|
+
|
|
70
|
+
console.log(chalk.dim(' ') + row);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrap an async action with error handling
|
|
75
|
+
*/
|
|
76
|
+
const withErrorHandling = <T extends unknown[]>(
|
|
77
|
+
action: (...args: T) => Promise<void>,
|
|
78
|
+
): ((...args: T) => Promise<void>) => {
|
|
79
|
+
return async (...args: T) => {
|
|
80
|
+
try {
|
|
81
|
+
await action(...args);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
formatError(error instanceof Error ? error.message : String(error));
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Flatten an object into dot-notation keys
|
|
91
|
+
*/
|
|
92
|
+
const flattenObject = (obj: Record<string, unknown>, prefix = ''): Record<string, unknown> => {
|
|
93
|
+
const result: Record<string, unknown> = {};
|
|
94
|
+
|
|
95
|
+
for (const key of Object.keys(obj)) {
|
|
96
|
+
const value = obj[key];
|
|
97
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
98
|
+
|
|
99
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
100
|
+
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
|
|
101
|
+
} else {
|
|
102
|
+
result[newKey] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
formatHeader,
|
|
111
|
+
formatSuccess,
|
|
112
|
+
formatError,
|
|
113
|
+
formatInfo,
|
|
114
|
+
formatWarning,
|
|
115
|
+
formatKeyValue,
|
|
116
|
+
formatTableHeader,
|
|
117
|
+
formatTableRow,
|
|
118
|
+
withErrorHandling,
|
|
119
|
+
flattenObject,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export { chalk };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Client — Agent Guidelines
|
|
2
|
+
|
|
3
|
+
This document describes the client module architecture for AI agents working on this codebase.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The client module provides a type-safe interface for communicating with the backend service. It supports multiple connection modes through an adapter pattern, allowing the same API to work in-process, via Unix socket (daemon), or over WebSocket.
|
|
8
|
+
|
|
9
|
+
## File Structure
|
|
10
|
+
|
|
11
|
+
| File | Purpose |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| `client.ts` | `BackendClient` class and `createClient()` factory |
|
|
14
|
+
| `client.adapters.ts` | Connection adapters (Direct, Daemon, WebSocket) |
|
|
15
|
+
| `client.types.ts` | Connection modes and client options types |
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
21
|
+
│ BackendClient │
|
|
22
|
+
│ ┌──────────────────────────────────────────────────────┐ │
|
|
23
|
+
│ │ Service Proxies (via Proxy) │ │
|
|
24
|
+
│ │ .documents.search() → "documents.search" │ │
|
|
25
|
+
│ │ .collections.sync() → "collections.sync" │ │
|
|
26
|
+
│ │ .system.ping() → "system.ping" │ │
|
|
27
|
+
│ └──────────────────────┬───────────────────────────────┘ │
|
|
28
|
+
│ │ │
|
|
29
|
+
│ ┌──────────────────────▼───────────────────────────────┐ │
|
|
30
|
+
│ │ ClientAdapter │ │
|
|
31
|
+
│ │ connect() | disconnect() | request() | isConnected │ │
|
|
32
|
+
│ └──────────────────────────────────────────────────────┘ │
|
|
33
|
+
└─────────────────────────────────────────────────────────────┘
|
|
34
|
+
│
|
|
35
|
+
┌───────────────┼───────────────┐
|
|
36
|
+
▼ ▼ ▼
|
|
37
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
38
|
+
│ Direct │ │ Daemon │ │ WebSocket │
|
|
39
|
+
│ (in-proc) │ │ (Unix sock) │ │ (remote) │
|
|
40
|
+
└─────────────┘ └─────────────┘ └─────────────┘
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Connection Modes
|
|
44
|
+
|
|
45
|
+
| Mode | Adapter | Use Case |
|
|
46
|
+
|------|---------|----------|
|
|
47
|
+
| `direct` | `DirectAdapter` | In-process backend, no daemon needed |
|
|
48
|
+
| `daemon` | `DaemonAdapter` | Connect to local daemon via Unix socket |
|
|
49
|
+
| `websocket` | `WebSocketAdapter` | Connect to remote server |
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### Basic Usage
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { createClient } from '#root/client/client.ts';
|
|
57
|
+
|
|
58
|
+
// Direct mode (in-process)
|
|
59
|
+
const client = await createClient({ mode: 'direct' });
|
|
60
|
+
|
|
61
|
+
// Daemon mode (Unix socket)
|
|
62
|
+
const client = await createClient({
|
|
63
|
+
mode: 'daemon',
|
|
64
|
+
socketPath: '/tmp/ctxpkg.sock', // optional
|
|
65
|
+
autoStartDaemon: true, // optional, default true
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// WebSocket mode
|
|
69
|
+
const client = await createClient({
|
|
70
|
+
mode: 'websocket',
|
|
71
|
+
url: 'ws://localhost:8080',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Use type-safe API
|
|
75
|
+
const results = await client.documents.search({ query: 'foo' });
|
|
76
|
+
await client.disconnect();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Service Proxies
|
|
80
|
+
|
|
81
|
+
The client uses `Proxy` to convert method calls into RPC requests:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
client.documents.search({ query: 'foo' })
|
|
85
|
+
// → adapter.request('documents.search', { query: 'foo' })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This provides full type safety — methods and parameters are typed from `BackendAPI`.
|
|
89
|
+
|
|
90
|
+
## Adding a New Adapter
|
|
91
|
+
|
|
92
|
+
1. Implement the `ClientAdapter` interface:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
class MyAdapter implements ClientAdapter {
|
|
96
|
+
async connect(): Promise<void> { /* ... */ }
|
|
97
|
+
async disconnect(): Promise<void> { /* ... */ }
|
|
98
|
+
isConnected(): boolean { /* ... */ }
|
|
99
|
+
async request(method: string, params?: unknown): Promise<unknown> { /* ... */ }
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
2. Add a new connection mode in `client.types.ts`:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
export type ConnectionMode = 'direct' | 'daemon' | 'websocket' | 'mymode';
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
3. Handle it in `BackendClient` constructor in `client.ts`.
|
|
110
|
+
|
|
111
|
+
## Key Patterns
|
|
112
|
+
|
|
113
|
+
### Request/Response Handling
|
|
114
|
+
|
|
115
|
+
Adapters must:
|
|
116
|
+
- Generate unique request IDs (use `randomUUID()`)
|
|
117
|
+
- Track pending requests for async response matching
|
|
118
|
+
- Handle timeouts and connection errors
|
|
119
|
+
- Parse responses and throw on errors
|
|
120
|
+
|
|
121
|
+
### Error Handling
|
|
122
|
+
|
|
123
|
+
Errors from the backend include a `code` property matching `ErrorCodes`:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
try {
|
|
127
|
+
await client.documents.search({ query: 'foo' });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code === ErrorCodes.Timeout) { /* ... */ }
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Daemon Auto-Start
|
|
134
|
+
|
|
135
|
+
`DaemonAdapter` uses `DaemonManager` to auto-start the daemon if not running. This is controlled by the `autoStartDaemon` option.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
|
|
5
|
+
import type { Request, Response } from '#root/backend/backend.protocol.ts';
|
|
6
|
+
import { ErrorCodes } from '#root/backend/backend.protocol.ts';
|
|
7
|
+
import { Backend } from '#root/backend/backend.ts';
|
|
8
|
+
import { DaemonManager } from '#root/daemon/daemon.manager.ts';
|
|
9
|
+
import { destroy } from '#root/utils/utils.services.ts';
|
|
10
|
+
|
|
11
|
+
type ClientAdapter = {
|
|
12
|
+
connect(): Promise<void>;
|
|
13
|
+
disconnect(): Promise<void>;
|
|
14
|
+
isConnected(): boolean;
|
|
15
|
+
request(method: string, params?: unknown): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Direct adapter - instantiates backend in-process
|
|
19
|
+
class DirectAdapter implements ClientAdapter {
|
|
20
|
+
#backend: Backend | null = null;
|
|
21
|
+
|
|
22
|
+
async connect(): Promise<void> {
|
|
23
|
+
this.#backend = new Backend();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async disconnect(): Promise<void> {
|
|
27
|
+
if (this.#backend) {
|
|
28
|
+
await this.#backend[destroy]();
|
|
29
|
+
this.#backend = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
isConnected(): boolean {
|
|
34
|
+
return this.#backend !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async request(method: string, params?: unknown): Promise<unknown> {
|
|
38
|
+
if (!this.#backend) {
|
|
39
|
+
throw new Error('Not connected');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const request: Request = {
|
|
43
|
+
id: randomUUID(),
|
|
44
|
+
method,
|
|
45
|
+
params,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const response = await this.#backend.handleRequest(request);
|
|
49
|
+
return this.#handleResponse(response);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#handleResponse(response: Response): unknown {
|
|
53
|
+
if (response.error) {
|
|
54
|
+
const error = new Error(response.error.message);
|
|
55
|
+
(error as Error & { code: number }).code = response.error.code;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return response.result;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Daemon adapter - connects via Unix socket
|
|
63
|
+
class DaemonAdapter implements ClientAdapter {
|
|
64
|
+
#manager: DaemonManager;
|
|
65
|
+
#socket: WebSocket | null = null;
|
|
66
|
+
#pendingRequests = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
|
67
|
+
#timeout: number;
|
|
68
|
+
|
|
69
|
+
constructor(options?: { socketPath?: string; autoStart?: boolean; timeout?: number }) {
|
|
70
|
+
this.#manager = new DaemonManager({
|
|
71
|
+
socketPath: options?.socketPath,
|
|
72
|
+
autoStart: options?.autoStart ?? true,
|
|
73
|
+
});
|
|
74
|
+
this.#timeout = options?.timeout ?? 30000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async connect(): Promise<void> {
|
|
78
|
+
await this.#manager.ensureRunning();
|
|
79
|
+
|
|
80
|
+
const socketPath = this.#manager.getSocketPath();
|
|
81
|
+
// Connect to Unix socket - ws library uses this format: ws+unix:///path/to/socket
|
|
82
|
+
const socket = new WebSocket(`ws+unix://${socketPath}:/.`);
|
|
83
|
+
this.#socket = socket;
|
|
84
|
+
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
const timeout = setTimeout(() => {
|
|
87
|
+
reject(new Error('Connection timeout'));
|
|
88
|
+
}, 5000);
|
|
89
|
+
|
|
90
|
+
socket.on('open', () => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
socket.on('error', (error) => {
|
|
96
|
+
clearTimeout(timeout);
|
|
97
|
+
reject(error);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
socket.on('message', (data) => {
|
|
102
|
+
try {
|
|
103
|
+
const response: Response = JSON.parse(data.toString());
|
|
104
|
+
const pending = this.#pendingRequests.get(response.id);
|
|
105
|
+
if (pending) {
|
|
106
|
+
this.#pendingRequests.delete(response.id);
|
|
107
|
+
if (response.error) {
|
|
108
|
+
const error = new Error(response.error.message);
|
|
109
|
+
(error as Error & { code: number }).code = response.error.code;
|
|
110
|
+
pending.reject(error);
|
|
111
|
+
} else {
|
|
112
|
+
pending.resolve(response.result);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('[client] Failed to parse response:', error);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
socket.on('close', () => {
|
|
121
|
+
// Reject all pending requests
|
|
122
|
+
for (const [id, pending] of this.#pendingRequests) {
|
|
123
|
+
pending.reject(new Error('Connection closed'));
|
|
124
|
+
this.#pendingRequests.delete(id);
|
|
125
|
+
}
|
|
126
|
+
this.#socket = null;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async disconnect(): Promise<void> {
|
|
131
|
+
if (this.#socket) {
|
|
132
|
+
this.#socket.close();
|
|
133
|
+
this.#socket = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
isConnected(): boolean {
|
|
138
|
+
return this.#socket !== null && this.#socket.readyState === WebSocket.OPEN;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async request(method: string, params?: unknown): Promise<unknown> {
|
|
142
|
+
const socket = this.#socket;
|
|
143
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
144
|
+
throw new Error('Not connected');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const id = randomUUID();
|
|
148
|
+
const request: Request = { id, method, params };
|
|
149
|
+
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const timeout = setTimeout(() => {
|
|
152
|
+
this.#pendingRequests.delete(id);
|
|
153
|
+
const error = new Error('Request timeout');
|
|
154
|
+
(error as Error & { code: number }).code = ErrorCodes.Timeout;
|
|
155
|
+
reject(error);
|
|
156
|
+
}, this.#timeout);
|
|
157
|
+
|
|
158
|
+
this.#pendingRequests.set(id, {
|
|
159
|
+
resolve: (value) => {
|
|
160
|
+
clearTimeout(timeout);
|
|
161
|
+
resolve(value);
|
|
162
|
+
},
|
|
163
|
+
reject: (err) => {
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
reject(err);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
socket.send(JSON.stringify(request));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// WebSocket adapter - connects via remote WebSocket
|
|
175
|
+
class WebSocketAdapter implements ClientAdapter {
|
|
176
|
+
#url: string;
|
|
177
|
+
#socket: WebSocket | null = null;
|
|
178
|
+
#pendingRequests = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
|
179
|
+
#timeout: number;
|
|
180
|
+
|
|
181
|
+
constructor(url: string, options?: { timeout?: number }) {
|
|
182
|
+
this.#url = url;
|
|
183
|
+
this.#timeout = options?.timeout ?? 30000;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async connect(): Promise<void> {
|
|
187
|
+
const socket = new WebSocket(this.#url);
|
|
188
|
+
this.#socket = socket;
|
|
189
|
+
|
|
190
|
+
await new Promise<void>((resolve, reject) => {
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
reject(new Error('Connection timeout'));
|
|
193
|
+
}, 5000);
|
|
194
|
+
|
|
195
|
+
socket.on('open', () => {
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
socket.on('error', (error) => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
reject(error);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
socket.on('message', (data) => {
|
|
207
|
+
try {
|
|
208
|
+
const response: Response = JSON.parse(data.toString());
|
|
209
|
+
const pending = this.#pendingRequests.get(response.id);
|
|
210
|
+
if (pending) {
|
|
211
|
+
this.#pendingRequests.delete(response.id);
|
|
212
|
+
if (response.error) {
|
|
213
|
+
const error = new Error(response.error.message);
|
|
214
|
+
(error as Error & { code: number }).code = response.error.code;
|
|
215
|
+
pending.reject(error);
|
|
216
|
+
} else {
|
|
217
|
+
pending.resolve(response.result);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('[client] Failed to parse response:', error);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
socket.on('close', () => {
|
|
226
|
+
for (const [id, pending] of this.#pendingRequests) {
|
|
227
|
+
pending.reject(new Error('Connection closed'));
|
|
228
|
+
this.#pendingRequests.delete(id);
|
|
229
|
+
}
|
|
230
|
+
this.#socket = null;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async disconnect(): Promise<void> {
|
|
235
|
+
if (this.#socket) {
|
|
236
|
+
this.#socket.close();
|
|
237
|
+
this.#socket = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
isConnected(): boolean {
|
|
242
|
+
return this.#socket !== null && this.#socket.readyState === WebSocket.OPEN;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async request(method: string, params?: unknown): Promise<unknown> {
|
|
246
|
+
const socket = this.#socket;
|
|
247
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
248
|
+
throw new Error('Not connected');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const id = randomUUID();
|
|
252
|
+
const request: Request = { id, method, params };
|
|
253
|
+
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const timeout = setTimeout(() => {
|
|
256
|
+
this.#pendingRequests.delete(id);
|
|
257
|
+
const error = new Error('Request timeout');
|
|
258
|
+
(error as Error & { code: number }).code = ErrorCodes.Timeout;
|
|
259
|
+
reject(error);
|
|
260
|
+
}, this.#timeout);
|
|
261
|
+
|
|
262
|
+
this.#pendingRequests.set(id, {
|
|
263
|
+
resolve: (value) => {
|
|
264
|
+
clearTimeout(timeout);
|
|
265
|
+
resolve(value);
|
|
266
|
+
},
|
|
267
|
+
reject: (err) => {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
reject(err);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
socket.send(JSON.stringify(request));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type { ClientAdapter };
|
|
279
|
+
export { DirectAdapter, DaemonAdapter, WebSocketAdapter };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ClientOptions, BackendAPI } from './client.types.ts';
|
|
2
|
+
import { DirectAdapter, DaemonAdapter, WebSocketAdapter, type ClientAdapter } from './client.adapters.ts';
|
|
3
|
+
|
|
4
|
+
// Create a proxy that converts method calls to RPC requests
|
|
5
|
+
const createServiceProxy = <T extends keyof BackendAPI>(adapter: ClientAdapter, serviceName: T): BackendAPI[T] => {
|
|
6
|
+
const target = {} as Record<string, unknown>;
|
|
7
|
+
return new Proxy(target, {
|
|
8
|
+
get(_target, methodName: string) {
|
|
9
|
+
return async (params?: unknown) => {
|
|
10
|
+
const method = `${serviceName}.${methodName}`;
|
|
11
|
+
return adapter.request(method, params ?? {});
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
}) as BackendAPI[T];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class BackendClient implements BackendAPI {
|
|
18
|
+
#adapter: ClientAdapter;
|
|
19
|
+
#connected = false;
|
|
20
|
+
|
|
21
|
+
// Type-safe service proxies
|
|
22
|
+
readonly documents: BackendAPI['documents'];
|
|
23
|
+
readonly collections: BackendAPI['collections'];
|
|
24
|
+
readonly system: BackendAPI['system'];
|
|
25
|
+
|
|
26
|
+
constructor(options: ClientOptions) {
|
|
27
|
+
// Create appropriate adapter
|
|
28
|
+
switch (options.mode) {
|
|
29
|
+
case 'direct':
|
|
30
|
+
this.#adapter = new DirectAdapter();
|
|
31
|
+
break;
|
|
32
|
+
case 'daemon':
|
|
33
|
+
this.#adapter = new DaemonAdapter({
|
|
34
|
+
socketPath: options.socketPath,
|
|
35
|
+
autoStart: options.autoStartDaemon,
|
|
36
|
+
timeout: options.timeout,
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
case 'websocket':
|
|
40
|
+
if (!options.url) {
|
|
41
|
+
throw new Error('WebSocket URL is required for websocket mode');
|
|
42
|
+
}
|
|
43
|
+
this.#adapter = new WebSocketAdapter(options.url, {
|
|
44
|
+
timeout: options.timeout,
|
|
45
|
+
});
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`Unknown connection mode: ${options.mode}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create type-safe service proxies
|
|
52
|
+
this.documents = createServiceProxy(this.#adapter, 'documents');
|
|
53
|
+
this.collections = createServiceProxy(this.#adapter, 'collections');
|
|
54
|
+
this.system = createServiceProxy(this.#adapter, 'system');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async connect(): Promise<void> {
|
|
58
|
+
await this.#adapter.connect();
|
|
59
|
+
this.#connected = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async disconnect(): Promise<void> {
|
|
63
|
+
await this.#adapter.disconnect();
|
|
64
|
+
this.#connected = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isConnected(): boolean {
|
|
68
|
+
return this.#connected && this.#adapter.isConnected();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Generic request method for advanced usage
|
|
72
|
+
async request<T>(method: string, params?: unknown): Promise<T> {
|
|
73
|
+
return this.#adapter.request(method, params) as Promise<T>;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Factory function for creating a client with auto-detection
|
|
78
|
+
const createClient = async (options?: Partial<ClientOptions>): Promise<BackendClient> => {
|
|
79
|
+
const mode = options?.mode ?? 'direct';
|
|
80
|
+
const client = new BackendClient({ mode, ...options } as ClientOptions);
|
|
81
|
+
await client.connect();
|
|
82
|
+
return client;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export { BackendClient, createClient };
|
|
86
|
+
export type { ClientOptions, BackendAPI };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Re-export backend API types for client use
|
|
2
|
+
export type { BackendAPI, GetBackendAPIResponse, GetBackendAPIParams } from '#root/backend/backend.types.ts';
|
|
3
|
+
|
|
4
|
+
// Connection modes
|
|
5
|
+
export type ConnectionMode = 'direct' | 'daemon' | 'websocket';
|
|
6
|
+
|
|
7
|
+
// Client options
|
|
8
|
+
export type ClientOptions = {
|
|
9
|
+
mode: ConnectionMode;
|
|
10
|
+
// For 'websocket' mode
|
|
11
|
+
url?: string;
|
|
12
|
+
// For 'daemon' mode
|
|
13
|
+
socketPath?: string;
|
|
14
|
+
autoStartDaemon?: boolean;
|
|
15
|
+
// Common options
|
|
16
|
+
timeout?: number;
|
|
17
|
+
};
|