@linxin666/dsh-pet 0.3.13 → 0.3.15

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.
@@ -21,7 +21,7 @@ import type { PetDefinition } from '../registry.ts'
21
21
  import type { DecorationView } from '../contracts/status-decoration.ts'
22
22
  import type { PetFeedback } from './pet-store.ts'
23
23
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
24
- import { sequenceFrameAt } from './sequences.ts'
24
+ import { createSequenceTimeline } from './sequences.ts'
25
25
  import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
26
26
  import { NS } from './locales.ts'
27
27
  import styles from './pet.module.css'
@@ -339,6 +339,20 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
339
339
  const reduceMotion = typeof window !== 'undefined'
340
340
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
341
341
  const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
342
+ // Sequence state hoisted into the effect scope: the cumulative duration
343
+ // table and each item's trimmed track would otherwise be recomputed every
344
+ // tick (map/reduce plus two slices per frame), the same waste the
345
+ // single-track branch below avoids.
346
+ const timeline = sequence === undefined ? undefined : createSequenceTimeline(sequence, tracks)
347
+ const sequenceItems = sequence === undefined ? undefined : new Map(
348
+ sequence.map(itemAnimation => {
349
+ const itemRow = rowOfTrack(itemAnimation)
350
+ return [itemAnimation, {
351
+ row: itemRow,
352
+ track: trimTrack(tracks[itemAnimation], rows[itemRow] ?? tracks[itemAnimation].frames.length),
353
+ }]
354
+ }),
355
+ )
342
356
  const leadAnimation = sequence?.[0] ?? animation
343
357
  const row = rowOfTrack(leadAnimation)
344
358
  const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
