Skip to content

Commit 0c5a0c6

Browse files
author
cw
committed
feat: add unified API server and Node.js IPC client for full integration
- Add unified API server on port 9529 with REST and WebSocket endpoints - Add API translator for HTTP JSON to IPC request/response conversion - Add Node.js CLI wrapper with IPC fallback when Rust binary unavailable - Integrate unified API server into daemon startup/shutdown lifecycle - Add @msgpack/msgpack dependency for IPC protocol support
1 parent 9dc1425 commit 0c5a0c6

File tree

11 files changed

+1938
-15
lines changed

11 files changed

+1938
-15
lines changed

daemon/lib/api/api_translator.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:opencli_daemon/ipc/ipc_protocol.dart';
2+
3+
/// Translates between HTTP JSON requests and IPC protocol messages
4+
class ApiTranslator {
5+
/// Convert HTTP JSON body to IpcRequest
6+
static IpcRequest httpToIpcRequest(Map<String, dynamic> json) {
7+
return IpcRequest(
8+
method: json['method'] as String,
9+
params: List<String>.from(json['params'] ?? []),
10+
context: Map<String, String>.from(json['context'] ?? {}),
11+
requestId: _generateRequestId(),
12+
timeoutMs: (json['timeout_ms'] as int?) ?? 30000,
13+
);
14+
}
15+
16+
/// Convert IpcResponse to HTTP JSON
17+
static Map<String, dynamic> ipcResponseToHttp(IpcResponse response) {
18+
return {
19+
'success': response.success,
20+
'result': response.result,
21+
'duration_ms': response.durationUs / 1000,
22+
'request_id': response.requestId,
23+
'cached': response.cached,
24+
if (!response.success && response.error != null) 'error': response.error,
25+
};
26+
}
27+
28+
/// Format errors for HTTP response
29+
static Map<String, dynamic> errorToHttp(String error, String? requestId) {
30+
return {
31+
'success': false,
32+
'error': error,
33+
'request_id': requestId,
34+
};
35+
}
36+
37+
/// Generate unique request ID
38+
static String _generateRequestId() {
39+
return DateTime.now().millisecondsSinceEpoch.toRadixString(16);
40+
}
41+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:io';
4+
import 'package:shelf/shelf.dart' as shelf;
5+
import 'package:shelf/shelf_io.dart' as shelf_io;
6+
import 'package:shelf_router/shelf_router.dart';
7+
import 'package:opencli_daemon/core/request_router.dart';
8+
import 'package:opencli_daemon/ipc/ipc_protocol.dart';
9+
import 'package:opencli_daemon/api/api_translator.dart';
10+
import 'package:opencli_daemon/api/message_handler.dart';
11+
12+
/// Unified API server on port 9529 for Web UI integration
13+
///
14+
/// Provides HTTP REST API that bridges to the existing RequestRouter,
15+
/// allowing Web UI to execute commands and methods via HTTP.
16+
class UnifiedApiServer {
17+
final RequestRouter _requestRouter;
18+
final MessageHandler _messageHandler;
19+
final int port;
20+
HttpServer? _server;
21+
22+
UnifiedApiServer({
23+
required RequestRouter requestRouter,
24+
required MessageHandler messageHandler,
25+
this.port = 9529,
26+
}) : _requestRouter = requestRouter,
27+
_messageHandler = messageHandler;
28+
29+
Future<void> start() async {
30+
final router = Router();
31+
32+
// POST /api/v1/execute - Main execution endpoint
33+
router.post('/api/v1/execute', _handleExecute);
34+
35+
// GET /api/v1/status - Status proxy
36+
router.get('/api/v1/status', _handleStatus);
37+
38+
// GET /health - Health check
39+
router.get('/health', _handleHealth);
40+
41+
// WebSocket /ws - Real-time messaging
42+
router.get('/ws', _messageHandler.handler);
43+
44+
final handler = const shelf.Pipeline()
45+
.addMiddleware(shelf.logRequests())
46+
.addMiddleware(_corsMiddleware())
47+
.addMiddleware(_errorHandlingMiddleware())
48+
.addHandler(router.call);
49+
50+
try {
51+
_server = await shelf_io.serve(
52+
handler,
53+
InternetAddress.loopbackIPv4,
54+
port,
55+
);
56+
print(
57+
'✓ Unified API server listening on http://localhost:${_server!.port}');
58+
print(
59+
' - Execute API: POST http://localhost:${_server!.port}/api/v1/execute');
60+
print(' - WebSocket: ws://localhost:${_server!.port}/ws');
61+
} catch (e) {
62+
print('⚠️ Failed to start unified API server: $e');
63+
rethrow;
64+
}
65+
}
66+
67+
Future<void> stop() async {
68+
await _server?.close(force: true);
69+
_server = null;
70+
}
71+
72+
/// Handle POST /api/v1/execute
73+
///
74+
/// Expected request body: {"method": "...", "params": [...], "context": {...}}
75+
/// Returns: {"success": true/false, "result": "...", ...}
76+
Future<shelf.Response> _handleExecute(shelf.Request request) async {
77+
final startTime = DateTime.now();
78+
79+
try {
80+
// Parse JSON body
81+
final body = await request.readAsString();
82+
83+
if (body.isEmpty) {
84+
return shelf.Response.badRequest(
85+
body: jsonEncode(
86+
ApiTranslator.errorToHttp('Empty request body', null),
87+
),
88+
headers: {'Content-Type': 'application/json'},
89+
);
90+
}
91+
92+
final json = jsonDecode(body) as Map<String, dynamic>;
93+
94+
// Validate required fields
95+
if (!json.containsKey('method')) {
96+
return shelf.Response.badRequest(
97+
body: jsonEncode(
98+
ApiTranslator.errorToHttp('Missing required field: method', null),
99+
),
100+
headers: {'Content-Type': 'application/json'},
101+
);
102+
}
103+
104+
// Convert to IpcRequest
105+
final ipcRequest = ApiTranslator.httpToIpcRequest(json);
106+
107+
// Route through RequestRouter
108+
final result = await _requestRouter.route(ipcRequest);
109+
110+
// Calculate duration
111+
final duration =
112+
DateTime.now().difference(startTime).inMicroseconds;
113+
114+
// Build response
115+
final ipcResponse = IpcResponse(
116+
success: true,
117+
result: result,
118+
durationUs: duration,
119+
cached: false,
120+
requestId: ipcRequest.requestId,
121+
);
122+
123+
// Convert back to HTTP JSON
124+
final responseJson = ApiTranslator.ipcResponseToHttp(ipcResponse);
125+
126+
return shelf.Response.ok(
127+
jsonEncode(responseJson),
128+
headers: {'Content-Type': 'application/json'},
129+
);
130+
} on FormatException catch (e) {
131+
return shelf.Response.badRequest(
132+
body: jsonEncode(
133+
ApiTranslator.errorToHttp('Invalid JSON: ${e.message}', null),
134+
),
135+
headers: {'Content-Type': 'application/json'},
136+
);
137+
} catch (e, stack) {
138+
print('Execute error: $e\n$stack');
139+
final errorJson = ApiTranslator.errorToHttp(e.toString(), null);
140+
return shelf.Response.internalServerError(
141+
body: jsonEncode(errorJson),
142+
headers: {'Content-Type': 'application/json'},
143+
);
144+
}
145+
}
146+
147+
/// Handle GET /api/v1/status
148+
Future<shelf.Response> _handleStatus(shelf.Request request) async {
149+
final status = {
150+
'status': 'running',
151+
'version': '0.1.0',
152+
'timestamp': DateTime.now().toIso8601String(),
153+
};
154+
155+
return shelf.Response.ok(
156+
jsonEncode(status),
157+
headers: {'Content-Type': 'application/json'},
158+
);
159+
}
160+
161+
/// Handle GET /health
162+
Future<shelf.Response> _handleHealth(shelf.Request request) async {
163+
return shelf.Response.ok('OK');
164+
}
165+
166+
/// CORS middleware for Web UI access
167+
shelf.Middleware _corsMiddleware() {
168+
return (shelf.Handler handler) {
169+
return (shelf.Request request) async {
170+
if (request.method == 'OPTIONS') {
171+
return shelf.Response.ok('', headers: _corsHeaders);
172+
}
173+
174+
final response = await handler(request);
175+
return response.change(headers: _corsHeaders);
176+
};
177+
};
178+
}
179+
180+
Map<String, String> get _corsHeaders => {
181+
'Access-Control-Allow-Origin': '*',
182+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
183+
'Access-Control-Allow-Headers': 'Content-Type',
184+
};
185+
186+
/// Error handling middleware
187+
shelf.Middleware _errorHandlingMiddleware() {
188+
return (shelf.Handler handler) {
189+
return (shelf.Request request) async {
190+
try {
191+
return await handler(request);
192+
} catch (e, stack) {
193+
print('API Error: $e\n$stack');
194+
return shelf.Response.internalServerError(
195+
body: jsonEncode({'error': e.toString()}),
196+
headers: {'Content-Type': 'application/json'},
197+
);
198+
}
199+
};
200+
};
201+
}
202+
}

daemon/lib/core/daemon.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import 'package:opencli_daemon/ui/plugin_marketplace_ui.dart';
1414
import 'package:opencli_daemon/ui/terminal_ui.dart';
1515
import 'package:opencli_daemon/telemetry/telemetry.dart';
1616
import 'package:opencli_daemon/plugins/mcp_manager.dart';
17+
import 'package:opencli_daemon/api/unified_api_server.dart';
18+
import 'package:opencli_daemon/api/message_handler.dart';
1719

1820
class Daemon {
1921
static const String version = '0.2.0';
@@ -31,6 +33,7 @@ class Daemon {
3133
late final TelemetryManager _telemetry;
3234
WebUILauncher? _webUILauncher;
3335
PluginMarketplaceUI? _pluginMarketplaceUI;
36+
UnifiedApiServer? _unifiedApiServer;
3437

3538
final Completer<void> _exitSignal = Completer<void>();
3639
late final String _deviceId;
@@ -169,7 +172,7 @@ class Daemon {
169172
TerminalUI.success('Status server listening on port 9875', prefix: ' ✓');
170173

171174
// Start plugin marketplace UI
172-
TerminalUI.printInitStep('Starting plugin marketplace UI', last: true);
175+
TerminalUI.printInitStep('Starting plugin marketplace UI');
173176
final pluginsDir = _findPluginsDirectory();
174177
_pluginMarketplaceUI = PluginMarketplaceUI(
175178
port: 9877,
@@ -179,6 +182,16 @@ class Daemon {
179182
await _pluginMarketplaceUI!.start();
180183
TerminalUI.success('Plugin marketplace UI listening on port 9877', prefix: ' ✓');
181184

185+
// Start unified API server for Web UI integration
186+
TerminalUI.printInitStep('Starting unified API server', last: true);
187+
_unifiedApiServer = UnifiedApiServer(
188+
requestRouter: _router,
189+
messageHandler: MessageHandler(), // Create new instance for unified API
190+
port: 9529,
191+
);
192+
await _unifiedApiServer!.start();
193+
TerminalUI.success('Unified API server listening on port 9529', prefix: ' ✓');
194+
182195
// Auto-start Web UI (optional, can be disabled via config)
183196
final autoStartWebUI = Platform.environment['OPENCLI_AUTO_START_WEB_UI'] != 'false';
184197
if (autoStartWebUI) {
@@ -196,6 +209,12 @@ class Daemon {
196209

197210
// Print summary of all services
198211
final services = [
212+
{
213+
'name': 'Unified API',
214+
'url': 'http://localhost:9529/api/v1',
215+
'icon': '🔗',
216+
'enabled': true,
217+
},
199218
{
200219
'name': 'Plugin Marketplace',
201220
'url': 'http://localhost:9877',
@@ -270,6 +289,9 @@ class Daemon {
270289
Future<void> stop() async {
271290
TerminalUI.printSection('Shutdown', emoji: '🛑');
272291

292+
TerminalUI.printInitStep('Stopping unified API server');
293+
await _unifiedApiServer?.stop();
294+
273295
TerminalUI.printInitStep('Stopping plugin marketplace UI');
274296
await _pluginMarketplaceUI?.stop();
275297

0 commit comments

Comments
 (0)