@linxin666/dsh-pet 0.1.15 → 0.1.16

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.
Files changed (74) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +270 -334
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +641 -87
  9. package/lib/types/client/PetDockEntry.d.ts +14 -12
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +13 -8
  12. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +42 -7
  15. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  16. package/lib/types/client/PetSprite.d.ts.map +1 -0
  17. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  18. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PluginSettingsCard.js +3 -1
  20. package/lib/types/client/index.d.ts +11 -8
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +30 -12
  23. package/lib/types/client/locales.d.ts +10 -10
  24. package/lib/types/client/locales.js +10 -10
  25. package/lib/types/client/pet-store.d.ts +5 -0
  26. package/lib/types/client/pet-store.d.ts.map +1 -1
  27. package/lib/types/client/pet-store.js +4 -0
  28. package/lib/types/client/settings-form.d.ts +62 -0
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/settings-form.js +61 -12
  31. package/lib/types/client/spritesheet.d.ts +14 -48
  32. package/lib/types/client/spritesheet.d.ts.map +1 -1
  33. package/lib/types/client/spritesheet.js +13 -81
  34. package/lib/types/index.d.ts +24 -11
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/lib/types/index.js +53 -33
  37. package/lib/types/ledger.d.ts +4 -2
  38. package/lib/types/ledger.d.ts.map +1 -1
  39. package/lib/types/ledger.js +11 -4
  40. package/lib/types/persist.d.ts +10 -3
  41. package/lib/types/persist.d.ts.map +1 -1
  42. package/lib/types/persist.js +32 -5
  43. package/lib/types/registry.d.ts +158 -0
  44. package/lib/types/registry.d.ts.map +1 -0
  45. package/lib/types/registry.js +276 -0
  46. package/lib/types/routes.d.ts +10 -10
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +169 -51
  49. package/lib/types/service.d.ts +46 -13
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +73 -16
  52. package/package.json +2 -2
  53. package/src/client/PetDockEntry.tsx +34 -26
  54. package/src/client/PetSettingsCard.tsx +67 -22
  55. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  56. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  57. package/src/client/PluginSettingsCard.tsx +7 -1
  58. package/src/client/index.ts +36 -14
  59. package/src/client/locales.ts +10 -10
  60. package/src/client/pet-store.ts +9 -0
  61. package/src/client/settings-card.module.css +3 -0
  62. package/src/client/settings-form.ts +114 -12
  63. package/src/client/spritesheet.ts +16 -92
  64. package/src/index.test.ts +23 -0
  65. package/src/index.ts +89 -45
  66. package/src/ledger.test.ts +5 -3
  67. package/src/ledger.ts +11 -4
  68. package/src/persist.test.ts +52 -3
  69. package/src/persist.ts +42 -8
  70. package/src/registry.test.ts +132 -0
  71. package/src/registry.ts +400 -0
  72. package/src/routes.ts +166 -53
  73. package/src/service.ts +102 -20
  74. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Pet registry — the multi-pet contract. One pet is a directory holding a
