~cytrogen/gstack

ref: 9c5f479745acc90533a7ff75a00771b9056c43ef gstack/design/src/serve.ts -rw-r--r-- 7.4 KiB
9c5f4797 — Cytrogen fork: 频率分级路由 + 触发式描述符重写 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
/**
 * HTTP server for the design comparison board feedback loop.
 *
 * Replaces the broken file:// + DOM polling approach. The server:
 * 1. Serves the comparison board HTML over HTTP
 * 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
 * 3. Prints feedback JSON to stdout (agent reads it)
 * 4. Stays alive across regeneration rounds (stateful)
 * 5. Auto-opens in the user's default browser
 *
 * State machine:
 *
 *   SERVING ──(POST submit)──► DONE ──► exit 0
 *      │
 *      ├──(POST regenerate/remix)──► REGENERATING
 *      │                                  │
 *      │                          (POST /api/reload)
 *      │                                  │
 *      │                                  ▼
 *      │                             RELOADING ──► SERVING
 *      │
 *      └──(timeout)──► exit 1
 *
 * Feedback delivery (two channels, both always active):
 *   Stdout: feedback JSON (one line per event) — for foreground mode
 *   Disk:   feedback-pending.json (regenerate/remix) or feedback.json (submit)
 *           written next to the HTML file — for background mode polling
 *
 * The agent typically backgrounds $D serve and polls for feedback-pending.json.
 * When found: read it, delete it, generate new variants, POST /api/reload.
 *
 * Stderr: structured telemetry (SERVE_STARTED, SERVE_FEEDBACK_RECEIVED, etc.)
 */

import fs from "fs";
import path from "path";
import { spawn } from "child_process";

export interface ServeOptions {
  html: string;
  port?: number;
  timeout?: number; // seconds, default 600 (10 min)
}

type ServerState = "serving" | "regenerating" | "done";

export async function serve(options: ServeOptions): Promise<void> {
  const { html, port = 0, timeout = 600 } = options;

  // Validate HTML file exists
  if (!fs.existsSync(html)) {
    console.error(`SERVE_ERROR: HTML file not found: ${html}`);
    process.exit(1);
  }

  let htmlContent = fs.readFileSync(html, "utf-8");
  let state: ServerState = "serving";
  let timeoutTimer: ReturnType<typeof setTimeout> | null = null;

  const server = Bun.serve({
    port,
    fetch(req) {
      const url = new URL(req.url);

      // Serve the comparison board HTML
      if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
        // Inject the server URL so the board can POST feedback
        const injected = htmlContent.replace(
          "</head>",
          `<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
        );
        return new Response(injected, {
          headers: { "Content-Type": "text/html; charset=utf-8" },
        });
      }

      // Progress polling endpoint (used by board during regeneration)
      if (req.method === "GET" && url.pathname === "/api/progress") {
        return Response.json({ status: state });
      }

      // Feedback submission from the board
      if (req.method === "POST" && url.pathname === "/api/feedback") {
        return handleFeedback(req);
      }

      // Reload endpoint (used by the agent to swap in new board HTML)
      if (req.method === "POST" && url.pathname === "/api/reload") {
        return handleReload(req);
      }

      return new Response("Not found", { status: 404 });
    },
  });

  const actualPort = server.port;
  const boardUrl = `http://127.0.0.1:${actualPort}`;

  console.error(`SERVE_STARTED: port=${actualPort} html=${html}`);

  // Auto-open in user's default browser
  openBrowser(boardUrl);

  // Set timeout
  timeoutTimer = setTimeout(() => {
    console.error(`SERVE_TIMEOUT: after=${timeout}s`);
    server.stop();
    process.exit(1);
  }, timeout * 1000);

  async function handleFeedback(req: Request): Promise<Response> {
    let body: any;
    try {
      body = await req.json();
    } catch {
      return Response.json({ error: "Invalid JSON" }, { status: 400 });
    }

    // Validate expected shape
    if (typeof body !== "object" || body === null) {
      return Response.json({ error: "Expected JSON object" }, { status: 400 });
    }

    const isSubmit = body.regenerated === false;
    const isRegenerate = body.regenerated === true;
    const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate");

    console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`);

    // Print feedback JSON to stdout (for foreground mode)
    console.log(JSON.stringify(body));

    // ALWAYS write feedback to disk so the agent can poll for it
    // (agent typically backgrounds $D serve, can't read stdout)
    const feedbackDir = path.dirname(html);
    const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
    const feedbackPath = path.join(feedbackDir, feedbackFile);
    fs.writeFileSync(feedbackPath, JSON.stringify(body, null, 2));

    if (isSubmit) {
      state = "done";
      if (timeoutTimer) clearTimeout(timeoutTimer);

      // Give the response time to send before exiting
      setTimeout(() => {
        server.stop();
        process.exit(0);
      }, 100);

      return Response.json({ received: true, action: "submitted" });
    }

    if (isRegenerate) {
      state = "regenerating";
      // Reset timeout for regeneration (agent needs time to generate new variants)
      if (timeoutTimer) clearTimeout(timeoutTimer);
      timeoutTimer = setTimeout(() => {
        console.error(`SERVE_TIMEOUT: after=${timeout}s (during regeneration)`);
        server.stop();
        process.exit(1);
      }, timeout * 1000);

      return Response.json({ received: true, action: "regenerate" });
    }

    return Response.json({ received: true, action: "unknown" });
  }

  async function handleReload(req: Request): Promise<Response> {
    let body: any;
    try {
      body = await req.json();
    } catch {
      return Response.json({ error: "Invalid JSON" }, { status: 400 });
    }

    const newHtmlPath = body.html;
    if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
      return Response.json(
        { error: `HTML file not found: ${newHtmlPath}` },
        { status: 400 }
      );
    }

    // Swap the HTML content
    htmlContent = fs.readFileSync(newHtmlPath, "utf-8");
    state = "serving";

    console.error(`SERVE_RELOADED: html=${newHtmlPath}`);

    // Reset timeout
    if (timeoutTimer) clearTimeout(timeoutTimer);
    timeoutTimer = setTimeout(() => {
      console.error(`SERVE_TIMEOUT: after=${timeout}s`);
      server.stop();
      process.exit(1);
    }, timeout * 1000);

    return Response.json({ reloaded: true });
  }

  // Keep the process alive
  await new Promise(() => {});
}

/**
 * Open a URL in the user's default browser.
 * Handles macOS (open), Linux (xdg-open), and headless environments.
 */
function openBrowser(url: string): void {
  const platform = process.platform;
  let cmd: string;

  if (platform === "darwin") {
    cmd = "open";
  } else if (platform === "linux") {
    cmd = "xdg-open";
  } else {
    // Windows or unknown — just print the URL
    console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
    console.error(`Open this URL in your browser: ${url}`);
    return;
  }

  try {
    const child = spawn(cmd, [url], {
      stdio: "ignore",
      detached: true,
    });
    child.unref();
    console.error(`SERVE_BROWSER_OPENED: url=${url}`);
  } catch {
    // open/xdg-open not available (headless CI environment)
    console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
    console.error(`Open this URL in your browser: ${url}`);
  }
}