@playcraft/cli 0.0.10 → 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.
@@ -4,15 +4,10 @@ import { fileURLToPath } from 'url';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { ProcessManager } from '../process-manager.js';
6
6
  import { isPortAvailable, findAvailablePort } from '../port-utils.js';
7
- import { Logger } from '../logger.js';
8
- import { createServer } from '../server.js';
9
- import { SocketServer } from '../socket.js';
10
- import { Watcher } from '../watcher.js';
11
- import { FSHandler } from '../fs-handler.js';
12
- import http from 'http';
13
7
  import pc from 'picocolors';
14
8
  import ora from 'ora';
15
9
  import inquirer from 'inquirer';
10
+ import { PlayCraftAgent } from '../agent/agent.js';
16
11
  const __filename = fileURLToPath(import.meta.url);
17
12
  const __dirname = path.dirname(__filename);
18
13
  export async function startCommand(options) {
@@ -23,6 +18,7 @@ export async function startCommand(options) {
23
18
  token: options.token,
24
19
  dir: options.dir,
25
20
  port: options.port ? parseInt(options.port) : undefined,
21
+ mode: options.mode || undefined,
26
22
  });
27
23
  if (!config.projectId) {
28
24
  spinner.fail('项目 ID 未设置');
@@ -101,6 +97,7 @@ async function startDaemon(config) {
101
97
  PLAYCRAFT_PORT: config.port.toString(),
102
98
  PLAYCRAFT_DIR: config.dir,
103
99
  PLAYCRAFT_DAEMON: 'true',
100
+ PLAYCRAFT_MODE: config.mode || 'full-local',
104
101
  },
105
102
  });
106
103
  child.unref();
@@ -126,76 +123,11 @@ async function startDaemon(config) {
126
123
  }
127
124
  }