3
+ * 'pet.json' manifest plus an atlas image; nothing else is required, and no
4
+ * host or client code changes when a pet is added. The registry scans three
5
+ * sources, later sources overriding earlier ones on an id collision:
6
+ *
7
+ * 1. the package's own 'assets' subdirectories (built-in pets);
8
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
9
+ * 3. 'PetConfig.pets' manifests composed by the embedding application
10
+ * (highest precedence).
11
+ *
12
+ * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
13
+ * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
14
+ * that only carry 'frames' keep working: geometry, per-row frame counts and
15
+ * per-track rhythm all fall back to the hatch-pet contract defaults, and the
16
+ * whale-girl manifest overrides its own durations.
17
+ * @module @linxin666/dsh-pet/registry
18
+ */
19
+
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import type { PetAnimation } from './state.ts'
25
+
26
+ /** Fixed row order of the 9-state animation contract. */
27
+ export const PET_ROW_ORDER: readonly PetAnimation[] = [
28
+ 'idle',
29
+ 'running-right',
30
+ 'running-left',
31
+ 'waving',
32
+ 'jumping',
33
+ 'failed',
34
+ 'waiting',
35
+ 'running',
36
+ 'review',
37
+ ]
38
+
39
+ /** Atlas cell size in px. */
40
+ export interface PetCell {
41
+ width: number
42
+ height: number
43
+ }
44
+
45
+ /** Atlas cell size in px (Codex/hatch-pet contract). */
46
+ export const DEFAULT_PET_CELL: PetCell = { width: 192, height: 208 }
47
+ /** Columns per row (max frames per track). */
48
+ export const DEFAULT_PET_COLUMNS = 8
49
+ /** Rows in the atlas (fixed by the animation contract). */
50
+ export const DEFAULT_PET_ROW_COUNT = 9
51
+
52
+ /**
53
+ * Per-row used-column counts from the hatch-pet contract table. Manifests
54
+ * that carry no 'frames' field (the Codex custom-pet shape) resolve here.
55
+ */
56
+ export const DEFAULT_FRAME_COUNTS: readonly number[] = [6, 8, 8, 4, 5, 8, 6, 6, 6]
57
+
58
+ /** Absolute package root, resolved from a module URL (lib/ or src/). */
59
+ export function petPackageRoot(importMetaUrl: string): string {
60
+ return fileURLToPath(new URL('../', importMetaUrl))
61
+ }
62
+
63
+ /** Resolve the hatch-pet custom pets directory (CODEX_HOME or ~/.codex). */
64
+ export function codexPetsDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
65
+ const raw = env.CODEX_HOME !== undefined && env.CODEX_HOME.trim() !== ''
66
+ ? env.CODEX_HOME.trim()
67
+ : join(home, '.codex')
68
+ const expanded = raw === '~'
69
+ ? home
70
+ : (raw.startsWith('~/') || raw.startsWith('~\\')) ? join(home, raw.slice(2)) : raw
71
+ return join(expanded, 'pets')
72
+ }
73
+
74
+ /** Finite non-negative integer guard, else the fallback. */
75
+ function finiteInt(value: unknown, fallback: number, max: number): number {
76
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= max
77
+ ? value
78
+ : fallback
79
+ }
80
+
81
+ /** Build the browser URL of one pet asset. */
82
+ function assetUrl(prefix: string, id: string, file: string): string {
83
+ const path = file.split('/').filter(segment => segment !== '').join('/')
84
+ return prefix + '/' + encodeURIComponent(id) + '/' + path
85
+ }
86
+
87
+ /** One animation track as served to the browser half. */
88
+ export interface PetTrackDef {
89
+ /** Frame indices (columns) played in order. */
90
+ frames: number[]
91
+ /** Per-frame duration in ms; same length as frames. */
92
+ durations: number[]
93
+ /** Whether the track loops; a non-looping track holds its last frame. */
94
+ loop: boolean
95
+ /** Track to switch to after a non-looping track finishes. */
96
+ fallback?: PetAnimation
97
+ }
98
+
99
+ /** Default per-track rhythm (hatch-pet contract table). */
100
+ export const DEFAULT_TRACK_PATTERNS: Record<PetAnimation, {
101
+ durations: number[]
102
+ loop: boolean
103
+ fallback?: PetAnimation
104
+ }> = {
105
+ idle: { durations: [280, 110, 110, 140, 140, 320], loop: true },
106
+ 'running-right': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
107
+ 'running-left': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
108
+ waving: { durations: [140, 140, 140, 280], loop: true },
109
+ jumping: { durations: [140, 140, 140, 140, 280], loop: false, fallback: 'idle' },
110
+ failed: { durations: [140, 140, 140, 140, 140, 140, 140, 240], loop: false, fallback: 'idle' },
111
+ waiting: { durations: [150, 150, 150, 150, 150, 260], loop: true },
112
+ running: { durations: [120, 120, 120, 120, 120, 220], loop: true },
113
+ review: { durations: [150, 150, 150, 150, 150, 280], loop: true },
114
+ }
115
+
116
+ /** Manifest shape a pet directory (or 'PetConfig.pets' entry) declares. */
117
+ export interface PetManifest {
118
+ /** Unique pet id, lowercase kebab-case. */
119
+ id: string
120
+ /** Human-readable display name (settings selector, panel header). */
121
+ displayName: string
122
+ /** One-line description. */
123
+ description?: string
124
+ /** Atlas path relative to the manifest's directory. */
125
+ spritesheetPath: string
126
+ /** Atlas cell size; defaults to the Codex contract 192x208. */
127
+ cell?: { width?: number; height?: number }
128
+ /** Columns per row; defaults to 8. */
129
+ columns?: number
130
+ /**
131
+ * Per-row frame counts (9 entries, row order above). Manifests that omit
132
+ * it resolve the hatch-pet contract table.
133
+ */
134
+ frames?: number[]
135
+ /** Optional per-track rhythm overrides; omitted tracks use the defaults. */
136
+ tracks?: Partial<Record<PetAnimation, PetTrackOverride>>
137
+ }
138
+
139
+ /** Per-track rhythm overrides a manifest may carry. */
140
+ export interface PetTrackOverride {
141
+ /** Per-frame durations in ms (cycled to the row's frame count). */
142
+ durations?: number[]
143
+ /** Whether the track loops. */
144
+ loop?: boolean
145
+ /** Track to switch to after a non-looping track finishes. */
146
+ fallback?: PetAnimation
147
+ }
148
+
149
+ /** A normalized pet as served to the browser half. */
150
+ export interface PetDefinition {
151
+ id: string
152
+ displayName: string
153
+ description: string
154
+ /** Atlas cell size in px. */
155
+ cell: PetCell
156
+ /** Columns per row. */
157
+ columns: number
158
+ /** Per-row frame counts (length 9, row order above). */
159
+ rows: number[]
160
+ /** Fully resolved animation tracks (frames + durations + loop/fallback). */
161
+ tracks: Record<PetAnimation, PetTrackDef>
162
+ /** Browser URL of the atlas (served by the host asset route). */
163
+ atlasUrl: string
164
+ /** Browser URL of the manifest (served by the host asset route). */
165
+ manifestUrl: string
166
+ }
167
+
168
+ /** A resolved pet plus its host-side file location. */
169
+ export interface PetEntry extends PetDefinition {
170
+ /** Absolute directory holding the manifest and atlas. */
171
+ dir: string
172
+ /** Atlas path relative to 'dir' (declared by the manifest). */
173
+ spritesheetPath: string
174
+ }
175
+
176
+ /** Registry load result: resolved entries plus load warnings. */
177
+ export interface PetRegistry {
178
+ entries: PetEntry[]
179
+ warnings: string[]
180
+ byId(id: string): PetEntry | undefined
181
+ /** The pet an installation falls back to when the selection is unknown. */
182
+ defaultEntry(): PetEntry
183
+ }
184
+
185
+ /** Registry sources. */
186
+ export interface PetRegistryOptions {
187
+ /** Absolute package root whose 'assets/*' hold built-in pets. */
188
+ packageRoot: string
189
+ /** Asset route prefix the browser URLs are built under. */
190
+ assetPrefix?: string
191
+ /** Custom pet directory (defaults to '${CODEX_HOME:-~/.codex}/pets'). */
192
+ petsDir?: string
193
+ /** Extra manifest entries composed by the embedding application. */
194
+ extra?: readonly PetManifest[]
195
+ }
196
+
197
+ /** Stable id charset: keeps asset URLs plain and filesystem-safe. */
198
+ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
199
+ /** Safe path-segment charset for atlas files. */
200
+ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
201
+ const PET_NAME_MAX_LENGTH = 80
202
+
203
+ /**
204
+ * Normalize one parsed manifest into a renderable pet entry, or undefined
205
+ * (with a warning recorded) when the manifest violates the contract.
206
+ */
207
+ export function resolvePetManifest(
208
+ raw: unknown,
209
+ dir: string,
210
+ options: { assetPrefix?: string; warnings?: string[] } = {},
211
+ ): PetEntry | undefined {
212
+ const { assetPrefix = '/pet', warnings = [] } = options
213
+ const warn = (message: string): void => { warnings.push(message) }
214
+ if (typeof raw !== 'object' || raw === null) {
215
+ warn('manifest is not an object')
216
+ return undefined
217
+ }
218
+ const source = raw as Record<string, unknown>
219
+ const id = typeof source.id === 'string' ? source.id.trim() : ''
220
+ if (!PET_ID_PATTERN.test(id)) {
221
+ warn('manifest id ' + JSON.stringify(String(source.id)) + ' is not a lowercase kebab id')
222
+ return undefined
223
+ }
224
+ const displayName = typeof source.displayName === 'string' && source.displayName.trim() !== ''
225
+ ? source.displayName.trim().slice(0, PET_NAME_MAX_LENGTH)
226
+ : id
227
+ const description = typeof source.description === 'string'
228
+ ? source.description.trim()
229
+ : ''
230
+ const spritesheet = typeof source.spritesheetPath === 'string' && source.spritesheetPath.trim() !== ''
231
+ ? source.spritesheetPath.trim()
232
+ : 'spritesheet.webp'
233
+ const spritesheetPath = spritesheet.split('/').filter(segment => segment !== '')
234
+ if (
235
+ spritesheetPath.length === 0
236
+ || isAbsolute(spritesheet)
237
+ || spritesheet.includes('\\')
238
+ || spritesheetPath.some(segment => segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))
239
+ ) {
240
+ warn('manifest spritesheetPath ' + JSON.stringify(spritesheet) + ' is not a safe relative path')
241
+ return undefined
242
+ }
243
+ const rawCell = (typeof source.cell === 'object' && source.cell !== null ? source.cell : {}) as Record<string, unknown>
244
+ const cell = {
245
+ width: finiteInt(rawCell.width, DEFAULT_PET_CELL.width, 2048),
246
+ height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048),
247
+ }
248
+ const columns = finiteInt(source.columns, DEFAULT_PET_COLUMNS, 32)
249
+ const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
250
+ const value = Array.isArray(source.frames) ? source.frames[index] : undefined
251
+ return finiteInt(value, fallback, columns)
252
+ })
253
+ const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
254
+ const tracks = {} as Record<PetAnimation, PetTrackDef>
255
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
256
+ const pattern = DEFAULT_TRACK_PATTERNS[animation]
257
+ const override = trackOverrides[animation]
258
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0
259
+ ? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
260
+ : pattern.durations
261
+ if (durations.length === 0) {
262
+ warn('manifest ' + id + ': track ' + animation + ' carries no usable durations')
263
+ return undefined
264
+ }
265
+ const frameCount = Math.max(1, Math.min(rows[row]!, columns))
266
+ const sized = durations.length >= frameCount
267
+ ? durations.slice(0, frameCount)
268
+ : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
269
+ tracks[animation] = {
270
+ frames: Array.from({ length: frameCount }, (_, index) => index),
271
+ durations: sized,
272
+ loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
273
+ ...(override?.fallback === undefined
274
+ ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
275
+ : PET_ROW_ORDER.includes(override.fallback)
276
+ ? { fallback: override.fallback }
277
+ : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
278
+ }
279
+ }
280
+ return {
281
+ id,
282
+ displayName,
283
+ description,
284
+ cell,
285
+ columns,
286
+ rows,
287
+ tracks,
288
+ atlasUrl: assetUrl(assetPrefix, id, spritesheet),
289
+ manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
290
+ dir,
291
+ spritesheetPath: spritesheetPath.join('/'),
292
+ }
293
+ }
294
+
295
+ /** Scan one directory of pet folders; entries come back in name order. */
296
+ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[] }): PetEntry[] {
297
+ if (!existsSync(dir)) return []
298
+ let names: string[] = []
299
+ try {
300
+ names = readdirSync(dir).filter(name => !name.startsWith('.'))
301
+ } catch {
302
+ return []
303
+ }
304
+ names.sort()
305
+ const entries: PetEntry[] = []
306
+ for (const name of names) {
307
+ const manifestFile = join(dir, name, 'pet.json')
308
+ if (!existsSync(manifestFile)) continue
309
+ const parsed = readPetJson(manifestFile, options.warnings)
310
+ if (parsed === undefined) continue
311
+ const entry = resolvePetManifest(parsed, join(dir, name), options)
312
+ if (entry !== undefined) entries.push(entry)
313
+ }
314
+ return entries
315
+ }
316
+
317
+ /** Read and parse one manifest file; undefined (warning recorded) on failure. */
318
+ function readPetJson(file: string, warnings: string[] | undefined): unknown {
319
+ try {
320
+ return JSON.parse(readFileSync(file, 'utf8'))
321
+ } catch (error) {
322
+ warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
323
+ return undefined
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
329
+ * custom pets directory, then composed 'extra' manifests (each later source
330
+ * overrides an earlier one on id collision). The registry never throws on a
331
+ * bad manifest: it skips it and records a warning.
332
+ */
333
+ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
334
+ const { packageRoot, assetPrefix = '/pet' } = options
335
+ const warnings: string[] = []
336
+ const byId = new Map<string, PetEntry>()
337
+ const builtinIds = new Set<string>()
338
+
339
+ for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
340
+ if (byId.has(entry.id)) {
341
+ warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins')
342
+ continue
343
+ }
344
+ byId.set(entry.id, entry)
345
+ builtinIds.add(entry.id)
346
+ }
347
+
348
+ const petsDir = options.petsDir ?? codexPetsDir()
349
+ if (petsDir !== '') {
350
+ for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
351
+ if (byId.has(entry.id)) warnings.push('custom pet ' + entry.id + ' overrides the built-in one')
352
+ byId.set(entry.id, entry)
353
+ }
354
+ }
355
+
356
+ for (const manifest of options.extra ?? []) {
357
+ const dir = manifest.spritesheetPath === undefined || isAbsolute(manifest.spritesheetPath)
358
+ ? join(packageRoot, 'assets', 'extra')
359
+ : dirname(resolve(packageRoot, manifest.spritesheetPath))
360
+ const entry = resolvePetManifest(manifest, dir, { assetPrefix, warnings })
361
+ if (entry === undefined) continue
362
+ if (byId.has(entry.id)) warnings.push('composed pet ' + entry.id + ' overrides an earlier registration')
363
+ byId.set(entry.id, entry)
364
+ }
365
+
366
+ const entries = [...byId.values()]
367
+ return {
368
+ entries,
369
+ warnings,
370
+ byId: (id: string) => byId.get(id),
371
+ defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
372
+ }
373
+ }
374
+
375
+ /** Strip host-only fields, leaving the client-visible definition. */
376
+ export function petEntryView(entry: PetEntry): PetDefinition {
377
+ return {
378
+ id: entry.id,
379
+ displayName: entry.displayName,
380
+ description: entry.description,
381
+ cell: entry.cell,
382
+ columns: entry.columns,
383
+ rows: entry.rows,
384
+ tracks: entry.tracks,
385
+ atlasUrl: entry.atlasUrl,
386
+ manifestUrl: entry.manifestUrl,
387
+ }
388
+ }
389
+
390
+ /** The absolute file a pet's atlas resolves to (host asset route). */
391
+ export function petAtlasFile(entry: PetEntry): string {
392
+ return join(entry.dir, entry.spritesheetPath)
393
+ }
394
+
395
+ /** The directory basename of one entry (legacy asset URL alias, e.g. whale). */
396
+ export function petDirAlias(entry: PetEntry): string {
397
+ return basename(entry.dir)
398
+ }
399
+
400
+
package/src/routes.ts CHANGED
@@ -1,35 +1,47 @@
1
1
  /**
2
2
  * Pet HTTP routes — the browser half talks to the host through plain
3
- * same-origin JSON endpoints (`/api/pet/*`) and loads the whale-girl atlas
4
- * from `/pet/whale/*`. The `/plugins/` endpoint only serves client bundles
5
- * and RPC domains are platform-registered, so the pet serves its own API
6
- * and media — the same pattern as dsh-remote-web-ui's `/api/pair` family.
3
+ * same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
4
+ * '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
5
+ * domains are platform-registered, so the pet serves its own API and media —
6
+ * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
7
+ * is one prefix registration serving every registry entry (manifest, atlas,
8
+ * optional previews), so adding a pet never touches route wiring.
7
9
  * @module @linxin666/dsh-pet/routes
8
10
  */
