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