@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/LICENSE +202 -29
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/assets/whale-refined/previews/idle.gif +0 -0
- package/lib/client.js +68 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +115 -52
- package/lib/types/client/PetSettingsCard.d.ts +5 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +29 -5
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +27 -19
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +7 -2
- package/lib/types/http.d.ts +43 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +92 -0
- package/lib/types/registry.d.ts +8 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +16 -44
- package/lib/types/service.d.ts +8 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +19 -1
- package/package.json +1 -1
- package/src/client/PetSettingsCard.tsx +23 -5
- package/src/client/PetSprite.test.tsx +16 -0
- package/src/client/PetSprite.tsx +51 -27
- package/src/client/index.ts +7 -2
- package/src/client/pet.module.css +34 -2
- package/src/client/settings-section.module.css +3 -3
- package/src/http.ts +105 -0
- package/src/registry.test.ts +83 -1
- package/src/registry.ts +40 -8
- package/src/routes.ts +16 -45
- package/src/service.ts +19 -1
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
2875
|
-
|
|
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 >
|
|
2909
|
-
warn(what + " exceeds the
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
3742
|
-
|
|
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((
|
|
3759
|
-
|
|
3760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAC7F,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAI1F,OAAO,EAAoD,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,IAAI,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAG1J,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IAC1B,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,qBAAqB;IACrB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,6DAA6D;IAC7D,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,0CAA0C;AAC1C,MAAM,WAAW,oBAAqB,SAAQ,SAAS;IACrD,4BAA4B;IAC5B,OAAO,EAAE,cAAc,CAAA;IACvB,qBAAqB;IACrB,OAAO,EAAE,cAAc,CAAA;IACvB,iBAAiB;IACjB,IAAI,EAAE,cAAc,CAAA;IACpB,mBAAmB;IACnB,KAAK,EAAE,cAAc,CAAA;IACrB,oBAAoB;IACpB,MAAM,EAAE,cAAc,CAAA;IACtB,oBAAoB;IACpB,KAAK,EAAE,cAAc,CAAA;IACrB,uCAAuC;IACvC,iBAAiB,EAAE,cAAc,CAAA;IACjC,wEAAwE;IACxE,UAAU,EAAE,SAAS;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACvD,+EAA+E;IAC/E,cAAc,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAC7C;AAED,gEAAgE;AAChE,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,KAAK,EAAE;QACL,iEAAiE;QACjE,eAAe,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAA;KACrD,CAAA;CACF;AAQD,0EAA0E;AAC1E,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,OAAO,GAAG,SAAS,CAAA;IAC1B,OAAO,EAAE,MAAM,CAAA;CAChB;AAiBD,2DAA2D;AAC3D,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuB;IAC5C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAI3D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAe;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,WAAW,CAA0B;IAC7C,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,QAAQ,CAAI;
|
|
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(() => {
|
|
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
|
|
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,
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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,
|
|
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
|
+
}
|