@playcraft/cli 0.0.11 → 0.0.12

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.
@@ -0,0 +1,202 @@
1
+ import http from 'node:http';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import pc from 'picocolors';
5
+ import { Logger } from '../logger.js';
6
+ import { createServer } from '../server.js';
7
+ import { SocketServer } from '../socket.js';
8
+ import { Watcher } from '../watcher.js';
9
+ import { FSHandler } from '../fs-handler.js';
10
+ import { createLocalBackendRouter } from './local-backend.js';
11
+ import { CloudConnectionManager } from './cloud-connection.js';
12
+ import { createApiProxyRouter } from './api-proxy.js';
13
+ import { SyncManager } from '../sync/sync-manager.js';
14
+ import { JwtService } from '@playcraft/common/auth';
15
+ import { MessengerServer } from '@playcraft/common/messenger';
16
+ import { ShareDBRealtimeServer } from '@playcraft/common/sharedb';
17
+ export class PlayCraftAgent {
18
+ config;
19
+ isDaemon;
20
+ runtime = null;
21
+ constructor(config, isDaemon) {
22
+ this.config = config;
23
+ this.isDaemon = isDaemon;
24
+ }
25
+ get mode() {
26
+ return (this.config.mode || 'full-local');
27
+ }
28
+ async start() {
29
+ const logger = new Logger(this.config.projectId || 'default', this.isDaemon);
30
+ await logger.initialize();
31
+ await logger.info(`Starting agent (mode=${this.mode}) for project: ${this.config.projectId || 'default'}`);
32
+ await logger.info(`Directory: ${this.config.dir}`);
33
+ await logger.info(`Port: ${this.config.port}`);
34
+ if (!this.isDaemon) {
35
+ console.log(pc.cyan(`\n🚀 PlayCraft Agent 启动中...\n`));
36
+ console.log(`${pc.bold('模式:')} ${this.mode === 'full-local' ? pc.green('full-local') : pc.yellow('hybrid')}`);
37
+ console.log(`${pc.bold('项目 ID:')} ${this.config.projectId || pc.yellow('未设置')}`);
38
+ console.log(`${pc.bold('目录:')} ${this.config.dir}`);
39
+ console.log(`${pc.bold('端口:')} ${this.config.port}\n`);
40
+ }
41
+ const fsHandler = new FSHandler(this.config);
42
+ const app = createServer(this.config, fsHandler);
43
+ const server = http.createServer(app);
44
+ let messenger;
45
+ let realtime;
46
+ let invalidateFsCache;
47
+ let cloudConnectionRef;
48
+ let syncManager;
49
+ if (this.mode === 'full-local') {
50
+ const jwt = new JwtService();
51
+ const emitSecret = process.env.EMIT_SECRET || 'dev-emit-secret-change-me';
52
+ // 1) Messenger (ws://localhost:<port>/messenger)
53
+ messenger = new MessengerServer({
54
+ path: '/messenger',
55
+ onAuth: async (token) => jwt.verify(token),
56
+ onMessage: async (_client, msg) => {
57
+ // 最小实现:交给 local backend 的 /api/assets/move 等来处理(如果需要)
58
+ // 现阶段先保留钩子,避免阻断协议演进
59
+ void msg;
60
+ },
61
+ });
62
+ messenger.attach(server);
63
+ // 2) Local Backend (/api)
64
+ const dbPath = PlayCraftAgent.getDefaultDbPath();
65
+ const localBackend = createLocalBackendRouter({
66
+ projectId: this.config.projectId || 'default',
67
+ projectDir: this.config.dir,
68
+ dbPath,
69
+ emitSecret,
70
+ onEmit: (name, data, projectId) => {
71
+ messenger?.broadcast(name, data, projectId);
72
+ },
73
+ onFileChange: (invalidate) => {
74
+ invalidateFsCache = invalidate;
75
+ },
76
+ });
77
+ app.use('/api', localBackend.router);
78
+ // 3) /emit (internal emit compatibility)
79
+ app.post('/emit', (req, res) => {
80
+ const handler = messenger.createEmitHandler(emitSecret);
81
+ handler(req, res);
82
+ });
83
+ // 4) Realtime (ws://localhost:<port>/realtime)
84
+ realtime = new ShareDBRealtimeServer({
85
+ path: '/realtime',
86
+ backendUrl: `http://localhost:${this.config.port}`,
87
+ internalSecret: emitSecret,
88
+ onAuth: async (accessToken) => jwt.verify(accessToken),
89
+ onDocCreate: async (collection, id) => {
90
+ // Prefer internal endpoints from local backend
91
+ const url = `http://localhost:${this.config.port}/api/${collection}/internal/${id}`;
92
+ const res = await fetch(url, { headers: { 'X-Internal-Secret': emitSecret } });
93
+ if (res.ok)
94
+ return await res.json();
95
+ return null;
96
+ },
97
+ });
98
+ realtime.attach(server);
99
+ await logger.info(`Full Local services enabled: backend=/api realtime=/realtime messenger=/messenger`);
100
+ }
101
+ else if (this.mode === 'hybrid') {
102
+ if (!this.config.token || !this.config.url) {
103
+ throw new Error('Hybrid mode requires token and url in config');
104
+ }
105
+ const cloudConnection = new CloudConnectionManager({
106
+ url: this.config.url,
107
+ token: this.config.token,
108
+ projectId: this.config.projectId || 'default',
109
+ onMessage: (_source, data) => {
110
+ void data;
111
+ },
112
+ onStateChange: (state) => {
113
+ void logger.info(`Cloud connection state: ${state}`);
114
+ },
115
+ onDisconnect: () => {
116
+ void logger.warn('Cloud connection lost');
117
+ },
118
+ });
119
+ await cloudConnection.connect();
120
+ const apiProxy = createApiProxyRouter({ connection: cloudConnection });
121
+ app.use('/api', apiProxy);
122
+ syncManager = new SyncManager({
123
+ cloudConnection,
124
+ projectDir: this.config.dir,
125
+ });
126
+ cloudConnectionRef = cloudConnection;
127
+ await logger.info(`Hybrid mode: cloud connected, API proxy at /api`);
128
+ }
129
+ let lastConnectionCount = 0;
130
+ const socketServer = new SocketServer(server, this.config, async (count) => {
131
+ if (count > lastConnectionCount) {
132
+ const message = `✅ 编辑器已连接 (共 ${count} 个连接)`;
133
+ await logger.info(message);
134
+ if (!this.isDaemon)
135
+ console.log(pc.green(message));
136
+ }
137
+ else if (count < lastConnectionCount) {
138
+ const message = count === 0 ? '⚠️ 编辑器已断开连接,等待重新连接...' : `⚠️ 连接数减少 (剩余 ${count} 个连接)`;
139
+ await logger.info(message);
140
+ if (!this.isDaemon)
141
+ console.log(pc.yellow(message));
142
+ }
143
+ lastConnectionCount = count;
144
+ });
145
+ const watcher = new Watcher(this.config, async (filePath, type) => {
146
+ await logger.info(`[${type.toUpperCase()}] ${filePath}`);
147
+ invalidateFsCache?.();
148
+ socketServer.notifyFileChange(filePath, type);
149
+ if (syncManager && (type === 'add' || type === 'modify')) {
150
+ void syncManager.upload(filePath);
151
+ }
152
+ });
153
+ await new Promise((resolve) => {
154
+ server.listen(this.config.port, async () => {
155
+ await logger.info(`Local server running at http://localhost:${this.config.port}`);
156
+ if (!this.isDaemon) {
157
+ console.log(pc.green(`✅ 本地服务运行在 http://localhost:${this.config.port}`));
158
+ console.log(pc.dim('等待编辑器连接...\n'));
159
+ }
160
+ resolve();
161
+ });
162
+ });
163
+ this.runtime = { server, socketServer, watcher, logger, messenger, realtime, cloudConnection: cloudConnectionRef, syncManager };
164
+ }
165
+ async stop() {
166
+ const rt = this.runtime;
167
+ if (!rt)
168
+ return;
169
+ if (!this.isDaemon)
170
+ console.log(pc.yellow('\n正在关闭 agent...'));
171
+ const forceExitTimeout = setTimeout(() => {
172
+ if (!this.isDaemon)
173
+ console.log(pc.red('强制退出...'));
174
+ process.exit(1);
175
+ }, 3000);
176
+ try {
177
+ await rt.watcher.close();
178
+ rt.socketServer.destroy();
179
+ await rt.cloudConnection?.disconnect();
180
+ rt.realtime?.close();
181
+ rt.messenger?.close();
182
+ await new Promise((resolve) => {
183
+ rt.server.close(() => resolve());
184
+ setTimeout(() => resolve(), 1000);
185
+ });
186
+ await rt.logger.close();
187
+ clearTimeout(forceExitTimeout);
188
+ this.runtime = null;
189
+ if (!this.isDaemon)
190
+ console.log(pc.green('✅ Agent 已关闭'));
191
+ }
192
+ catch (error) {
193
+ clearTimeout(forceExitTimeout);
194
+ if (!this.isDaemon)
195
+ console.error(pc.red('关闭时出错:'), error);
196
+ process.exit(1);
197
+ }
198
+ }
199
+ static getDefaultDbPath() {
200
+ return path.join(os.homedir(), '.playcraft', 'local-db.sqlite');
201
+ }
202
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * API Proxy – forwards REST requests from local agent to cloud backend in Hybrid mode.
3
+ * - Request forwarding with Bearer token (via CloudConnectionManager)
4
+ * - Optional response cache for GET requests
5
+ * - Error status and message pass-through
6
+ */
7
+ import express from 'express';
8
+ const CACHE_TTL_MS = 5_000;
9
+ const MAX_CACHE_ENTRIES = 200;
10
+ export function createApiProxyRouter(options) {
11
+ const router = express.Router();
12
+ const connection = options.connection;
13
+ const useCache = options.cache !== false;
14
+ const cacheTtl = options.cacheTtlMs ?? CACHE_TTL_MS;
15
+ const cache = new Map();
16
+ function cacheKey(method, path, query) {
17
+ return `${method}:${path}${query ? `?${query}` : ''}`;
18
+ }
19
+ function pruneCache() {
20
+ if (cache.size <= MAX_CACHE_ENTRIES)
21
+ return;
22
+ const now = Date.now();
23
+ for (const [key, entry] of cache) {
24
+ if (entry.expiresAt < now)
25
+ cache.delete(key);
26
+ }
27
+ if (cache.size > MAX_CACHE_ENTRIES) {
28
+ const keys = [...cache.keys()];
29
+ keys.slice(0, keys.length - MAX_CACHE_ENTRIES).forEach((k) => cache.delete(k));
30
+ }
31
+ }
32
+ router.use(express.json({ limit: '50mb' }));
33
+ router.all('*', async (req, res) => {
34
+ const method = req.method;
35
+ const path = req.path || '/';
36
+ const apiPath = `/api${path}`;
37
+ const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
38
+ if (useCache && method === 'GET') {
39
+ const key = cacheKey(method, apiPath, query);
40
+ const hit = cache.get(key);
41
+ if (hit && hit.expiresAt > Date.now()) {
42
+ res.status(hit.status).json(hit.body);
43
+ return;
44
+ }
45
+ }
46
+ try {
47
+ const body = method !== 'GET' && method !== 'HEAD' ? req.body : undefined;
48
+ const fullPath = query ? `${apiPath}?${query}` : apiPath;
49
+ const result = await connection.request(method, fullPath, body);
50
+ if (useCache && method === 'GET') {
51
+ const key = cacheKey(method, apiPath, query);
52
+ cache.set(key, {
53
+ body: result,
54
+ status: 200,
55
+ expiresAt: Date.now() + cacheTtl,
56
+ });
57
+ pruneCache();
58
+ }
59
+ res.status(200).json(result);
60
+ }
61
+ catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ const status = message.includes('401') ? 401 : message.includes('403') ? 403 : message.includes('404') ? 404 : 502;
64
+ res.status(status).json({ error: message });
65
+ }
66
+ });
67
+ return router;
68
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * CloudConnectionManager – manages cloud connection lifecycle for Hybrid mode.
3
+ * - Connection state machine: disconnected → connecting → connected ↔ reconnecting
4
+ * - JWT token authentication (REST + WebSocket)
5
+ * - Heartbeat (30s) and exponential backoff reconnection
6
+ */
7
+ import WebSocket from 'ws';
8
+ const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
9
+ const DEFAULT_MAX_RECONNECT_DELAY = 60_000;
10
+ const INITIAL_RECONNECT_DELAY = 1_000;
11
+ function toWsUrl(httpUrl) {
12
+ const u = httpUrl.replace(/\/+$/, '');
13
+ if (u.startsWith('https://'))
14
+ return u.replace('https://', 'wss://');
15
+ if (u.startsWith('http://'))
16
+ return u.replace('http://', 'ws://');
17
+ return `wss://${u}`;
18
+ }
19
+ function baseUrl(url) {
20
+ return url.replace(/\/+$/, '');
21
+ }
22
+ export class CloudConnectionManager {
23
+ options;
24
+ restBaseUrl;
25
+ wsBaseUrl;
26
+ _connectionState = 'disconnected';
27
+ messengerWs = null;
28
+ heartbeatTimer = null;
29
+ reconnectTimer = null;
30
+ reconnectAttempts = 0;
31
+ lastPongAt = 0;
32
+ constructor(options) {
33
+ this.options = {
34
+ ...options,
35
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL,
36
+ maxReconnectDelay: options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY,
37
+ };
38
+ this.restBaseUrl = baseUrl(options.url);
39
+ this.wsBaseUrl = toWsUrl(options.url);
40
+ }
41
+ get isConnected() {
42
+ return this._connectionState === 'connected';
43
+ }
44
+ get connectionState() {
45
+ return this._connectionState;
46
+ }
47
+ setState(state) {
48
+ if (this._connectionState === state)
49
+ return;
50
+ this._connectionState = state;
51
+ this.options.onStateChange?.(state);
52
+ if (state === 'disconnected' || state === 'reconnecting') {
53
+ this.options.onDisconnect?.();
54
+ }
55
+ }
56
+ async connect() {
57
+ if (this._connectionState === 'connected')
58
+ return;
59
+ if (this._connectionState === 'connecting')
60
+ return;
61
+ this.setState('connecting');
62
+ this.reconnectAttempts = 0;
63
+ try {
64
+ await this.connectMessenger();
65
+ this.setState('connected');
66
+ this.startHeartbeat();
67
+ }
68
+ catch (err) {
69
+ this.setState('reconnecting');
70
+ this.scheduleReconnect();
71
+ throw err;
72
+ }
73
+ }
74
+ get messengerUrl() {
75
+ const sep = this.wsBaseUrl.includes('?') ? '&' : '?';
76
+ return `${this.wsBaseUrl}/messenger${sep}token=${encodeURIComponent(this.options.token)}`;
77
+ }
78
+ connectMessenger() {
79
+ return new Promise((resolve, reject) => {
80
+ const ws = new WebSocket(this.messengerUrl, {
81
+ handshakeTimeout: 10_000,
82
+ });
83
+ const onOpen = () => {
84
+ ws.send(JSON.stringify({ name: 'authenticate', data: { token: this.options.token } }));
85
+ ws.send(JSON.stringify({ name: 'project.watch', data: { id: this.options.projectId } }));
86
+ };
87
+ const onMessage = (raw) => {
88
+ const text = raw.toString();
89
+ if (text === 'pong' || text === '"pong"') {
90
+ this.lastPongAt = Date.now();
91
+ return;
92
+ }
93
+ try {
94
+ const msg = JSON.parse(text);
95
+ if (msg && typeof msg === 'object' && msg.name) {
96
+ this.options.onMessage?.('messenger', msg);
97
+ }
98
+ }
99
+ catch {
100
+ // ignore non-JSON
101
+ }
102
+ };
103
+ let resolved = false;
104
+ const resolveOnce = () => {
105
+ if (resolved)
106
+ return;
107
+ resolved = true;
108
+ resolve();
109
+ };
110
+ ws.on('open', onOpen);
111
+ ws.on('message', (raw) => {
112
+ onMessage(raw);
113
+ try {
114
+ const msg = JSON.parse(raw.toString());
115
+ if (msg?.name === 'welcome' || msg?.name === 'project.permissions') {
116
+ resolveOnce();
117
+ }
118
+ }
119
+ catch {
120
+ // ignore
121
+ }
122
+ });
123
+ const timeout = setTimeout(resolveOnce, 8000);
124
+ ws.once('close', () => {
125
+ clearTimeout(timeout);
126
+ this.cleanupMessenger();
127
+ if (this._connectionState === 'connected') {
128
+ this.setState('reconnecting');
129
+ this.scheduleReconnect();
130
+ }
131
+ });
132
+ ws.once('error', (err) => {
133
+ clearTimeout(timeout);
134
+ if (!resolved) {
135
+ resolved = true;
136
+ reject(err);
137
+ }
138
+ });
139
+ this.messengerWs = ws;
140
+ });
141
+ }
142
+ startHeartbeat() {
143
+ this.stopHeartbeat();
144
+ this.lastPongAt = Date.now();
145
+ this.heartbeatTimer = setInterval(() => {
146
+ if (this.messengerWs?.readyState === WebSocket.OPEN) {
147
+ this.messengerWs.send('ping');
148
+ const elapsed = Date.now() - this.lastPongAt;
149
+ if (elapsed > this.options.heartbeatInterval * 2) {
150
+ this.cleanupMessenger();
151
+ this.setState('reconnecting');
152
+ this.scheduleReconnect();
153
+ }
154
+ }
155
+ }, this.options.heartbeatInterval);
156
+ }
157
+ stopHeartbeat() {
158
+ if (this.heartbeatTimer) {
159
+ clearInterval(this.heartbeatTimer);
160
+ this.heartbeatTimer = null;
161
+ }
162
+ }
163
+ scheduleReconnect() {
164
+ if (this.reconnectTimer)
165
+ return;
166
+ const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts), this.options.maxReconnectDelay);
167
+ this.reconnectAttempts += 1;
168
+ this.reconnectTimer = setTimeout(() => {
169
+ this.reconnectTimer = null;
170
+ this.connect().catch(() => {
171
+ // already handled in connect -> scheduleReconnect
172
+ });
173
+ }, delay);
174
+ }
175
+ cleanupMessenger() {
176
+ this.stopHeartbeat();
177
+ if (this.messengerWs) {
178
+ try {
179
+ this.messengerWs.removeAllListeners();
180
+ this.messengerWs.close();
181
+ }
182
+ catch {
183
+ // ignore
184
+ }
185
+ this.messengerWs = null;
186
+ }
187
+ }
188
+ async disconnect() {
189
+ if (this.reconnectTimer) {
190
+ clearTimeout(this.reconnectTimer);
191
+ this.reconnectTimer = null;
192
+ }
193
+ this.reconnectAttempts = 0;
194
+ this.cleanupMessenger();
195
+ this.setState('disconnected');
196
+ }
197
+ /**
198
+ * REST request to cloud API. Path should start with / (e.g. /api/health).
199
+ */
200
+ async request(method, path, data) {
201
+ const url = path.startsWith('http') ? path : `${this.restBaseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
202
+ const headers = {
203
+ Authorization: `Bearer ${this.options.token}`,
204
+ 'Content-Type': 'application/json',
205
+ };
206
+ const init = {
207
+ method,
208
+ headers,
209
+ };
210
+ if (data !== undefined && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
211
+ init.body = JSON.stringify(data);
212
+ }
213
+ const res = await fetch(url, init);
214
+ if (!res.ok) {
215
+ const text = await res.text();
216
+ throw new Error(`Cloud API ${method} ${path}: ${res.status} ${text}`);
217
+ }
218
+ const contentType = res.headers.get('content-type');
219
+ if (contentType?.includes('application/json')) {
220
+ return (await res.json());
221
+ }
222
+ return (await res.text());
223
+ }
224
+ sendToMessenger(name, data) {
225
+ if (this.messengerWs?.readyState !== WebSocket.OPEN)
226
+ return;
227
+ this.messengerWs.send(JSON.stringify({ name, data }));
228
+ }
229
+ /** Placeholder for future Realtime WebSocket client. */
230
+ sendToRealtime(_op) {
231
+ // Realtime client not implemented in MVP; SyncManager uses REST.
232
+ }
233
+ }
@@ -0,0 +1,67 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import http from 'node:http';
3
+ import { CloudConnectionManager } from './cloud-connection.js';
4
+ describe('CloudConnectionManager', () => {
5
+ let server;
6
+ let baseUrl;
7
+ beforeAll(async () => {
8
+ server = http.createServer((req, res) => {
9
+ const auth = req.headers.authorization;
10
+ if (auth !== 'Bearer test-token') {
11
+ res.writeHead(401);
12
+ res.end(JSON.stringify({ error: 'unauthorized' }));
13
+ return;
14
+ }
15
+ if (req.method === 'GET' && req.url === '/api/health') {
16
+ res.writeHead(200, { 'Content-Type': 'application/json' });
17
+ res.end(JSON.stringify({ ok: true }));
18
+ return;
19
+ }
20
+ if (req.method === 'POST' && req.url === '/api/sync/upload') {
21
+ let body = '';
22
+ req.on('data', (c) => (body += c));
23
+ req.on('end', () => {
24
+ res.writeHead(200, { 'Content-Type': 'application/json' });
25
+ res.end(JSON.stringify({ success: true }));
26
+ });
27
+ return;
28
+ }
29
+ res.writeHead(404);
30
+ res.end('not found');
31
+ });
32
+ await new Promise((resolve) => {
33
+ server.listen(0, '127.0.0.1', () => resolve());
34
+ });
35
+ const addr = server.address();
36
+ baseUrl = `http://127.0.0.1:${addr.port}`;
37
+ });
38
+ afterAll(async () => {
39
+ await new Promise((resolve) => server.close(() => resolve()));
40
+ });
41
+ it('request() sends Bearer token and returns JSON', async () => {
42
+ const conn = new CloudConnectionManager({
43
+ url: baseUrl,
44
+ token: 'test-token',
45
+ projectId: 'test-project',
46
+ });
47
+ const result = await conn.request('GET', '/api/health');
48
+ expect(result).toEqual({ ok: true });
49
+ });
50
+ it('request() throws on 401', async () => {
51
+ const conn = new CloudConnectionManager({
52
+ url: baseUrl,
53
+ token: 'wrong-token',
54
+ projectId: 'test-project',
55
+ });
56
+ await expect(conn.request('GET', '/api/health')).rejects.toThrow(/401/);
57
+ });
58
+ it('connectionState is disconnected when not connected', () => {
59
+ const conn = new CloudConnectionManager({
60
+ url: baseUrl,
61
+ token: 'test-token',
62
+ projectId: 'test-project',
63
+ });
64
+ expect(conn.connectionState).toBe('disconnected');
65
+ expect(conn.isConnected).toBe(false);
66
+ });
67
+ });