@huaqiu/component-gen-server 0.3.6

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,754 @@
1
+ import { createRequire } from "node:module";
2
+ import { createServer } from "node:http";
3
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, extname, join, resolve } from "node:path";
5
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
6
+ import { AUTH_ROUTE_PREFIX, InMemoryHuaqiuAuthService, createAuthHandler } from "@huaqiu/dsh-auth";
7
+ import { ARTIFACTS_ROUTE_PREFIX, HuaqiuArtifactService, createArtifactsHandler } from "@huaqiu/dsh-artifacts";
8
+ import { createComponentGenBackend } from "@huaqiu/dsh-tool-symbol-footprint";
9
+ import { randomUUID } from "node:crypto";
10
+ //#region src/history.ts
11
+ /**
12
+ * `@huaqiu/component-gen-server` — history store.
13
+ *
14
+ * Plain filesystem (no SQLite): `<dir>/history.json` + `<dir>/inputs/<id>`.
15
+ * History is user-level (not project-level). Entries are appended by the job
16
+ * runner on terminal states; input thumbnails are stored by the routes layer
17
+ * at POST /jobs time. `imageId` in an entry's `input` points into `inputs/`.
18
+ */
19
+ const INPUT_DIR = "inputs";
20
+ function readJsonFile(path, fallback) {
21
+ try {
22
+ if (!existsSync(path)) return fallback;
23
+ return JSON.parse(readFileSync(path, "utf8"));
24
+ } catch {
25
+ return fallback;
26
+ }
27
+ }
28
+ function writeJsonFile(path, value) {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
31
+ }
32
+ /** `data:image/...;base64,....` → { mime, bytes } | null. */
33
+ function parseDataUrl(dataUrl) {
34
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
35
+ if (!m) return null;
36
+ try {
37
+ return {
38
+ mime: m[1],
39
+ bytes: Buffer.from(m[2], "base64")
40
+ };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ var HistoryStore = class {
46
+ dir;
47
+ file;
48
+ entries = [];
49
+ constructor(dir) {
50
+ this.dir = dir;
51
+ this.file = join(dir, "history.json");
52
+ this.entries = readJsonFile(this.file, []);
53
+ }
54
+ /** All entries, newest first. */
55
+ sorted() {
56
+ return [...this.entries].sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
57
+ }
58
+ async append(entry) {
59
+ this.entries = [entry, ...this.entries.filter((e) => e.id !== entry.id)];
60
+ writeJsonFile(this.file, this.entries);
61
+ return entry;
62
+ }
63
+ async list(query) {
64
+ const limit = Math.max(1, Math.min(100, query.limit ?? 20));
65
+ const sorted = this.sorted();
66
+ const start = query.cursor ? sorted.findIndex((e) => e.id === query.cursor) + 1 : 0;
67
+ const slice = start < 0 ? [] : sorted.slice(start, start + limit);
68
+ return {
69
+ entries: slice,
70
+ nextCursor: start + slice.length < sorted.length ? slice[slice.length - 1]?.id ?? null : null
71
+ };
72
+ }
73
+ async get(id) {
74
+ return this.entries.find((e) => e.id === id) ?? null;
75
+ }
76
+ async patch(id, patch) {
77
+ const idx = this.entries.findIndex((e) => e.id === id);
78
+ if (idx < 0) return null;
79
+ const next = {
80
+ ...this.entries[idx],
81
+ ...patch.status !== void 0 ? { status: patch.status } : {},
82
+ ...patch.error !== void 0 ? { error: patch.error } : {},
83
+ ...patch.edited !== void 0 ? { edited: patch.edited } : {},
84
+ ...patch.result !== void 0 ? { result: patch.result } : {}
85
+ };
86
+ this.entries[idx] = next;
87
+ writeJsonFile(this.file, this.entries);
88
+ return next;
89
+ }
90
+ async delete(id) {
91
+ const entry = this.entries.find((e) => e.id === id);
92
+ this.entries = this.entries.filter((e) => e.id !== id);
93
+ writeJsonFile(this.file, this.entries);
94
+ if (entry?.input?.imageId) {
95
+ try {
96
+ unlinkSync(join(this.dir, INPUT_DIR, entry.input.imageId));
97
+ } catch {}
98
+ try {
99
+ unlinkSync(join(this.dir, INPUT_DIR, `${entry.input.imageId}.mime`));
100
+ } catch {}
101
+ }
102
+ }
103
+ async saveImage(imageId, dataUrl) {
104
+ const parsed = parseDataUrl(dataUrl);
105
+ if (!parsed) throw new Error("component-gen: invalid image data URL");
106
+ const dir = join(this.dir, INPUT_DIR);
107
+ mkdirSync(dir, { recursive: true });
108
+ writeFileSync(join(dir, imageId), parsed.bytes);
109
+ writeFileSync(join(dir, `${imageId}.mime`), parsed.mime, "utf8");
110
+ }
111
+ async readImage(imageId) {
112
+ const dir = join(this.dir, INPUT_DIR);
113
+ const path = join(dir, imageId);
114
+ if (!existsSync(path)) return null;
115
+ let mime = "image/png";
116
+ try {
117
+ const sidecar = readFileSync(join(dir, `${imageId}.mime`), "utf8").trim();
118
+ if (sidecar) mime = sidecar;
119
+ } catch {}
120
+ return {
121
+ bytes: readFileSync(path),
122
+ mime
123
+ };
124
+ }
125
+ };
126
+ function newHistoryId() {
127
+ return `hst_${randomUUID().slice(0, 18)}`;
128
+ }
129
+ function newImageId() {
130
+ return `img_${randomUUID().slice(0, 18)}`;
131
+ }
132
+ //#endregion
133
+ //#region src/jobs.ts
134
+ var JobStore = class {
135
+ jobs = /* @__PURE__ */ new Map();
136
+ listeners = /* @__PURE__ */ new Map();
137
+ create(req, meta) {
138
+ const now = (/* @__PURE__ */ new Date()).toISOString();
139
+ const id = `job_${randomUUID().slice(0, 18)}`;
140
+ const state = {
141
+ id,
142
+ kind: req.kind,
143
+ status: "queued",
144
+ createdAt: now,
145
+ updatedAt: now
146
+ };
147
+ this.jobs.set(id, {
148
+ state,
149
+ controller: new AbortController()
150
+ });
151
+ return state;
152
+ }
153
+ get(id) {
154
+ return this.jobs.get(id)?.state;
155
+ }
156
+ abort(id) {
157
+ const rec = this.jobs.get(id);
158
+ if (!rec) return false;
159
+ rec.controller.abort();
160
+ return true;
161
+ }
162
+ signal(id) {
163
+ return this.jobs.get(id)?.controller.signal;
164
+ }
165
+ subscribe(id, cb) {
166
+ if (!this.jobs.has(id)) return null;
167
+ let set = this.listeners.get(id);
168
+ if (!set) {
169
+ set = /* @__PURE__ */ new Set();
170
+ this.listeners.set(id, set);
171
+ }
172
+ set.add(cb);
173
+ return () => {
174
+ set?.delete(cb);
175
+ if (set && set.size === 0) this.listeners.delete(id);
176
+ };
177
+ }
178
+ emit(id, event) {
179
+ const set = this.listeners.get(id);
180
+ if (!set) return;
181
+ for (const cb of [...set]) try {
182
+ cb(event);
183
+ } catch {}
184
+ }
185
+ /** Update job state (public — the runner writes progress/status). */
186
+ update(id, patch, event) {
187
+ const rec = this.jobs.get(id);
188
+ if (!rec) return patch;
189
+ rec.state = {
190
+ ...rec.state,
191
+ ...patch,
192
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
193
+ };
194
+ if (event) this.emit(id, event);
195
+ return rec.state;
196
+ }
197
+ /** Update + emit the canonical event for a terminal state. */
198
+ settle(id, patch) {
199
+ const state = this.update(id, patch);
200
+ const now = (/* @__PURE__ */ new Date()).toISOString();
201
+ if (state.status === "completed") this.emit(id, {
202
+ type: "completed",
203
+ job: state,
204
+ at: now
205
+ });
206
+ else if (state.status === "failed") this.emit(id, {
207
+ type: "failed",
208
+ error: state.error ?? "generation failed",
209
+ result: state.result,
210
+ at: now
211
+ });
212
+ else if (state.status === "cancelled") this.emit(id, {
213
+ type: "cancelled",
214
+ at: now
215
+ });
216
+ else if (state.status === "needs_confirmation") this.emit(id, {
217
+ type: "needs_confirmation",
218
+ dimensions: state.dimensions ?? {},
219
+ pkgType: state.pkgType ?? null,
220
+ fileName: state.fileName ?? null,
221
+ at: now
222
+ });
223
+ return state;
224
+ }
225
+ remove(id) {
226
+ this.jobs.delete(id);
227
+ this.listeners.delete(id);
228
+ }
229
+ };
230
+ function isAbortError(err) {
231
+ return err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
232
+ }
233
+ /** Map the tool-body `needs_auth` outcome to a job failure with the marker. */
234
+ function isNeedsAuth(result) {
235
+ return result?.status === "needs_auth";
236
+ }
237
+ /**
238
+ * Run one generation to a terminal state. Returns the final JobState.
239
+ * History recording happens here so entry and state cannot drift.
240
+ */
241
+ async function runGeneration(store, backend, history, id, req, meta, onProgress) {
242
+ if (!store.get(id)) return {
243
+ state: {
244
+ id,
245
+ kind: req.kind,
246
+ status: "failed",
247
+ error: "job not found",
248
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
249
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
250
+ },
251
+ recorded: false
252
+ };
253
+ const signal = store.signal(id);
254
+ const progress = (message) => {
255
+ store.update(id, {
256
+ status: "running",
257
+ progress: message
258
+ });
259
+ onProgress?.(message);
260
+ };
261
+ const exec = { signal };
262
+ try {
263
+ if (req.kind === "symbol") {
264
+ progress("正在生成 Symbol…");
265
+ const result = await backend.generateSymbol({
266
+ imageDataUrl: req.input.imageDataUrl ?? "",
267
+ instruction: req.input.instruction
268
+ }, exec);
269
+ if (isNeedsAuth(result)) return fail("needs_auth");
270
+ const state = store.settle(id, {
271
+ status: "completed",
272
+ result
273
+ });
274
+ await record(history, meta, req, state);
275
+ return {
276
+ state,
277
+ recorded: true
278
+ };
279
+ }
280
+ if (req.kind === "extract-footprint") {
281
+ progress("正在提取封装尺寸…");
282
+ const result = await backend.extractFootprint({
283
+ imageDataUrl: req.input.imageDataUrl ?? "",
284
+ packageType: req.input.packageType,
285
+ instruction: req.input.instruction
286
+ }, exec);
287
+ if (isNeedsAuth(result)) return fail("needs_auth");
288
+ if (result.status === "needs_confirmation") {
289
+ const dims = result.dimensions && typeof result.dimensions === "object" ? result.dimensions : {};
290
+ const pkg = typeof result.pkgType === "string" ? result.pkgType : req.input.packageType ?? null;
291
+ const fileName = typeof result.fileName === "string" ? result.fileName : null;
292
+ return {
293
+ state: store.settle(id, {
294
+ status: "needs_confirmation",
295
+ dimensions: dims,
296
+ pkgType: pkg,
297
+ fileName
298
+ }),
299
+ recorded: false
300
+ };
301
+ }
302
+ if (result.status === "cancelled") {
303
+ const state = store.settle(id, {
304
+ status: "cancelled",
305
+ result
306
+ });
307
+ await record(history, meta, req, state);
308
+ return {
309
+ state,
310
+ recorded: true
311
+ };
312
+ }
313
+ const state = store.settle(id, {
314
+ status: "completed",
315
+ result
316
+ });
317
+ await record(history, meta, req, state);
318
+ return {
319
+ state,
320
+ recorded: true
321
+ };
322
+ }
323
+ progress("正在生成封装…");
324
+ const result = await backend.generateFootprint({
325
+ packageType: req.input.packageType ?? "",
326
+ fileName: req.input.fileName,
327
+ dimensions: req.input.dimensions ?? {}
328
+ }, exec);
329
+ if (isNeedsAuth(result)) return fail("needs_auth");
330
+ if (result.status === "cancelled") {
331
+ const state = store.settle(id, {
332
+ status: "cancelled",
333
+ result
334
+ });
335
+ await record(history, meta, req, state);
336
+ return {
337
+ state,
338
+ recorded: true
339
+ };
340
+ }
341
+ const state = store.settle(id, {
342
+ status: "completed",
343
+ result
344
+ });
345
+ await record(history, meta, req, state);
346
+ return {
347
+ state,
348
+ recorded: true
349
+ };
350
+ } catch (err) {
351
+ if (isAbortError(err)) {
352
+ const state = store.settle(id, { status: "cancelled" });
353
+ await record(history, meta, req, state).catch(() => {});
354
+ return {
355
+ state,
356
+ recorded: true
357
+ };
358
+ }
359
+ const message = String(err?.message || err);
360
+ const state = store.settle(id, {
361
+ status: "failed",
362
+ error: message
363
+ });
364
+ await record(history, meta, req, state).catch(() => {});
365
+ return {
366
+ state,
367
+ recorded: true
368
+ };
369
+ }
370
+ function fail(kind) {
371
+ const state = store.settle(id, {
372
+ status: "failed",
373
+ error: kind === "needs_auth" ? "Huaqiu EDA login required" : "generation failed",
374
+ result: { status: kind }
375
+ });
376
+ record(history, meta, req, state).catch(() => {});
377
+ return {
378
+ state,
379
+ recorded: true
380
+ };
381
+ }
382
+ }
383
+ /** Build + append a history entry for a terminal job state. */
384
+ async function record(history, meta, req, state) {
385
+ const kind = state.kind === "symbol" ? "symbol" : "footprint";
386
+ const status = state.status === "completed" ? "generated" : state.status === "cancelled" ? "cancelled" : "failed";
387
+ const result = state.result;
388
+ const artifact = result?.artifact && typeof result.artifact === "object" ? result.artifact : null;
389
+ const entry = {
390
+ id: newHistoryId(),
391
+ kind,
392
+ createdAt: state.updatedAt ?? state.createdAt,
393
+ status,
394
+ input: {
395
+ ...meta.imageId ? { imageId: meta.imageId } : {},
396
+ ...req.input.instruction ? { instruction: req.input.instruction } : {},
397
+ ...req.input.packageType ? { packageType: req.input.packageType } : {},
398
+ ...req.input.dimensions && Object.keys(req.input.dimensions).length > 0 ? { dimensions: req.input.dimensions } : {}
399
+ },
400
+ ...req.input.edited && Object.keys(req.input.edited).length > 0 ? { edited: req.input.edited } : {},
401
+ ...status === "generated" && artifact?.id ? { result: {
402
+ artifactId: String(artifact.id),
403
+ filename: typeof artifact.filename === "string" ? artifact.filename : result?.filename ?? `${kind}.kicad_${kind === "symbol" ? "sym" : "mod"}`,
404
+ ...typeof result?.fileUrl === "string" ? { fileUrl: result.fileUrl } : {},
405
+ ...typeof artifact.size === "number" ? { size: artifact.size } : {}
406
+ } } : {},
407
+ ...status === "failed" && state.error ? { error: state.error } : {}
408
+ };
409
+ await history.append(entry);
410
+ }
411
+ const MAX_IMAGE_BYTES = 4194304;
412
+ //#endregion
413
+ //#region src/routes.ts
414
+ function sendJson(res, status, body) {
415
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
416
+ res.end(JSON.stringify(body));
417
+ }
418
+ function readBody(req) {
419
+ return new Promise((resolve, reject) => {
420
+ const chunks = [];
421
+ req.on("data", (c) => chunks.push(c));
422
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
423
+ req.on("error", reject);
424
+ });
425
+ }
426
+ function pathnameOf(url) {
427
+ const u = url ?? "";
428
+ const q = u.indexOf("?");
429
+ return (q >= 0 ? u.slice(0, q) : u).replace(/\/+$/, "");
430
+ }
431
+ function jsonBodyOf(text) {
432
+ return JSON.parse(text || "{}");
433
+ }
434
+ /** Write one SSE frame and flush. */
435
+ function sse(res, event, payload) {
436
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
437
+ }
438
+ function isFinal(status) {
439
+ return status === "needs_confirmation" || status === "completed" || status === "failed" || status === "cancelled";
440
+ }
441
+ /** Map a current job state to its replay SSE event (or null when queued/running). */
442
+ function replayEventOf(state) {
443
+ if (state.status === "needs_confirmation") return {
444
+ type: "needs_confirmation",
445
+ dimensions: state.dimensions ?? {},
446
+ pkgType: state.pkgType ?? null,
447
+ fileName: state.fileName ?? null,
448
+ at: state.updatedAt
449
+ };
450
+ if (state.status === "completed") return {
451
+ type: "completed",
452
+ job: state,
453
+ at: state.updatedAt
454
+ };
455
+ if (state.status === "failed") return {
456
+ type: "failed",
457
+ error: state.error ?? "generation failed",
458
+ result: state.result,
459
+ at: state.updatedAt
460
+ };
461
+ if (state.status === "cancelled") return {
462
+ type: "cancelled",
463
+ at: state.updatedAt
464
+ };
465
+ return null;
466
+ }
467
+ function createComponentGenHandler(deps) {
468
+ const store = new JobStore();
469
+ return async (req, res) => {
470
+ const raw = pathnameOf(req.url);
471
+ if (!raw.startsWith("/api/v1/huaqiu/component-gen")) {
472
+ sendJson(res, 404, { error: "not found" });
473
+ return;
474
+ }
475
+ const path = raw.slice(28) || "/";
476
+ const method = req.method ?? "GET";
477
+ try {
478
+ if (method === "GET" && path === "/config") {
479
+ sendJson(res, 200, {
480
+ hostMode: deps.hostMode === true,
481
+ capabilities: {
482
+ symbol: true,
483
+ footprint: true
484
+ },
485
+ limits: { imageBytes: MAX_IMAGE_BYTES }
486
+ });
487
+ return;
488
+ }
489
+ if (method === "POST" && path === "/jobs") {
490
+ const body = jsonBodyOf(await readBody(req));
491
+ if (!body || body.kind !== "symbol" && body.kind !== "extract-footprint" && body.kind !== "generate-footprint") {
492
+ sendJson(res, 400, { error: "invalid job kind (expected symbol | extract-footprint | generate-footprint)" });
493
+ return;
494
+ }
495
+ const input = body.input ?? {};
496
+ if (input.imageDataUrl && input.imageDataUrl.length > 4194304) {
497
+ sendJson(res, 413, {
498
+ error: "image too large",
499
+ detail: `max ${MAX_IMAGE_BYTES} bytes`
500
+ });
501
+ return;
502
+ }
503
+ const meta = {};
504
+ if (input.imageDataUrl) {
505
+ const imageId = newImageId();
506
+ await deps.history.saveImage(imageId, input.imageDataUrl);
507
+ meta.imageId = imageId;
508
+ }
509
+ const state = store.create({
510
+ kind: body.kind,
511
+ input
512
+ }, meta);
513
+ runGeneration(store, deps.backend, deps.history, state.id, {
514
+ kind: body.kind,
515
+ input
516
+ }, meta).catch((err) => {
517
+ console.warn("[component-gen] background run failed", String(err?.message || err));
518
+ });
519
+ res.writeHead(202, { "content-type": "application/json; charset=utf-8" });
520
+ res.end(JSON.stringify({ jobId: state.id }));
521
+ return;
522
+ }
523
+ const jobGet = /^\/jobs\/([^/]+)$/.exec(path);
524
+ if (method === "GET" && jobGet) {
525
+ const state = store.get(jobGet[1]);
526
+ if (!state) {
527
+ sendJson(res, 404, { error: "job not found" });
528
+ return;
529
+ }
530
+ sendJson(res, 200, state);
531
+ return;
532
+ }
533
+ const jobEvents = /^\/jobs\/([^/]+)\/events$/.exec(path);
534
+ if (method === "GET" && jobEvents) {
535
+ const id = jobEvents[1];
536
+ const state = store.get(id);
537
+ if (!state) {
538
+ sendJson(res, 404, { error: "job not found" });
539
+ return;
540
+ }
541
+ res.writeHead(200, {
542
+ "content-type": "text/event-stream; charset=utf-8",
543
+ "cache-control": "no-cache",
544
+ connection: "keep-alive"
545
+ });
546
+ res.write(": ok\n\n");
547
+ const replay = replayEventOf(state);
548
+ if (replay) sse(res, replay.type, replay);
549
+ if (isFinal(state.status)) {
550
+ res.end();
551
+ return;
552
+ }
553
+ const unsub = store.subscribe(id, (e) => {
554
+ sse(res, e.type, e);
555
+ if (e.type === "needs_confirmation" || e.type === "completed" || e.type === "failed" || e.type === "cancelled") {
556
+ unsub?.();
557
+ res.end();
558
+ }
559
+ });
560
+ req.on("close", () => unsub?.());
561
+ return;
562
+ }
563
+ const jobDel = /^\/jobs\/([^/]+)$/.exec(path);
564
+ if (method === "DELETE" && jobDel) {
565
+ const ok = store.abort(jobDel[1]);
566
+ sendJson(res, ok ? 200 : 404, { ok });
567
+ return;
568
+ }
569
+ if (method === "GET" && path === "/history") {
570
+ const url = new URL(req.url ?? "/", "http://localhost");
571
+ const query = {
572
+ limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : void 0,
573
+ cursor: url.searchParams.get("cursor")
574
+ };
575
+ sendJson(res, 200, await deps.history.list(query));
576
+ return;
577
+ }
578
+ const histImage = /^\/history\/([^/]+)\/image$/.exec(path);
579
+ if (method === "GET" && histImage) {
580
+ const img = await deps.history.readImage(histImage[1]);
581
+ if (!img) {
582
+ sendJson(res, 404, { error: "image not found" });
583
+ return;
584
+ }
585
+ res.writeHead(200, {
586
+ "content-type": img.mime,
587
+ "cache-control": "public, max-age=3600"
588
+ });
589
+ res.end(Buffer.from(img.bytes));
590
+ return;
591
+ }
592
+ const histGet = /^\/history\/([^/]+)$/.exec(path);
593
+ if (method === "GET" && histGet) {
594
+ const entry = await deps.history.get(histGet[1]);
595
+ if (!entry) {
596
+ sendJson(res, 404, { error: "history not found" });
597
+ return;
598
+ }
599
+ sendJson(res, 200, entry);
600
+ return;
601
+ }
602
+ const histPatch = /^\/history\/([^/]+)$/.exec(path);
603
+ if (method === "PATCH" && histPatch) {
604
+ const patch = jsonBodyOf(await readBody(req));
605
+ const entry = await deps.history.patch(histPatch[1], patch);
606
+ if (!entry) {
607
+ sendJson(res, 404, { error: "history not found" });
608
+ return;
609
+ }
610
+ sendJson(res, 200, entry);
611
+ return;
612
+ }
613
+ const histDel = /^\/history\/([^/]+)$/.exec(path);
614
+ if (method === "DELETE" && histDel) {
615
+ await deps.history.delete(histDel[1]);
616
+ sendJson(res, 200, { ok: true });
617
+ return;
618
+ }
619
+ sendJson(res, 404, { error: "not found" });
620
+ } catch (err) {
621
+ sendJson(res, 500, {
622
+ error: "internal error",
623
+ detail: String(err)
624
+ });
625
+ }
626
+ };
627
+ }
628
+ //#endregion
629
+ //#region src/standalone.ts
630
+ /**
631
+ * `@huaqiu/component-gen-server` — standalone server.
632
+ *
633
+ * A self-contained local server (no DSH host needed) that serves:
634
+ * - the `@huaqiu/component-gen-app` static bundle (dist),
635
+ * - the component-gen API (`/api/v1/huaqiu/component-gen/*`),
636
+ * - the `@huaqiu/dsh-auth` session routes (login bridge; the same
637
+ * `InMemoryHuaqiuAuthService` the DSH node half uses),
638
+ * - the `@huaqiu/dsh-artifacts` routes (preview artifact content).
639
+ *
640
+ * The generation backend is the plugin's own `createComponentGenBackend` —
641
+ * same `runGenerate*` functions, no reimplementation. Auth is injected through
642
+ * the dsh-auth public service; the backend never implements the login flow.
643
+ *
644
+ * Usage:
645
+ * hq-component-gen [--port 8787]
646
+ */
647
+ const require = createRequire(import.meta.url);
648
+ const MIME = {
649
+ ".html": "text/html; charset=utf-8",
650
+ ".js": "text/javascript; charset=utf-8",
651
+ ".mjs": "text/javascript; charset=utf-8",
652
+ ".css": "text/css; charset=utf-8",
653
+ ".json": "application/json; charset=utf-8",
654
+ ".svg": "image/svg+xml",
655
+ ".png": "image/png",
656
+ ".jpg": "image/jpeg",
657
+ ".jpeg": "image/jpeg",
658
+ ".ico": "image/x-icon",
659
+ ".woff2": "font/woff2",
660
+ ".map": "application/json; charset=utf-8"
661
+ };
662
+ function resolveAppDist(override) {
663
+ if (override) return resolve(override);
664
+ try {
665
+ const pkgPath = require.resolve("@huaqiu/component-gen-app/package.json");
666
+ return join(pkgPath.replace(/package\.json$/, ""), "dist");
667
+ } catch {
668
+ return resolve(new URL("../../component-gen-app/dist", import.meta.url).pathname);
669
+ }
670
+ }
671
+ /** Static file responder with traversal protection. */
672
+ function serveStatic(root, urlPath, res) {
673
+ const decoded = decodeURIComponent(urlPath.split("?")[0] ?? "/");
674
+ let rel = decoded === "/" ? "/index.html" : decoded;
675
+ if (rel.startsWith("/")) rel = rel.slice(1);
676
+ const target = resolve(root, rel);
677
+ if (!target.startsWith(resolve(root)) || !target.startsWith(root)) {
678
+ res.writeHead(403);
679
+ res.end("forbidden");
680
+ return;
681
+ }
682
+ if (!existsSync(target) || !statSync(target).isFile()) {
683
+ const idx = join(root, "index.html");
684
+ if (existsSync(idx)) {
685
+ res.writeHead(200, { "content-type": MIME[".html"] });
686
+ res.end(readFileSync(idx));
687
+ return;
688
+ }
689
+ res.writeHead(404);
690
+ res.end("not found");
691
+ return;
692
+ }
693
+ res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
694
+ res.end(readFileSync(target));
695
+ }
696
+ async function createStandaloneServer(options = {}) {
697
+ const port = options.port ?? 8787;
698
+ const host = options.host ?? "127.0.0.1";
699
+ const appDist = resolveAppDist(options.appDist);
700
+ const history = new HistoryStore(options.historyDir ?? dshHomePath("component-gen"));
701
+ const auth = new InMemoryHuaqiuAuthService(options.authConfig);
702
+ const artifacts = new HuaqiuArtifactService({ baseDir: options.artifactsDir ?? dshHomePath("artifacts") });
703
+ const env = {
704
+ auth: auth.auth,
705
+ artifacts,
706
+ hitlLanguage: options.hitlLanguage ?? "zh",
707
+ deps: { processEnv: typeof process !== "undefined" ? process.env : void 0 },
708
+ getUserQuestions: () => void 0
709
+ };
710
+ const componentGen = createComponentGenHandler({
711
+ backend: createComponentGenBackend(env),
712
+ history,
713
+ hostMode: auth.hostMode
714
+ });
715
+ const server = createServer(async (req, res) => {
716
+ const urlPath = req.url ?? "/";
717
+ if (urlPath.startsWith(AUTH_ROUTE_PREFIX)) {
718
+ await createAuthHandler(auth)(req, res);
719
+ return;
720
+ }
721
+ if (urlPath.startsWith(ARTIFACTS_ROUTE_PREFIX)) {
722
+ await createArtifactsHandler(artifacts)(req, res);
723
+ return;
724
+ }
725
+ if (urlPath.startsWith("/api/v1/huaqiu/component-gen")) {
726
+ await componentGen(req, res);
727
+ return;
728
+ }
729
+ serveStatic(appDist, urlPath, res);
730
+ });
731
+ await new Promise((resolveListen) => {
732
+ server.listen(port, host, resolveListen);
733
+ });
734
+ return {
735
+ server,
736
+ port,
737
+ auth,
738
+ history,
739
+ close: async () => new Promise((resolveClose) => server.close(() => resolveClose()))
740
+ };
741
+ }
742
+ /** CLI entry (`hq-component-gen`). */
743
+ async function main() {
744
+ const args = process.argv.slice(2);
745
+ let port = 8787;
746
+ for (let i = 0; i < args.length; i++) if (args[i] === "--port" && args[i + 1]) port = Number(args[i + 1]);
747
+ const app = await createStandaloneServer({ port });
748
+ const urls = [`http://localhost:${app.port}/?page=footprint`, `http://localhost:${app.port}/?page=symbol`];
749
+ console.log(`[hq-component-gen] standalone server on ${urls[0]}`);
750
+ console.log(`[hq-component-gen] symbol: ${urls[1]}`);
751
+ }
752
+ if (process.argv[1] && /standalone/.test(process.argv[1])) main();
753
+ //#endregion
754
+ export { createStandaloneServer };