@dickpy/dsh-imagegen 1.0.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.
package/src/routes.ts ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * The /api/dsh-imagegen route family: a loopback-only settings bridge for the
3
+ * plugin's own namespace (describe/mutate, mirroring the dsh-web-ui family
4
+ * bridge wire) and the generate proxy that forwards to the configured
5
+ * OpenAI-compatible endpoint with the API key held host-side.
6
+ */
7
+
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
10
+ import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
11
+ import { generateImage, type UpstreamConfig } from './engine.ts'
12
+ import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
13
+ import { GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, type GeneratedImage, type GenerateRequest, type HistoryEntryInput } from './protocol.ts'
14
+
15
+ /** Cap on JSON request bodies (settings ops and generate payloads are small). */
16
+ const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
17
+
18
+ /** Cap on history append bodies (base64 result images can be much larger). */
19
+ const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024
20
+
21
+ /** Settings seam face the bridge needs (the host settings provider). */
22
+ export interface SettingsSeam {
23
+ describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
24
+ mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
25
+ readonly writable?: boolean
26
+ }
27
+
28
+ /** Route dependencies. */
29
+ export interface ImageGenRoutesDeps {
30
+ /** The settings seam (namespace storage). */
31
+ settings: SettingsSeam
32
+ /** Resolve the current upstream config (composition entry + settings). */
33
+ resolve: () => UpstreamConfig
34
+ }
35
+
36
+ /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
37
+ function isLoopbackRequest(request: IncomingMessage): boolean {
38
+ const address = request.socket.remoteAddress
39
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
40
+ const host = request.headers.host
41
+ if (typeof host !== 'string') return false
42
+ let hostUrl: URL
43
+ try {
44
+ hostUrl = new URL(`http://${host}`)
45
+ } catch {
46
+ return false
47
+ }
48
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
49
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
50
+ const origin = request.headers.origin
51
+ if (origin === undefined) return true
52
+ try {
53
+ return new URL(origin).host === hostUrl.host
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /** One JSON response. */
60
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
61
+ const payload = JSON.stringify(body)
62
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
63
+ res.end(payload)
64
+ }
65
+
66
+ /** Read a JSON request body (undefined when too large or unparseable). */
67
+ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
68
+ const chunks: Buffer[] = []
69
+ let size = 0
70
+ for await (const chunk of req) {
71
+ const buffer = chunk as Buffer
72
+ size += buffer.length
73
+ if (size > maxBytes) return undefined
74
+ chunks.push(buffer)
75
+ }
76
+ try {
77
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
78
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ /** Human-readable text from an unknown thrown value. */
85
+ function messageOf(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error)
87
+ }
88
+
89
+ /** Validate a submitted history entry (images carry base64). */
90
+ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
91
+ const raw = body.entry
92
+ if (raw === null || typeof raw !== 'object') return undefined
93
+ const entry = raw as Record<string, unknown>
94
+ if (typeof entry.id !== 'string' || typeof entry.createdAt !== 'number') return undefined
95
+ if (entry.mode !== 'text' && entry.mode !== 'edit') return undefined
96
+ if (typeof entry.model !== 'string' || typeof entry.prompt !== 'string') return undefined
97
+ if (typeof entry.size !== 'string' || typeof entry.quality !== 'string' || typeof entry.detail !== 'string') return undefined
98
+ if (typeof entry.n !== 'number') return undefined
99
+ if (!Array.isArray(entry.images)) return undefined
100
+ const images: GeneratedImage[] = []
101
+ for (const item of entry.images) {
102
+ if (item === null || typeof item !== 'object') return undefined
103
+ const image = item as Record<string, unknown>
104
+ if (typeof image.b64 !== 'string' || typeof image.mime !== 'string') return undefined
105
+ images.push({
106
+ b64: image.b64,
107
+ mime: image.mime,
108
+ ...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
109
+ })
110
+ }
111
+ return {
112
+ id: entry.id,
113
+ createdAt: entry.createdAt,
114
+ mode: entry.mode,
115
+ model: entry.model,
116
+ prompt: entry.prompt,
117
+ size: entry.size,
118
+ quality: entry.quality,
119
+ detail: entry.detail,
120
+ n: entry.n,
121
+ images,
122
+ ...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
123
+ }
124
+ }
125
+
126
+ /** Extract the image file name from a history-image request URL. */
127
+ function imageFileFrom(rawUrl: string | undefined, basePath: string): string | undefined {
128
+ if (rawUrl === undefined) return undefined
129
+ let pathname: string
130
+ try {
131
+ pathname = new URL(rawUrl, 'http://localhost').pathname
132
+ } catch {
133
+ return undefined
134
+ }
135
+ if (!pathname.startsWith(`${basePath}/`)) return undefined
136
+ return decodeURIComponent(pathname.slice(basePath.length + 1))
137
+ }
138
+
139
+ /** Project one settings descriptor onto the bridge wire view. */
140
+ function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
141
+ return {
142
+ ns: String(descriptor.ns),
143
+ schema: descriptor.schema,
144
+ value: descriptor.value,
145
+ ...descriptor.base === undefined ? {} : { base: descriptor.base },
146
+ ...descriptor.user === undefined ? {} : { user: descriptor.user },
147
+ ...descriptor.secrets === undefined ? {} : {
148
+ secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
149
+ },
150
+ revision: descriptor.revision,
151
+ }
152
+ }
153
+
154
+ /** Map a seam failure onto the bridge refusal envelope. */
155
+ function failureOf(error: unknown): { ok: false; code: string; message: string } {
156
+ if (error instanceof SettingsConflictError) {
157
+ return { ok: false, code: 'settings-conflict', message: error.message }
158
+ }
159
+ const message = error instanceof Error ? error.message : String(error)
160
+ return { ok: false, code: 'settings-rejected', message }
161
+ }
162
+
163
+ /**
164
+ * Build every /api/dsh-imagegen route.
165
+ * @param deps - settings seam + config resolver.
166
+ * @returns the route registrations.
167
+ */
168
+ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
169
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
170
+ if (!isLoopbackRequest(req)) {
171
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
172
+ return false
173
+ }
174
+ if (req.method !== method) {
175
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
176
+ return false
177
+ }
178
+ return true
179
+ }
180
+
181
+ return [
182
+ // -------------------------------------------------- settings describe
183
+ {
184
+ kind: 'exact',
185
+ path: SETTINGS_API.describe,
186
+ handler: async (req, res) => {
187
+ if (!guard(req, res, 'POST')) return
188
+ const descriptor = deps.settings.describe({ redactSecrets: true })
189
+ .find(candidate => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE)
190
+ writeJson(res, 200, {
191
+ ok: true,
192
+ value: {
193
+ namespaces: descriptor === undefined ? [] : [toView(descriptor)],
194
+ writable: deps.settings.writable !== false,
195
+ },
196
+ })
197
+ },
198
+ },
199
+ // ----------------------------------------------------- settings mutate
200
+ {
201
+ kind: 'exact',
202
+ path: SETTINGS_API.mutate,
203
+ handler: async (req, res) => {
204
+ if (!guard(req, res, 'POST')) return
205
+ const body = await readJsonBody(req)
206
+ if (body === undefined) {
207
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
208
+ return
209
+ }
210
+ const ns = typeof body.ns === 'string' ? body.ns : ''
211
+ if (ns !== IMAGEGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
212
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
213
+ return
214
+ }
215
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
216
+ try {
217
+ await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
218
+ } catch (error) {
219
+ writeJson(res, 200, failureOf(error))
220
+ return
221
+ }
222
+ const descriptor = deps.settings.describe({ redactSecrets: true })
223
+ .find(candidate => String(candidate.ns) === ns)
224
+ if (descriptor === undefined) {
225
+ writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
226
+ return
227
+ }
228
+ writeJson(res, 200, { ok: true, value: toView(descriptor) })
229
+ },
230
+ },
231
+ // ----------------------------------------------------------- generate
232
+ {
233
+ kind: 'exact',
234
+ path: GENERATE_API,
235
+ handler: async (req, res) => {
236
+ if (!guard(req, res, 'POST')) return
237
+ const body = await readJsonBody(req)
238
+ if (body === undefined) {
239
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
240
+ return
241
+ }
242
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
243
+ if (prompt === '') {
244
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
245
+ return
246
+ }
247
+ if (prompt.length > 2000) {
248
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt exceeds 2000 characters' })
249
+ return
250
+ }
251
+ const request: GenerateRequest = {
252
+ mode: body.mode === 'edit' ? 'edit' : 'text',
253
+ model: typeof body.model === 'string' ? body.model : 'gpt-image-2',
254
+ prompt,
255
+ size: typeof body.size === 'string' ? body.size : 'auto',
256
+ quality: typeof body.quality === 'string' ? body.quality : 'auto',
257
+ n: typeof body.n === 'number' ? body.n : 1,
258
+ detail: typeof body.detail === 'string' ? body.detail : '',
259
+ ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
260
+ }
261
+ try {
262
+ const result = await generateImage(deps.resolve(), request)
263
+ writeJson(res, 200, { ok: true, ...result })
264
+ } catch (error) {
265
+ const message = error instanceof Error ? error.message : String(error)
266
+ const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
267
+ ? (error as { code: string }).code
268
+ : 'generate-failed'
269
+ writeJson(res, 200, { ok: false, code, message })
270
+ }
271
+ },
272
+ },
273
+ // ----------------------------------------------------- history list
274
+ {
275
+ kind: 'exact',
276
+ path: HISTORY_API.list,
277
+ handler: async (req, res) => {
278
+ if (!guard(req, res, 'POST')) return
279
+ try {
280
+ writeJson(res, 200, { ok: true, entries: await listHistory() })
281
+ } catch (error) {
282
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
283
+ }
284
+ },
285
+ },
286
+ // --------------------------------------------------- history append
287
+ {
288
+ kind: 'exact',
289
+ path: HISTORY_API.append,
290
+ handler: async (req, res) => {
291
+ if (!guard(req, res, 'POST')) return
292
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
293
+ if (body === undefined) {
294
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
295
+ return
296
+ }
297
+ const entry = parseHistoryEntryInput(body)
298
+ if (entry === undefined) {
299
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed history entry' })
300
+ return
301
+ }
302
+ try {
303
+ writeJson(res, 200, { ok: true, entries: await appendHistory(entry) })
304
+ } catch (error) {
305
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
306
+ }
307
+ },
308
+ },
309
+ // --------------------------------------------------- history remove
310
+ {
311
+ kind: 'exact',
312
+ path: HISTORY_API.remove,
313
+ handler: async (req, res) => {
314
+ if (!guard(req, res, 'POST')) return
315
+ const body = await readJsonBody(req)
316
+ const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
317
+ if (id === '') {
318
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'history id is required' })
319
+ return
320
+ }
321
+ try {
322
+ writeJson(res, 200, { ok: true, entries: await removeHistory(id) })
323
+ } catch (error) {
324
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
325
+ }
326
+ },
327
+ },
328
+ // ---------------------------------------------------- history clear
329
+ {
330
+ kind: 'exact',
331
+ path: HISTORY_API.clear,
332
+ handler: async (req, res) => {
333
+ if (!guard(req, res, 'POST')) return
334
+ try {
335
+ writeJson(res, 200, { ok: true, entries: await clearHistory() })
336
+ } catch (error) {
337
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
338
+ }
339
+ },
340
+ },
341
+ // ------------------------------------------------ history image (prefix)
342
+ {
343
+ kind: 'prefix',
344
+ path: HISTORY_API.image,
345
+ handler: async (req, res) => {
346
+ if (!isLoopbackRequest(req)) {
347
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
348
+ return
349
+ }
350
+ if (req.method !== 'GET') {
351
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
352
+ return
353
+ }
354
+ const file = imageFileFrom(req.url, HISTORY_API.image)
355
+ if (file === undefined) {
356
+ writeJson(res, 404, { error: 'not found' })
357
+ return
358
+ }
359
+ const found = await readHistoryImage(file)
360
+ if (found === undefined) {
361
+ writeJson(res, 404, { error: 'not found' })
362
+ return
363
+ }
364
+ res.writeHead(200, {
365
+ 'content-type': found.mime,
366
+ 'content-length': found.data.length,
367
+ 'cache-control': 'private, max-age=3600',
368
+ })
369
+ res.end(found.data)
370
+ },
371
+ },
372
+ ]
373
+ }