9
11
 
10
- import type { IncomingMessage, ServerResponse } from 'node:http'
12
+ import { existsSync } from 'node:fs'
11
13
  import { readFile } from 'node:fs/promises'
12
14
  import { join } from 'node:path'
13
- import { fileURLToPath } from 'node:url'
15
+ import type { IncomingMessage, ServerResponse } from 'node:http'
14
16
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
15
17
  import type { PetService } from './service.ts'
16
18
  import type { PetInteraction } from './affinity.ts'
19
+ import { petEntryView, type PetEntry, type PetRegistry } from './registry.ts'
17
20
 
18
21
  /** Browser-facing base path of the pet API. */
19
22
  export const PET_API_PREFIX = '/api/pet'
20
23
 
21
- /** Browser-facing base path of the pet asset routes. */
22
- export const PET_ASSET_PREFIX = '/pet/whale'
24
+ /** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
25
+ export const PET_ASSET_PREFIX = '/pet'
23
26
 
24
- /** Relative (to package root) asset files exposed under the prefix. */
25
- const ASSET_FILES = [
26
- { name: 'spritesheet.webp', mime: 'image/webp' },
27
- { name: 'pet.json', mime: 'application/json' },
28
- ] as const
27
+ const MANIFEST_FILE = 'pet.json'
28
+ const PREVIEW_DIR = 'previews'
29
+ const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/
29
30
 
