@linxin666/dsh-pet 0.2.2 → 0.2.4
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/README.i18n.yaml +2 -2
- package/README.md +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/src/routes.ts
CHANGED
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
* @module @linxin666/dsh-pet/routes
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync } from 'node:fs'
|
|
12
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
13
13
|
import { readFile } from 'node:fs/promises'
|
|
14
|
-
import { join } from 'node:path'
|
|
14
|
+
import { join, sep } from 'node:path'
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
16
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
17
17
|
import type { PetService } from './service.ts'
|
|
18
18
|
import type { PetInteraction } from './affinity.ts'
|
|
19
|
-
import { petEntryView, type PetEntry, type PetRegistry } from './registry.ts'
|
|
19
|
+
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
20
20
|
import { isLoopbackRequest } from './loopback.ts'
|
|
21
|
+
import { dshHome } from './dsh-home.ts'
|
|
21
22
|
|
|
22
23
|
/** Browser-facing base path of the pet API. */
|
|
23
24
|
export const PET_API_PREFIX = '/api/pet'
|
|
@@ -29,6 +30,53 @@ const MANIFEST_FILE = 'pet.json'
|
|
|
29
30
|
const PREVIEW_DIR = 'previews'
|
|
30
31
|
const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
|
|
35
|
+
* issue #623). Constants are tested directly; makePetRoutes accepts an
|
|
36
|
+
* override so tests can exercise the 413 path with tiny caps.
|
|
37
|
+
*/
|
|
38
|
+
export const PET_ASSET_CAPS = {
|
|
39
|
+
/** pet.json manifest. */
|
|
40
|
+
manifest: 64 * 1024,
|
|
41
|
+
/** Atlas, preview and Live2D texture imagery. */
|
|
42
|
+
image: 20 * 1024 * 1024,
|
|
43
|
+
/** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
|
|
44
|
+
model: 32 * 1024 * 1024,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/** Size-cap profile the asset route enforces (test seam). */
|
|
48
|
+
export interface PetAssetCaps {
|
|
49
|
+
manifest: number
|
|
50
|
+
image: number
|
|
51
|
+
model: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
|
|
55
|
+
const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
|
|
56
|
+
|
|
57
|
+
/** Lowercased file extension ('' when none). */
|
|
58
|
+
function extensionOf(file: string): string {
|
|
59
|
+
const dot = file.lastIndexOf('.')
|
|
60
|
+
return dot < 0 ? '' : file.slice(dot).toLowerCase()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* realpath containment: resolve both sides and require the candidate to stay
|
|
65
|
+
* inside the base directory. A pet directory (or an atlas/preview inside it)
|
|
66
|
+
* that is a symlink escaping its root is rejected, never followed.
|
|
67
|
+
*/
|
|
68
|
+
export function containedRealpath(base: string, candidate: string): string | undefined {
|
|
69
|
+
try {
|
|
70
|
+
const realBase = realpathSync(base)
|
|
71
|
+
const realCandidate = realpathSync(candidate)
|
|
72
|
+
return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
|
|
73
|
+
? realCandidate
|
|
74
|
+
: undefined
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
32
80
|
const MIME_BY_EXT: Readonly<Record<string, string>> = {
|
|
33
81
|
'.webp': 'image/webp',
|
|
34
82
|
'.png': 'image/png',
|
|
@@ -145,10 +193,14 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
|
|
|
145
193
|
/**
|
|
146
194
|
* The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
|
|
147
195
|
* legacy directory alias), then serves exactly the files a manifest declares:
|
|
148
|
-
* pet.json, the
|
|
149
|
-
*
|
|
196
|
+
* pet.json, the entry's servable set (the sprite2d atlas, or the live2d
|
|
197
|
+
* model3.json plus its reference closure — pet-center M3), and optional
|
|
198
|
+
* 'previews/<name>' media. The servable match is an exact string comparison
|
|
199
|
+
* against scan-time normalized paths, so crafted '..' or '.' segments never
|
|
200
|
+
* match; containedRealpath stays as the second layer. Composed pets without
|
|
201
|
+
* a manifest file get a synthesized pet.json.
|
|
150
202
|
*/
|
|
151
|
-
function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
203
|
+
function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
152
204
|
const aliases = dirAliases(registry)
|
|
153
205
|
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
154
206
|
if (!guard(req, res)) return
|
|
@@ -204,14 +256,14 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
|
204
256
|
const manifestFile = join(entry.dir, MANIFEST_FILE)
|
|
205
257
|
file = existsSync(manifestFile) ? manifestFile : undefined
|
|
206
258
|
if (file === undefined) synthesized = true
|
|
207
|
-
} else if (rest.length > 0 &&
|
|
208
|
-
file = join(entry.dir,
|
|
259
|
+
} else if (rest.length > 0 && entry.servable.includes(rel)) {
|
|
260
|
+
file = join(entry.dir, rel)
|
|
209
261
|
} else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
|
|
210
262
|
const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
|
|
211
263
|
file = existsSync(preview) ? preview : undefined
|
|
212
264
|
}
|
|
213
265
|
if (synthesized) {
|
|
214
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
|
|
266
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8')
|
|
215
267
|
res.writeHead(200, {
|
|
216
268
|
'content-type': 'application/json; charset=utf-8',
|
|
217
269
|
'content-length': String(body.byteLength),
|
|
@@ -229,12 +281,258 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
|
229
281
|
res.end()
|
|
230
282
|
return
|
|
231
283
|
}
|
|
232
|
-
|
|
284
|
+
// realpath containment: a symlink escaping the pet directory is refused.
|
|
285
|
+
const resolved = containedRealpath(entry.dir, file)
|
|
286
|
+
if (resolved === undefined) {
|
|
287
|
+
res.writeHead(403)
|
|
288
|
+
res.end()
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
// Enforce the size ceiling before the file is read into memory.
|
|
292
|
+
const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
|
|
293
|
+
? caps.manifest
|
|
294
|
+
: IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model
|
|
295
|
+
try {
|
|
296
|
+
if (statSync(resolved).size > cap) {
|
|
297
|
+
res.writeHead(413)
|
|
298
|
+
res.end()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
res.writeHead(404)
|
|
303
|
+
res.end()
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
readFile(resolved).then((body) => {
|
|
307
|
+
res.writeHead(200, {
|
|
308
|
+
'content-type': mimeFor(resolved),
|
|
309
|
+
'content-length': String(body.byteLength),
|
|
310
|
+
'cache-control': 'no-cache',
|
|
311
|
+
})
|
|
312
|
+
if (req.method === 'HEAD') {
|
|
313
|
+
res.end()
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
res.end(body)
|
|
317
|
+
}, () => {
|
|
318
|
+
res.writeHead(404)
|
|
319
|
+
res.end()
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
325
|
+
export const PET_RUNTIME_PREFIX = PET_API_PREFIX + '/runtime'
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The runtime files the route may serve, by exact name (no slashes, no
|
|
329
|
+
* user-controlled path segments, so traversal is structurally impossible):
|
|
330
|
+
* the user-supplied Cubism Core from the pet runtime directory (the plugin
|
|
331
|
+
* never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
|
|
332
|
+
* MIT vendor bundle from the package lib directory.
|
|
333
|
+
*/
|
|
334
|
+
const RUNTIME_FILES: Readonly<Record<string, { root: 'runtimeDir' | 'vendorDir' }>> = {
|
|
335
|
+
'live2dcubismcore.min.js': { root: 'runtimeDir' },
|
|
336
|
+
'live2d-vendor.js': { root: 'vendorDir' },
|
|
337
|
+
'live2d-vendor.js.map': { root: 'vendorDir' },
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Size ceiling for one runtime file (the Cubism Core is ~200 KB today). */
|
|
341
|
+
export const PET_RUNTIME_CAP = 16 * 1024 * 1024
|
|
342
|
+
|
|
343
|
+
/** Runtime file roots (test seam; defaults resolve from the environment). */
|
|
344
|
+
export interface PetRuntimeRoots {
|
|
345
|
+
/** User-supplied runtime directory (defaults to '$DSH_HOME/pets/.runtime'). */
|
|
346
|
+
runtimeDir?: string
|
|
347
|
+
/** Plugin vendor bundle directory (defaults to the package 'lib'). */
|
|
348
|
+
vendorDir?: string
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The runtime handler behind '/api/pet/runtime/<name>'. A missing file
|
|
353
|
+
* answers 404 with a JSON marker the client renderer turns into install
|
|
354
|
+
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
355
|
+
* state, not an error).
|
|
356
|
+
*/
|
|
357
|
+
function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
|
|
358
|
+
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
359
|
+
if (!guard(req, res)) return
|
|
360
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
361
|
+
res.writeHead(405)
|
|
362
|
+
res.end()
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
let pathname: string
|
|
366
|
+
try {
|
|
367
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
|
|
368
|
+
} catch {
|
|
369
|
+
res.writeHead(400)
|
|
370
|
+
res.end()
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
const rest = pathname.slice(PET_RUNTIME_PREFIX.length).replace(/^\/+/, '')
|
|
374
|
+
let name: string
|
|
375
|
+
try {
|
|
376
|
+
name = decodeURIComponent(rest)
|
|
377
|
+
} catch {
|
|
378
|
+
res.writeHead(400)
|
|
379
|
+
res.end()
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
const spec = RUNTIME_FILES[name]
|
|
383
|
+
// Exact-name allow-list: anything with a path separator never matches.
|
|
384
|
+
if (spec === undefined) {
|
|
385
|
+
res.writeHead(404)
|
|
386
|
+
res.end()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
|
|
390
|
+
const file = join(base, name)
|
|
391
|
+
if (!existsSync(file)) {
|
|
392
|
+
json(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
const resolved = containedRealpath(base, file)
|
|
396
|
+
if (resolved === undefined) {
|
|
397
|
+
res.writeHead(403)
|
|
398
|
+
res.end()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
if (statSync(resolved).size > PET_RUNTIME_CAP) {
|
|
403
|
+
res.writeHead(413)
|
|
404
|
+
res.end()
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
res.writeHead(404)
|
|
409
|
+
res.end()
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
readFile(resolved).then((body) => {
|
|
413
|
+
res.writeHead(200, {
|
|
414
|
+
'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
|
|
415
|
+
'content-length': String(body.byteLength),
|
|
416
|
+
'cache-control': 'no-cache',
|
|
417
|
+
})
|
|
418
|
+
if (req.method === 'HEAD') {
|
|
419
|
+
res.end()
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
res.end(body)
|
|
423
|
+
}, () => {
|
|
424
|
+
res.writeHead(404)
|
|
425
|
+
res.end()
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
432
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
433
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
434
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
435
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
436
|
+
*/
|
|
437
|
+
function decorationHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
438
|
+
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
439
|
+
if (!guard(req, res)) return
|
|
440
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
441
|
+
res.writeHead(405)
|
|
442
|
+
res.end()
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
let pathname: string
|
|
446
|
+
try {
|
|
447
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
|
|
448
|
+
} catch {
|
|
449
|
+
res.writeHead(400)
|
|
450
|
+
res.end()
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const segments = pathname.split('/').filter(segment => segment !== '')
|
|
454
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '')
|
|
455
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
456
|
+
res.writeHead(404)
|
|
457
|
+
res.end()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
for (let i = 0; i < prefixSegments.length; i += 1) {
|
|
461
|
+
if (segments[i] !== prefixSegments[i]) {
|
|
462
|
+
res.writeHead(404)
|
|
463
|
+
res.end()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let id: string
|
|
468
|
+
try {
|
|
469
|
+
id = decodeURIComponent(segments[prefixSegments.length])
|
|
470
|
+
} catch {
|
|
471
|
+
res.writeHead(400)
|
|
472
|
+
res.end()
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
const entry = registry.decorationById?.(id)
|
|
476
|
+
if (entry === undefined) {
|
|
477
|
+
res.writeHead(404)
|
|
478
|
+
res.end()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
const rest: string[] = []
|
|
482
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
483
|
+
let decoded: string
|
|
484
|
+
try {
|
|
485
|
+
decoded = decodeURIComponent(segment)
|
|
486
|
+
} catch {
|
|
487
|
+
res.writeHead(400)
|
|
488
|
+
res.end()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
rest.push(decoded)
|
|
492
|
+
}
|
|
493
|
+
const rel = rest.join('/')
|
|
494
|
+
if (!entry.servable.includes(rel)) {
|
|
495
|
+
res.writeHead(404)
|
|
496
|
+
res.end()
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const file = join(entry.dir, rel)
|
|
500
|
+
const resolved = containedRealpath(entry.dir, file)
|
|
501
|
+
if (resolved === undefined) {
|
|
502
|
+
res.writeHead(403)
|
|
503
|
+
res.end()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const cap = rel === 'decoration.json' ? caps.manifest : caps.image
|
|
507
|
+
let stat: ReturnType<typeof statSync>
|
|
508
|
+
try {
|
|
509
|
+
stat = statSync(resolved)
|
|
510
|
+
if (stat.size > cap) {
|
|
511
|
+
res.writeHead(413)
|
|
512
|
+
res.end()
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
res.writeHead(404)
|
|
517
|
+
res.end()
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
// Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
|
|
521
|
+
// validator lets repeat requests settle as 304 — the ornament remounts
|
|
522
|
+
// on whisper and display-session flips, and without a validator each
|
|
523
|
+
// remount would re-download the full strip body.
|
|
524
|
+
const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
|
|
525
|
+
if (req.headers['if-none-match'] === etag) {
|
|
526
|
+
res.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
527
|
+
res.end()
|
|
528
|
+
return
|
|
529
|
+
}
|
|
233
530
|
readFile(resolved).then((body) => {
|
|
234
531
|
res.writeHead(200, {
|
|
235
532
|
'content-type': mimeFor(resolved),
|
|
236
533
|
'content-length': String(body.byteLength),
|
|
237
534
|
'cache-control': 'no-cache',
|
|
535
|
+
etag,
|
|
238
536
|
})
|
|
239
537
|
if (req.method === 'HEAD') {
|
|
240
538
|
res.end()
|
|
@@ -248,12 +546,13 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
|
248
546
|
}
|
|
249
547
|
}
|
|
250
548
|
|
|
251
|
-
/** Build the full route family (API + assets) for one service. */
|
|
252
|
-
export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
|
|
549
|
+
/** Build the full route family (API + assets + runtime) for one service. */
|
|
550
|
+
export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
|
|
253
551
|
const { service } = deps
|
|
254
552
|
const apiRoutes: WebRoute[] = [
|
|
255
553
|
getRoute(PET_API_PREFIX + '/state', () => service.state()),
|
|
256
554
|
getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
|
|
555
|
+
getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
257
556
|
postRoute(PET_API_PREFIX + '/interact', (body) => {
|
|
258
557
|
const kind = body.kind as PetInteraction | undefined
|
|
259
558
|
if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
|
|
@@ -285,10 +584,25 @@ export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
|
|
|
285
584
|
const assetRoute: WebRoute = {
|
|
286
585
|
kind: 'prefix',
|
|
287
586
|
path: PET_ASSET_PREFIX,
|
|
288
|
-
handler: assetHandler(service.registrySnapshot()),
|
|
587
|
+
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const runtimeRoute: WebRoute = {
|
|
591
|
+
kind: 'prefix',
|
|
592
|
+
path: PET_RUNTIME_PREFIX,
|
|
593
|
+
handler: runtimeHandler({
|
|
594
|
+
runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
|
|
595
|
+
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
|
|
596
|
+
}),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const decorationRoute: WebRoute = {
|
|
600
|
+
kind: 'prefix',
|
|
601
|
+
path: DECORATION_ASSET_PREFIX,
|
|
602
|
+
handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
289
603
|
}
|
|
290
604
|
|
|
291
|
-
return [...apiRoutes, assetRoute]
|
|
605
|
+
return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
|
|
292
606
|
}
|
|
293
607
|
|
|
294
608
|
// Re-exported for the package surface (the registry owns the definition now).
|
package/src/service.ts
CHANGED
|
@@ -36,14 +36,19 @@ import {
|
|
|
36
36
|
type PetDisplayConfig,
|
|
37
37
|
} from './persist.ts'
|
|
38
38
|
import {
|
|
39
|
+
DEFAULT_DECORATION_ID,
|
|
40
|
+
decorationView,
|
|
39
41
|
loadPetRegistry,
|
|
40
42
|
petEntryView,
|
|
41
43
|
petPackageRoot,
|
|
42
44
|
type PetDefinition,
|
|
43
45
|
type PetManifest,
|
|
44
46
|
type PetRegistry,
|
|
47
|
+
type PetRegistryDiagnostic,
|
|
45
48
|
} from './registry.ts'
|
|
46
|
-
import { WHISPER_TTL_MS } from './chatter.ts'
|
|
49
|
+
import { WHISPER_TTL_MS, type VoicePackOverrides, type VoicePoolsProvider } from './chatter.ts'
|
|
50
|
+
import { mergeVoicePacks } from './voice-pack.ts'
|
|
51
|
+
import type { DecorationView } from './contracts/status-decoration.ts'
|
|
47
52
|
import {
|
|
48
53
|
defaultPetStateConfig,
|
|
49
54
|
PetStateMachine,
|
|
@@ -64,6 +69,8 @@ export interface PetConfig {
|
|
|
64
69
|
persistDir?: string
|
|
65
70
|
/** Master switch for the plugin (browser half + host routes). */
|
|
66
71
|
enabled?: boolean
|
|
72
|
+
/** Status-decoration master switch (pet-center M5, #567); defaults to on. */
|
|
73
|
+
decorationEnabled?: boolean
|
|
67
74
|
/** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
|
|
68
75
|
registry?: PetRegistry
|
|
69
76
|
/** Extra manifest entries composed by the embedding application. */
|
|
@@ -90,6 +97,11 @@ export interface PetSettingsSection {
|
|
|
90
97
|
bottom: number
|
|
91
98
|
/** Master switch for the plugin (browser half + host routes). */
|
|
92
99
|
enabled?: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Status-decoration master switch (pet-center M5, #567). Defaults to on;
|
|
102
|
+
* the settings surface mirrors this field and can turn it off.
|
|
103
|
+
*/
|
|
104
|
+
decorationEnabled?: boolean
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
|
|
@@ -158,6 +170,12 @@ export interface PetStateView {
|
|
|
158
170
|
* rendered by the client as a distinct whisper bubble.
|
|
159
171
|
*/
|
|
160
172
|
whisper?: string
|
|
173
|
+
/**
|
|
174
|
+
* The active status decoration (pet-center M5, #567), when the master
|
|
175
|
+
* switch is on and the default decoration entry exists. Absent means the
|
|
176
|
+
* browser half renders no ornament.
|
|
177
|
+
*/
|
|
178
|
+
decoration?: DecorationView
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
/** Result of `pet.interact`. */
|
|
@@ -199,9 +217,17 @@ export class PetService extends Service {
|
|
|
199
217
|
private readonly registry: PetRegistry
|
|
200
218
|
private readonly persistDir: string
|
|
201
219
|
private enabled: boolean
|
|
220
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
221
|
+
private decorationEnabled: boolean
|
|
202
222
|
private disposeActivity: (() => void) | undefined
|
|
203
223
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
204
224
|
private displaySession: Session | undefined
|
|
225
|
+
/**
|
|
226
|
+
* Effective voice-pack overrides for the currently selected pet (M4,
|
|
227
|
+
* #677). Cached per pet id; the registry is an immutable snapshot, so the
|
|
228
|
+
* global pack and each entry's pack cannot change behind the cache.
|
|
229
|
+
*/
|
|
230
|
+
private voiceCache: { petId: string; overrides: VoicePackOverrides } | undefined
|
|
205
231
|
/**
|
|
206
232
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
207
233
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
@@ -236,10 +262,28 @@ export class PetService extends Service {
|
|
|
236
262
|
this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
|
|
237
263
|
this.machine = new PetStateMachine(this.stateConfig)
|
|
238
264
|
this.enabled = config.enabled ?? true
|
|
265
|
+
this.decorationEnabled = config.decorationEnabled ?? true
|
|
239
266
|
|
|
240
267
|
this.syncActivity()
|
|
241
268
|
}
|
|
242
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The draw-time voice-pool provider handed to every projection runtime.
|
|
272
|
+
* It re-resolves when the selected pet changes, so live engines re-voice
|
|
273
|
+
* on the next draw without being rebuilt (M4, #677).
|
|
274
|
+
*/
|
|
275
|
+
private voicePools(): VoicePoolsProvider {
|
|
276
|
+
return () => {
|
|
277
|
+
const entry = this.activeEntry()
|
|
278
|
+
if (this.voiceCache !== undefined && this.voiceCache.petId === entry.id) {
|
|
279
|
+
return this.voiceCache.overrides
|
|
280
|
+
}
|
|
281
|
+
const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {}
|
|
282
|
+
this.voiceCache = { petId: entry.id, overrides }
|
|
283
|
+
return overrides
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
243
287
|
/** Whether the pet service consumes session activity while enabled. */
|
|
244
288
|
isEnabled(): boolean {
|
|
245
289
|
return this.enabled
|
|
@@ -257,7 +301,7 @@ export class PetService extends Service {
|
|
|
257
301
|
|
|
258
302
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
259
303
|
async pets(): Promise<PetDefinition[]> {
|
|
260
|
-
return this.registry.entries.map(petEntryView)
|
|
304
|
+
return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice))
|
|
261
305
|
}
|
|
262
306
|
|
|
263
307
|
/** The loaded registry (the asset routes serve its entries). */
|
|
@@ -265,6 +309,21 @@ export class PetService extends Service {
|
|
|
265
309
|
return this.registry
|
|
266
310
|
}
|
|
267
311
|
|
|
312
|
+
/** RPC: structured registry diagnostics (pet-center M2, issue #623). */
|
|
313
|
+
async diagnostics(): Promise<{ diagnostics: PetRegistryDiagnostic[] }> {
|
|
314
|
+
return { diagnostics: this.registry.diagnostics }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The active status decoration view (M5, #567): the default 'whale' entry
|
|
319
|
+
* (user directories override built-ins by id), gated by the master switch.
|
|
320
|
+
*/
|
|
321
|
+
private activeDecoration(): DecorationView | undefined {
|
|
322
|
+
if (!this.decorationEnabled) return undefined
|
|
323
|
+
const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID)
|
|
324
|
+
return entry === undefined ? undefined : decorationView(entry)
|
|
325
|
+
}
|
|
326
|
+
|
|
268
327
|
/** The selected pet's registry entry. */
|
|
269
328
|
activeEntry(): NonNullable<PetRegistry['entries'][number]> {
|
|
270
329
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
|
|
@@ -364,7 +423,7 @@ export class PetService extends Service {
|
|
|
364
423
|
let activity = this.sessionActivity.get(session)
|
|
365
424
|
if (activity === undefined) {
|
|
366
425
|
activity = {
|
|
367
|
-
runtime: emptyProjectionRuntime(),
|
|
426
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
368
427
|
machine: new PetStateMachine(this.stateConfig),
|
|
369
428
|
}
|
|
370
429
|
this.sessionActivity.set(session, activity)
|
|
@@ -443,6 +502,7 @@ export class PetService extends Service {
|
|
|
443
502
|
* @param section - the resolved settings section.
|
|
444
503
|
*/
|
|
445
504
|
applySettingsSection(section: PetSettingsSection): void {
|
|
505
|
+
this.decorationEnabled = section.decorationEnabled ?? true
|
|
446
506
|
const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
|
|
447
507
|
if (selected !== undefined) {
|
|
448
508
|
this.ledger.setPetId(selected.id)
|
|
@@ -518,6 +578,7 @@ export class PetService extends Service {
|
|
|
518
578
|
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
519
579
|
? whisper.text
|
|
520
580
|
: undefined
|
|
581
|
+
const decoration = this.activeDecoration()
|
|
521
582
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
522
583
|
// so polling the state cannot trigger pet.json writes.
|
|
523
584
|
return {
|
|
@@ -527,6 +588,7 @@ export class PetService extends Service {
|
|
|
527
588
|
sessionActive: snapshot.sessionActive,
|
|
528
589
|
sessions,
|
|
529
590
|
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
591
|
+
...(decoration === undefined ? {} : { decoration }),
|
|
530
592
|
affinity: this.ledger.affinityView(Date.now()),
|
|
531
593
|
display: { ...this.ledger.snapshot.display },
|
|
532
594
|
pet: {
|