@openspecui/server 0.9.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.
Files changed (2) hide show
  1. package/dist/index.mjs +680 -0
  2. package/package.json +39 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,680 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
4
+ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
+ import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
+ import { WebSocketServer } from "ws";
7
+ import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, sniffGlobalCli } from "@openspecui/core";
8
+ import { initTRPC } from "@trpc/server";
9
+ import { observable } from "@trpc/server/observable";
10
+ import { z } from "zod";
11
+ import { createServer as createServer$1 } from "node:net";
12
+
13
+ //#region src/reactive-subscription.ts
14
+ /**
15
+ * 创建响应式订阅
16
+ *
17
+ * 自动追踪 task 中的文件依赖,当依赖变更时自动重新执行并推送新数据。
18
+ *
19
+ * @param task 要执行的异步任务,内部的文件读取会被自动追踪
20
+ * @returns tRPC observable
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // 在 router 中使用
25
+ * subscribe: publicProcedure.subscription(({ ctx }) => {
26
+ * return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta())
27
+ * })
28
+ * ```
29
+ */
30
+ function createReactiveSubscription(task) {
31
+ return observable((emit) => {
32
+ const context = new ReactiveContext();
33
+ const controller = new AbortController();
34
+ (async () => {
35
+ try {
36
+ for await (const data of context.stream(task, controller.signal)) emit.next(data);
37
+ } catch (err) {
38
+ if (!controller.signal.aborted) emit.error(err);
39
+ }
40
+ })();
41
+ return () => {
42
+ controller.abort();
43
+ };
44
+ });
45
+ }
46
+ /**
47
+ * 创建带输入参数的响应式订阅
48
+ *
49
+ * @param task 接收输入参数的异步任务
50
+ * @returns 返回一个函数,接收输入参数并返回 tRPC observable
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * // 在 router 中使用
55
+ * subscribeOne: publicProcedure
56
+ * .input(z.object({ id: z.string() }))
57
+ * .subscription(({ ctx, input }) => {
58
+ * return createReactiveSubscriptionWithInput(
59
+ * (id: string) => ctx.adapter.readSpec(id)
60
+ * )(input.id)
61
+ * })
62
+ * ```
63
+ */
64
+ function createReactiveSubscriptionWithInput(task) {
65
+ return (input) => {
66
+ return createReactiveSubscription(() => task(input));
67
+ };
68
+ }
69
+
70
+ //#endregion
71
+ //#region src/cli-stream-observable.ts
72
+ /**
73
+ * 创建安全的 CLI 流式 observable
74
+ *
75
+ * 解决的问题:
76
+ * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
77
+ * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
78
+ * 3. 确保取消时正确清理资源
79
+ *
80
+ * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
81
+ */
82
+ function createCliStreamObservable(startStream) {
83
+ return observable((emit) => {
84
+ let cancel;
85
+ let completed = false;
86
+ /**
87
+ * 安全的事件处理器
88
+ * - 检查是否已完成,防止重复调用
89
+ * - 使用 try-catch 防止异常导致服务器崩溃
90
+ */
91
+ const safeEventHandler = (event) => {
92
+ if (completed) return;
93
+ try {
94
+ emit.next(event);
95
+ if (event.type === "exit") {
96
+ completed = true;
97
+ emit.complete();
98
+ }
99
+ } catch (err) {
100
+ console.error("[CLI Stream] Error emitting event:", err);
101
+ if (!completed) {
102
+ completed = true;
103
+ try {
104
+ emit.error(err instanceof Error ? err : new Error(String(err)));
105
+ } catch {}
106
+ }
107
+ }
108
+ };
109
+ startStream(safeEventHandler).then((cancelFn) => {
110
+ cancel = cancelFn;
111
+ }).catch((err) => {
112
+ console.error("[CLI Stream] Error starting stream:", err);
113
+ if (!completed) {
114
+ completed = true;
115
+ try {
116
+ emit.error(err instanceof Error ? err : new Error(String(err)));
117
+ } catch {}
118
+ }
119
+ });
120
+ return () => {
121
+ completed = true;
122
+ cancel?.();
123
+ };
124
+ });
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/router.ts
129
+ const t = initTRPC.context().create();
130
+ const router = t.router;
131
+ const publicProcedure = t.procedure;
132
+ /**
133
+ * Dashboard router - overview and status
134
+ */
135
+ const dashboardRouter = router({
136
+ getData: publicProcedure.query(async ({ ctx }) => {
137
+ return ctx.adapter.getDashboardData();
138
+ }),
139
+ isInitialized: publicProcedure.query(async ({ ctx }) => {
140
+ return ctx.adapter.isInitialized();
141
+ }),
142
+ subscribe: publicProcedure.subscription(({ ctx }) => {
143
+ return createReactiveSubscription(() => ctx.adapter.getDashboardData());
144
+ }),
145
+ subscribeInitialized: publicProcedure.subscription(({ ctx }) => {
146
+ return createReactiveSubscription(() => ctx.adapter.isInitialized());
147
+ })
148
+ });
149
+ /**
150
+ * Spec router - spec CRUD operations
151
+ */
152
+ const specRouter = router({
153
+ list: publicProcedure.query(async ({ ctx }) => {
154
+ return ctx.adapter.listSpecs();
155
+ }),
156
+ listWithMeta: publicProcedure.query(async ({ ctx }) => {
157
+ return ctx.adapter.listSpecsWithMeta();
158
+ }),
159
+ get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
160
+ return ctx.adapter.readSpec(input.id);
161
+ }),
162
+ getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
163
+ return ctx.adapter.readSpecRaw(input.id);
164
+ }),
165
+ save: publicProcedure.input(z.object({
166
+ id: z.string(),
167
+ content: z.string()
168
+ })).mutation(async ({ ctx, input }) => {
169
+ await ctx.adapter.writeSpec(input.id, input.content);
170
+ return { success: true };
171
+ }),
172
+ validate: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
173
+ return ctx.adapter.validateSpec(input.id);
174
+ }),
175
+ subscribe: publicProcedure.subscription(({ ctx }) => {
176
+ return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta());
177
+ }),
178
+ subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
179
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpec(id))(input.id);
180
+ }),
181
+ subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
182
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpecRaw(id))(input.id);
183
+ })
184
+ });
185
+ /**
186
+ * Change router - change proposal operations
187
+ */
188
+ const changeRouter = router({
189
+ list: publicProcedure.query(async ({ ctx }) => {
190
+ return ctx.adapter.listChanges();
191
+ }),
192
+ listWithMeta: publicProcedure.query(async ({ ctx }) => {
193
+ return ctx.adapter.listChangesWithMeta();
194
+ }),
195
+ listArchived: publicProcedure.query(async ({ ctx }) => {
196
+ return ctx.adapter.listArchivedChanges();
197
+ }),
198
+ get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
199
+ return ctx.adapter.readChange(input.id);
200
+ }),
201
+ getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
202
+ return ctx.adapter.readChangeRaw(input.id);
203
+ }),
204
+ save: publicProcedure.input(z.object({
205
+ id: z.string(),
206
+ proposal: z.string(),
207
+ tasks: z.string().optional()
208
+ })).mutation(async ({ ctx, input }) => {
209
+ await ctx.adapter.writeChange(input.id, input.proposal, input.tasks);
210
+ return { success: true };
211
+ }),
212
+ archive: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
213
+ return ctx.adapter.archiveChange(input.id);
214
+ }),
215
+ validate: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
216
+ return ctx.adapter.validateChange(input.id);
217
+ }),
218
+ toggleTask: publicProcedure.input(z.object({
219
+ changeId: z.string(),
220
+ taskIndex: z.number().int().positive(),
221
+ completed: z.boolean()
222
+ })).mutation(async ({ ctx, input }) => {
223
+ if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
224
+ return { success: true };
225
+ }),
226
+ subscribe: publicProcedure.subscription(({ ctx }) => {
227
+ return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
228
+ }),
229
+ subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
230
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChange(id))(input.id);
231
+ }),
232
+ subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
233
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
234
+ }),
235
+ subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
236
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeRaw(id))(input.id);
237
+ })
238
+ });
239
+ /**
240
+ * Init router - project initialization
241
+ */
242
+ const initRouter = router({ init: publicProcedure.mutation(async ({ ctx }) => {
243
+ await ctx.adapter.init();
244
+ return { success: true };
245
+ }) });
246
+ /**
247
+ * Project router - project-level files (project.md, AGENTS.md)
248
+ */
249
+ const projectRouter = router({
250
+ getProjectMd: publicProcedure.query(async ({ ctx }) => {
251
+ return ctx.adapter.readProjectMd();
252
+ }),
253
+ getAgentsMd: publicProcedure.query(async ({ ctx }) => {
254
+ return ctx.adapter.readAgentsMd();
255
+ }),
256
+ saveProjectMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
257
+ await ctx.adapter.writeProjectMd(input.content);
258
+ return { success: true };
259
+ }),
260
+ saveAgentsMd: publicProcedure.input(z.object({ content: z.string() })).mutation(async ({ ctx, input }) => {
261
+ await ctx.adapter.writeAgentsMd(input.content);
262
+ return { success: true };
263
+ }),
264
+ subscribeProjectMd: publicProcedure.subscription(({ ctx }) => {
265
+ return createReactiveSubscription(() => ctx.adapter.readProjectMd());
266
+ }),
267
+ subscribeAgentsMd: publicProcedure.subscription(({ ctx }) => {
268
+ return createReactiveSubscription(() => ctx.adapter.readAgentsMd());
269
+ })
270
+ });
271
+ /**
272
+ * Archive router - archived changes
273
+ */
274
+ const archiveRouter = router({
275
+ list: publicProcedure.query(async ({ ctx }) => {
276
+ return ctx.adapter.listArchivedChanges();
277
+ }),
278
+ listWithMeta: publicProcedure.query(async ({ ctx }) => {
279
+ return ctx.adapter.listArchivedChangesWithMeta();
280
+ }),
281
+ get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
282
+ return ctx.adapter.readArchivedChange(input.id);
283
+ }),
284
+ getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
285
+ return ctx.adapter.readArchivedChangeRaw(input.id);
286
+ }),
287
+ subscribe: publicProcedure.subscription(({ ctx }) => {
288
+ return createReactiveSubscription(() => ctx.adapter.listArchivedChangesWithMeta());
289
+ }),
290
+ subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
291
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChange(id))(input.id);
292
+ }),
293
+ subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
294
+ return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChangeFiles(id))(input.id);
295
+ })
296
+ });
297
+ z.object({
298
+ type: z.enum([
299
+ "spec",
300
+ "change",
301
+ "archive",
302
+ "project"
303
+ ]),
304
+ action: z.enum([
305
+ "create",
306
+ "update",
307
+ "delete"
308
+ ]),
309
+ id: z.string().optional(),
310
+ path: z.string(),
311
+ timestamp: z.number()
312
+ });
313
+ /**
314
+ * Realtime router - file change subscriptions
315
+ */
316
+ const realtimeRouter = router({
317
+ onFileChange: publicProcedure.subscription(({ ctx }) => {
318
+ return observable((emit) => {
319
+ if (!ctx.watcher) {
320
+ emit.error(/* @__PURE__ */ new Error("File watcher not available"));
321
+ return () => {};
322
+ }
323
+ const handler = (event) => {
324
+ emit.next(event);
325
+ };
326
+ ctx.watcher.on("change", handler);
327
+ return () => {
328
+ ctx.watcher?.off("change", handler);
329
+ };
330
+ });
331
+ }),
332
+ onSpecChange: publicProcedure.input(z.object({ specId: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
333
+ return observable((emit) => {
334
+ if (!ctx.watcher) {
335
+ emit.error(/* @__PURE__ */ new Error("File watcher not available"));
336
+ return () => {};
337
+ }
338
+ const handler = (event) => {
339
+ if (event.type !== "spec") return;
340
+ if (input?.specId && event.id !== input.specId) return;
341
+ emit.next(event);
342
+ };
343
+ ctx.watcher.on("change", handler);
344
+ return () => {
345
+ ctx.watcher?.off("change", handler);
346
+ };
347
+ });
348
+ }),
349
+ onChangeChange: publicProcedure.input(z.object({ changeId: z.string().optional() }).optional()).subscription(({ ctx, input }) => {
350
+ return observable((emit) => {
351
+ if (!ctx.watcher) {
352
+ emit.error(/* @__PURE__ */ new Error("File watcher not available"));
353
+ return () => {};
354
+ }
355
+ const handler = (event) => {
356
+ if (event.type !== "change" && event.type !== "archive") return;
357
+ if (input?.changeId && event.id !== input.changeId) return;
358
+ emit.next(event);
359
+ };
360
+ ctx.watcher.on("change", handler);
361
+ return () => {
362
+ ctx.watcher?.off("change", handler);
363
+ };
364
+ });
365
+ })
366
+ });
367
+ /**
368
+ * Config router - configuration management
369
+ */
370
+ const configRouter = router({
371
+ get: publicProcedure.query(async ({ ctx }) => {
372
+ return ctx.configManager.readConfig();
373
+ }),
374
+ getEffectiveCliCommand: publicProcedure.query(async ({ ctx }) => {
375
+ return ctx.configManager.getCliCommandString();
376
+ }),
377
+ getDefaultCliCommand: publicProcedure.query(async () => {
378
+ return getDefaultCliCommandString();
379
+ }),
380
+ update: publicProcedure.input(z.object({
381
+ cli: z.object({ command: z.string() }).optional(),
382
+ ui: z.object({ theme: z.enum([
383
+ "light",
384
+ "dark",
385
+ "system"
386
+ ]) }).optional()
387
+ })).mutation(async ({ ctx, input }) => {
388
+ await ctx.configManager.writeConfig(input);
389
+ return { success: true };
390
+ }),
391
+ setCliCommand: publicProcedure.input(z.object({ command: z.string() })).mutation(async ({ ctx, input }) => {
392
+ await ctx.configManager.setCliCommand(input.command);
393
+ return { success: true };
394
+ }),
395
+ subscribe: publicProcedure.subscription(({ ctx }) => {
396
+ return createReactiveSubscription(() => ctx.configManager.readConfig());
397
+ })
398
+ });
399
+ /**
400
+ * CLI router - execute external openspec CLI commands
401
+ */
402
+ const cliRouter = router({
403
+ checkAvailability: publicProcedure.query(async ({ ctx }) => {
404
+ return ctx.cliExecutor.checkAvailability();
405
+ }),
406
+ sniffGlobalCli: publicProcedure.query(async () => {
407
+ return sniffGlobalCli();
408
+ }),
409
+ installGlobalCliStream: publicProcedure.subscription(({ ctx }) => {
410
+ return observable((emit) => {
411
+ const cancel = ctx.cliExecutor.executeCommandStream([
412
+ "npm",
413
+ "install",
414
+ "-g",
415
+ "@fission-ai/openspec"
416
+ ], (event) => {
417
+ emit.next(event);
418
+ if (event.type === "exit") emit.complete();
419
+ });
420
+ return () => {
421
+ cancel();
422
+ };
423
+ });
424
+ }),
425
+ runCommandStream: publicProcedure.input(z.object({
426
+ command: z.string(),
427
+ args: z.array(z.string()).default([])
428
+ })).subscription(({ ctx, input }) => {
429
+ return createCliStreamObservable(async (onEvent) => ctx.cliExecutor.executeCommandStream([input.command, ...input.args], onEvent));
430
+ }),
431
+ getAvailableTools: publicProcedure.query(() => {
432
+ return getAvailableTools().map((tool) => ({
433
+ name: tool.name,
434
+ value: tool.value,
435
+ available: tool.available,
436
+ successLabel: tool.successLabel
437
+ }));
438
+ }),
439
+ getAllTools: publicProcedure.query(() => {
440
+ return getAllTools().map((tool) => ({
441
+ name: tool.name,
442
+ value: tool.value,
443
+ available: tool.available,
444
+ successLabel: tool.successLabel
445
+ }));
446
+ }),
447
+ getConfiguredTools: publicProcedure.query(async ({ ctx }) => {
448
+ return getConfiguredTools(ctx.projectDir);
449
+ }),
450
+ subscribeConfiguredTools: publicProcedure.subscription(({ ctx }) => {
451
+ return createReactiveSubscription(() => getConfiguredTools(ctx.projectDir));
452
+ }),
453
+ init: publicProcedure.input(z.object({ tools: z.union([
454
+ z.array(z.string()),
455
+ z.literal("all"),
456
+ z.literal("none")
457
+ ]).optional() }).optional()).mutation(async ({ ctx, input }) => {
458
+ return ctx.cliExecutor.init(input?.tools ?? "all");
459
+ }),
460
+ archive: publicProcedure.input(z.object({
461
+ changeId: z.string(),
462
+ skipSpecs: z.boolean().optional(),
463
+ noValidate: z.boolean().optional()
464
+ })).mutation(async ({ ctx, input }) => {
465
+ return ctx.cliExecutor.archive(input.changeId, {
466
+ skipSpecs: input.skipSpecs,
467
+ noValidate: input.noValidate
468
+ });
469
+ }),
470
+ validate: publicProcedure.input(z.object({
471
+ type: z.enum(["spec", "change"]).optional(),
472
+ id: z.string().optional()
473
+ })).mutation(async ({ ctx, input }) => {
474
+ return ctx.cliExecutor.validate(input.type, input.id);
475
+ }),
476
+ validateStream: publicProcedure.input(z.object({
477
+ type: z.enum(["spec", "change"]).optional(),
478
+ id: z.string().optional()
479
+ })).subscription(({ ctx, input }) => {
480
+ return createCliStreamObservable((onEvent) => ctx.cliExecutor.validateStream(input.type, input.id, onEvent));
481
+ }),
482
+ execute: publicProcedure.input(z.object({ args: z.array(z.string()) })).mutation(async ({ ctx, input }) => {
483
+ return ctx.cliExecutor.execute(input.args);
484
+ }),
485
+ initStream: publicProcedure.input(z.object({ tools: z.union([
486
+ z.array(z.string()),
487
+ z.literal("all"),
488
+ z.literal("none")
489
+ ]).optional() }).optional()).subscription(({ ctx, input }) => {
490
+ return createCliStreamObservable((onEvent) => ctx.cliExecutor.initStream(input?.tools ?? "all", onEvent));
491
+ }),
492
+ archiveStream: publicProcedure.input(z.object({
493
+ changeId: z.string(),
494
+ skipSpecs: z.boolean().optional(),
495
+ noValidate: z.boolean().optional()
496
+ })).subscription(({ ctx, input }) => {
497
+ return createCliStreamObservable((onEvent) => ctx.cliExecutor.archiveStream(input.changeId, {
498
+ skipSpecs: input.skipSpecs,
499
+ noValidate: input.noValidate
500
+ }, onEvent));
501
+ })
502
+ });
503
+ /**
504
+ * Main app router
505
+ */
506
+ const appRouter = router({
507
+ dashboard: dashboardRouter,
508
+ spec: specRouter,
509
+ change: changeRouter,
510
+ archive: archiveRouter,
511
+ project: projectRouter,
512
+ init: initRouter,
513
+ realtime: realtimeRouter,
514
+ config: configRouter,
515
+ cli: cliRouter
516
+ });
517
+
518
+ //#endregion
519
+ //#region src/port-utils.ts
520
+ /**
521
+ * Check if a port is available by trying to listen on it.
522
+ * Uses default binding (both IPv4 and IPv6) to detect conflicts.
523
+ */
524
+ function isPortAvailable(port) {
525
+ return new Promise((resolve) => {
526
+ const server = createServer$1();
527
+ server.once("error", () => {
528
+ resolve(false);
529
+ });
530
+ server.once("listening", () => {
531
+ server.close(() => resolve(true));
532
+ });
533
+ server.listen(port);
534
+ });
535
+ }
536
+ /**
537
+ * Find an available port starting from the given port.
538
+ * Will try up to maxAttempts ports sequentially.
539
+ *
540
+ * @param startPort - The preferred port to start checking from
541
+ * @param maxAttempts - Maximum number of ports to try (default: 10)
542
+ * @returns The first available port found
543
+ * @throws Error if no available port is found in the range
544
+ */
545
+ async function findAvailablePort(startPort, maxAttempts = 10) {
546
+ for (let i = 0; i < maxAttempts; i++) {
547
+ const port = startPort + i;
548
+ if (await isPortAvailable(port)) return port;
549
+ }
550
+ throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
551
+ }
552
+
553
+ //#endregion
554
+ //#region src/server.ts
555
+ /**
556
+ * OpenSpecUI HTTP/WebSocket server.
557
+ *
558
+ * Provides tRPC endpoints for:
559
+ * - Dashboard data and project status
560
+ * - Spec CRUD operations
561
+ * - Change proposal management
562
+ * - AI-assisted operations (review, translate, suggest)
563
+ * - Realtime file change subscriptions via WebSocket
564
+ *
565
+ * @module server
566
+ */
567
+ /**
568
+ * Create an OpenSpecUI HTTP server with optional WebSocket support
569
+ */
570
+ function createServer(config) {
571
+ const adapter = new OpenSpecAdapter(config.projectDir);
572
+ const configManager = new ConfigManager(config.projectDir);
573
+ const cliExecutor = new CliExecutor(configManager, config.projectDir);
574
+ const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
575
+ const app = new Hono();
576
+ const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
577
+ app.use("*", cors({
578
+ origin: corsOrigins,
579
+ credentials: true
580
+ }));
581
+ app.get("/api/health", (c) => {
582
+ return c.json({
583
+ status: "ok",
584
+ projectDir: config.projectDir,
585
+ watcherEnabled: !!watcher
586
+ });
587
+ });
588
+ app.use("/trpc/*", async (c) => {
589
+ return await fetchRequestHandler({
590
+ endpoint: "/trpc",
591
+ req: c.req.raw,
592
+ router: appRouter,
593
+ createContext: () => ({
594
+ adapter,
595
+ configManager,
596
+ cliExecutor,
597
+ watcher,
598
+ projectDir: config.projectDir
599
+ })
600
+ });
601
+ });
602
+ const createContext = () => ({
603
+ adapter,
604
+ configManager,
605
+ cliExecutor,
606
+ watcher,
607
+ projectDir: config.projectDir
608
+ });
609
+ return {
610
+ app,
611
+ adapter,
612
+ configManager,
613
+ cliExecutor,
614
+ watcher,
615
+ createContext,
616
+ port: config.port ?? 3100
617
+ };
618
+ }
619
+ /**
620
+ * Create WebSocket server for tRPC subscriptions
621
+ */
622
+ async function createWebSocketServer(server, httpServer, config) {
623
+ await initWatcherPool(config.projectDir);
624
+ const wss = new WebSocketServer({ noServer: true });
625
+ const handler = applyWSSHandler({
626
+ wss,
627
+ router: appRouter,
628
+ createContext: server.createContext
629
+ });
630
+ httpServer.on("upgrade", (...args) => {
631
+ const [request, socket, head] = args;
632
+ if (request.url?.startsWith("/trpc")) wss.handleUpgrade(request, socket, head, (ws) => {
633
+ wss.emit("connection", ws, request);
634
+ });
635
+ });
636
+ server.watcher?.start();
637
+ return {
638
+ wss,
639
+ handler,
640
+ close: () => {
641
+ handler.broadcastReconnectNotification();
642
+ wss.close();
643
+ server.watcher?.stop();
644
+ }
645
+ };
646
+ }
647
+ /**
648
+ * Start the OpenSpec UI server with WebSocket support.
649
+ * Automatically finds an available port if the preferred port is occupied.
650
+ *
651
+ * @param config - Server configuration
652
+ * @param setupApp - Optional callback to configure the Hono app before starting (e.g., add static file middleware)
653
+ * @returns Running server instance with actual port and close function
654
+ */
655
+ async function startServer(config, setupApp) {
656
+ const preferredPort = config.port ?? 3100;
657
+ const port = await findAvailablePort(preferredPort);
658
+ const server = createServer({
659
+ ...config,
660
+ port
661
+ });
662
+ if (setupApp) setupApp(server.app);
663
+ const httpServer = serve({
664
+ fetch: server.app.fetch,
665
+ port
666
+ });
667
+ const wsServer = await createWebSocketServer(server, httpServer, { projectDir: config.projectDir });
668
+ return {
669
+ url: `http://localhost:${port}`,
670
+ port,
671
+ preferredPort,
672
+ close: async () => {
673
+ wsServer.close();
674
+ httpServer.close();
675
+ }
676
+ };
677
+ }
678
+
679
+ //#endregion
680
+ export { createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@openspecui/server",
3
+ "version": "0.9.0",
4
+ "type": "module",
5
+ "main": "dist/index.mjs",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.mjs"
9
+ }
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "dependencies": {
15
+ "@hono/node-server": "^1.14.1",
16
+ "@trpc/server": "^11.0.0",
17
+ "hono": "^4.7.3",
18
+ "ws": "^8.18.0",
19
+ "yargs": "^18.0.0",
20
+ "zod": "^3.24.1",
21
+ "@openspecui/core": "0.9.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.10.2",
25
+ "@types/ws": "^8.5.13",
26
+ "@types/yargs": "^17.0.35",
27
+ "tsdown": "^0.16.6",
28
+ "tsx": "^4.19.2",
29
+ "typescript": "^5.7.2",
30
+ "vitest": "^2.1.8"
31
+ },
32
+ "scripts": {
33
+ "build": "tsdown src/index.ts --format esm --no-dts",
34
+ "typecheck": "tsc --noEmit -p tsconfig.check.json",
35
+ "dev": "tsx watch src/standalone.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ }
39
+ }