@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/src/routes.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — HTTP routes for the component-gen API.
3
+ *
4
+ * Mounted by the DSH plugin on `ctx.webServer` (kind 'prefix',
5
+ * `/api/v1/huaqiu/component-gen`) and by the standalone server. Route paths
6
+ * are parsed from `req.url` manually (DSH `WebRoute` supports only exact /
7
+ * prefix — no `:param`). CORS-free: everything is same-origin in both hosts.
8
+ */
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+ import type { ComponentGenBackend } from './backend.js'
11
+ import { HistoryStore, newImageId } from './history.js'
12
+ import { JobStore, runGeneration, type JobMeta } from './jobs.js'
13
+ import {
14
+ COMPONENT_GEN_ROUTE_PREFIX, MAX_IMAGE_BYTES,
15
+ type ComponentGenConfig, type HistoryPatch, type HistoryQuery, type JobEvent,
16
+ type JobState, type StartJobRequest,
17
+ } from './types.js'
18
+
19
+ export interface ComponentGenHandlerDeps {
20
+ backend: ComponentGenBackend
21
+ history: HistoryStore
22
+ hostMode?: boolean
23
+ getAccessToken?: () => Promise<string | null>
24
+ }
25
+
26
+ export type ComponentGenHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void> | void
27
+
28
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
29
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
30
+ res.end(JSON.stringify(body))
31
+ }
32
+
33
+ function readBody(req: IncomingMessage): Promise<string> {
34
+ return new Promise((resolve, reject) => {
35
+ const chunks: Buffer[] = []
36
+ req.on('data', (c: Buffer) => chunks.push(c))
37
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
38
+ req.on('error', reject)
39
+ })
40
+ }
41
+
42
+ function pathnameOf(url: string | undefined): string {
43
+ const u = url ?? ''
44
+ const q = u.indexOf('?')
45
+ return (q >= 0 ? u.slice(0, q) : u).replace(/\/+$/, '')
46
+ }
47
+
48
+ function jsonBodyOf<T>(text: string): T {
49
+ return JSON.parse(text || '{}') as T
50
+ }
51
+
52
+ /** Write one SSE frame and flush. */
53
+ function sse(res: ServerResponse, event: string, payload: unknown): void {
54
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`)
55
+ }
56
+
57
+ function isFinal(status: JobState['status']): boolean {
58
+ return status === 'needs_confirmation' || status === 'completed' || status === 'failed' || status === 'cancelled'
59
+ }
60
+
61
+ /** Map a current job state to its replay SSE event (or null when queued/running). */
62
+ function replayEventOf(state: JobState): JobEvent | null {
63
+ if (state.status === 'needs_confirmation') {
64
+ return { type: 'needs_confirmation', dimensions: state.dimensions ?? {}, pkgType: state.pkgType ?? null, fileName: state.fileName ?? null, at: state.updatedAt }
65
+ }
66
+ if (state.status === 'completed') return { type: 'completed', job: state, at: state.updatedAt }
67
+ if (state.status === 'failed') return { type: 'failed', error: state.error ?? 'generation failed', result: state.result, at: state.updatedAt }
68
+ if (state.status === 'cancelled') return { type: 'cancelled', at: state.updatedAt }
69
+ return null
70
+ }
71
+
72
+ export function createComponentGenHandler(deps: ComponentGenHandlerDeps): ComponentGenHandler {
73
+ const store = new JobStore()
74
+
75
+ return async (req, res) => {
76
+ const raw = pathnameOf(req.url)
77
+ if (!raw.startsWith(COMPONENT_GEN_ROUTE_PREFIX)) {
78
+ sendJson(res, 404, { error: 'not found' })
79
+ return
80
+ }
81
+ const path = raw.slice(COMPONENT_GEN_ROUTE_PREFIX.length) || '/'
82
+ const method = req.method ?? 'GET'
83
+
84
+ try {
85
+ // GET /config
86
+ if (method === 'GET' && path === '/config') {
87
+ const cfg: ComponentGenConfig = {
88
+ hostMode: deps.hostMode === true,
89
+ capabilities: { symbol: true, footprint: true },
90
+ limits: { imageBytes: MAX_IMAGE_BYTES },
91
+ }
92
+ sendJson(res, 200, cfg)
93
+ return
94
+ }
95
+
96
+ // POST /jobs
97
+ if (method === 'POST' && path === '/jobs') {
98
+ const body = jsonBodyOf<StartJobRequest>(await readBody(req))
99
+ if (!body || (body.kind !== 'symbol' && body.kind !== 'extract-footprint' && body.kind !== 'generate-footprint')) {
100
+ sendJson(res, 400, { error: 'invalid job kind (expected symbol | extract-footprint | generate-footprint)' })
101
+ return
102
+ }
103
+ const input = body.input ?? {}
104
+ if (input.imageDataUrl && input.imageDataUrl.length > MAX_IMAGE_BYTES) {
105
+ sendJson(res, 413, { error: 'image too large', detail: `max ${MAX_IMAGE_BYTES} bytes` })
106
+ return
107
+ }
108
+ const meta: JobMeta = {}
109
+ if (input.imageDataUrl) {
110
+ const imageId = newImageId()
111
+ await deps.history.saveImage(imageId, input.imageDataUrl)
112
+ meta.imageId = imageId
113
+ }
114
+ const state = store.create({ kind: body.kind, input }, meta)
115
+ void runGeneration(store, deps.backend, deps.history, state.id, { kind: body.kind, input }, meta).catch((err) => {
116
+ console.warn('[component-gen] background run failed', String((err as Error)?.message || err))
117
+ })
118
+ res.writeHead(202, { 'content-type': 'application/json; charset=utf-8' })
119
+ res.end(JSON.stringify({ jobId: state.id }))
120
+ return
121
+ }
122
+
123
+ // GET /jobs/:id
124
+ const jobGet = /^\/jobs\/([^/]+)$/.exec(path)
125
+ if (method === 'GET' && jobGet) {
126
+ const state = store.get(jobGet[1]!)
127
+ if (!state) { sendJson(res, 404, { error: 'job not found' }); return }
128
+ sendJson(res, 200, state)
129
+ return
130
+ }
131
+
132
+ // GET /jobs/:id/events (SSE)
133
+ const jobEvents = /^\/jobs\/([^/]+)\/events$/.exec(path)
134
+ if (method === 'GET' && jobEvents) {
135
+ const id = jobEvents[1]!
136
+ const state = store.get(id)
137
+ if (!state) { sendJson(res, 404, { error: 'job not found' }); return }
138
+ res.writeHead(200, {
139
+ 'content-type': 'text/event-stream; charset=utf-8',
140
+ 'cache-control': 'no-cache',
141
+ connection: 'keep-alive',
142
+ })
143
+ res.write(': ok\n\n')
144
+
145
+ // Replay current state first (covers jobs that finished pre-subscribe).
146
+ const replay = replayEventOf(state)
147
+ if (replay) sse(res, replay.type, replay)
148
+ if (isFinal(state.status)) {
149
+ res.end()
150
+ return
151
+ }
152
+
153
+ const unsub = store.subscribe(id, (e) => {
154
+ sse(res, e.type, e)
155
+ if (e.type === 'needs_confirmation' || e.type === 'completed' || e.type === 'failed' || e.type === 'cancelled') {
156
+ unsub?.()
157
+ res.end()
158
+ }
159
+ })
160
+ req.on('close', () => unsub?.())
161
+ return
162
+ }
163
+
164
+ // DELETE /jobs/:id
165
+ const jobDel = /^\/jobs\/([^/]+)$/.exec(path)
166
+ if (method === 'DELETE' && jobDel) {
167
+ const ok = store.abort(jobDel[1]!)
168
+ sendJson(res, ok ? 200 : 404, { ok })
169
+ return
170
+ }
171
+
172
+ // GET /history
173
+ if (method === 'GET' && path === '/history') {
174
+ const url = new URL(req.url ?? '/', 'http://localhost')
175
+ const query: HistoryQuery = {
176
+ limit: url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : undefined,
177
+ cursor: url.searchParams.get('cursor'),
178
+ }
179
+ sendJson(res, 200, await deps.history.list(query))
180
+ return
181
+ }
182
+
183
+ // GET /history/:imageId/image
184
+ // `:imageId` is the stored thumbnail id (`img_…`), not a history entry
185
+ // id — the client's `ports.inputImage` sends it directly.
186
+ const histImage = /^\/history\/([^/]+)\/image$/.exec(path)
187
+ if (method === 'GET' && histImage) {
188
+ const img = await deps.history.readImage(histImage[1]!)
189
+ if (!img) { sendJson(res, 404, { error: 'image not found' }); return }
190
+ res.writeHead(200, { 'content-type': img.mime, 'cache-control': 'public, max-age=3600' })
191
+ res.end(Buffer.from(img.bytes))
192
+ return
193
+ }
194
+
195
+ // GET /history/:id
196
+ const histGet = /^\/history\/([^/]+)$/.exec(path)
197
+ if (method === 'GET' && histGet) {
198
+ const entry = await deps.history.get(histGet[1]!)
199
+ if (!entry) { sendJson(res, 404, { error: 'history not found' }); return }
200
+ sendJson(res, 200, entry)
201
+ return
202
+ }
203
+
204
+ // PATCH /history/:id
205
+ const histPatch = /^\/history\/([^/]+)$/.exec(path)
206
+ if (method === 'PATCH' && histPatch) {
207
+ const patch = jsonBodyOf<HistoryPatch>(await readBody(req))
208
+ const entry = await deps.history.patch(histPatch[1]!, patch)
209
+ if (!entry) { sendJson(res, 404, { error: 'history not found' }); return }
210
+ sendJson(res, 200, entry)
211
+ return
212
+ }
213
+
214
+ // DELETE /history/:id
215
+ const histDel = /^\/history\/([^/]+)$/.exec(path)
216
+ if (method === 'DELETE' && histDel) {
217
+ await deps.history.delete(histDel[1]!)
218
+ sendJson(res, 200, { ok: true })
219
+ return
220
+ }
221
+
222
+ sendJson(res, 404, { error: 'not found' })
223
+ } catch (err) {
224
+ sendJson(res, 500, { error: 'internal error', detail: String(err) })
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — standalone server.
3
+ *
4
+ * A self-contained local server (no DSH host needed) that serves:
5
+ * - the `@huaqiu/component-gen-app` static bundle (dist),
6
+ * - the component-gen API (`/api/v1/huaqiu/component-gen/*`),
7
+ * - the `@huaqiu/dsh-auth` session routes (login bridge; the same
8
+ * `InMemoryHuaqiuAuthService` the DSH node half uses),
9
+ * - the `@huaqiu/dsh-artifacts` routes (preview artifact content).
10
+ *
11
+ * The generation backend is the plugin's own `createComponentGenBackend` —
12
+ * same `runGenerate*` functions, no reimplementation. Auth is injected through
13
+ * the dsh-auth public service; the backend never implements the login flow.
14
+ *
15
+ * Usage:
16
+ * hq-component-gen [--port 8787]
17
+ */
18
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
19
+ import { createRequire } from 'node:module'
20
+ import { existsSync, readFileSync, statSync } from 'node:fs'
21
+ import { join, extname, resolve } from 'node:path'
22
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
23
+ import { InMemoryHuaqiuAuthService, createAuthHandler, AUTH_ROUTE_PREFIX, type HuaqiuAuthService } from '@huaqiu/dsh-auth'
24
+ import { HuaqiuArtifactService, createArtifactsHandler, ARTIFACTS_ROUTE_PREFIX } from '@huaqiu/dsh-artifacts'
25
+ import { runGenerateSymbol, runGenerateFootprintFromImage, runGenerateFootprintFromDimensions, createComponentGenBackend, type SymbolFootprintEnv } from '@huaqiu/dsh-tool-symbol-footprint'
26
+ import { createComponentGenHandler } from './index.js'
27
+ import { HistoryStore } from './history.js'
28
+ import { COMPONENT_GEN_ROUTE_PREFIX } from './types.js'
29
+
30
+ const require = createRequire(import.meta.url)
31
+
32
+ export interface StandaloneServerOptions {
33
+ port?: number
34
+ host?: string
35
+ /** component-gen-app dist dir (default: resolved from the package). */
36
+ appDist?: string
37
+ /** history dir (default `~/.dsh/component-gen/`). */
38
+ historyDir?: string
39
+ /** artifacts dir (default `~/.dsh/artifacts/`). */
40
+ artifactsDir?: string
41
+ authConfig?: Record<string, unknown> | null
42
+ hitlLanguage?: 'zh' | 'en'
43
+ }
44
+
45
+ export interface StandaloneServer {
46
+ server: ReturnType<typeof createServer>
47
+ port: number
48
+ auth: HuaqiuAuthService
49
+ history: HistoryStore
50
+ close(): Promise<void>
51
+ }
52
+
53
+ const MIME: Record<string, string> = {
54
+ '.html': 'text/html; charset=utf-8',
55
+ '.js': 'text/javascript; charset=utf-8',
56
+ '.mjs': 'text/javascript; charset=utf-8',
57
+ '.css': 'text/css; charset=utf-8',
58
+ '.json': 'application/json; charset=utf-8',
59
+ '.svg': 'image/svg+xml',
60
+ '.png': 'image/png',
61
+ '.jpg': 'image/jpeg',
62
+ '.jpeg': 'image/jpeg',
63
+ '.ico': 'image/x-icon',
64
+ '.woff2': 'font/woff2',
65
+ '.map': 'application/json; charset=utf-8',
66
+ }
67
+
68
+ function resolveAppDist(override?: string): string {
69
+ if (override) return resolve(override)
70
+ try {
71
+ const pkgPath = require.resolve('@huaqiu/component-gen-app/package.json')
72
+ return join(pkgPath.replace(/package\.json$/, ''), 'dist')
73
+ } catch {
74
+ // Monorepo fallback (before publish): lib/standalone.mjs → packages/component-gen-app/dist
75
+ const local = resolve(new URL('../../component-gen-app/dist', import.meta.url).pathname)
76
+ return local
77
+ }
78
+ }
79
+
80
+ /** Static file responder with traversal protection. */
81
+ function serveStatic(root: string, urlPath: string, res: ServerResponse): void {
82
+ const decoded = decodeURIComponent(urlPath.split('?')[0] ?? '/')
83
+ let rel = decoded === '/' ? '/index.html' : decoded
84
+ if (rel.startsWith('/')) rel = rel.slice(1)
85
+ const target = resolve(root, rel)
86
+ if (!target.startsWith(resolve(root)) || !target.startsWith(root)) {
87
+ res.writeHead(403); res.end('forbidden'); return
88
+ }
89
+ if (!existsSync(target) || !statSync(target).isFile()) {
90
+ // SPA fallback: serve index.html for unknown non-asset paths.
91
+ const idx = join(root, 'index.html')
92
+ if (existsSync(idx)) {
93
+ res.writeHead(200, { 'content-type': MIME['.html'] })
94
+ res.end(readFileSync(idx))
95
+ return
96
+ }
97
+ res.writeHead(404); res.end('not found'); return
98
+ }
99
+ res.writeHead(200, { 'content-type': MIME[extname(target).toLowerCase()] ?? 'application/octet-stream' })
100
+ res.end(readFileSync(target))
101
+ }
102
+
103
+ export async function createStandaloneServer(options: StandaloneServerOptions = {}): Promise<StandaloneServer> {
104
+ const port = options.port ?? 8787
105
+ const host = options.host ?? '127.0.0.1'
106
+ const appDist = resolveAppDist(options.appDist)
107
+ const history = new HistoryStore(options.historyDir ?? dshHomePath('component-gen'))
108
+
109
+ const auth = new InMemoryHuaqiuAuthService(options.authConfig as never)
110
+ const artifacts = new HuaqiuArtifactService({ baseDir: options.artifactsDir ?? dshHomePath('artifacts') })
111
+
112
+ const env: SymbolFootprintEnv = {
113
+ auth: auth.auth,
114
+ artifacts,
115
+ hitlLanguage: options.hitlLanguage ?? 'zh',
116
+ deps: { processEnv: typeof process !== 'undefined' ? process.env : undefined },
117
+ // The app is the driver: never pause on the native HIL popup.
118
+ getUserQuestions: () => undefined,
119
+ }
120
+ const backend = createComponentGenBackend(env)
121
+ const componentGen = createComponentGenHandler({
122
+ backend,
123
+ history,
124
+ hostMode: auth.hostMode,
125
+ })
126
+
127
+ const server = createServer(async (req, res) => {
128
+ const urlPath = req.url ?? '/'
129
+ if (urlPath.startsWith(AUTH_ROUTE_PREFIX)) {
130
+ await createAuthHandler(auth)(req, res)
131
+ return
132
+ }
133
+ if (urlPath.startsWith(ARTIFACTS_ROUTE_PREFIX)) {
134
+ await createArtifactsHandler(artifacts)(req, res)
135
+ return
136
+ }
137
+ if (urlPath.startsWith(COMPONENT_GEN_ROUTE_PREFIX)) {
138
+ await componentGen(req, res)
139
+ return
140
+ }
141
+ serveStatic(appDist, urlPath, res)
142
+ })
143
+
144
+ await new Promise<void>((resolveListen) => {
145
+ server.listen(port, host, resolveListen)
146
+ })
147
+
148
+ return {
149
+ server,
150
+ port,
151
+ auth,
152
+ history,
153
+ close: async () => new Promise((resolveClose) => server.close(() => resolveClose())),
154
+ }
155
+ }
156
+
157
+ /** CLI entry (`hq-component-gen`). */
158
+ async function main(): Promise<void> {
159
+ const args = process.argv.slice(2)
160
+ let port = 8787
161
+ for (let i = 0; i < args.length; i++) {
162
+ if (args[i] === '--port' && args[i + 1]) port = Number(args[i + 1])
163
+ }
164
+ const app = await createStandaloneServer({ port })
165
+ const urls = [`http://localhost:${app.port}/?page=footprint`, `http://localhost:${app.port}/?page=symbol`]
166
+ console.log(`[hq-component-gen] standalone server on ${urls[0]}`)
167
+ console.log(`[hq-component-gen] symbol: ${urls[1]}`)
168
+ }
169
+
170
+ if (process.argv[1] && /standalone/.test(process.argv[1])) {
171
+ void main()
172
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — shared domain types.
3
+ *
4
+ * These mirror `@huaqiu/component-gen-app/src/ports.ts` (structurally
5
+ * identical, JSON interop) but are owned by the server as the authority.
6
+ */
7
+
8
+ export type ComponentGenPage = 'symbol' | 'footprint'
9
+
10
+ export type JobKind = 'symbol' | 'extract-footprint' | 'generate-footprint'
11
+
12
+ export type JobStatus =
13
+ | 'queued'
14
+ | 'running'
15
+ | 'needs_confirmation'
16
+ | 'completed'
17
+ | 'failed'
18
+ | 'cancelled'
19
+
20
+ export interface JobInput {
21
+ imageDataUrl?: string
22
+ instruction?: string
23
+ packageType?: string
24
+ dimensions?: Record<string, number>
25
+ fileName?: string
26
+ edited?: Record<string, boolean>
27
+ }
28
+
29
+ export interface JobState {
30
+ id: string
31
+ kind: JobKind
32
+ status: JobStatus
33
+ progress?: string
34
+ result?: Record<string, unknown>
35
+ dimensions?: Record<string, unknown>
36
+ pkgType?: string | null
37
+ fileName?: string | null
38
+ error?: string
39
+ createdAt: string
40
+ updatedAt: string
41
+ }
42
+
43
+ export type JobEvent =
44
+ | { type: 'progress'; message: string; at: string }
45
+ | { type: 'needs_confirmation'; dimensions: Record<string, unknown>; pkgType?: string | null; fileName?: string | null; at: string }
46
+ | { type: 'completed'; job: JobState; at: string }
47
+ | { type: 'failed'; error: string; result?: Record<string, unknown>; at: string }
48
+ | { type: 'cancelled'; at: string }
49
+
50
+ export interface StartJobRequest {
51
+ kind: JobKind
52
+ input: JobInput
53
+ }
54
+
55
+ export interface HistoryQuery {
56
+ limit?: number
57
+ cursor?: string | null
58
+ }
59
+
60
+ export interface HistoryPage {
61
+ entries: HistoryEntry[]
62
+ nextCursor?: string | null
63
+ }
64
+
65
+ export interface HistoryEntry {
66
+ id: string
67
+ kind: 'symbol' | 'footprint'
68
+ createdAt: string
69
+ status: 'generated' | 'failed' | 'cancelled'
70
+ input: {
71
+ imageId?: string
72
+ instruction?: string
73
+ packageType?: string
74
+ dimensions?: Record<string, number>
75
+ }
76
+ edited?: Record<string, boolean>
77
+ result?: {
78
+ artifactId: string
79
+ filename: string
80
+ fileUrl?: string
81
+ size?: number
82
+ }
83
+ error?: string
84
+ }
85
+
86
+ export interface HistoryPatch {
87
+ status?: HistoryEntry['status']
88
+ result?: HistoryEntry['result']
89
+ error?: string
90
+ edited?: Record<string, boolean>
91
+ }
92
+
93
+ export interface ComponentGenConfig {
94
+ hostMode: boolean
95
+ capabilities: { symbol: boolean; footprint: boolean }
96
+ limits: { imageBytes: number }
97
+ }
98
+
99
+ export const COMPONENT_GEN_ROUTE_PREFIX = '/api/v1/huaqiu/component-gen'
100
+
101
+ export const MAX_IMAGE_BYTES = 4 * 1024 * 1024