@@ -357,16 +371,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
357
371
  const tick = (ts: number): void => {
358
372
  const delta = ts - last
359
373
  last = ts
360
- if (sequence !== undefined) {
374
+ if (timeline !== undefined && sequenceItems !== undefined) {
361
375
  sequenceElapsed += delta
362
- const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
363
- const currentRow = rowOfTrack(current.animation)
364
- const currentTrack = trimTrack(
365
- tracks[current.animation],
366
- rows[currentRow] ?? tracks[current.animation].frames.length,
367
- )
368
- const col = currentTrack.frames[current.frameIndex]!
369
- const pos = framePosition(cell, currentRow, col, scaleRef.current)
376
+ const current = timeline.frameAt(sequenceElapsed)
377
+ const item = sequenceItems.get(current.animation)!
378
+ const col = item.track.frames[current.frameIndex]!
379
+ const pos = framePosition(cell, item.row, col, scaleRef.current)
370
380
  const posStr = pos.x + 'px ' + pos.y + 'px'
371
381
  if (posStr !== lastPosStr) {
372
382
  lastPosStr = posStr
@@ -61,6 +61,8 @@ interface FakeClientLifecycle {
61
61
  dispose(): void
62
62
  settingsListenerCount(): number
63
63
  emitSettings(): void
64
+ sessionsListenerCount(): number
65
+ setEnabled(enabled: boolean): void
64
66
  }
65
67
 
66
68
  const activeLifecycles: FakeClientLifecycle[] = []
@@ -73,11 +75,13 @@ afterEach(() => {
73
75
  function fakeContext(): FakeClientLifecycle {
74
76
  const disposers: (() => void)[] = []
75
77
  const settingsListeners = new Set<() => void>()
78
+ const sessionListeners = new Set<() => void>()
79
+ let settingsValue: { enabled?: boolean } | undefined
76
80
  const scope = {
77
81
  getSnapshot: () => ({
78
82
  status: 'ready',
79
83
  writable: true,
80
- value: undefined,
84
+ value: settingsValue,
81
85
  base: undefined,
82
86
  user: {},
83
87
  revision: 1,
@@ -114,8 +118,8 @@ function fakeContext(): FakeClientLifecycle {
114
118
  list: {
115
119
  getSnapshot: () => ({ current: undefined, byId: {} }),
116
120
  subscribe: (listener: () => void) => {
117
- const set = new Set<() => void>([listener])
118
- return () => { set.delete(listener) }
121
+ sessionListeners.add(listener)
122
+ return () => { sessionListeners.delete(listener) }
119
123
  },
120
124
  },
121
125
  open: () => {},
@@ -133,6 +137,8 @@ function fakeContext(): FakeClientLifecycle {
133
137
  emitSettings: () => {
134
138
  for (const listener of settingsListeners) listener()
135
139
  },
140
+ sessionsListenerCount: () => sessionListeners.size,
141
+ setEnabled: (enabled: boolean) => { settingsValue = { enabled } },
136
142
  }
137
143
  activeLifecycles.push(lifecycle)
138
144
  return lifecycle
@@ -208,4 +214,26 @@ describe('pet client apply', () => {
208
214
  expect(roots).toHaveLength(1)
209
215
  expect(stale.isConnected).toBe(false)
210
216
  })
217
+
218
+ it('unsubscribes the session watch when the pet is disabled from settings', () => {
219
+ const lifecycle = fakeContext()
220
+ apply(lifecycle.ctx)
221
+ // The poll loop and the current-session watch both subscribe through
222
+ // ctx.effect, so exactly one sessions.list listener is live per mount.
223
+ expect(lifecycle.sessionsListenerCount()).toBe(1)
224
+
225
+ // Toggling the plugin off tears the UI down without disposing the fiber:
226
+ // the session watch must go with it, or every later session-store
227
+ // notification keeps polling a dead pet forever.
228
+ lifecycle.setEnabled(false)
229
+ lifecycle.emitSettings()
230
+ expect(lifecycle.sessionsListenerCount()).toBe(0)
231
+ expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
232
+
233
+ // Re-enabling mounts a fresh UI with a fresh watch.
234
+ lifecycle.setEnabled(true)
235
+ lifecycle.emitSettings()
236
+ expect(lifecycle.sessionsListenerCount()).toBe(1)
237
+ expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
238
+ })
211
239
  })
@@ -403,6 +403,7 @@ export function apply(ctx: ClientContext): void {
403
403
  petRoot.unmount()
404
404
  container.remove()
405
405
  disposePoll()
406
+ disposeSessionWatch()
406
407
  disposeUi = undefined
407
408
  }
408
409
  // The slot teardown is the takeover hook a later apply body runs; it
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The pet store's poll-publish contract against the real client-store engine:
3
+ * an unchanged snapshot must skip the write (immer records no modification,
4
+ * zustand never notifies), while any transition that matters still publishes.
5
+ */
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import type { PetStateView } from '../service.ts'
8
+ import { createPetStore } from './pet-store.ts'
9
+
10
+ const snapshot = (over: Partial<PetStateView> = {}): PetStateView => ({
11
+ animation: 'idle',
12
+ phase: 'idle',
13
+ sessionActive: false,
14
+ affinity: {
15
+ points: 3,
16
+ rank: '好奇',
17
+ rankEmoji: '',
18
+ pets: 1,
19
+ feeds: 0,
20
+ turns: 0,
21
+ petCooldown: false,
22
+ feedCooldown: false,
23
+ },
24
+ display: { visible: true, size: 160, right: 24, bottom: 120 },
25
+ pet: { id: 'whale-girl', displayName: 'Whale', description: '' },
26
+ name: 'Whale',
27
+ treats: { stocked: 2, max: 10 },
28
+ ...over,
29
+ })
30
+
31
+ describe('pet store setSnapshot publish skipping', () => {
32
+ it('does not notify when a poll republishes an unchanged snapshot', () => {
33
+ const store = createPetStore().create()
34
+ const listener = vi.fn()
35
+ store.subscribe(listener)
36
+
37
+ const first = snapshot()
38
+ store.actions.setSnapshot(first)
39
+ expect(listener).toHaveBeenCalledTimes(1)
40
+ expect(store.getSnapshot().state).toBe('ready')
41
+
42
+ // A fresh, deep-equal object is what every poll tick delivers.
43
+ store.actions.setSnapshot(snapshot())
44
+ expect(listener).toHaveBeenCalledTimes(1)
45
+ expect(store.getSnapshot().snapshot).toBe(first)
46
+
47
+ // Any content change publishes again.
48
+ store.actions.setSnapshot(snapshot({ phase: 'thinking', animation: 'running' }))
49
+ expect(listener).toHaveBeenCalledTimes(2)
50
+ })
51
+
52
+ it('publishes after an error state even when the payload is unchanged', () => {
53
+ const store = createPetStore().create()
54
+ const listener = vi.fn()
55
+ store.subscribe(listener)
56
+ store.actions.setSnapshot(snapshot())
57
+
58
+ store.actions.setState('error', 'pet.state transport error')
59
+ expect(listener).toHaveBeenCalledTimes(2)
60
+
61
+ // The success transition (error -> ready) must land despite the equal
62
+ // payload, or the UI would stay stuck on the transport-error banner.
63
+ store.actions.setSnapshot(snapshot())
64
+ expect(listener).toHaveBeenCalledTimes(3)
65
+ expect(store.getSnapshot().state).toBe('ready')
66
+ expect(store.getSnapshot().error).toBeNull()
67
+ })
68
+
69
+ it('still patches the gameplay view and feedback on top of a skipped poll', () => {
70
+ const store = createPetStore().create()
71
+ store.actions.setSnapshot(snapshot())
72
+ const base = store.getSnapshot().snapshot!
73
+
74
+ store.actions.setGameplayView({ stats: { mood: 70 }, mode: 'work' })
75
+ expect(store.getSnapshot().snapshot).not.toBe(base)
76
+ expect(store.getSnapshot().snapshot?.gameplay).toEqual({ stats: { mood: 70 }, mode: 'work' })
77
+
78
+ store.actions.setFeedback({ text: '喵', kind: 'pet', at: 1 })
79
+ expect(store.getSnapshot().feedback).toEqual({ text: '喵', kind: 'pet', at: 1 })
80
+
81
+ // The next identical poll (a fresh but deep-equal object) skips the
82
+ // publish and keeps the exact snapshot reference the UI already holds.
83
+ const before = store.getSnapshot().snapshot
84
+ store.actions.setSnapshot(snapshot({ gameplay: { stats: { mood: 70 }, mode: 'work' } }))
85
+ expect(store.getSnapshot().snapshot).toBe(before)
86
+ })
87
+ })
@@ -66,6 +66,14 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
66
66
  }),
67
67
  actions: {
68
68
  setSnapshot: (draft, snapshot) => {
69
+ // The 2 s poll republishes the full snapshot even while the pet is
70
+ // idle. Skipping an unchanged payload keeps immer's produce at zero
71
+ // modifications, so zustand never notifies and the whole sprite tree
72
+ // skips the re-render. Equality is JSON-based and skip-only: both
73
+ // sides come from the same host serializer, and any mismatch falls
74
+ // through to the normal publish — the failure mode is one extra
75
+ // render, never a stale pet.
76
+ if (draft.state === 'ready' && draft.error === null && sameSnapshot(draft.snapshot, snapshot)) return
69
77
  draft.snapshot = snapshot
70
78
  draft.state = 'ready'
71
79
  draft.error = null
@@ -89,6 +97,16 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
89
97
 
90
98
  export type { PetInteraction }
91
99
 
100
+ /**
101
+ * Content equality for consecutive poll snapshots. JSON compare, not field
102
+ * enumeration: an exact string match is the only way to skip the publish, so
103
+ * a missed field can never freeze the UI — it can only cost the render the
104
+ * optimization exists to save.
105
+ */
106
+ function sameSnapshot(previous: PetStateView | null, next: PetStateView): boolean {
107
+ return previous !== null && JSON.stringify(previous) === JSON.stringify(next)
108
+ }
109
+
92
110
  /**
93
111
  * A live pet store instance (one per host, owned by the plugin apply body —
94
112
  * the pet itself is host-global, so its UI state must not ride the slot
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { PetTrackDef } from '../registry.ts'
3
3
  import type { PetAnimation } from '../state.ts'
4
- import { sequenceFrameAt } from './sequences.ts'
4
+ import { createSequenceTimeline, sequenceFrameAt } from './sequences.ts'
5
5
 
6
6
  const track = (durations: number[]): PetTrackDef => ({
7
7
  frames: durations.map((_, index) => index),
@@ -31,3 +31,23 @@ describe('sequenceFrameAt', () => {
31
31
  expect(sequenceFrameAt(sequence, tracks, 1_420)).toEqual({ animation: 'running', frameIndex: 0 })
32
32
  })
33
33
  })
34
+
35
+ describe('createSequenceTimeline', () => {
36
+ const sequence: PetAnimation[] = ['running', 'waiting']
37
+
38
+ it('resolves identically to the per-call helper at animation-sample elapsed values', () => {
39
+ // The sprite frame loop asks once per rAF tick with accumulated
40
+ // millisecond offsets; the precomputed table must agree everywhere.
41
+ const timeline = createSequenceTimeline(sequence, tracks)
42
+ for (let elapsed = 0; elapsed <= 1_500; elapsed += 7) {
43
+ expect(timeline.frameAt(elapsed)).toEqual(sequenceFrameAt(sequence, tracks, elapsed))
44
+ }
45
+ })
46
+
47
+ it('stays deterministic across repeated queries with the same table', () => {
48
+ const timeline = createSequenceTimeline(sequence, tracks)
49
+ expect(timeline.frameAt(350)).toEqual(timeline.frameAt(350))
50
+ expect(timeline.frameAt(0)).toEqual({ animation: 'running', frameIndex: 0 })
51
+ expect(timeline.frameAt(1_420)).toEqual({ animation: 'running', frameIndex: 0 })
52
+ })
53
+ })
@@ -8,26 +8,47 @@ export interface SequenceFrame {
8
8
  frameIndex: number
9
9
  }
10
10
 
11
+ /**
12
+ * Precomputed timeline for one looping sequence: the per-item duration table
13
+ * is built once instead of per query. The sprite frame loop asks at
14
+ * animation rate (~60 Hz), where the per-call map/reduce is pure waste.
15
+ */
16
+ export interface SequenceTimeline {
17
+ frameAt(elapsedMs: number): SequenceFrame
18
+ }
19
+
20
+ /** Build a {@link SequenceTimeline} over one manifest sequence. */
21
+ export function createSequenceTimeline(
22
+ sequence: readonly PetAnimation[],
23
+ tracks: Record<PetAnimation, PetTrackDef>,
24
+ ): SequenceTimeline {
25
+ const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
26
+ const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
27
+ return {
28
+ frameAt(elapsedMs: number): SequenceFrame {
29
+ let offset = Math.max(0, elapsedMs) % sequenceDuration
30
+ let itemIndex = 0
31
+ while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
32
+ offset -= itemDurations[itemIndex]!
33
+ itemIndex += 1
34
+ }
35
+ const animation = sequence[itemIndex]!
36
+ const track = tracks[animation]
37
+ let frameIndex = 0
38
+ while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
39
+ offset -= track.durations[frameIndex]!
40
+ frameIndex += 1
41
+ }
42
+ return { animation, frameIndex }
43
+ },
44
+ }
45
+ }
46
+
11
47
  /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
12
48
  export function sequenceFrameAt(
13
49
  sequence: readonly PetAnimation[],
14
50
  tracks: Record<PetAnimation, PetTrackDef>,
15
51
  elapsedMs: number,
16
52
  ): SequenceFrame {
17
- const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
18
- const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
19
- let offset = Math.max(0, elapsedMs) % sequenceDuration
20
- let itemIndex = 0
21
- while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
22
- offset -= itemDurations[itemIndex]!
23
- itemIndex += 1
24
- }
25
- const animation = sequence[itemIndex]!
26
- const track = tracks[animation]
27
- let frameIndex = 0
28
- while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
29
- offset -= track.durations[frameIndex]!
30
- frameIndex += 1
31
- }
32
- return { animation, frameIndex }
53
+ return createSequenceTimeline(sequence, tracks).frameAt(elapsedMs)
33
54
  }
package/src/index.ts CHANGED
@@ -190,21 +190,37 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
190
190
  }
191
191
  }
192
192
  ctx.inject(['settings'], (settingsCtx) => {
193
- settingsCtx.settings.installSection(
194
- ctx,
195
- PET_SETTINGS_NAMESPACE as SettingsNamespace,
196
- makePetSettingsSchema(service.selectedPetId()),
197
- base,
198
- {
199
- setSource: (source) => { current = source },
200
- onChange: () => {
193
+ try {
194
+ const schema = makePetSettingsSchema(service.selectedPetId())
195
+ if (typeof settingsCtx.settings?.installSection === 'function') {
196
+ settingsCtx.settings.installSection(
197
+ ctx,
198
+ PET_SETTINGS_NAMESPACE as SettingsNamespace,
199
+ schema,
200
+ base,
201
+ {
202
+ setSource: (source) => { current = source },
203
+ onChange: () => {
204
+ const section = current()
205
+ service.applySettingsSection(section)
206
+ service.setEnabled(section.enabled ?? true)
207
+ syncRoutes()
208
+ },
209
+ },
210
+ )
211
+ } else if (typeof settingsCtx.settings?.register === 'function') {
212
+ const scope = settingsCtx.settings.register(PET_SETTINGS_NAMESPACE as SettingsNamespace, schema, { base })
213
+ current = () => scope?.get?.() ?? base
214
+ scope?.watch?.(() => {
201
215
  const section = current()
202
216
  service.applySettingsSection(section)
203
217
  service.setEnabled(section.enabled ?? true)
204
218
  syncRoutes()
205
- },
206
- },
207
- )
219
+ })
220
+ }
221
+ } catch {
222
+ // Defensive fallback against settings registration differences
223
+ }
208
224
  })
209
225
  syncRoutes()
210
226
  }
package/src/routes.ts CHANGED
@@ -65,14 +65,30 @@ function extensionOf(file: string): string {
65
65
  return dot < 0 ? '' : file.slice(dot).toLowerCase()
66
66
  }
67
67
 
68
+ /**
69
+ * Realpaths of containment bases, resolved once per base instead of once per
70
+ * request: every asset/runtime/decoration request used to pay a base
71
+ * realpathSync on the hot path. Bases are registry entry directories and the
72
+ * runtime roots — an immutable, registry-bounded set. Only successes are
73
+ * cached (a missing base keeps failing per request), and a symlinked base
74
+ * re-pointed mid-process fails containment until restart, which is the
75
+ * deny-safe direction.
76
+ */
77
+ const REAL_BASE_CACHE = new Map<string, string>()
78
+
68
79
  /**
69
80
  * realpath containment: resolve both sides and require the candidate to stay
70
81
  * inside the base directory. A pet directory (or an atlas/preview inside it)
71
- * that is a symlink escaping its root is rejected, never followed.
82
+ * that is a symlink escaping its root is rejected, never followed. The
83
+ * candidate is realpath'ed live on every call; only the base side is cached.
72
84
  */
73
85
  export function containedRealpath(base: string, candidate: string): string | undefined {
74
86
  try {
75
- const realBase = realpathSync(base)
87
+ let realBase = REAL_BASE_CACHE.get(base)
88
+ if (realBase === undefined) {
89
+ realBase = realpathSync(base)
90
+ REAL_BASE_CACHE.set(base, realBase)
91
+ }
76
92
  const realCandidate = realpathSync(candidate)
77
93
  return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
78
94
  ? realCandidate
@@ -98,6 +114,19 @@ function mimeFor(file: string): string {
98
114
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
99
115
  }
100
116
 
117
+ /** Weak validator from size + mtime, shared by the three file routes. */
118
+ function weakEtag(stat: { size: number; mtimeMs: number }): string {
119
+ return '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
120
+ }
121
+
122
+ /** Answer 304 when If-None-Match matches the etag; true when handled. */
123
+ function revalidated(req: IncomingMessage, res: ServerResponse, etag: string): boolean {
124
+ if (req.headers['if-none-match'] !== etag) return false
125
+ res.writeHead(304, { etag, 'cache-control': 'no-cache' })
126
+ res.end()
127
+ return true
128
+ }
129
+
101
130
  /** Require the method or answer 405. */
102
131
  function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
103
132
  if (req.method === method) return true
@@ -177,6 +206,13 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
177
206
  */
178
207
  function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
179
208
  const aliases = dirAliases(registry)
209
+ // The servable match is a Set probe instead of a linear scan: a full live2d
210
+ // closure routinely exceeds a hundred files, each fetched through this
211
+ // handler per mount. The registry is an immutable snapshot, so the sets are
212
+ // built once per handler.
213
+ const servableById = new Map<string, ReadonlySet<string>>(
214
+ registry.entries.map(entry => [entry.id, new Set(entry.servable)]),
215
+ )
180
216
  return ((req: IncomingMessage, res: ServerResponse) => {
181
217
  if (!guard(ctx, req, res)) return
182
218
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -231,7 +267,7 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
231
267
  const manifestFile = join(entry.dir, MANIFEST_FILE)
232
268
  file = existsSync(manifestFile) ? manifestFile : undefined
233
269
  if (file === undefined) synthesized = true
234
- } else if (rest.length > 0 && entry.servable.includes(rel)) {
270
+ } else if (rest.length > 0 && servableById.get(entry.id)?.has(rel)) {
235
271
  file = join(entry.dir, rel)
236
272
  } else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
237
273
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
@@ -267,8 +303,10 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
267
303
  const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
268
304
  ? caps.manifest
269
305
  : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model
306
+ let stat: ReturnType<typeof statSync>
270
307
  try {
271
- if (statSync(resolved).size > cap) {
308
+ stat = statSync(resolved)
309
+ if (stat.size > cap) {
272
310
  res.writeHead(413)
273
311
  res.end()
274
312
  return
@@ -278,11 +316,18 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
278
316
  res.end()
279
317
  return
280
318
  }
319
+ // 'no-cache' forces revalidation before every reuse, and the validator
320
+ // lets repeat requests settle as 304 — atlases and frames are the largest
321
+ // payloads the plugin serves (up to the 20 MB image cap), and without a
322
+ // validator every remount or page load would re-download them in full.
323
+ const etag = weakEtag(stat)
324
+ if (revalidated(req, res, etag)) return
281
325
  return readFile(resolved).then((body) => {
282
326
  res.writeHead(200, {
283
327
  'content-type': mimeFor(resolved),
284
328
  'content-length': String(body.byteLength),
285
329
  'cache-control': 'no-cache',
330
+ etag,
286
331
  })
287
332
  if (req.method === 'HEAD') {
288
333
  res.end()
@@ -373,8 +418,10 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
373
418
  res.end()
374
419
  return
375
420
  }
421
+ let stat: ReturnType<typeof statSync>
376
422
  try {
377
- if (statSync(resolved).size > PET_RUNTIME_CAP) {
423
+ stat = statSync(resolved)
424
+ if (stat.size > PET_RUNTIME_CAP) {
378
425
  res.writeHead(413)
379
426
  res.end()
380
427
  return
@@ -384,11 +431,17 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
384
431
  res.end()
385
432
  return
386
433
  }
434
+ // Same validator as the asset routes: the Cubism Core and vendor bundle
435
+ // ride every page load of a live2d pet, so revalidation should settle as
436
+ // 304 instead of re-downloading the full bodies.
437
+ const etag = weakEtag(stat)
438
+ if (revalidated(req, res, etag)) return
387
439
  return readFile(resolved).then((body) => {
388
440
  res.writeHead(200, {
389
441
  'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
390
442
  'content-length': String(body.byteLength),
391
443
  'cache-control': 'no-cache',
444
+ etag,
392
445
  })
393
446
  if (req.method === 'HEAD') {
394
447
  res.end()
@@ -496,12 +549,8 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
496
549
  // validator lets repeat requests settle as 304 — the ornament remounts
497
550
  // on whisper and display-session flips, and without a validator each
498
551
  // remount would re-download the full strip body.
499
- const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
500
- if (req.headers['if-none-match'] === etag) {
501
- res.writeHead(304, { etag, 'cache-control': 'no-cache' })
502
- res.end()
503
- return
504
- }
552
+ const etag = weakEtag(stat)
553
+ if (revalidated(req, res, etag)) return
505
554
  readFile(resolved).then((body) => {
506
555
  res.writeHead(200, {
507
556
  'content-type': mimeFor(resolved),