128
125
  async function startForeground(config) {
129
- const logger = new Logger(config.projectId || 'default', false);
130
- await logger.initialize();
131
- console.log(pc.cyan(`\n🚀 PlayCraft Agent 启动中...\n`));
132
- console.log(`${pc.bold('项目 ID:')} ${config.projectId || pc.yellow('未设置')}`);
133
- console.log(`${pc.bold('目录:')} ${config.dir}`);
134
- console.log(`${pc.bold('端口:')} ${config.port}\n`);
135
- const fsHandler = new FSHandler(config);
136
- const app = createServer(config, fsHandler);
137
- const server = http.createServer(app);
138
- // Connection status indicator
139
- let lastConnectionCount = 0;
140
- const socketServer = new SocketServer(server, config, async (count) => {
141
- if (count > lastConnectionCount) {
142
- const message = `✅ 编辑器已连接 (共 ${count} 个连接)`;
143
- await logger.info(message);
144
- console.log(pc.green(message));
145
- }
146
- else if (count < lastConnectionCount) {
147
- const message = count === 0
148
- ? '⚠️ 编辑器已断开连接,等待重新连接...'
149
- : `⚠️ 连接数减少 (剩余 ${count} 个连接)`;
150
- await logger.info(message);
151
- console.log(pc.yellow(message));
152
- }
153
- lastConnectionCount = count;
154
- });
155
- const watcher = new Watcher(config, async (filePath, type) => {
156
- const message = `[${type.toUpperCase()}] ${filePath}`;
157
- await logger.info(message);
158
- socketServer.notifyFileChange(filePath, type);
159
- });
160
- server.listen(config.port, async () => {
161
- await logger.info(`Local server running at http://localhost:${config.port}`);
162
- console.log(pc.green(`✅ 本地服务运行在 http://localhost:${config.port}`));
163
- console.log(pc.dim('等待编辑器连接...\n'));
164
- });
165
- // 优雅关闭
126
+ const agent = new PlayCraftAgent(config, false);
127
+ await agent.start();
166
128
  const shutdown = async () => {
167
- console.log(pc.yellow('\n正在关闭 agent...'));
168
- // Set a timeout to force exit if graceful shutdown fails
169
- const forceExitTimeout = setTimeout(() => {
170
- console.log(pc.red('强制退出...'));
171
- process.exit(1);
172
- }, 3000); // 3 seconds timeout
173
- try {
174
- // Close watcher
175
- await watcher.close();
176
- // Close WebSocket connections
177
- socketServer.destroy();
178
- // Close HTTP server
179
- await new Promise((resolve) => {
180
- server.close(() => {
181
- resolve();
182
- });
183
- // Force close if it takes too long
184
- setTimeout(() => {
185
- resolve();
186
- }, 1000);
187
- });
188
- // Close logger
189
- await logger.close();
190
- clearTimeout(forceExitTimeout);
191
- console.log(pc.green('✅ Agent 已关闭'));
192
- process.exit(0);
193
- }
194
- catch (error) {
195
- clearTimeout(forceExitTimeout);
196
- console.error(pc.red('关闭时出错:'), error);
197
- process.exit(1);
198
- }
129
+ await agent.stop();
130
+ process.exit(0);
199
131
  };
200
132
  process.on('SIGINT', shutdown);
201
133
  process.on('SIGTERM', shutdown);
@@ -207,6 +139,7 @@ export async function startInternal() {
207
139
  const port = parseInt(process.env.PLAYCRAFT_PORT || '2468');
208
140
  const dir = process.env.PLAYCRAFT_DIR || process.cwd();
209
141
  const isDaemon = process.env.PLAYCRAFT_DAEMON === 'true';
142
+ const mode = process.env.PLAYCRAFT_MODE || 'full-local';
210
143
  if (!projectId) {
211
144
  console.error('PLAYCRAFT_PROJECT_ID 环境变量未设置');
212
145
  process.exit(1);
@@ -216,68 +149,20 @@ export async function startInternal() {
216
149
  token,
217
150
  dir,
218
151
  port,
152
+ mode,
219
153
  };
220
- const logger = new Logger(config.projectId || 'default', isDaemon);
221
- await logger.initialize();
222
- await logger.info(`Starting PlayCraft Agent for project: ${config.projectId || 'default'}`);
223
- await logger.info(`Directory: ${config.dir}`);
224
- await logger.info(`Port: ${config.port}`);
225
- const fsHandler = new FSHandler(config);
226
- const app = createServer(config, fsHandler);
227
- const server = http.createServer(app);
228
- // Connection status tracking for daemon mode
229
- let lastConnectionCount = 0;
230
- const socketServer = new SocketServer(server, config, async (count) => {
231
- if (count > lastConnectionCount) {
232
- await logger.info(`Editor connected (${count} connection(s))`);
233
- }
234
- else if (count < lastConnectionCount) {
235
- await logger.info(count === 0 ? 'Editor disconnected' : `Connection decreased (${count} remaining)`);
236
- }
237
- lastConnectionCount = count;
238
- });
239
- const watcher = new Watcher(config, async (filePath, type) => {
240
- await logger.info(`[${type.toUpperCase()}] ${filePath}`);
241
- socketServer.notifyFileChange(filePath, type);
242
- });
243
- server.listen(config.port, async () => {
244
- await logger.info(`Local server running at http://localhost:${config.port}`);
245
- });
154
+ const agent = new PlayCraftAgent(config, isDaemon);
155
+ await agent.start();
246
156
  // 保存 PID
247
157
  await ProcessManager.savePid(config.projectId || 'default', process.pid);
248
- // 优雅关闭
249
158
  const shutdown = async () => {
250
- await logger.info('Shutting down agent...');
251
- // Set a timeout to force exit
252
- const forceExitTimeout = setTimeout(() => {
253
- process.exit(1);
254
- }, 3000);
255
159
  try {
256
- // Close watcher
257
- await watcher.close();
258
- // Close WebSocket connections
259
- socketServer.destroy();
260
- // Close HTTP server
261
- await new Promise((resolve) => {
262
- server.close(() => {
263
- resolve();
264
- });
265
- setTimeout(() => {
266
- resolve();
267
- }, 1000);
268
- });
269
- // Remove PID file
160
+ await agent.stop();
161
+ }
162
+ finally {
270
163
  await ProcessManager.removePid(config.projectId || 'default');
271
- // Close logger
272
- await logger.close();
273
- clearTimeout(forceExitTimeout);
274
164
  process.exit(0);
275
165
  }
276
- catch (error) {
277
- clearTimeout(forceExitTimeout);
278
- await logger.error(`Error during shutdown: ${error}`);
279
- process.exit(1);
280
- }
281
166
  };
282
167
  process.on('SIGINT', shutdown);
283
168
  process.on('SIGTERM', shutdown);
@@ -0,0 +1,40 @@
1
+ import { loadConfig } from '../config.js';
2
+ import { createSyncEngineFromConfig } from '../sync/sync-engine.js';
3
+ import pc from 'picocolors';
4
+ export async function syncCommand(action, options) {
5
+ const config = await loadConfig({});
6
+ if (config.mode !== 'hybrid') {
7
+ console.log(pc.yellow('Sync 命令仅在 hybrid 模式下可用'));
8
+ console.log(pc.dim('当前模式: ' + (config.mode || 'full-local')));
9
+ console.log(pc.dim('请设置 mode: "hybrid" 并配置 url 和 token'));
10
+ process.exit(1);
11
+ }
12
+ if (!config.url || !config.token || !config.projectId) {
13
+ console.error(pc.red('hybrid 模式需要配置 url、token 和 projectId'));
14
+ console.log(pc.dim('请在 playcraft.agent.config.json 或环境变量中设置'));
15
+ process.exit(1);
16
+ }
17
+ try {
18
+ const syncEngine = await createSyncEngineFromConfig();
19
+ switch (action) {
20
+ case 'push':
21
+ await syncEngine.push(options);
22
+ break;
23
+ case 'pull':
24
+ await syncEngine.pull(options);
25
+ break;
26
+ case 'status':
27
+ await syncEngine.status();
28
+ break;
29
+ default:
30
+ console.error(pc.red(`未知操作: ${action}`));
31
+ console.log(pc.yellow('可用: push, pull, status'));
32
+ process.exit(1);
33
+ }
34
+ }
35
+ catch (err) {
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ console.error(pc.red(message));
38
+ process.exit(1);
39
+ }
40
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * upgrade 命令
3
+ * 检查并更新 CLI 到最新版本
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+ import pc from 'picocolors';
9
+ import ora from 'ora';
10
+ import { getLatestVersion } from '../utils/version-checker.js';
11
+ import { promptForUpdate } from '../utils/updater.js';
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ /**
15
+ * 获取 package.json
16
+ */
17
+ function getPackageJson() {
18
+ return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
19
+ }
20
+ /**
21
+ * upgrade 命令处理函数
22
+ * @param options 命令选项
23
+ */
24
+ export async function upgradeCommand(options) {
25
+ const packageJson = getPackageJson();
26
+ const currentVersion = packageJson.version;
27
+ console.log(pc.bold('\n📦 PlayCraft CLI 版本检查\n'));
28
+ console.log(`当前版本: ${pc.cyan(currentVersion)}`);
29
+ try {
30
+ const spinner = ora('正在检查最新版本...').start();
31
+ const latestVersion = await getLatestVersion(packageJson);
32
+ spinner.stop();
33
+ console.log(`最新版本: ${pc.green(latestVersion)}`);
34
+ if (currentVersion === latestVersion) {
35
+ console.log(pc.green('\n✓ 已是最新版本!\n'));
36
+ return;
37
+ }
38
+ // 比较版本号
39
+ const [currentMajor, currentMinor, currentPatch] = currentVersion.split('.').map(Number);
40
+ const [latestMajor, latestMinor, latestPatch] = latestVersion.split('.').map(Number);
41
+ let updateType = 'patch';
42
+ if (latestMajor > currentMajor) {
43
+ updateType = 'major';
44
+ }
45
+ else if (latestMinor > currentMinor) {
46
+ updateType = 'minor';
47
+ }
48
+ const typeLabels = {
49
+ major: '主版本',
50
+ minor: '次版本',
51
+ patch: '补丁版本',
52
+ };
53
+ console.log(pc.yellow(`\n⚠ 有新版本可用 (${typeLabels[updateType]}更新)\n`));
54
+ if (options.checkOnly) {
55
+ console.log(pc.yellow(`运行 ${pc.bold('playcraft upgrade')} 来更新到最新版本\n`));
56
+ return;
57
+ }
58
+ // 触发交互式更新
59
+ await promptForUpdate({
60
+ latest: latestVersion,
61
+ current: currentVersion,
62
+ type: updateType,
63
+ name: packageJson.name,
64
+ }, currentVersion);
65
+ }
66
+ catch (error) {
67
+ console.error(pc.red(`\n✗ 检查版本失败: ${error instanceof Error ? error.message : String(error)}\n`));
68
+ console.error(pc.gray('提示: 请检查网络连接或稍后重试\n'));
69
+ process.exit(1);
70
+ }
71
+ }
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ export const ConfigSchema = z.object({
8
8
  dir: z.string().default(process.cwd()),
9
9
  port: z.number().default(2468),
10
10
  url: z.string().optional(), // Cloud API URL
11
+ mode: z.enum(['full-local', 'hybrid']).default('full-local'),
11
12
  });
12
13
  const explorer = cosmiconfig('playcraft', {
13
14
  searchPlaces: ['playcraft.config.json', 'playcraft.agent.config.json'],
@@ -24,6 +25,7 @@ export async function loadConfig(cliOptions) {
24
25
  projectId: cliOptions.projectId || process.env.PLAYCRAFT_PROJECT_ID || agentConfig.projectId,
25
26
  token: cliOptions.token || process.env.PLAYCRAFT_TOKEN || agentConfig.token,
26
27
  port: cliOptions.port || (process.env.PLAYCRAFT_PORT ? parseInt(process.env.PLAYCRAFT_PORT) : undefined) || agentConfig.port,
28
+ mode: cliOptions.mode || process.env.PLAYCRAFT_MODE || agentConfig.mode,
27
29
  });
28
30
  // Resolve absolute path for dir
29
31
  config.dir = path.resolve(process.cwd(), config.dir);
@@ -80,4 +80,25 @@ export class FSHandler {
80
80
  throw error;
81
81
  }
82
82
  }
83
+ async writeFile(relativePath, content) {
84
+ if (!this.isSafePath(relativePath)) {
85
+ throw new Error('Access denied: path is outside of the project directory');
86
+ }
87
+ const fullPath = path.join(this.config.dir, relativePath);
88
+ const dir = path.dirname(fullPath);
89
+ await fs.mkdir(dir, { recursive: true });
90
+ if (typeof content === 'string') {
91
+ await fs.writeFile(fullPath, content, 'utf-8');
92
+ }
93
+ else {
94
+ await fs.writeFile(fullPath, content);
95
+ }
96
+ }
97
+ async deleteFile(relativePath) {
98
+ if (!this.isSafePath(relativePath)) {
99
+ throw new Error('Access denied: path is outside of the project directory');
100
+ }
101
+ const fullPath = path.join(this.config.dir, relativePath);
102
+ await fs.unlink(fullPath);
103
+ }
83
104
  }
package/dist/index.js CHANGED
@@ -10,6 +10,10 @@ import { statusCommand } from './commands/status.js';
10
10
  import { logsCommand } from './commands/logs.js';
11
11
  import { configCommand } from './commands/config.js';
12
12
  import { buildCommand } from './commands/build.js';
13
+ import { upgradeCommand } from './commands/upgrade.js';
14
+ import { checkForUpdates } from './utils/version-checker.js';
15
+ import { syncCommand } from './commands/sync.js';
16
+ import { fixIdsCommand } from './commands/fix-ids.js';
13
17
  const __filename = fileURLToPath(import.meta.url);
14
18
  const __dirname = dirname(__filename);
15
19
  const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -18,6 +22,11 @@ program
18
22
  .name('playcraft')
19
23
  .description('PlayCraft Local Dev Agent - 本地开发助手')
20
24
  .version(packageJson.version);
25
+ // 版本检查(非阻塞,后台执行)
26
+ // 只在非 upgrade 命令时检查,避免重复提示
27
+ if (!process.argv.includes('upgrade')) {
28
+ checkForUpdates(packageJson);
29
+ }
21
30
  // init 命令
22
31
  program
23
32
  .command('init')
@@ -34,6 +43,7 @@ program
34
43
  .option('-t, --token <token>', '认证令牌')
35
44
  .option('-d, --dir <path>', '监听目录', process.cwd())
36
45
  .option('--port <port>', '服务端口', '2468')
46
+ .option('--mode <mode>', '运行模式: full-local(默认) | hybrid', 'full-local')
37
47
  .option('--daemon', '守护进程模式(后台运行)')
38
48
  .action(async (options) => {
39
49
  // 检查是否是内部调用(守护进程)
@@ -82,6 +92,33 @@ program
82
92
  .action(async (action, options) => {
83
93
  await configCommand(action, options);
84
94
  });
95
+ // sync 命令(hybrid 模式下本地/云端双向同步)
96
+ program
97
+ .command('sync')
98
+ .description('本地/云端数据同步(hybrid 模式)')
99
+ .argument('<action>', 'push | pull | status')
100
+ .option('--force', '强制同步')
101
+ .action(async (action, options) => {
102
+ if (action !== 'push' && action !== 'pull' && action !== 'status') {
103
+ console.error('sync action must be push, pull or status');
104
+ process.exit(1);
105
+ }
106
+ await syncCommand(action, options);
107
+ });
108
+ // fix-ids 命令:修复已导入项目的 ID 映射
109
+ program
110
+ .command('fix-ids')
111
+ .description('修复已导入项目的 ID 映射(PlayCanvas ID → PlayCraft numericId)')
112
+ .argument('<project-id>', '项目 ID(numericId 或 UUID)')
113
+ .option('--api-url <url>', '后端 API 地址', process.env.BACKEND_API_URL || 'http://localhost:3001')
114
+ .option('--token <token>', '认证令牌(可选)')
115
+ .action(async (projectId, options) => {
116
+ await fixIdsCommand({
117
+ projectId,
118
+ apiUrl: options.apiUrl,
119
+ token: options.token,
120
+ });
121
+ });
85
122
  // build 命令 - 完整流程(阶段1 + 阶段2)
86
123
  program
87
124
  .command('build')
@@ -92,6 +129,8 @@ program
92
129
  .option('-o, --output <path>', '输出目录', './dist')
93
130
  .option('-c, --config <file>', '配置文件路径')
94
131
  .option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
132
+ .option('--clean', '构建前清理旧的输出目录(默认启用)', true)
133
+ .option('--no-clean', '跳过清理旧的输出目录')
95
134
  .option('--compress', '压缩引擎代码')
96
135
  .option('--analyze', '生成打包分析报告')
97
136
  .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
@@ -157,6 +196,7 @@ program
157
196
  .option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
158
197
  .option('--no-compress-models', '禁用模型压缩')
159
198
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
199
+ .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
160
200
  .action(async (projectPath, options) => {
161
201
  await buildCommand(projectPath, {
162
202
  ...options,
@@ -172,6 +212,8 @@ program
172
212
  .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
173
213
  .option('-o, --output <path>', '输出目录', './dist')
174
214
  .option('-c, --config <file>', '配置文件路径')
215
+ .option('--clean', '构建前清理旧的输出目录(默认不清理,使用覆盖模式)')
216
+ .option('--no-clean', '使用覆盖模式(默认行为)')
175
217
  .option('--compress', '压缩引擎代码')
176
218
  .option('--analyze', '生成打包分析报告')
177
219
  .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
@@ -190,6 +232,7 @@ program
190
232
  .option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
191
233
  .option('--no-compress-models', '禁用模型压缩')
192
234
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
235
+ .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
193
236
  .action(async (baseBuildDir, options) => {
194
237
  await buildCommand(baseBuildDir, {
195
238
  ...options,
@@ -205,4 +248,12 @@ program
205
248
  const { inspectCommand } = await import('./commands/inspect.js');
206
249
  await inspectCommand(projectPath);
207
250
  });
251
+ // upgrade 命令 - 检查并更新 CLI
252
+ program
253
+ .command('upgrade')
254
+ .description('检查并更新 CLI 到最新版本')
255
+ .option('--check-only', '仅检查版本,不更新')
256
+ .action(async (options) => {
257
+ await upgradeCommand(options);
258
+ });
208
259
  program.parse(process.argv);
package/dist/server.js CHANGED
@@ -73,6 +73,34 @@ export function createServer(config, fsHandler) {
73
73
  res.status(500).json({ error: error.message });
74
74
  }
75
75
  });
76
+ // Write file
77
+ app.post('/file', async (req, res) => {
78
+ const { path: filePath, content } = req.body ?? {};
79
+ if (!filePath) {
80
+ return res.status(400).json({ error: 'Missing path parameter' });
81
+ }
82
+ try {
83
+ await fsHandler.writeFile(filePath, content ?? '');
84
+ res.json({ success: true, path: filePath });
85
+ }
86
+ catch (error) {
87
+ res.status(500).json({ error: error.message });
88
+ }
89
+ });
90
+ // Delete file
91
+ app.delete('/file', async (req, res) => {
92
+ const filePath = req.query.path;
93
+ if (!filePath) {
94
+ return res.status(400).json({ error: 'Missing path parameter' });
95
+ }
96
+ try {
97
+ await fsHandler.deleteFile(filePath);
98
+ res.json({ success: true });
99
+ }
100
+ catch (error) {
101
+ res.status(500).json({ error: error.message });
102
+ }
103
+ });
76
104
  // Batch get files
77
105
  app.post('/files', async (req, res) => {
78
106
  const { paths } = req.body;
package/dist/socket.js CHANGED
@@ -1,21 +1,73 @@
1
1
  import { WebSocketServer, WebSocket } from 'ws';
2
+ /** Match file path against glob pattern. Supports * (segment) and ** (any path). */
3
+ function matchPattern(filePath, pattern) {
4
+ const pathNorm = filePath.replace(/\\/g, '/');
5
+ const parts = pathNorm.split('/').filter(Boolean);
6
+ const patternParts = pattern.replace(/\\/g, '/').split('/').filter(Boolean);
7
+ let pi = 0;
8
+ let fi = 0;
9
+ while (pi < patternParts.length && fi < parts.length) {
10
+ const p = patternParts[pi];
11
+ if (p === '**') {
12
+ pi++;
13
+ if (pi === patternParts.length)
14
+ return true;
15
+ while (fi < parts.length) {
16
+ if (matchRest(parts, patternParts, fi, pi))
17
+ return true;
18
+ fi++;
19
+ }
20
+ return false;
21
+ }
22
+ if (p !== '*' && p !== parts[fi])
23
+ return false;
24
+ pi++;
25
+ fi++;
26
+ }
27
+ if (pi < patternParts.length && patternParts[pi] === '**')
28
+ pi++;
29
+ return pi === patternParts.length && fi === parts.length;
30
+ }
31
+ function matchRest(parts, patternParts, fi, pi) {
32
+ while (pi < patternParts.length && fi < parts.length) {
33
+ const p = patternParts[pi];
34
+ if (p === '**') {
35
+ pi++;
36
+ if (pi === patternParts.length)
37
+ return true;
38
+ while (fi < parts.length) {
39
+ if (matchRest(parts, patternParts, fi, pi))
40
+ return true;
41
+ fi++;
42
+ }
43
+ return false;
44
+ }
45
+ if (p !== '*' && p !== parts[fi])
46
+ return false;
47
+ pi++;
48
+ fi++;
49
+ }
50
+ if (pi < patternParts.length && patternParts[pi] === '**')
51
+ pi++;
52
+ return pi === patternParts.length && fi === parts.length;
53
+ }
2
54
  export class SocketServer {
3
55
  config;
4
56
  wss;
5
- clients = new Set();
57
+ clients = new Map();
6
58
  pingInterval = null;
7
59
  onConnectionChange;
8
60
  constructor(server, config, onConnectionChange) {
9
61
  this.config = config;
10
- this.wss = new WebSocketServer({ server });
62
+ // 注意:必须显式指定 path='/',避免拦截 /realtime、/messenger 等其他 WebSocket 服务
63
+ this.wss = new WebSocketServer({ server, path: '/' });
11
64
  this.onConnectionChange = onConnectionChange;
12
65
  this.wss.on('connection', (ws) => {
13
- this.clients.add(ws);
66
+ this.clients.set(ws, { patterns: ['**/*'] });
14
67
  // Notify connection change
15
68
  if (this.onConnectionChange) {
16
69
  this.onConnectionChange(this.clients.size);
17
70
  }
18
- // Handle incoming messages
19
71
  ws.on('message', (data) => {
20
72
  try {
21
73
  const message = JSON.parse(data.toString());
@@ -27,12 +79,10 @@ export class SocketServer {
27
79
  });
28
80
  ws.on('close', () => {
29
81
  this.clients.delete(ws);
30
- // Notify connection change
31
82
  if (this.onConnectionChange) {
32
83
  this.onConnectionChange(this.clients.size);
33
84
  }
34
85
  });
35
- // Send initial hello
36
86
  ws.send(JSON.stringify({
37
87
  type: 'hello',
38
88
  payload: {
@@ -40,10 +90,8 @@ export class SocketServer {
40
90
  version: '1.0.0'
41
91
  }
42
92
  }));
43
- // Send connection status
44
93
  this.broadcast('connection_status', { connected: true });
45
94
  });
46
- // Start ping interval
47
95
  this.startPingInterval();
48
96
  }
49
97
  handleMessage(ws, message) {
@@ -51,14 +99,18 @@ export class SocketServer {
51
99
  case 'ping':
52
100
  ws.send(JSON.stringify({ type: 'pong', payload: message.payload }));
53
101
  break;
54
- case 'subscribe':
55
- // Store subscription patterns for this client (simplified implementation)
56
- // In a full implementation, we would filter file change notifications
102
+ case 'subscribe': {
103
+ const patterns = message.payload?.patterns || ['**/*'];
104
+ const info = this.clients.get(ws);
105
+ if (info) {
106
+ info.patterns = Array.isArray(patterns) ? patterns : [patterns];
107
+ }
57
108
  ws.send(JSON.stringify({
58
109
  type: 'subscribed',
59
- payload: { patterns: message.payload?.patterns || [] }
110
+ payload: { patterns: info?.patterns ?? patterns }
60
111
  }));
61
112
  break;
113
+ }
62
114
  }
63
115
  }
64
116
  startPingInterval() {
@@ -66,7 +118,7 @@ export class SocketServer {
66
118
  clearInterval(this.pingInterval);
67
119
  }
68
120
  this.pingInterval = setInterval(() => {
69
- for (const client of this.clients) {
121
+ for (const client of this.clients.keys()) {
70
122
  if (client.readyState === WebSocket.OPEN) {
71
123
  try {
72
124
  client.send(JSON.stringify({ type: 'ping', payload: Date.now() }));
@@ -83,8 +135,7 @@ export class SocketServer {
83
135
  clearInterval(this.pingInterval);
84
136
  this.pingInterval = null;
85
137
  }
86
- // Close all WebSocket connections
87
- for (const client of this.clients) {
138
+ for (const client of this.clients.keys()) {
88
139
  try {
89
140
  client.close();
90
141
  }
@@ -93,23 +144,31 @@ export class SocketServer {
93
144
  }
94
145
  }
95
146
  this.clients.clear();
96
- // Close WebSocket server
97
147
  this.wss.close();
98
148
  }
99
149
  broadcast(type, payload) {
100
150
  const message = JSON.stringify({ type, payload });
101
- for (const client of this.clients) {
151
+ for (const client of this.clients.keys()) {
102
152
  if (client.readyState === WebSocket.OPEN) {
103
153
  client.send(message);
104
154
  }
105
155
  }
106
156
  }
107
- notifyFileChange(path, changeType) {
108
- this.broadcast('file_changed', {
109
- path,
157
+ notifyFileChange(filePath, changeType) {
158
+ const payload = {
159
+ path: filePath,
110
160
  changeType,
111
161
  timestamp: new Date().toISOString()
112
- });
162
+ };
163
+ const message = JSON.stringify({ type: 'file_changed', payload });
164
+ for (const [ws, info] of this.clients) {
165
+ if (ws.readyState !== WebSocket.OPEN)
166
+ continue;
167
+ const matches = info.patterns.some((pattern) => matchPattern(filePath, pattern));
168
+ if (matches) {
169
+ ws.send(message);
170
+ }
171
+ }
113
172
  }
114
173
  getConnectionCount() {
115
174
  return this.clients.size;