@linxin666/dsh-pet 0.2.9 → 0.3.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.
@@ -302,9 +302,20 @@ function resolveLive2dEntry(manifest, dir, options) {
302
302
  record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block');
303
303
  return undefined;
304
304
  }
305
+ const modelFile = join(dir, block.model);
305
306
  let model3;
306
307
  try {
307
- model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'));
308
+ // Stat guard before the read: a pathological model file — huge, or a
309
+ // FIFO/device — is skipped with a warning instead of stalling the host
310
+ // at scan time, mirroring the voice/decoration descriptor discipline.
311
+ // The guard stays silent on stat errors, so a missing or unreadable
312
+ // path is re-stat'ed here to fall through to the original fail-closed
313
+ // 'not readable' diagnostic below.
314
+ if (guardedScannedJsonStat(modelFile, options, 'live2d model ' + block.model, PET_SCAN_LIVE2D_MODEL_CAP) === undefined) {
315
+ statSync(modelFile);
316
+ return undefined;
317
+ }
318
+ model3 = JSON.parse(readFileSync(modelFile, 'utf8'));
308
319
  }
309
320
  catch (error) {
310
321
  record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
@@ -373,7 +384,7 @@ function scanPetDir(dir, options) {
373
384
  const manifestFile = join(dir, name, 'pet.json');
374
385
  if (!existsSync(manifestFile))
375
386
  continue;
376
- const parsed = readPetJson(manifestFile, options.warnings);
387
+ const parsed = readPetJson(manifestFile, options);
377
388
  if (parsed === undefined)
378
389
  continue;
379
390
  const entryDir = join(dir, name);
@@ -406,13 +417,20 @@ function scanPetDir(dir, options) {
406
417
  }
407
418
  return entries;
408
419
  }
409
- /** Read and parse one manifest file; undefined (warning recorded) on failure. */
410
- function readPetJson(file, warnings) {
420
+ /**
421
+ * Read and parse one pet.json manifest; undefined (warning recorded) on
422
+ * failure. The descriptor stat guard applies first: a pathological file —
423
+ * huge, or a FIFO/device — is skipped with a warning instead of stalling
424
+ * or OOM-ing the host at scan time (same discipline as voice/decoration).
425
+ */
426
+ function readPetJson(file, options) {
427
+ if (guardedScannedJsonStat(file, options, 'pet manifest') === undefined)
428
+ return undefined;
411
429
  try {
412
430
  return JSON.parse(readFileSync(file, 'utf8'));
413
431
  }
414
432
  catch (error) {
415
- warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)));
433
+ options.warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)));
416
434
  return undefined;
417
435
  }
418
436
  }