30
- /** Absolute package root, resolved from this module's own location (lib/). */
31
- export function petPackageRoot(importMetaUrl: string): string {
32
- return fileURLToPath(new URL('../', importMetaUrl))
31
+ const MIME_BY_EXT: Readonly<Record<string, string>> = {
32
+ '.webp': 'image/webp',
33
+ '.png': 'image/png',
34
+ '.gif': 'image/gif',
35
+ '.jpg': 'image/jpeg',
36
+ '.jpeg': 'image/jpeg',
37
+ '.json': 'application/json',
38
+ }
39
+
40
+ /** Content type by file extension (safe fallback: octet-stream). */
41
+ function mimeFor(file: string): string {
42
+ const dot = file.lastIndexOf('.')
43
+ if (dot < 0) return 'application/octet-stream'
44
+ return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
33
45
  }
34
46
 
35
47
  /** Write one JSON response. */
@@ -53,8 +65,6 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
53
65
  req.on('data', (chunk: Buffer) => {
54
66
  size += chunk.length
55
67
  if (size > 64 * 1024) {
56
- // Reject first so the error handler can write the 400 response,
57
- // then close the connection once the response is flushed.
58
68
  reject(new Error('body-too-large'))
59
69
  queueMicrotask(() => req.destroy())
60
70
  return
@@ -112,60 +122,163 @@ function postRoute(path: string, run: (body: Record<string, unknown>) => Promise
112
122
  }
113
123
  }
114
124
 
115
- /** Build the full route family (API + assets) for one service + package root. */
116
- export function makePetRoutes(deps: { service: PetService; packageRoot: string }): WebRoute[] {
117
- const { service, packageRoot } = deps
125
+ /** Legacy URL aliases: each entry's directory basename (e.g. 'whale'). */
126
+ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
127
+ const aliases = new Map<string, PetEntry>()
128
+ for (const entry of registry.entries) {
129
+ const alias = entry.dir.split(/[\\/]/).pop() ?? ''
130
+ if (alias !== '' && !aliases.has(alias)) aliases.set(alias, entry)
131
+ }
132
+ return aliases
133
+ }
134
+
135
+ /**
136
+ * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
137
+ * legacy directory alias), then serves exactly the files a manifest declares:
138
+ * pet.json, the declared spritesheet path, and optional 'previews/<name>'
139
+ * media. Composed pets without a manifest file get a synthesized pet.json.
140
+ */
141
+ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
142
+ const aliases = dirAliases(registry)
143
+ return (req: IncomingMessage, res: ServerResponse): void => {
144
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
145
+ res.writeHead(405)
146
+ res.end()
147
+ return
148
+ }
149
+ let pathname: string
150
+ try {
151
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
152
+ } catch {
153
+ res.writeHead(400)
154
+ res.end()
155
+ return
156
+ }
157
+ const segments = pathname.split('/').filter(segment => segment !== '')
158
+ if (segments[0] !== 'pet' || segments[1] === undefined) {
159
+ res.writeHead(404)
160
+ res.end()
161
+ return
162
+ }
163
+ let id: string
164
+ try {
165
+ id = decodeURIComponent(segments[1])
166
+ } catch {
167
+ res.writeHead(400)
168
+ res.end()
169
+ return
170
+ }
171
+ const entry = registry.byId(id) ?? aliases.get(id)
172
+ if (entry === undefined) {
173
+ res.writeHead(404)
174
+ res.end()
175
+ return
176
+ }
177
+ const rest: string[] = []
178
+ for (const segment of segments.slice(2)) {
179
+ let decoded: string
180
+ try {
181
+ decoded = decodeURIComponent(segment)
182
+ } catch {
183
+ res.writeHead(400)
184
+ res.end()
185
+ return
186
+ }
187
+ rest.push(decoded)
188
+ }
189
+ const rel = rest.join('/')
190
+ let file: string | undefined
191
+ let synthesized = false
192
+ if (rest.length === 1 && rest[0] === MANIFEST_FILE) {
193
+ const manifestFile = join(entry.dir, MANIFEST_FILE)
194
+ file = existsSync(manifestFile) ? manifestFile : undefined
195
+ if (file === undefined) synthesized = true
196
+ } else if (rest.length > 0 && rel === entry.spritesheetPath) {
197
+ file = join(entry.dir, entry.spritesheetPath)
198
+ } else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
199
+ const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
200
+ file = existsSync(preview) ? preview : undefined
201
+ }
202
+ if (synthesized) {
203
+ const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
204
+ res.writeHead(200, {
205
+ 'content-type': 'application/json; charset=utf-8',
206
+ 'content-length': String(body.byteLength),
207
+ 'cache-control': 'no-cache',
208
+ })
209
+ if (req.method === 'HEAD') {
210
+ res.end()
211
+ return
212
+ }
213
+ res.end(body)
214
+ return
215
+ }
216
+ if (file === undefined) {
217
+ res.writeHead(404)
218
+ res.end()
219
+ return
220
+ }
221
+ const resolved = file
222
+ readFile(resolved).then((body) => {
223
+ res.writeHead(200, {
224
+ 'content-type': mimeFor(resolved),
225
+ 'content-length': String(body.byteLength),
226
+ 'cache-control': 'no-cache',
227
+ })
228
+ if (req.method === 'HEAD') {
229
+ res.end()
230
+ return
231
+ }
232
+ res.end(body)
233
+ }, () => {
234
+ res.writeHead(404)
235
+ res.end()
236
+ })
237
+ }
238
+ }
239
+
240
+ /** Build the full route family (API + assets) for one service. */
241
+ export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
242
+ const { service } = deps
118
243
  const apiRoutes: WebRoute[] = [
119
- getRoute(`${PET_API_PREFIX}/state`, () => service.state()),
120
- postRoute(`${PET_API_PREFIX}/interact`, (body) => {
244
+ getRoute(PET_API_PREFIX + '/state', () => service.state()),
245
+ getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
246
+ postRoute(PET_API_PREFIX + '/interact', (body) => {
121
247
  const kind = body.kind as PetInteraction | undefined
122
248
  if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
123
249
  return service.interact(kind)
124
250
  }),
125
- postRoute(`${PET_API_PREFIX}/set-visible`, (body) => {
251
+ postRoute(PET_API_PREFIX + '/set-visible', (body) => {
126
252
  const visible = body.visible
127
253
  if (typeof visible !== 'boolean') return Promise.reject(new Error('invalid-visible'))
128
254
  return service.setVisible(visible)
129
255
  }),
130
- postRoute(`${PET_API_PREFIX}/set-config`, (body) => service.setConfig({
256
+ postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
131
257
  ...(typeof body.size === 'number' ? { size: body.size } : {}),
132
258
  ...(typeof body.right === 'number' ? { right: body.right } : {}),
133
259
  ...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
134
260
  ...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
135
261
  })),
136
- postRoute(`${PET_API_PREFIX}/set-name`, (body) => {
262
+ postRoute(PET_API_PREFIX + '/set-name', (body) => {
137
263
  const name = body.name
138
264
  if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
139
265
  return service.setName(name)
140
266
  }),
267
+ postRoute(PET_API_PREFIX + '/set-pet', (body) => {
268
+ const petId = body.petId
269
+ if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
270
+ return service.setPetId(petId)
271
+ }),
141
272
  ]
142
273
 
143
- const assetRoutes: WebRoute[] = ASSET_FILES.map((file): WebRoute => ({
144
- kind: 'exact',
145
- path: `${PET_ASSET_PREFIX}/${file.name}`,
146
- handler: (req: IncomingMessage, res: ServerResponse): Promise<void> | void => {
147
- if (req.method !== 'GET' && req.method !== 'HEAD') {
148
- res.writeHead(405)
149
- res.end()
150
- return
151
- }
152
- return readFile(join(packageRoot, 'assets', 'whale', file.name)).then((body) => {
153
- res.writeHead(200, {
154
- 'content-type': file.mime,
155
- 'content-length': String(body.byteLength),
156
- 'cache-control': 'no-cache',
157
- })
158
- if (req.method === 'HEAD') {
159
- res.end()
160
- return
161
- }
162
- res.end(body)
163
- }, () => {
164
- res.writeHead(404)
165
- res.end()
166
- })
167
- },
168
- }))
274
+ const assetRoute: WebRoute = {
275
+ kind: 'prefix',
276
+ path: PET_ASSET_PREFIX,
277
+ handler: assetHandler(service.registrySnapshot()),
278
+ }
169
279
 
170
- return [...apiRoutes, ...assetRoutes]
280
+ return [...apiRoutes, assetRoute]
171
281
  }
282
+
283
+ // Re-exported for the package surface (the registry owns the definition now).
284
+ export { petPackageRoot } from './registry.ts'