@linxin666/dsh-pet 0.2.8 → 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.
package/lib/index.js CHANGED
@@ -2774,9 +2774,14 @@ function resolveLive2dEntry(manifest, dir, options) {
2774
2774
  record("error", "pet " + manifest.id + ": renderer live2d requires a live2d block");
2775
2775
  return;
2776
2776
  }
2777
+ const modelFile = join(dir, block.model);
2777
2778
  let model3;
2778
2779
  try {
2779
- model3 = JSON.parse(readFileSync(join(dir, block.model), "utf8"));
2780
+ if (guardedScannedJsonStat(modelFile, options, "live2d model " + block.model, 33554432) === void 0) {
2781
+ statSync(modelFile);
2782
+ return;
2783
+ }
2784
+ model3 = JSON.parse(readFileSync(modelFile, "utf8"));
2780
2785
  } catch (error) {
2781
2786
  record("error", "pet " + manifest.id + ": live2d model " + block.model + " is not readable: " + (error instanceof Error ? error.message : String(error)));
2782
2787
  return;
@@ -2833,7 +2838,7 @@ function scanPetDir(dir, options) {
2833
2838
  for (const name of names) {
2834
2839
  const manifestFile = join(dir, name, "pet.json");
2835
2840
  if (!existsSync(manifestFile)) continue;
2836
- const parsed = readPetJson(manifestFile, options.warnings);
2841
+ const parsed = readPetJson(manifestFile, options);
2837
2842
  if (parsed === void 0) continue;
2838
2843
  const entryDir = join(dir, name);
2839
2844
  const verdict = parsePetManifest(parsed, entryDir);
@@ -2871,22 +2876,38 @@ function scanPetDir(dir, options) {
2871
2876
  }
2872
2877
  return entries;
2873
2878
  }
2874
- /** Read and parse one manifest file; undefined (warning recorded) on failure. */
2875
- function readPetJson(file, warnings) {
2879
+ /**
2880
+ * Read and parse one pet.json manifest; undefined (warning recorded) on
2881
+ * failure. The descriptor stat guard applies first: a pathological file —
2882
+ * huge, or a FIFO/device — is skipped with a warning instead of stalling
2883
+ * or OOM-ing the host at scan time (same discipline as voice/decoration).
2884
+ */
2885
+ function readPetJson(file, options) {
2886
+ if (guardedScannedJsonStat(file, options, "pet manifest") === void 0) return void 0;
2876
2887
  try {
2877
2888
  return JSON.parse(readFileSync(file, "utf8"));
2878
2889
  } catch (error) {
2879
- warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
2890
+ options.warnings?.push("skipping " + file + ": " + (error instanceof Error ? error.message : String(error)));
2880
2891
  return;
2881
2892
  }
2882
2893
  }
2883
2894
  /**
2895
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
2896
+ * .voice.json, decoration.json): the registry reads these synchronously at
2897
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
2898
+ * symlink — must not hang or exhaust the host before the warn-and-drop
2899
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
2900
+ */
2901
+ const PET_SCAN_JSON_CAP = 64 * 1024;
2902
+ /**
2884
2903
  * Stat one scanned JSON descriptor with a regular-file + size guard, so a
2885
2904
  * pathological user file is skipped with a warning instead of stalling or
2886
2905
  * OOM-ing the host at startup. Returns the Stats, or undefined when the
2887
- * caller must skip the file (a warning was recorded).
2906
+ * caller must skip the file (a warning was recorded). 'cap' defaults to
2907
+ * the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
2908
+ * larger live2d ceiling.
2888
2909
  */
2889
- function guardedScannedJsonStat(file, options, what) {
2910
+ function guardedScannedJsonStat(file, options, what, cap = PET_SCAN_JSON_CAP) {
2890
2911
  let st;
2891
2912
  try {
2892
2913
  st = statSync(file);
@@ -2905,8 +2926,8 @@ function guardedScannedJsonStat(file, options, what) {
2905
2926
  warn(what + " is not a regular file; ignored");
2906
2927
  return;
2907
2928
  }
2908
- if (st.size > 65536) {
2909
- warn(what + " exceeds the 65536-byte scan ceiling; ignored");
2929
+ if (st.size > cap) {
2930
+ warn(what + " exceeds the " + cap + "-byte scan ceiling; ignored");
2910
2931
  return;
2911
2932
  }
2912
2933
  return st;
@@ -3203,6 +3224,12 @@ var PetService = class extends Service {
3203
3224
  * disposed sessions are removed by the 'session/disposed' listener.
3204
3225
  */
3205
3226
  sessionActivity = /* @__PURE__ */ new Map();
3227
+ /**
3228
+ * Sessions whose reward source is the official event stream. This metadata
3229
+ * outlives transient visual resets so a derived legacy `done` cannot reward
3230
+ * the same turn again after the pet is disabled and re-enabled.
3231
+ */
3232
+ officialEventSessions = /* @__PURE__ */ new WeakSet();
3206
3233
  constructor(ctx, config = {}) {
3207
3234
  super(ctx, "pet");
3208
3235
  this.persistDir = config.persistDir ?? petHomeDir();
@@ -3316,6 +3343,7 @@ var PetService = class extends Service {
3316
3343
  setEnabled(enabled) {
3317
3344
  this.enabled = enabled;
3318
3345
  this.syncActivity();
3346
+ if (!enabled) this.resetActivity();
3319
3347
  }
3320
3348
  syncActivity() {
3321
3349
  if (this.disposeActivity !== void 0) {
@@ -3340,10 +3368,12 @@ var PetService = class extends Service {
3340
3368
  const transition = projectOfficialEvent(event, runtime);
3341
3369
  if (transition === void 0) return;
3342
3370
  runtime.officialEventsSeen = true;
3371
+ this.officialEventSessions.add(session);
3343
3372
  this.applyActivity(session, transition.input, transition.whisper);
3344
3373
  if (transition.completedTurn !== void 0) this.rewardTurn(String(session.id), transition.completedTurn);
3345
3374
  }), this.ctx.on("session/disposed", (session) => {
3346
3375
  this.ledger.forgetSession(String(session.id));
3376
+ this.officialEventSessions.delete(session);
3347
3377
  this.sessionActivity.delete(session);
3348
3378
  if (session !== this.displaySession) return;
3349
3379
  this.displaySession = void 0;
@@ -3360,12 +3390,20 @@ var PetService = class extends Service {
3360
3390
  };
3361
3391
  })();
3362
3392
  }
3393
+ /** Drop transient activity because terminal events missed while disabled cannot be replayed safely. */
3394
+ resetActivity() {
3395
+ this.displaySession = void 0;
3396
+ this.sessionActivity.clear();
3397
+ this.machine.onSessionDisposed();
3398
+ }
3363
3399
  /** Return the per-session activity record, creating it on first sight. */
3364
3400
  activityOf(session) {
3365
3401
  let activity = this.sessionActivity.get(session);
3366
3402
  if (activity === void 0) {
3403
+ const runtime = emptyProjectionRuntime(this.voicePools());
3404
+ runtime.officialEventsSeen = this.officialEventSessions.has(session);
3367
3405
  activity = {
3368
- runtime: emptyProjectionRuntime(this.voicePools()),
3406
+ runtime,
3369
3407
  machine: new PetStateMachine(this.stateConfig)
3370
3408
  };
3371
3409
  this.sessionActivity.set(session, activity);
@@ -3604,6 +3642,63 @@ function isPairingAccess(value) {
3604
3642
  return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
3605
3643
  }
3606
3644
  //#endregion
3645
+ //#region src/http.ts
3646
+ /** Default body cap for readJsonBody: 64 KiB. */
3647
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
3648
+ /** Family-default JSON response headers; callers may append or override. */
3649
+ const JSON_HEADERS = {
3650
+ "content-type": "application/json; charset=utf-8",
3651
+ "referrer-policy": "no-referrer"
3652
+ };
3653
+ /**
3654
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
3655
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
3656
+ * Overflow destroys the request instead of draining the remainder (no drain
3657
+ * call, matching the current repo-wide behavior); callers must not keep
3658
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
3659
+ * also yield null.
3660
+ */
3661
+ async function readJsonBody(req, opts = {}) {
3662
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
3663
+ const chunks = [];
3664
+ let size = 0;
3665
+ for await (const chunk of req) {
3666
+ const buffer = chunk;
3667
+ size += buffer.length;
3668
+ if (size > maxBytes) {
3669
+ req.destroy();
3670
+ return null;
3671
+ }
3672
+ chunks.push(buffer);
3673
+ }
3674
+ const text = Buffer.concat(chunks).toString("utf8");
3675
+ if (text === "") return null;
3676
+ try {
3677
+ const parsed = JSON.parse(text);
3678
+ if (opts.objectOnly && !isJsonObject(parsed)) return null;
3679
+ return parsed;
3680
+ } catch {
3681
+ return null;
3682
+ }
3683
+ }
3684
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
3685
+ function isJsonObject(value) {
3686
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3687
+ }
3688
+ /**
3689
+ * Write one JSON response. Default headers are the family defaults
3690
+ * (content-type and referrer-policy); caller headers are appended or
3691
+ * override them.
3692
+ */
3693
+ function writeJson(res, status, body, headers = {}) {
3694
+ const payload = JSON.stringify(body);
3695
+ res.writeHead(status, {
3696
+ ...JSON_HEADERS,
3697
+ ...headers
3698
+ });
3699
+ res.end(payload);
3700
+ }
3701
+ //#endregion
3607
3702
  //#region src/routes.ts
3608
3703
  /**
3609
3704
  * Pet HTTP routes — the browser half talks to the host through plain
@@ -3679,52 +3774,19 @@ function mimeFor(file) {
3679
3774
  if (dot < 0) return "application/octet-stream";
3680
3775
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? "application/octet-stream";
3681
3776
  }
3682
- /** Write one JSON response. */
3683
- function json(res, status, body) {
3684
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3685
- res.end(JSON.stringify(body));
3686
- }
3687
3777
  /** Require the method or answer 405. */
3688
3778
  function requireMethod(req, res, method) {
3689
3779
  if (req.method === method) return true;
3690
- json(res, 405, {
3780
+ writeJson(res, 405, {
3691
3781
  ok: false,
3692
3782
  error: "method-not-allowed"
3693
3783
  });
3694
3784
  return false;
3695
3785
  }
3696
- /** Read a JSON request body (bounded). */
3697
- function readJsonBody(req) {
3698
- return new Promise((resolve, reject) => {
3699
- let size = 0;
3700
- const chunks = [];
3701
- req.on("data", (chunk) => {
3702
- size += chunk.length;
3703
- if (size > 64 * 1024) {
3704
- reject(/* @__PURE__ */ new Error("body-too-large"));
3705
- queueMicrotask(() => req.destroy());
3706
- return;
3707
- }
3708
- chunks.push(chunk);
3709
- });
3710
- req.on("end", () => {
3711
- if (chunks.length === 0) {
3712
- resolve({});
3713
- return;
3714
- }
3715
- try {
3716
- resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
3717
- } catch {
3718
- reject(/* @__PURE__ */ new Error("invalid-json"));
3719
- }
3720
- });
3721
- req.on("error", reject);
3722
- });
3723
- }
3724
3786
  /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
3725
3787
  function guard(ctx, req, res) {
3726
3788
  if (isPetAllowed(ctx, req)) return true;
3727
- json(res, 403, {
3789
+ writeJson(res, 403, {
3728
3790
  ok: false,
3729
3791
  error: "forbidden: loopback-only"
3730
3792
  });
@@ -3738,8 +3800,8 @@ function getRoute(ctx, path, run) {
3738
3800
  handler: (req, res) => {
3739
3801
  if (!guard(ctx, req, res)) return;
3740
3802
  if (!requireMethod(req, res, "GET")) return;
3741
- run().then((value) => json(res, 200, value), (error) => {
3742
- json(res, 500, {
3803
+ run().then((value) => writeJson(res, 200, value), (error) => {
3804
+ writeJson(res, 500, {
3743
3805
  ok: false,
3744
3806
  error: error instanceof Error ? error.message : String(error)
3745
3807
  });
@@ -3755,15 +3817,16 @@ function postRoute(ctx, path, run) {
3755
3817
  handler: (req, res) => {
3756
3818
  if (!guard(ctx, req, res)) return Promise.resolve();
3757
3819
  if (!requireMethod(req, res, "POST")) return Promise.resolve();
3758
- return readJsonBody(req).then((body) => {
3759
- return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
3760
- json(res, 400, {
3820
+ return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
3821
+ const payload = parsed ?? {};
3822
+ return run(typeof payload === "object" && payload !== null ? payload : {}).then((value) => writeJson(res, 200, value), (error) => {
3823
+ writeJson(res, 400, {
3761
3824
  ok: false,
3762
3825
  error: error instanceof Error ? error.message : String(error)
3763
3826
  });
3764
3827
  });
3765
3828
  }, (error) => {
3766
- json(res, 400, {
3829
+ writeJson(res, 400, {
3767
3830
  ok: false,
3768
3831
  error: error instanceof Error ? error.message : String(error)
3769
3832
  });
@@ -3959,7 +4022,7 @@ function runtimeHandler(ctx, roots) {
3959
4022
  const base = spec.root === "runtimeDir" ? roots.runtimeDir : roots.vendorDir;
3960
4023
  const file = join(base, name);
3961
4024
  if (!existsSync(file)) {
3962
- json(res, 404, {
4025
+ writeJson(res, 404, {
3963
4026
  ok: false,
3964
4027
  error: "runtime-file-missing",
3965
4028
  file: name
@@ -72,6 +72,9 @@ export declare class PetSettingsCardController {
72
72
  private diagnostics;
73
73
  private loaded;
74
74
  private attempts;
75
+ private disposed;
76
+ /** Pending deferred-load or retry timer; cancelled by dispose(). */
77
+ private pendingTimer;
75
78
  /** @param scope - the bound settings scope for the 'pet' namespace. */
76
79
  constructor(scope: SettingsScope<PetSettings>);
77
80
  /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
@@ -85,8 +88,8 @@ export declare class PetSettingsCardController {
85
88
  */
86
89
  inject(): PetSettingsCardFace;
87
90
  /**
88
- * Release the card's scope subscription and bound stores; the slot
89
- * disposer calls this on teardown.
91
+ * Release the card's scope subscription, bound stores and pending load
92
+ * timers; the slot disposer calls this on teardown.
90
93
  */
91
94
  dispose(): void;
92
95
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PetSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PetSettingsCard.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PetSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PetSettingsCard.tsx"],"names":[],"mappings":"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"}
@@ -29,6 +29,9 @@ export class PetSettingsCardController {
29
29
  diagnostics = [];
30
30
  loaded = false;
31
31
  attempts = 0;
32
+ disposed = false;
33
+ /** Pending deferred-load or retry timer; cancelled by dispose(). */
34
+ pendingTimer;
32
35
  /** @param scope - the bound settings scope for the 'pet' namespace. */
33
36
  constructor(scope) {
34
37
  this.form = new CardForm(scope, [
@@ -45,7 +48,10 @@ export class PetSettingsCardController {
45
48
  // the first registry request until that pass completes so transport
46
49
  // plugins (notably remote-web-ui on a paired non-loopback origin) can
47
50
  // install their fetch channel before /api/pet/pets is issued.
48
- window.setTimeout(() => {
51
+ this.pendingTimer = window.setTimeout(() => {
52
+ this.pendingTimer = undefined;
53
+ if (this.disposed)
54
+ return;
49
55
  void this.loadPets();
50
56
  void this.loadDiagnostics();
51
57
  }, 0);
@@ -54,6 +60,8 @@ export class PetSettingsCardController {
54
60
  async loadDiagnostics() {
55
61
  try {
56
62
  this.diagnostics = await fetchPetDiagnostics();
63
+ if (this.disposed)
64
+ return;
57
65
  this.store.set(this.projection());
58
66
  }
59
67
  catch {
@@ -62,10 +70,12 @@ export class PetSettingsCardController {
62
70
  }
63
71
  /** Resolve the registry choices once (retried a few times on failure). */
64
72
  async loadPets() {
65
- if (this.loaded)
73
+ if (this.loaded || this.disposed)
66
74
  return;
67
75
  try {
68
76
  const list = await fetchPetChoices();
77
+ if (this.disposed)
78
+ return;
69
79
  this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id));
70
80
  for (const choice of list)
71
81
  this.petLabels.set(choice.id, choice.displayName);
@@ -73,9 +83,16 @@ export class PetSettingsCardController {
73
83
  this.store.set(this.projection());
74
84
  }
75
85
  catch {
86
+ if (this.disposed)
87
+ return;
76
88
  this.attempts += 1;
77
89
  if (this.attempts < 3) {
78
- window.setTimeout(() => { void this.loadPets(); }, 3000);
90
+ this.pendingTimer = window.setTimeout(() => {
91
+ this.pendingTimer = undefined;
92
+ if (this.disposed)
93
+ return;
94
+ void this.loadPets();
95
+ }, 3000);
79
96
  }
80
97
  }
81
98
  }
@@ -101,10 +118,17 @@ export class PetSettingsCardController {
101
118
  return { hooks: { petSettingsCard: this.store }, ...this.form.actions() };
102
119
  }
103
120
  /**
104
- * Release the card's scope subscription and bound stores; the slot
105
- * disposer calls this on teardown.
121
+ * Release the card's scope subscription, bound stores and pending load
122
+ * timers; the slot disposer calls this on teardown.
106
123
  */
107
124
  dispose() {
125
+ if (this.disposed)
126
+ return;
127
+ this.disposed = true;
128
+ if (this.pendingTimer !== undefined) {
129
+ window.clearTimeout(this.pendingTimer);
130
+ this.pendingTimer = undefined;
131
+ }
108
132
  this.form.dispose();
109
133
  }
110
134
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAgGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAygB5D"}
1
+ {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AAgGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAiiB5D"}
@@ -407,25 +407,33 @@ export function PetSprite(props) {
407
407
  return;
408
408
  clearHideTimer();
409
409
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300);
410
- }, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
411
- width: spriteWidth,
412
- height: spriteHeight,
413
- ...(props.visual === undefined
414
- ? {
415
- backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
416
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
417
- backgroundRepeat: 'no-repeat',
418
- backgroundPosition: '0 0',
419
- }
420
- : {}),
421
- cursor: dragRef.current === null ? 'grab' : 'grabbing',
422
- }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onClick: () => {
423
- // A pointer sequence that moved (dragged) still fires a trailing
424
- // click; skip the pet when that happened.
425
- if (draggedRef.current)
426
- return;
427
- props.onPet();
428
- }, role: "button", "aria-label": definition.displayName, children: props.visual }), feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
410
+ }, children: [_jsxs("div", { className: styles.spriteWrap, style: { width: spriteWidth, height: spriteHeight }, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
411
+ width: spriteWidth,
412
+ height: spriteHeight,
413
+ ...(props.visual === undefined
414
+ ? {
415
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
416
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
417
+ backgroundRepeat: 'no-repeat',
418
+ backgroundPosition: '0 0',
419
+ }
420
+ : {}),
421
+ cursor: dragRef.current === null ? 'grab' : 'grabbing',
422
+ }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onClick: () => {
423
+ // A pointer sequence that moved (dragged) still fires a trailing
424
+ // click; skip the pet when that happened.
425
+ if (draggedRef.current)
426
+ return;
427
+ props.onPet();
428
+ }, role: "button", "aria-label": definition.displayName, children: props.visual }), _jsx("button", { type: "button", className: styles.closeButton, "aria-label": panelLabel('hide', props.t('pet.hide')), title: panelLabel('hide', props.t('pet.hide')), "data-testid": "pet-close", onPointerDown: (e) => {
429
+ // Keep the close control from starting a drag on the sprite.
430
+ e.stopPropagation();
431
+ }, onClick: (e) => {
432
+ // The close control sits beside the pet button; do not pet as a
433
+ // side effect of closing the overlay.
434
+ e.stopPropagation();
435
+ props.onHide();
436
+ }, children: "\u00D7" })] }), feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
429
437
  // The whisper rides the display session's bubble — the stack's
430
438
  // primary entry (DOM-first, rendered bottom-most by the reversed
431
439
  // column so it stays glued to the sprite when extras open above).
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAgEnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqQ9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAgEnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA0Q9C"}
@@ -70,8 +70,13 @@ export function apply(ctx) {
70
70
  };
71
71
  // First-level settings section: one staged form over the 'pet' settings
72
72
  // namespace, registered as a top-level settings page. The controller loads
73
- // the petId choices from the registry endpoint itself.
73
+ // the petId choices from the registry endpoint itself — the registry lists
74
+ // the available pets (built-in assets plus user dirs), so the section only
75
+ // ever shows installed pets. Installing new pets happens in the Workshop
76
+ // store.
74
77
  const petSettings = new PetSettingsCardController(settingsScope);
78
+ // The section entry owns the controller: unregistering it (fiber disposal,
79
+ // hot reload) releases the scope subscription through petSettings.dispose.
75
80
  ctx.slots.inject('settings.section', () => {
76
81
  const unregister = ctx.slots.register({
77
82
  name: 'settings.section',
@@ -82,8 +87,8 @@ export function apply(ctx) {
82
87
  inject: () => petSettings.inject(),
83
88
  }, PetSettingsSection);
84
89
  return () => {
85
- petSettings.dispose();
86
90
  unregister();
91
+ petSettings.dispose();
87
92
  };
88
93
  });
89
94
  // The global pet entry, its store, and the poll loop live while the plugin
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared JSON body/response helpers for the host route families: one strict
3
+ * bounded body reader, one lenient bounded body reader, one JSON object
4
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
6
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
7
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
8
+ *
9
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
10
+ * edit this shared source and re-run the sync instead of editing a copy.
11
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
12
+ * belong in the same change as its introduction.
13
+ * @module dsh-web-ui-shared/host/http
14
+ */
15
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
+ /**
17
+ * Strict bounded body reader: parse a request body of at most maxBytes as
18
+ * JSON.
19
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
20
+ * invalid or empty payload.
21
+ */
22
+ export declare function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown>;
23
+ /**
24
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
25
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
26
+ * Overflow destroys the request instead of draining the remainder (no drain
27
+ * call, matching the current repo-wide behavior); callers must not keep
28
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
29
+ * also yield null.
30
+ */
31
+ export declare function readJsonBody(req: IncomingMessage, opts?: {
32
+ maxBytes?: number;
33
+ objectOnly?: boolean;
34
+ }): Promise<unknown | null>;
35
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
36
+ export declare function asJsonObject(value: unknown): Record<string, unknown> | undefined;
37
+ /**
38
+ * Write one JSON response. Default headers are the family defaults
39
+ * (content-type and referrer-policy); caller headers are appended or
40
+ * override them.
41
+ */
42
+ export declare function writeJson(res: ServerResponse, status: number, body: unknown, headers?: OutgoingHttpHeaders): void;
43
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/http.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAWrF;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU9F;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,eAAe,EACpB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GACrD,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAsBzB;AAOD,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAEhF;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,mBAAwB,GAChC,IAAI,CAIN"}
@@ -0,0 +1,92 @@
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
+ /** Default body cap for readJsonBody: 64 KiB. */
17
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
18
+ /** Family-default JSON response headers; callers may append or override. */
19
+ const JSON_HEADERS = {
20
+ 'content-type': 'application/json; charset=utf-8',
21
+ 'referrer-policy': 'no-referrer',
22
+ };
23
+ /**
24
+ * Strict bounded body reader: parse a request body of at most maxBytes as
25
+ * JSON.
26
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
27
+ * invalid or empty payload.
28
+ */
29
+ export async function readBoundedJson(req, maxBytes) {
30
+ const chunks = [];
31
+ let size = 0;
32
+ for await (const chunk of req) {
33
+ const buffer = chunk;
34
+ size += buffer.length;
35
+ if (size > maxBytes)
36
+ throw new Error('body too large');
37
+ chunks.push(buffer);
38
+ }
39
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
40
+ }
41
+ /**
42
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
43
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
44
+ * Overflow destroys the request instead of draining the remainder (no drain
45
+ * call, matching the current repo-wide behavior); callers must not keep
46
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
47
+ * also yield null.
48
+ */
49
+ export async function readJsonBody(req, opts = {}) {
50
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
51
+ const chunks = [];
52
+ let size = 0;
53
+ for await (const chunk of req) {
54
+ const buffer = chunk;
55
+ size += buffer.length;
56
+ if (size > maxBytes) {
57
+ req.destroy();
58
+ return null;
59
+ }
60
+ chunks.push(buffer);
61
+ }
62
+ const text = Buffer.concat(chunks).toString('utf8');
63
+ if (text === '')
64
+ return null;
65
+ try {
66
+ const parsed = JSON.parse(text);
67
+ if (opts.objectOnly && !isJsonObject(parsed))
68
+ return null;
69
+ return parsed;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
76
+ function isJsonObject(value) {
77
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
78
+ }
79
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
80
+ export function asJsonObject(value) {
81
+ return isJsonObject(value) ? value : undefined;
82
+ }
83
+ /**
84
+ * Write one JSON response. Default headers are the family defaults
85
+ * (content-type and referrer-policy); caller headers are appended or
86
+ * override them.
87
+ */
88
+ export function writeJson(res, status, body, headers = {}) {
89
+ const payload = JSON.stringify(body);
90
+ res.writeHead(status, { ...JSON_HEADERS, ...headers });
91
+ res.end(payload);
92
+ }