@@ -424,13 +442,23 @@ function readPetJson(file, warnings) {
424
442
  * discipline can apply (review-spd follow-up, pet-center M4/M5).
425
443
  */
426
444
  export const PET_SCAN_JSON_CAP = 64 * 1024;
445
+ /**
446
+ * Scan-time read ceiling for a live2d model3.json, matching the asset
447
+ * route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
448
+ * larger than the other scanned JSON, but a pathological file — huge, or a
449
+ * FIFO/device — must still be skipped with a warning instead of stalling
450
+ * or OOM-ing the host at plugin startup (same review-spd follow-up).
451
+ */
452
+ export const PET_SCAN_LIVE2D_MODEL_CAP = 32 * 1024 * 1024;
427
453
  /**
428
454
  * Stat one scanned JSON descriptor with a regular-file + size guard, so a
429
455
  * pathological user file is skipped with a warning instead of stalling or
430
456
  * OOM-ing the host at startup. Returns the Stats, or undefined when the
431
- * caller must skip the file (a warning was recorded).
457
+ * caller must skip the file (a warning was recorded). 'cap' defaults to
458
+ * the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
459
+ * larger live2d ceiling.
432
460
  */
433
- function guardedScannedJsonStat(file, options, what) {
461
+ function guardedScannedJsonStat(file, options, what, cap = PET_SCAN_JSON_CAP) {
434
462
  let st;
435
463
  try {
436
464
  st = statSync(file);
@@ -446,8 +474,8 @@ function guardedScannedJsonStat(file, options, what) {
446
474
  warn(what + ' is not a regular file; ignored');
447
475
  return undefined;
448
476
  }
449
- if (st.size > PET_SCAN_JSON_CAP) {
450
- warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored');
477
+ if (st.size > cap) {
478
+ warn(what + ' exceeds the ' + cap + '-byte scan ceiling; ignored');
451
479
  return undefined;
452
480
  }
453
481
  return st;
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAM9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;;IAEzB,iDAAiD;;IAEjD,8EAA8E;;CAEtE,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAsPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAwDjI;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAO9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;;IAEzB,iDAAiD;;IAEjD,8EAA8E;;CAEtE,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAwND,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAwDjI;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
@@ -17,6 +17,7 @@ import { join, sep } from 'node:path';
17
17
  import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot } from "./registry.js";
18
18
  import { isPetAllowed } from "./access.js";
19
19
  import { dshHome } from "./dsh-home.js";
20
+ import { readJsonBody, writeJson } from "./http.js";
20
21
  /** Browser-facing base path of the pet API. */
21
22
  export const PET_API_PREFIX = '/api/pet';
22
23
  /** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
@@ -76,52 +77,18 @@ function mimeFor(file) {
76
77
  return 'application/octet-stream';
77
78
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream';
78
79
  }
79
- /** Write one JSON response. */
80
- function json(res, status, body) {
81
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
82
- res.end(JSON.stringify(body));
83
- }
84
80
  /** Require the method or answer 405. */
85
81
  function requireMethod(req, res, method) {
86
82
  if (req.method === method)
87
83
  return true;
88
- json(res, 405, { ok: false, error: 'method-not-allowed' });
84
+ writeJson(res, 405, { ok: false, error: 'method-not-allowed' });
89
85
  return false;
90
86
  }
91
- /** Read a JSON request body (bounded). */
92
- function readJsonBody(req) {
93
- return new Promise((resolve, reject) => {
94
- let size = 0;
95
- const chunks = [];
96
- req.on('data', (chunk) => {
97
- size += chunk.length;
98
- if (size > 64 * 1024) {
99
- reject(new Error('body-too-large'));
100
- queueMicrotask(() => req.destroy());
101
- return;
102
- }
103
- chunks.push(chunk);
104
- });
105
- req.on('end', () => {
106
- if (chunks.length === 0) {
107
- resolve({});
108
- return;
109
- }
110
- try {
111
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
112
- }
113
- catch {
114
- reject(new Error('invalid-json'));
115
- }
116
- });
117
- req.on('error', reject);
118
- });
119
- }
120
87
  /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
121
88
  function guard(ctx, req, res) {
122
89
  if (isPetAllowed(ctx, req))
123
90
  return true;
124
- json(res, 403, { ok: false, error: 'forbidden: loopback-only' });
91
+ writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' });
125
92
  return false;
126
93
  }
127
94
  /** Wrap one async service call as a GET JSON route. */
@@ -134,8 +101,8 @@ function getRoute(ctx, path, run) {
134
101
  return;
135
102
  if (!requireMethod(req, res, 'GET'))
136
103
  return;
137
- run().then((value) => json(res, 200, value), (error) => {
138
- json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
104
+ run().then((value) => writeJson(res, 200, value), (error) => {
105
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
139
106
  });
140
107
  },
141
108
  };
@@ -150,13 +117,18 @@ function postRoute(ctx, path, run) {
150
117
  return Promise.resolve();
151
118
  if (!requireMethod(req, res, 'POST'))
152
119
  return Promise.resolve();
153
- return readJsonBody(req).then((body) => {
154
- const record = (typeof body === 'object' && body !== null) ? body : {};
155
- return run(record).then((value) => json(res, 200, value), (error) => {
156
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
120
+ // Shared lenient reader (64 KiB cap): an empty body yields null and is
121
+ // restored to {} at the call site (legacy empty-body semantics); invalid
122
+ // JSON and over-limit bodies also yield null, so the endpoint validators
123
+ // below keep answering 400 with the same { ok: false, error } envelope.
124
+ return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
125
+ const payload = parsed ?? {};
126
+ const record = (typeof payload === 'object' && payload !== null) ? payload : {};
127
+ return run(record).then((value) => writeJson(res, 200, value), (error) => {
128
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
157
129
  });
158
130
  }, (error) => {
159
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
131
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
160
132
  });
161
133
  },
162
134
  };
@@ -369,7 +341,7 @@ function runtimeHandler(ctx, roots) {
369
341
  const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir;
370
342
  const file = join(base, name);
371
343
  if (!existsSync(file)) {
372
- json(res, 404, { ok: false, error: 'runtime-file-missing', file: name });
344
+ writeJson(res, 404, { ok: false, error: 'runtime-file-missing', file: name });
373
345
  return;
374
346
  }
375
347
  const resolved = containedRealpath(base, file);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
- "version": "0.2.9",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -103,6 +103,9 @@ export class PetSettingsCardController {
103
103
  private diagnostics: PetDiagnosticView[] = []
104
104
  private loaded = false
105
105
  private attempts = 0
106
+ private disposed = false
107
+ /** Pending deferred-load or retry timer; cancelled by dispose(). */
108
+ private pendingTimer: number | undefined
106
109
 
107
110
  /** @param scope - the bound settings scope for the 'pet' namespace. */
108
111
  constructor(scope: SettingsScope<PetSettings>) {
@@ -120,7 +123,9 @@ export class PetSettingsCardController {
120
123
  // the first registry request until that pass completes so transport
121
124
  // plugins (notably remote-web-ui on a paired non-loopback origin) can
122
125
  // install their fetch channel before /api/pet/pets is issued.
123
- window.setTimeout(() => {
126
+ this.pendingTimer = window.setTimeout(() => {
127
+ this.pendingTimer = undefined
128
+ if (this.disposed) return
124
129
  void this.loadPets()
125
130
  void this.loadDiagnostics()
126
131
  }, 0)
@@ -130,6 +135,7 @@ export class PetSettingsCardController {
130
135
  private async loadDiagnostics(): Promise<void> {
131
136
  try {
132
137
  this.diagnostics = await fetchPetDiagnostics()
138
+ if (this.disposed) return
133
139
  this.store.set(this.projection())
134
140
  } catch {
135
141
  this.diagnostics = []
@@ -138,17 +144,23 @@ export class PetSettingsCardController {
138
144
 
139
145
  /** Resolve the registry choices once (retried a few times on failure). */
140
146
  private async loadPets(): Promise<void> {
141
- if (this.loaded) return
147
+ if (this.loaded || this.disposed) return
142
148
  try {
143
149
  const list = await fetchPetChoices()
150
+ if (this.disposed) return
144
151
  this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id))
145
152
  for (const choice of list) this.petLabels.set(choice.id, choice.displayName)
146
153
  this.loaded = true
147
154
  this.store.set(this.projection())
148
155
  } catch {
156
+ if (this.disposed) return
149
157
  this.attempts += 1
150
158
  if (this.attempts < 3) {
151
- window.setTimeout(() => { void this.loadPets() }, 3000)
159
+ this.pendingTimer = window.setTimeout(() => {
160
+ this.pendingTimer = undefined
161
+ if (this.disposed) return
162
+ void this.loadPets()
163
+ }, 3000)
152
164
  }
153
165
  }
154
166
  }
@@ -177,10 +189,16 @@ export class PetSettingsCardController {
177
189
  }
178
190
 
179
191
  /**
180
- * Release the card's scope subscription and bound stores; the slot
181
- * disposer calls this on teardown.
192
+ * Release the card's scope subscription, bound stores and pending load
193
+ * timers; the slot disposer calls this on teardown.
182
194
  */
183
195
  dispose(): void {
196
+ if (this.disposed) return
197
+ this.disposed = true
198
+ if (this.pendingTimer !== undefined) {
199
+ window.clearTimeout(this.pendingTimer)
200
+ this.pendingTimer = undefined
201
+ }
184
202
  this.form.dispose()
185
203
  }
186
204
  }
@@ -159,6 +159,22 @@ describe('PetSprite custom visual (pet-center M3)', () => {
159
159
  })
160
160
  })
161
161
 
162
+ describe('PetSprite always-visible close control', () => {
163
+ it('renders a corner close button and hides without petting', () => {
164
+ const onHide = vi.fn()
165
+ const onPet = vi.fn()
166
+ renderPet({ onHide, onPet })
167
+
168
+ const close = screen.getByTestId('pet-close')
169
+ expect(close.getAttribute('aria-label')).toBe('隐藏')
170
+ expect(close.getAttribute('title')).toBe('隐藏')
171
+ fireEvent.click(close)
172
+
173
+ expect(onHide).toHaveBeenCalledTimes(1)
174
+ expect(onPet).not.toHaveBeenCalled()
175
+ })
176
+ })
177
+
162
178
  describe('PetSprite rename input', () => {
163
179
  it('submits the draft on Enter outside composition', () => {
164
180
  const { onRename } = renderPet()
@@ -466,34 +466,58 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
466
466
  }}
467
467
  >
468
468
  <div
469
- ref={spriteRef}
470
- className={styles.sprite}
471
- style={{
472
- width: spriteWidth,
473
- height: spriteHeight,
474
- ...(props.visual === undefined
475
- ? {
476
- backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
477
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
478
- backgroundRepeat: 'no-repeat',
479
- backgroundPosition: '0 0',
480
- }
481
- : {}),
482
- cursor: dragRef.current === null ? 'grab' : 'grabbing',
483
- }}
484
- onPointerDown={onPointerDown}
485
- onPointerMove={onPointerMove}
486
- onPointerUp={onPointerUp}
487
- onClick={() => {
488
- // A pointer sequence that moved (dragged) still fires a trailing
489
- // click; skip the pet when that happened.
490
- if (draggedRef.current) return
491
- props.onPet()
492
- }}
493
- role="button"
494
- aria-label={definition.displayName}
469
+ className={styles.spriteWrap}
470
+ style={{ width: spriteWidth, height: spriteHeight }}
495
471
  >
496
- {props.visual}
472
+ <div
473
+ ref={spriteRef}
474
+ className={styles.sprite}
475
+ style={{
476
+ width: spriteWidth,
477
+ height: spriteHeight,
478
+ ...(props.visual === undefined
479
+ ? {
480
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
481
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
482
+ backgroundRepeat: 'no-repeat',
483
+ backgroundPosition: '0 0',
484
+ }
485
+ : {}),
486
+ cursor: dragRef.current === null ? 'grab' : 'grabbing',
487
+ }}
488
+ onPointerDown={onPointerDown}
489
+ onPointerMove={onPointerMove}
490
+ onPointerUp={onPointerUp}
491
+ onClick={() => {
492
+ // A pointer sequence that moved (dragged) still fires a trailing
493
+ // click; skip the pet when that happened.
494
+ if (draggedRef.current) return
495
+ props.onPet()
496
+ }}
497
+ role="button"
498
+ aria-label={definition.displayName}
499
+ >
500
+ {props.visual}
501
+ </div>
502
+ <button
503
+ type="button"
504
+ className={styles.closeButton}
505
+ aria-label={panelLabel('hide', props.t('pet.hide'))}
506
+ title={panelLabel('hide', props.t('pet.hide'))}
507
+ data-testid="pet-close"
508
+ onPointerDown={(e) => {
509
+ // Keep the close control from starting a drag on the sprite.
510
+ e.stopPropagation()
511
+ }}
512
+ onClick={(e) => {
513
+ // The close control sits beside the pet button; do not pet as a
514
+ // side effect of closing the overlay.
515
+ e.stopPropagation()
516
+ props.onHide()
517
+ }}
518
+ >
519
+ ×
520
+ </button>
497
521
  </div>
498
522
  {feedback !== null && (
499
523
  <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
@@ -121,8 +121,13 @@ export function apply(ctx: ClientContext): void {
121
121
 
122
122
  // First-level settings section: one staged form over the 'pet' settings
123
123
  // namespace, registered as a top-level settings page. The controller loads
124
- // the petId choices from the registry endpoint itself.
124
+ // the petId choices from the registry endpoint itself — the registry lists
125
+ // the available pets (built-in assets plus user dirs), so the section only
126
+ // ever shows installed pets. Installing new pets happens in the Workshop
127
+ // store.
125
128
  const petSettings = new PetSettingsCardController(settingsScope)
129
+ // The section entry owns the controller: unregistering it (fiber disposal,
130
+ // hot reload) releases the scope subscription through petSettings.dispose.
126
131
  ctx.slots.inject('settings.section', () => {
127
132
  const unregister = ctx.slots.register({
128
133
  name: 'settings.section',
@@ -133,8 +138,8 @@ export function apply(ctx: ClientContext): void {
133
138
  inject: () => petSettings.inject(),
134
139
  }, PetSettingsSection)
135
140
  return () => {
136
- petSettings.dispose()
137
141
  unregister()
142
+ petSettings.dispose()
138
143
  }
139
144
  })
140
145
 
@@ -9,11 +9,42 @@
9
9
  }
10
10
 
11
11
  .sprite {
12
- position: relative;
13
12
  image-rendering: auto;
14
13
  touch-action: none;
15
14
  }
16
15
 
16
+ .spriteWrap {
17
+ position: relative;
18
+ flex: 0 0 auto;
19
+ }
20
+
21
+ .closeButton {
22
+ position: absolute;
23
+ top: 4px;
24
+ right: 4px;
25
+ z-index: 2;
26
+ width: 24px;
27
+ height: 24px;
28
+ padding: 0;
29
+ border: 1px solid rgba(226, 232, 255, 0.72);
30
+ border-radius: 999px;
31
+ color: #f8fafc;
32
+ background: rgba(7, 11, 26, 0.78);
33
+ font: 600 18px/20px sans-serif;
34
+ cursor: pointer;
35
+ touch-action: manipulation;
36
+ transition: background 120ms ease, box-shadow 120ms ease;
37
+ }
38
+
39
+ .closeButton:hover {
40
+ background: rgba(44, 62, 126, 0.95);
41
+ }
42
+
43
+ .closeButton:focus-visible {
44
+ outline: none;
45
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.95);
46
+ }
47
+
17
48
  .bubble {
18
49
  position: absolute;
19
50
  bottom: 100%;
@@ -402,7 +433,8 @@
402
433
 
403
434
  .action,
404
435
  .summon,
405
- .bubbleMore {
436
+ .bubbleMore,
437
+ .closeButton {
406
438
  transition: none;
407
439
  }
408
440
  }
@@ -11,18 +11,18 @@
11
11
  border: 1px dashed var(--dsw-alias-border-l2);
12
12
  border-radius: 8px;
13
13
  font-size: 12px;
14
- color: var(--dsw-alias-label-dimmed);
14
+ color: var(--dsw-alias-label-tertiary);
15
15
  }
16
16
  .diagnosticsTitle {
17
17
  display: block;
18
18
  font-weight: 600;
19
19
  margin-bottom: 4px;
20
- color: var(--dsw-alias-label);
20
+ color: var(--dsw-alias-label-primary);
21
21
  }
22
22
  .diagnostics ul {
23
23
  margin: 0;
24
24
  padding-left: 16px;
25
25
  }
26
26
  .diagnostics li[data-level="error"] {
27
- color: var(--dsw-alias-error, #c04848);
27
+ color: var(--dsw-alias-label-error, #c04848);
28
28
  }
package/src/http.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared JSON body/response helpers for the host route families: one strict
4
+ * bounded body reader, one lenient bounded body reader, one JSON object
5
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
6
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
9
+ *
10
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
11
+ * edit this shared source and re-run the sync instead of editing a copy.
12
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
13
+ * belong in the same change as its introduction.
14
+ * @module dsh-web-ui-shared/host/http
15
+ */
16
+
17
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
18
+
19
+ /** Default body cap for readJsonBody: 64 KiB. */
20
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
21
+
22
+ /** Family-default JSON response headers; callers may append or override. */
23
+ const JSON_HEADERS = {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'referrer-policy': 'no-referrer',
26
+ } satisfies OutgoingHttpHeaders
27
+
28
+ /**
29
+ * Strict bounded body reader: parse a request body of at most maxBytes as
30
+ * JSON.
31
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
32
+ * invalid or empty payload.
33
+ */
34
+ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
35
+ const chunks: Buffer[] = []
36
+ let size = 0
37
+ for await (const chunk of req) {
38
+ const buffer = chunk as Buffer
39
+ size += buffer.length
40
+ if (size > maxBytes) throw new Error('body too large')
41
+ chunks.push(buffer)
42
+ }
43
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
44
+ }
45
+
46
+ /**
47
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
48
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
49
+ * Overflow destroys the request instead of draining the remainder (no drain
50
+ * call, matching the current repo-wide behavior); callers must not keep
51
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
52
+ * also yield null.
53
+ */
54
+ export async function readJsonBody(
55
+ req: IncomingMessage,
56
+ opts: { maxBytes?: number; objectOnly?: boolean } = {},
57
+ ): Promise<unknown | null> {
58
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
59
+ const chunks: Buffer[] = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buffer = chunk as Buffer
63
+ size += buffer.length
64
+ if (size > maxBytes) {
65
+ req.destroy()
66
+ return null
67
+ }
68
+ chunks.push(buffer)
69
+ }
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ if (text === '') return null
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (opts.objectOnly && !isJsonObject(parsed)) return null
75
+ return parsed
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
82
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
84
+ }
85
+
86
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
87
+ export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
88
+ return isJsonObject(value) ? value : undefined
89
+ }
90
+
91
+ /**
92
+ * Write one JSON response. Default headers are the family defaults
93
+ * (content-type and referrer-policy); caller headers are appended or
94
+ * override them.
95
+ */
96
+ export function writeJson(
97
+ res: ServerResponse,
98
+ status: number,
99
+ body: unknown,
100
+ headers: OutgoingHttpHeaders = {},
101
+ ): void {
102
+ const payload = JSON.stringify(body)
103
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
104
+ res.end(payload)
105
+ }