@dreb/dashboard 2.45.2 → 2.45.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/server/dashboard-images.d.ts +56 -0
- package/dist/server/dashboard-images.d.ts.map +1 -0
- package/dist/server/dashboard-images.js +317 -0
- package/dist/server/dashboard-images.js.map +1 -0
- package/dist/server/event-hub.d.ts +3 -0
- package/dist/server/event-hub.d.ts.map +1 -1
- package/dist/server/event-hub.js +7 -1
- package/dist/server/event-hub.js.map +1 -1
- package/dist/server/image-preview-worker.d.ts +7 -0
- package/dist/server/image-preview-worker.d.ts.map +1 -0
- package/dist/server/image-preview-worker.js +150 -0
- package/dist/server/image-preview-worker.js.map +1 -0
- package/dist/server/image-preview.d.ts +29 -0
- package/dist/server/image-preview.d.ts.map +1 -0
- package/dist/server/image-preview.js +76 -0
- package/dist/server/image-preview.js.map +1 -0
- package/dist/server/server.d.ts +8 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +85 -4
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +16 -0
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js +1 -0
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/assets/index-BcGevXt-.css +1 -0
- package/dist/static/assets/index-d9zGbh25.js +79 -0
- package/dist/static/index.html +2 -2
- package/dist/static/sw.js +1 -1
- package/package.json +2 -1
- package/dist/static/assets/index-BJcyaSXg.js +0 -79
- package/dist/static/assets/index-DdcUWCjw.css +0 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import * as photon from "@silvia-odwyer/photon-node";
|
|
3
|
+
import { MAX_PREVIEW_BYTES, MAX_PREVIEW_HEIGHT, MAX_PREVIEW_WIDTH } from "./image-preview.js";
|
|
4
|
+
function readExifOrientation(bytes) {
|
|
5
|
+
let tiff = -1;
|
|
6
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
7
|
+
let offset = 2;
|
|
8
|
+
while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {
|
|
9
|
+
const marker = bytes[offset + 1];
|
|
10
|
+
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
|
11
|
+
if (marker === 0xe1 && offset + 10 <= bytes.length) {
|
|
12
|
+
const start = offset + 4;
|
|
13
|
+
if (String.fromCharCode(...bytes.slice(start, start + 6)) === "Exif\0\0")
|
|
14
|
+
tiff = start + 6;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (length < 2)
|
|
18
|
+
break;
|
|
19
|
+
offset += 2 + length;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else if (bytes.length >= 12 &&
|
|
23
|
+
String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" &&
|
|
24
|
+
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP") {
|
|
25
|
+
let offset = 12;
|
|
26
|
+
while (offset + 8 <= bytes.length) {
|
|
27
|
+
const chunk = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
|
28
|
+
const size = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
|
|
29
|
+
const start = offset + 8;
|
|
30
|
+
if (chunk === "EXIF") {
|
|
31
|
+
tiff = String.fromCharCode(...bytes.slice(start, start + 6)) === "Exif\0\0" ? start + 6 : start;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
offset = start + size + (size % 2);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (tiff < 0 || tiff + 8 > bytes.length)
|
|
38
|
+
return 1;
|
|
39
|
+
const little = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
|
|
40
|
+
const read16 = (at) => (little ? bytes[at] | (bytes[at + 1] << 8) : (bytes[at] << 8) | bytes[at + 1]);
|
|
41
|
+
const read32 = (at) => little
|
|
42
|
+
? (bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24)) >>> 0
|
|
43
|
+
: ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0;
|
|
44
|
+
const ifd = tiff + read32(tiff + 4);
|
|
45
|
+
if (ifd + 2 > bytes.length)
|
|
46
|
+
return 1;
|
|
47
|
+
const count = read16(ifd);
|
|
48
|
+
for (let index = 0; index < count; index++) {
|
|
49
|
+
const entry = ifd + 2 + index * 12;
|
|
50
|
+
if (entry + 12 > bytes.length)
|
|
51
|
+
break;
|
|
52
|
+
if (read16(entry) === 0x0112) {
|
|
53
|
+
const value = read16(entry + 8);
|
|
54
|
+
return value >= 1 && value <= 8 ? value : 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
function rotate(image, clockwise) {
|
|
60
|
+
const width = image.get_width();
|
|
61
|
+
const height = image.get_height();
|
|
62
|
+
const source = image.get_raw_pixels();
|
|
63
|
+
const target = new Uint8Array(source.length);
|
|
64
|
+
for (let y = 0; y < height; y++) {
|
|
65
|
+
for (let x = 0; x < width; x++) {
|
|
66
|
+
const sourceIndex = (y * width + x) * 4;
|
|
67
|
+
const destinationPixel = clockwise ? x * height + (height - 1 - y) : (width - 1 - x) * height + y;
|
|
68
|
+
target.set(source.slice(sourceIndex, sourceIndex + 4), destinationPixel * 4);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return new photon.PhotonImage(target, height, width);
|
|
72
|
+
}
|
|
73
|
+
function orient(image, bytes) {
|
|
74
|
+
const orientation = readExifOrientation(bytes);
|
|
75
|
+
if (orientation === 2)
|
|
76
|
+
photon.fliph(image);
|
|
77
|
+
else if (orientation === 3) {
|
|
78
|
+
photon.fliph(image);
|
|
79
|
+
photon.flipv(image);
|
|
80
|
+
}
|
|
81
|
+
else if (orientation === 4)
|
|
82
|
+
photon.flipv(image);
|
|
83
|
+
else if (orientation >= 5) {
|
|
84
|
+
const rotated = rotate(image, orientation === 5 || orientation === 6);
|
|
85
|
+
image.free();
|
|
86
|
+
image = rotated;
|
|
87
|
+
if (orientation === 5 || orientation === 7)
|
|
88
|
+
photon.fliph(image);
|
|
89
|
+
}
|
|
90
|
+
return image;
|
|
91
|
+
}
|
|
92
|
+
export function generateImagePreview(bytes) {
|
|
93
|
+
let image = photon.PhotonImage.new_from_byteslice(bytes);
|
|
94
|
+
try {
|
|
95
|
+
image = orient(image, bytes);
|
|
96
|
+
const originalWidth = image.get_width();
|
|
97
|
+
const originalHeight = image.get_height();
|
|
98
|
+
if (originalWidth < 1 || originalHeight < 1)
|
|
99
|
+
throw new Error("Image has invalid dimensions");
|
|
100
|
+
const scale = Math.min(1, MAX_PREVIEW_WIDTH / originalWidth, MAX_PREVIEW_HEIGHT / originalHeight);
|
|
101
|
+
let width = Math.max(1, Math.round(originalWidth * scale));
|
|
102
|
+
let height = Math.max(1, Math.round(originalHeight * scale));
|
|
103
|
+
while (true) {
|
|
104
|
+
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
|
|
105
|
+
try {
|
|
106
|
+
const candidates = [
|
|
107
|
+
{ bytes: resized.get_bytes(), mimeType: "image/png" },
|
|
108
|
+
...[85, 70, 55, 40].map((quality) => ({
|
|
109
|
+
bytes: resized.get_bytes_jpeg(quality),
|
|
110
|
+
mimeType: "image/jpeg",
|
|
111
|
+
})),
|
|
112
|
+
];
|
|
113
|
+
const acceptable = candidates.filter((candidate) => candidate.bytes.byteLength <= MAX_PREVIEW_BYTES);
|
|
114
|
+
if (acceptable.length > 0) {
|
|
115
|
+
acceptable.sort((left, right) => left.bytes.byteLength - right.bytes.byteLength);
|
|
116
|
+
return { ...acceptable[0], width, height };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
resized.free();
|
|
121
|
+
}
|
|
122
|
+
if (width === 1 && height === 1)
|
|
123
|
+
break;
|
|
124
|
+
width = Math.max(1, Math.floor(width * 0.75));
|
|
125
|
+
height = Math.max(1, Math.floor(height * 0.75));
|
|
126
|
+
}
|
|
127
|
+
throw new Error("Could not encode image within the 256 KiB preview limit");
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
image.free();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const port = parentPort;
|
|
134
|
+
if (port) {
|
|
135
|
+
port.on("message", (request) => {
|
|
136
|
+
try {
|
|
137
|
+
const preview = generateImagePreview(new Uint8Array(request.bytes));
|
|
138
|
+
const transferable = Uint8Array.from(preview.bytes);
|
|
139
|
+
port.postMessage({ id: request.id, ok: true, ...preview, bytes: transferable.buffer }, [transferable.buffer]);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
port.postMessage({
|
|
143
|
+
id: request.id,
|
|
144
|
+
ok: false,
|
|
145
|
+
error: error instanceof Error ? error.message : String(error),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=image-preview-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-preview-worker.js","sourceRoot":"","sources":["../../src/server/image-preview-worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,MAAM,MAAM,4BAA4B,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAU9F,SAAS,mBAAmB,CAAC,KAAiB,EAAU;IACvD,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC;IACd,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QACjE,IAAI,MAAM,GAAG,CAAC,CAAC;QACf,OAAO,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;YAC7D,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YACjC,MAAM,MAAM,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YAC5D,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;gBACpD,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;gBACzB,IAAI,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,KAAK,UAAU;oBAAE,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC;gBAC3F,MAAM;YACP,CAAC;YACD,IAAI,MAAM,GAAG,CAAC;gBAAE,MAAM;YACtB,MAAM,IAAI,CAAC,GAAG,MAAM,CAAC;QACtB,CAAC;IACF,CAAC;SAAM,IACN,KAAK,CAAC,MAAM,IAAI,EAAE;QAClB,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,MAAM;QACpD,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,MAAM,EACpD,CAAC;QACF,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,OAAO,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;YACnC,MAAM,KAAK,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;YACtE,MAAM,IAAI,GACT,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YACtG,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;YACzB,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;gBACtB,IAAI,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;gBAChG,MAAM;YACP,CAAC;YACD,MAAM,GAAG,KAAK,GAAG,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC;QACpC,CAAC;IACF,CAAC;IACD,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC;IAChE,MAAM,MAAM,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC;IAC9G,MAAM,MAAM,GAAG,CAAC,EAAU,EAAE,EAAE,CAC7B,MAAM;QACL,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC;QAC1F,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC7F,MAAM,GAAG,GAAG,IAAI,GAAG,MAAM,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC;IACpC,IAAI,GAAG,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IACrC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC;QAC5C,MAAM,KAAK,GAAG,GAAG,GAAG,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;QACnC,IAAI,KAAK,GAAG,EAAE,GAAG,KAAK,CAAC,MAAM;YAAE,MAAM;QACrC,IAAI,MAAM,CAAC,KAAK,CAAC,KAAK,MAAM,EAAE,CAAC;YAC9B,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAChC,OAAO,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7C,CAAC;IACF,CAAC;IACD,OAAO,CAAC,CAAC;AAAA,CACT;AAED,SAAS,MAAM,CAAC,KAAkB,EAAE,SAAkB,EAAe;IACpE,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;IAChC,MAAM,MAAM,GAAG,KAAK,CAAC,UAAU,EAAE,CAAC;IAClC,MAAM,MAAM,GAAG,KAAK,CAAC,cAAc,EAAE,CAAC;IACtC,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACjC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;YAChC,MAAM,WAAW,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;YACxC,MAAM,gBAAgB,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,MAAM,GAAG,CAAC,CAAC;YAClG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,WAAW,GAAG,CAAC,CAAC,EAAE,gBAAgB,GAAG,CAAC,CAAC,CAAC;QAC9E,CAAC;IACF,CAAC;IACD,OAAO,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;AAAA,CACrD;AAED,SAAS,MAAM,CAAC,KAAkB,EAAE,KAAiB,EAAe;IACnE,MAAM,WAAW,GAAG,mBAAmB,CAAC,KAAK,CAAC,CAAC;IAC/C,IAAI,WAAW,KAAK,CAAC;QAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;SACtC,IAAI,WAAW,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACpB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACrB,CAAC;SAAM,IAAI,WAAW,KAAK,CAAC;QAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;SAC7C,IAAI,WAAW,IAAI,CAAC,EAAE,CAAC;QAC3B,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,EAAE,WAAW,KAAK,CAAC,IAAI,WAAW,KAAK,CAAC,CAAC,CAAC;QACtE,KAAK,CAAC,IAAI,EAAE,CAAC;QACb,KAAK,GAAG,OAAO,CAAC;QAChB,IAAI,WAAW,KAAK,CAAC,IAAI,WAAW,KAAK,CAAC;YAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACjE,CAAC;IACD,OAAO,KAAK,CAAC;AAAA,CACb;AAED,MAAM,UAAU,oBAAoB,CAAC,KAAiB,EAKpD;IACD,IAAI,KAAK,GAAG,MAAM,CAAC,WAAW,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;IACzD,IAAI,CAAC;QACJ,KAAK,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC7B,MAAM,aAAa,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;QACxC,MAAM,cAAc,GAAG,KAAK,CAAC,UAAU,EAAE,CAAC;QAC1C,IAAI,aAAa,GAAG,CAAC,IAAI,cAAc,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAC7F,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,iBAAiB,GAAG,aAAa,EAAE,kBAAkB,GAAG,cAAc,CAAC,CAAC;QAClG,IAAI,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,aAAa,GAAG,KAAK,CAAC,CAAC,CAAC;QAC3D,IAAI,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc,GAAG,KAAK,CAAC,CAAC,CAAC;QAC7D,OAAO,IAAI,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;YACpF,IAAI,CAAC;gBACJ,MAAM,UAAU,GAAuE;oBACtF,EAAE,KAAK,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE;oBACrD,GAAI,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAW,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;wBAChD,KAAK,EAAE,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC;wBACtC,QAAQ,EAAE,YAAqB;qBAC/B,CAAC,CAAC;iBACH,CAAC;gBACF,MAAM,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAC;gBACrG,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC3B,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;oBACjF,OAAO,EAAE,GAAG,UAAU,CAAC,CAAC,CAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;gBAC7C,CAAC;YACF,CAAC;oBAAS,CAAC;gBACV,OAAO,CAAC,IAAI,EAAE,CAAC;YAChB,CAAC;YACD,IAAI,KAAK,KAAK,CAAC,IAAI,MAAM,KAAK,CAAC;gBAAE,MAAM;YACvC,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC;YAC9C,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC;QACjD,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC5E,CAAC;YAAS,CAAC;QACV,KAAK,CAAC,IAAI,EAAE,CAAC;IACd,CAAC;AAAA,CACD;AAED,MAAM,IAAI,GAAG,UAAU,CAAC;AACxB,IAAI,IAAI,EAAE,CAAC;IACV,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAuB,EAAE,EAAE,CAAC;QAC/C,IAAI,CAAC;YACJ,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;YACpE,MAAM,YAAY,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YACpD,IAAI,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,KAAK,EAAE,YAAY,CAAC,MAAM,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/G,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,IAAI,CAAC,WAAW,CAAC;gBAChB,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,EAAE,EAAE,KAAK;gBACT,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;aAC7D,CAAC,CAAC;QACJ,CAAC;IAAA,CACD,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import { parentPort } from \"node:worker_threads\";\nimport * as photon from \"@silvia-odwyer/photon-node\";\nimport { MAX_PREVIEW_BYTES, MAX_PREVIEW_HEIGHT, MAX_PREVIEW_WIDTH } from \"./image-preview.js\";\n\ntype PhotonImage = ReturnType<typeof photon.PhotonImage.new_from_byteslice>;\n\ninterface PreviewRequest {\n\tid: number;\n\tbytes: ArrayBuffer;\n\tmimeType: string;\n}\n\nfunction readExifOrientation(bytes: Uint8Array): number {\n\tlet tiff = -1;\n\tif (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {\n\t\tlet offset = 2;\n\t\twhile (offset + 4 <= bytes.length && bytes[offset] === 0xff) {\n\t\t\tconst marker = bytes[offset + 1];\n\t\t\tconst length = (bytes[offset + 2] << 8) | bytes[offset + 3];\n\t\t\tif (marker === 0xe1 && offset + 10 <= bytes.length) {\n\t\t\t\tconst start = offset + 4;\n\t\t\t\tif (String.fromCharCode(...bytes.slice(start, start + 6)) === \"Exif\\0\\0\") tiff = start + 6;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif (length < 2) break;\n\t\t\toffset += 2 + length;\n\t\t}\n\t} else if (\n\t\tbytes.length >= 12 &&\n\t\tString.fromCharCode(...bytes.slice(0, 4)) === \"RIFF\" &&\n\t\tString.fromCharCode(...bytes.slice(8, 12)) === \"WEBP\"\n\t) {\n\t\tlet offset = 12;\n\t\twhile (offset + 8 <= bytes.length) {\n\t\t\tconst chunk = String.fromCharCode(...bytes.slice(offset, offset + 4));\n\t\t\tconst size =\n\t\t\t\tbytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);\n\t\t\tconst start = offset + 8;\n\t\t\tif (chunk === \"EXIF\") {\n\t\t\t\ttiff = String.fromCharCode(...bytes.slice(start, start + 6)) === \"Exif\\0\\0\" ? start + 6 : start;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\toffset = start + size + (size % 2);\n\t\t}\n\t}\n\tif (tiff < 0 || tiff + 8 > bytes.length) return 1;\n\tconst little = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;\n\tconst read16 = (at: number) => (little ? bytes[at] | (bytes[at + 1] << 8) : (bytes[at] << 8) | bytes[at + 1]);\n\tconst read32 = (at: number) =>\n\t\tlittle\n\t\t\t? (bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24)) >>> 0\n\t\t\t: ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0;\n\tconst ifd = tiff + read32(tiff + 4);\n\tif (ifd + 2 > bytes.length) return 1;\n\tconst count = read16(ifd);\n\tfor (let index = 0; index < count; index++) {\n\t\tconst entry = ifd + 2 + index * 12;\n\t\tif (entry + 12 > bytes.length) break;\n\t\tif (read16(entry) === 0x0112) {\n\t\t\tconst value = read16(entry + 8);\n\t\t\treturn value >= 1 && value <= 8 ? value : 1;\n\t\t}\n\t}\n\treturn 1;\n}\n\nfunction rotate(image: PhotonImage, clockwise: boolean): PhotonImage {\n\tconst width = image.get_width();\n\tconst height = image.get_height();\n\tconst source = image.get_raw_pixels();\n\tconst target = new Uint8Array(source.length);\n\tfor (let y = 0; y < height; y++) {\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tconst sourceIndex = (y * width + x) * 4;\n\t\t\tconst destinationPixel = clockwise ? x * height + (height - 1 - y) : (width - 1 - x) * height + y;\n\t\t\ttarget.set(source.slice(sourceIndex, sourceIndex + 4), destinationPixel * 4);\n\t\t}\n\t}\n\treturn new photon.PhotonImage(target, height, width);\n}\n\nfunction orient(image: PhotonImage, bytes: Uint8Array): PhotonImage {\n\tconst orientation = readExifOrientation(bytes);\n\tif (orientation === 2) photon.fliph(image);\n\telse if (orientation === 3) {\n\t\tphoton.fliph(image);\n\t\tphoton.flipv(image);\n\t} else if (orientation === 4) photon.flipv(image);\n\telse if (orientation >= 5) {\n\t\tconst rotated = rotate(image, orientation === 5 || orientation === 6);\n\t\timage.free();\n\t\timage = rotated;\n\t\tif (orientation === 5 || orientation === 7) photon.fliph(image);\n\t}\n\treturn image;\n}\n\nexport function generateImagePreview(bytes: Uint8Array): {\n\tbytes: Uint8Array;\n\tmimeType: \"image/png\" | \"image/jpeg\";\n\twidth: number;\n\theight: number;\n} {\n\tlet image = photon.PhotonImage.new_from_byteslice(bytes);\n\ttry {\n\t\timage = orient(image, bytes);\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tif (originalWidth < 1 || originalHeight < 1) throw new Error(\"Image has invalid dimensions\");\n\t\tconst scale = Math.min(1, MAX_PREVIEW_WIDTH / originalWidth, MAX_PREVIEW_HEIGHT / originalHeight);\n\t\tlet width = Math.max(1, Math.round(originalWidth * scale));\n\t\tlet height = Math.max(1, Math.round(originalHeight * scale));\n\t\twhile (true) {\n\t\t\tconst resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);\n\t\t\ttry {\n\t\t\t\tconst candidates: Array<{ bytes: Uint8Array; mimeType: \"image/png\" | \"image/jpeg\" }> = [\n\t\t\t\t\t{ bytes: resized.get_bytes(), mimeType: \"image/png\" },\n\t\t\t\t\t...([85, 70, 55, 40] as const).map((quality) => ({\n\t\t\t\t\t\tbytes: resized.get_bytes_jpeg(quality),\n\t\t\t\t\t\tmimeType: \"image/jpeg\" as const,\n\t\t\t\t\t})),\n\t\t\t\t];\n\t\t\t\tconst acceptable = candidates.filter((candidate) => candidate.bytes.byteLength <= MAX_PREVIEW_BYTES);\n\t\t\t\tif (acceptable.length > 0) {\n\t\t\t\t\tacceptable.sort((left, right) => left.bytes.byteLength - right.bytes.byteLength);\n\t\t\t\t\treturn { ...acceptable[0]!, width, height };\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t\tif (width === 1 && height === 1) break;\n\t\t\twidth = Math.max(1, Math.floor(width * 0.75));\n\t\t\theight = Math.max(1, Math.floor(height * 0.75));\n\t\t}\n\t\tthrow new Error(\"Could not encode image within the 256 KiB preview limit\");\n\t} finally {\n\t\timage.free();\n\t}\n}\n\nconst port = parentPort;\nif (port) {\n\tport.on(\"message\", (request: PreviewRequest) => {\n\t\ttry {\n\t\t\tconst preview = generateImagePreview(new Uint8Array(request.bytes));\n\t\t\tconst transferable = Uint8Array.from(preview.bytes);\n\t\t\tport.postMessage({ id: request.id, ok: true, ...preview, bytes: transferable.buffer }, [transferable.buffer]);\n\t\t} catch (error) {\n\t\t\tport.postMessage({\n\t\t\t\tid: request.id,\n\t\t\t\tok: false,\n\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t});\n\t\t}\n\t});\n}\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const MAX_PREVIEW_WIDTH = 1024;
|
|
2
|
+
export declare const MAX_PREVIEW_HEIGHT = 1024;
|
|
3
|
+
export declare const MAX_PREVIEW_BYTES: number;
|
|
4
|
+
export interface GeneratedImagePreview {
|
|
5
|
+
bytes: Uint8Array;
|
|
6
|
+
mimeType: "image/png" | "image/jpeg";
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ImagePreviewGenerator {
|
|
11
|
+
generate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** One worker keeps Photon decode/resize work off the ordered SSE path. */
|
|
15
|
+
export declare class ImagePreviewWorker implements ImagePreviewGenerator {
|
|
16
|
+
/** Injectable worker URL keeps abnormal-exit handling deterministic in tests. */
|
|
17
|
+
private readonly workerUrl;
|
|
18
|
+
private worker;
|
|
19
|
+
private nextId;
|
|
20
|
+
private closed;
|
|
21
|
+
private readonly pending;
|
|
22
|
+
constructor(
|
|
23
|
+
/** Injectable worker URL keeps abnormal-exit handling deterministic in tests. */
|
|
24
|
+
workerUrl?: import("url").URL);
|
|
25
|
+
generate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
private getWorker;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=image-preview.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-preview.d.ts","sourceRoot":"","sources":["../../src/server/image-preview.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,eAAO,MAAM,iBAAiB,QAAa,CAAC;AAE5C,MAAM,WAAW,qBAAqB;IACrC,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,WAAW,GAAG,YAAY,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACrC,QAAQ,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC9E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAmBD,2EAA2E;AAC3E,qBAAa,kBAAmB,YAAW,qBAAqB;IAU9D,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,SAAS;IAV3B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAGpB;IAEJ;IACC,iFAAiF;IAChE,SAAS,oBAAwD,EAC/E;IAEE,QAAQ,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAUlF;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAQ3B;IAED,OAAO,CAAC,SAAS;CA8BjB","sourcesContent":["import { Worker } from \"node:worker_threads\";\n\nexport const MAX_PREVIEW_WIDTH = 1024;\nexport const MAX_PREVIEW_HEIGHT = 1024;\nexport const MAX_PREVIEW_BYTES = 256 * 1024;\n\nexport interface GeneratedImagePreview {\n\tbytes: Uint8Array;\n\tmimeType: \"image/png\" | \"image/jpeg\";\n\twidth: number;\n\theight: number;\n}\n\nexport interface ImagePreviewGenerator {\n\tgenerate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview>;\n\tclose(): Promise<void>;\n}\n\ninterface WorkerSuccess {\n\tid: number;\n\tok: true;\n\tbytes: ArrayBuffer;\n\tmimeType: \"image/png\" | \"image/jpeg\";\n\twidth: number;\n\theight: number;\n}\n\ninterface WorkerFailure {\n\tid: number;\n\tok: false;\n\terror: string;\n}\n\ntype WorkerReply = WorkerSuccess | WorkerFailure;\n\n/** One worker keeps Photon decode/resize work off the ordered SSE path. */\nexport class ImagePreviewWorker implements ImagePreviewGenerator {\n\tprivate worker: Worker | undefined;\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate readonly pending = new Map<\n\t\tnumber,\n\t\t{ resolve: (preview: GeneratedImagePreview) => void; reject: (error: Error) => void }\n\t>();\n\n\tconstructor(\n\t\t/** Injectable worker URL keeps abnormal-exit handling deterministic in tests. */\n\t\tprivate readonly workerUrl = new URL(\"./image-preview-worker.js\", import.meta.url),\n\t) {}\n\n\tasync generate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview> {\n\t\tif (this.closed) throw new Error(\"Image preview worker is closed\");\n\t\tconst worker = this.getWorker();\n\t\tconst id = this.nextId++;\n\t\t// Do not detach repository-owned storage when transferring to the worker.\n\t\tconst copy = Uint8Array.from(bytes);\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tthis.pending.set(id, { resolve, reject });\n\t\t\tworker.postMessage({ id, bytes: copy.buffer, mimeType }, [copy.buffer]);\n\t\t});\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\tfor (const pending of this.pending.values()) pending.reject(new Error(\"Image preview worker closed\"));\n\t\tthis.pending.clear();\n\t\tconst worker = this.worker;\n\t\tthis.worker = undefined;\n\t\tif (worker) await worker.terminate();\n\t}\n\n\tprivate getWorker(): Worker {\n\t\tif (this.worker) return this.worker;\n\t\tconst worker = new Worker(this.workerUrl);\n\t\tworker.on(\"message\", (reply: WorkerReply) => {\n\t\t\tconst pending = this.pending.get(reply.id);\n\t\t\tif (!pending) return;\n\t\t\tthis.pending.delete(reply.id);\n\t\t\tif (!reply.ok) {\n\t\t\t\tpending.reject(new Error(reply.error));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tpending.resolve({\n\t\t\t\tbytes: new Uint8Array(reply.bytes),\n\t\t\t\tmimeType: reply.mimeType,\n\t\t\t\twidth: reply.width,\n\t\t\t\theight: reply.height,\n\t\t\t});\n\t\t});\n\t\tconst fail = (error: Error) => {\n\t\t\tfor (const pending of this.pending.values()) pending.reject(error);\n\t\t\tthis.pending.clear();\n\t\t\tthis.worker = undefined;\n\t\t};\n\t\tworker.on(\"error\", fail);\n\t\tworker.on(\"exit\", (code) => {\n\t\t\tif (!this.closed && code !== 0) fail(new Error(`Image preview worker exited with code ${code}`));\n\t\t});\n\t\tthis.worker = worker;\n\t\treturn worker;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
export const MAX_PREVIEW_WIDTH = 1024;
|
|
3
|
+
export const MAX_PREVIEW_HEIGHT = 1024;
|
|
4
|
+
export const MAX_PREVIEW_BYTES = 256 * 1024;
|
|
5
|
+
/** One worker keeps Photon decode/resize work off the ordered SSE path. */
|
|
6
|
+
export class ImagePreviewWorker {
|
|
7
|
+
workerUrl;
|
|
8
|
+
worker;
|
|
9
|
+
nextId = 1;
|
|
10
|
+
closed = false;
|
|
11
|
+
pending = new Map();
|
|
12
|
+
constructor(
|
|
13
|
+
/** Injectable worker URL keeps abnormal-exit handling deterministic in tests. */
|
|
14
|
+
workerUrl = new URL("./image-preview-worker.js", import.meta.url)) {
|
|
15
|
+
this.workerUrl = workerUrl;
|
|
16
|
+
}
|
|
17
|
+
async generate(bytes, mimeType) {
|
|
18
|
+
if (this.closed)
|
|
19
|
+
throw new Error("Image preview worker is closed");
|
|
20
|
+
const worker = this.getWorker();
|
|
21
|
+
const id = this.nextId++;
|
|
22
|
+
// Do not detach repository-owned storage when transferring to the worker.
|
|
23
|
+
const copy = Uint8Array.from(bytes);
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
this.pending.set(id, { resolve, reject });
|
|
26
|
+
worker.postMessage({ id, bytes: copy.buffer, mimeType }, [copy.buffer]);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async close() {
|
|
30
|
+
if (this.closed)
|
|
31
|
+
return;
|
|
32
|
+
this.closed = true;
|
|
33
|
+
for (const pending of this.pending.values())
|
|
34
|
+
pending.reject(new Error("Image preview worker closed"));
|
|
35
|
+
this.pending.clear();
|
|
36
|
+
const worker = this.worker;
|
|
37
|
+
this.worker = undefined;
|
|
38
|
+
if (worker)
|
|
39
|
+
await worker.terminate();
|
|
40
|
+
}
|
|
41
|
+
getWorker() {
|
|
42
|
+
if (this.worker)
|
|
43
|
+
return this.worker;
|
|
44
|
+
const worker = new Worker(this.workerUrl);
|
|
45
|
+
worker.on("message", (reply) => {
|
|
46
|
+
const pending = this.pending.get(reply.id);
|
|
47
|
+
if (!pending)
|
|
48
|
+
return;
|
|
49
|
+
this.pending.delete(reply.id);
|
|
50
|
+
if (!reply.ok) {
|
|
51
|
+
pending.reject(new Error(reply.error));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
pending.resolve({
|
|
55
|
+
bytes: new Uint8Array(reply.bytes),
|
|
56
|
+
mimeType: reply.mimeType,
|
|
57
|
+
width: reply.width,
|
|
58
|
+
height: reply.height,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const fail = (error) => {
|
|
62
|
+
for (const pending of this.pending.values())
|
|
63
|
+
pending.reject(error);
|
|
64
|
+
this.pending.clear();
|
|
65
|
+
this.worker = undefined;
|
|
66
|
+
};
|
|
67
|
+
worker.on("error", fail);
|
|
68
|
+
worker.on("exit", (code) => {
|
|
69
|
+
if (!this.closed && code !== 0)
|
|
70
|
+
fail(new Error(`Image preview worker exited with code ${code}`));
|
|
71
|
+
});
|
|
72
|
+
this.worker = worker;
|
|
73
|
+
return worker;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=image-preview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-preview.js","sourceRoot":"","sources":["../../src/server/image-preview.ts"],"names":[],"mappings":"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","sourcesContent":["import { Worker } from \"node:worker_threads\";\n\nexport const MAX_PREVIEW_WIDTH = 1024;\nexport const MAX_PREVIEW_HEIGHT = 1024;\nexport const MAX_PREVIEW_BYTES = 256 * 1024;\n\nexport interface GeneratedImagePreview {\n\tbytes: Uint8Array;\n\tmimeType: \"image/png\" | \"image/jpeg\";\n\twidth: number;\n\theight: number;\n}\n\nexport interface ImagePreviewGenerator {\n\tgenerate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview>;\n\tclose(): Promise<void>;\n}\n\ninterface WorkerSuccess {\n\tid: number;\n\tok: true;\n\tbytes: ArrayBuffer;\n\tmimeType: \"image/png\" | \"image/jpeg\";\n\twidth: number;\n\theight: number;\n}\n\ninterface WorkerFailure {\n\tid: number;\n\tok: false;\n\terror: string;\n}\n\ntype WorkerReply = WorkerSuccess | WorkerFailure;\n\n/** One worker keeps Photon decode/resize work off the ordered SSE path. */\nexport class ImagePreviewWorker implements ImagePreviewGenerator {\n\tprivate worker: Worker | undefined;\n\tprivate nextId = 1;\n\tprivate closed = false;\n\tprivate readonly pending = new Map<\n\t\tnumber,\n\t\t{ resolve: (preview: GeneratedImagePreview) => void; reject: (error: Error) => void }\n\t>();\n\n\tconstructor(\n\t\t/** Injectable worker URL keeps abnormal-exit handling deterministic in tests. */\n\t\tprivate readonly workerUrl = new URL(\"./image-preview-worker.js\", import.meta.url),\n\t) {}\n\n\tasync generate(bytes: Uint8Array, mimeType: string): Promise<GeneratedImagePreview> {\n\t\tif (this.closed) throw new Error(\"Image preview worker is closed\");\n\t\tconst worker = this.getWorker();\n\t\tconst id = this.nextId++;\n\t\t// Do not detach repository-owned storage when transferring to the worker.\n\t\tconst copy = Uint8Array.from(bytes);\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tthis.pending.set(id, { resolve, reject });\n\t\t\tworker.postMessage({ id, bytes: copy.buffer, mimeType }, [copy.buffer]);\n\t\t});\n\t}\n\n\tasync close(): Promise<void> {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\tfor (const pending of this.pending.values()) pending.reject(new Error(\"Image preview worker closed\"));\n\t\tthis.pending.clear();\n\t\tconst worker = this.worker;\n\t\tthis.worker = undefined;\n\t\tif (worker) await worker.terminate();\n\t}\n\n\tprivate getWorker(): Worker {\n\t\tif (this.worker) return this.worker;\n\t\tconst worker = new Worker(this.workerUrl);\n\t\tworker.on(\"message\", (reply: WorkerReply) => {\n\t\t\tconst pending = this.pending.get(reply.id);\n\t\t\tif (!pending) return;\n\t\t\tthis.pending.delete(reply.id);\n\t\t\tif (!reply.ok) {\n\t\t\t\tpending.reject(new Error(reply.error));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tpending.resolve({\n\t\t\t\tbytes: new Uint8Array(reply.bytes),\n\t\t\t\tmimeType: reply.mimeType,\n\t\t\t\twidth: reply.width,\n\t\t\t\theight: reply.height,\n\t\t\t});\n\t\t});\n\t\tconst fail = (error: Error) => {\n\t\t\tfor (const pending of this.pending.values()) pending.reject(error);\n\t\t\tthis.pending.clear();\n\t\t\tthis.worker = undefined;\n\t\t};\n\t\tworker.on(\"error\", fail);\n\t\tworker.on(\"exit\", (code) => {\n\t\t\tif (!this.closed && code !== 0) fail(new Error(`Image preview worker exited with code ${code}`));\n\t\t});\n\t\tthis.worker = worker;\n\t\treturn worker;\n\t}\n}\n"]}
|
package/dist/server/server.d.ts
CHANGED
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import express from "express";
|
|
10
10
|
import type { DashboardAuth } from "./auth.js";
|
|
11
|
+
import { DashboardImageService } from "./dashboard-images.js";
|
|
11
12
|
import { EventHub } from "./event-hub.js";
|
|
12
13
|
import type { RuntimePool } from "./runtime-pool.js";
|
|
14
|
+
export type DashboardServerApp = express.Express & {
|
|
15
|
+
/** Close dashboard-owned services. Safe to call more than once during shutdown. */
|
|
16
|
+
closeDashboard(): Promise<void>;
|
|
17
|
+
};
|
|
13
18
|
export interface DashboardServerOptions {
|
|
14
19
|
auth: DashboardAuth;
|
|
15
20
|
pool: RuntimePool;
|
|
@@ -25,6 +30,8 @@ export interface DashboardServerOptions {
|
|
|
25
30
|
onRestart?: () => void;
|
|
26
31
|
/** Injectable only to make SSE limits deterministic in integration tests. */
|
|
27
32
|
eventHub?: EventHub;
|
|
33
|
+
/** Injectable bounded image repository/preview service for deterministic tests and lifecycle ownership. */
|
|
34
|
+
imageService?: DashboardImageService;
|
|
28
35
|
/** Named heartbeat interval; defaults to 25 seconds. */
|
|
29
36
|
heartbeatIntervalMs?: number;
|
|
30
37
|
}
|
|
@@ -32,5 +39,5 @@ export declare const MAX_SSE_BUFFERED_BYTES: number;
|
|
|
32
39
|
export declare const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30000;
|
|
33
40
|
/** Parse the device cookie from a Cookie header. */
|
|
34
41
|
export declare function parseDeviceCookie(cookieHeader: string | undefined): string | undefined;
|
|
35
|
-
export declare function createDashboardServer(options: DashboardServerOptions):
|
|
42
|
+
export declare function createDashboardServer(options: DashboardServerOptions): DashboardServerApp;
|
|
36
43
|
//# sourceMappingURL=server.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,OAAO,MAAM,SAAS,CAAC;AAc9B,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAAE,QAAQ,EAA+C,MAAM,gBAAgB,CAAC;AAEvF,OAAO,KAAK,EAA4B,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG/E,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAuE;IACvE,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC1C,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sGAAsG;IACtG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2HAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,wDAAwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAGD,eAAO,MAAM,sBAAsB,QAAkB,CAAC;AACtD,eAAO,MAAM,+BAA+B,QAAS,CAAC;AA2CtD,oDAAoD;AACpD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQtF;AAQD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,OAAO,CAg1BtF","sourcesContent":["/**\n * Dashboard HTTP server — Express app wiring auth, the runtime pool, the SSE\n * hub, and the file API into the REST surface the browser client consumes.\n *\n * Bind address discipline: local mode binds 127.0.0.1 only. The\n * caller decides the bind address; `createDashboardServer` never listens by\n * itself. Remote mode still passes every request through DashboardAuth.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { NextFunction, Request, Response } from \"express\";\nimport express from \"express\";\nimport type {\n\tActiveRuntimeSnapshotDto,\n\tAuthStatusDto,\n\tClientConnectionDiagnosticDto,\n\tDashboardResyncDto,\n\tFleetDto,\n\tImageAttachmentDto,\n\tPairingCodeDto,\n\tRuntimeHydrationDto,\n\tSessionInfoDto,\n\tSessionInventoryDto,\n} from \"../shared/protocol.js\";\nimport { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES } from \"../shared/protocol.js\";\nimport type { AuthDecision, DashboardAuth } from \"./auth.js\";\nimport { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from \"./event-hub.js\";\nimport { defaultPlaces, FileApi } from \"./files.js\";\nimport type { DashboardRuntimeSnapshot, RuntimePool } from \"./runtime-pool.js\";\nimport { readSubagentMessages } from \"./subagent-log.js\";\n\nexport interface DashboardServerOptions {\n\tauth: DashboardAuth;\n\tpool: RuntimePool;\n\t/** Directory of built client assets; omit to skip static serving (tests). */\n\tstaticDir?: string;\n\t/** Session listing (cross-project) — injected so tests can stub it. */\n\tlistAllSessions: () => Promise<unknown[]>;\n\tdeleteSession: (path: string) => Promise<unknown>;\n\tlogger?: (line: string) => void;\n\t/** Build version of the running server process (for the settings footer / stale-server detection). */\n\tserverVersion?: string;\n\t/** Restart hook — when set, POST /api/server/restart invokes it (typically process exit for a supervisor to respawn). */\n\tonRestart?: () => void;\n\t/** Injectable only to make SSE limits deterministic in integration tests. */\n\teventHub?: EventHub;\n\t/** Named heartbeat interval; defaults to 25 seconds. */\n\theartbeatIntervalMs?: number;\n}\n\nconst DEVICE_COOKIE = \"dreb_dashboard_device\";\nexport const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;\nexport const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;\nconst CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;\n\nfunction isClientDiagnostic(value: unknown): value is ClientConnectionDiagnosticDto {\n\tif (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n\tconst body = value as Record<string, unknown>;\n\tconst allowed = new Set([\n\t\t\"connectionId\",\n\t\t\"state\",\n\t\t\"previousState\",\n\t\t\"attempt\",\n\t\t\"delayMs\",\n\t\t\"visibility\",\n\t\t\"lastAppliedSeq\",\n\t\t\"heartbeatAgeMs\",\n\t\t\"eventCount\",\n\t\t\"eventRatePerMinute\",\n\t\t\"processingLagTotalMs\",\n\t\t\"processingLagMaxMs\",\n\t]);\n\tif (Object.keys(body).some((key) => !allowed.has(key))) return false;\n\tconst states = new Set([\"connecting\", \"connected\", \"retrying\", \"resyncing\", \"disconnected\", \"auth_failed\"]);\n\tconst nonNegativeNumber = (item: unknown) => typeof item === \"number\" && Number.isFinite(item) && item >= 0;\n\tconst nonNegativeInteger = (item: unknown) => typeof item === \"number\" && Number.isSafeInteger(item) && item >= 0;\n\treturn (\n\t\ttypeof body.connectionId === \"string\" &&\n\t\t/^[0-9a-f-]{36}$/i.test(body.connectionId) &&\n\t\ttypeof body.state === \"string\" &&\n\t\tstates.has(body.state) &&\n\t\t(body.previousState === undefined ||\n\t\t\t(typeof body.previousState === \"string\" && states.has(body.previousState))) &&\n\t\tnonNegativeInteger(body.attempt) &&\n\t\tnonNegativeNumber(body.eventCount) &&\n\t\tnonNegativeNumber(body.eventRatePerMinute) &&\n\t\tnonNegativeNumber(body.processingLagTotalMs) &&\n\t\tnonNegativeNumber(body.processingLagMaxMs) &&\n\t\t(body.delayMs === undefined || nonNegativeNumber(body.delayMs)) &&\n\t\t(body.lastAppliedSeq === undefined || nonNegativeInteger(body.lastAppliedSeq)) &&\n\t\t(body.heartbeatAgeMs === undefined || nonNegativeNumber(body.heartbeatAgeMs)) &&\n\t\t(body.visibility === \"visible\" || body.visibility === \"hidden\")\n\t);\n}\n\n/** Parse the device cookie from a Cookie header. */\nexport function parseDeviceCookie(cookieHeader: string | undefined): string | undefined {\n\tif (!cookieHeader) return undefined;\n\tfor (const part of cookieHeader.split(\";\")) {\n\t\tconst eq = part.indexOf(\"=\");\n\t\tif (eq === -1) continue;\n\t\tif (part.slice(0, eq).trim() === DEVICE_COOKIE) return part.slice(eq + 1).trim();\n\t}\n\treturn undefined;\n}\n\ninterface AuthedRequest extends Request {\n\tauthDecision?: AuthDecision;\n\t/** Per-SSE-request opaque diagnostic correlation id. */\n\tsseConnectionId?: string;\n}\n\nexport function createDashboardServer(options: DashboardServerOptions): express.Express {\n\tconst { auth, pool } = options;\n\tconst serverStartedAt = new Date().toISOString();\n\tconst diagnosticConnections = new Map<string, { issuedAt: number; lastAt?: number }>();\n\tconst log = options.logger ?? ((line: string) => console.log(`[dashboard] ${line}`));\n\tconst files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : \"\"}`));\n\tconst hub = options.eventHub ?? new EventHub();\n\tpool.onEvent((key, event) => {\n\t\tif (event.type === \"dashboard_snapshot_barrier\" && typeof event.snapshotId === \"string\") {\n\t\t\t// This RPC marker has no browser frame: its synchronous sequence capture\n\t\t\t// orders the HTTP snapshot before all later EventHub publications.\n\t\t\tpool.recordDashboardBarrier(key, event.snapshotId, hub.currentSequence);\n\t\t\treturn;\n\t\t}\n\t\thub.publish(key, event);\n\t});\n\tpool.onFleetSnapshot((event) => hub.publish(\"\", { ...event }));\n\n\tconst app = express();\n\tapp.disable(\"x-powered-by\");\n\n\t// -- auth middleware (every route, fail-closed) ---------------------------\n\tapp.use((req: AuthedRequest, res: Response, next: NextFunction) => {\n\t\tif (req.path === \"/api/events\") req.sseConnectionId = randomUUID();\n\t\tauth\n\t\t\t.authenticate({\n\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\thostHeader: req.headers.host,\n\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\tdeviceToken: parseDeviceCookie(req.headers.cookie),\n\t\t\t})\n\t\t\t.then((decision) => {\n\t\t\t\treq.authDecision = decision;\n\t\t\t\tif (decision.allowed) return next();\n\t\t\t\tconst canRenderAuthScreen = decision.needsPairing || Boolean(decision.identity);\n\t\t\t\tif (canRenderAuthScreen) {\n\t\t\t\t\t// The auth/pairing endpoints must be reachable by allowed-but-unpaired\n\t\t\t\t\t// identities, and /api/auth must also be reachable by rejected\n\t\t\t\t\t// Tailscale identities so the SPA denial screen can name them.\n\t\t\t\t\tif (req.path === \"/api/auth\" || (decision.needsPairing && req.path === \"/api/pair\")) return next();\n\t\t\t\t\t// Let the SPA shell + static assets load so the client-side pairing or\n\t\t\t\t\t// denial screen can render. No data exposure: every /api/* data route\n\t\t\t\t\t// below stays fail-closed — only non-API GETs (the app shell) are allowed.\n\t\t\t\t\tif (req.method === \"GET\" && !req.path.startsWith(\"/api/\")) return next();\n\t\t\t\t}\n\t\t\t\tif (req.sseConnectionId) {\n\t\t\t\t\tlog(\n\t\t\t\t\t\t`sse ${JSON.stringify({\n\t\t\t\t\t\t\tconnectionId: req.sseConnectionId,\n\t\t\t\t\t\t\tkind: \"auth_denial\",\n\t\t\t\t\t\t\tmethod: req.method,\n\t\t\t\t\t\t\tpath: req.path,\n\t\t\t\t\t\t\tstatus: decision.status,\n\t\t\t\t\t\t})}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tlog(`denied ${req.method} ${req.path}: ${decision.reason}`);\n\t\t\t\t}\n\t\t\t\tres.status(decision.status).json({\n\t\t\t\t\terror: decision.reason,\n\t\t\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\t\t\tidentity: decision.identity?.loginName,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\t// authenticate() already catches internally; this is belt-and-suspenders.\n\t\t\t\tlog(`auth middleware error — denying: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(500).json({ error: \"Auth subsystem error — denied\" });\n\t\t\t});\n\t});\n\n\t// Authenticate before consuming request bodies. Diagnostics have their own\n\t// small parser limit; the larger limit exists only for prompt image payloads.\n\tapp.use(\"/api/events/diagnostic\", express.json({ limit: MAX_CLIENT_DIAGNOSTIC_BYTES }));\n\tapp.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));\n\tapp.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {\n\t\tif ((err as { type?: string }).type === \"entity.too.large\") {\n\t\t\tres.status(413).json({ error: \"Request body is too large\" });\n\t\t\treturn;\n\t\t}\n\t\tnext(err);\n\t});\n\n\t// -- auth/pairing ----------------------------------------------------------\n\tapp.get(\"/api/auth\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (decision.allowed) {\n\t\t\tconst status: AuthStatusDto =\n\t\t\t\tdecision.mode === \"local\"\n\t\t\t\t\t? { mode: \"local\" }\n\t\t\t\t\t: { mode: \"remote\", identity: decision.identity.loginName, device: decision.identity.device };\n\t\t\tres.json({ ...status, needsPairing: false });\n\t\t\treturn;\n\t\t}\n\t\tres.status(decision.status).json({\n\t\t\terror: decision.reason,\n\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\tidentity: decision.identity?.loginName,\n\t\t});\n\t});\n\n\tapp.get(\"/api/pairing-code\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (!decision.allowed || decision.mode !== \"local\") {\n\t\t\tres.status(403).json({ error: \"Pairing code is only available from the host machine\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!auth.isRemoteEnabled) {\n\t\t\tconst body: PairingCodeDto = { enabled: false };\n\t\t\tres.json(body);\n\t\t\treturn;\n\t\t}\n\t\tconst body: PairingCodeDto = { enabled: true, ...auth.currentPairingCode() };\n\t\tres.json(body);\n\t});\n\n\tapp.post(\"/api/pair\", (req: AuthedRequest, res) => {\n\t\tconst pin = typeof req.body?.pin === \"string\" ? req.body.pin : \"\";\n\t\tauth\n\t\t\t.pair(\n\t\t\t\t{\n\t\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\t\thostHeader: req.headers.host,\n\t\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\t\tdeviceToken: undefined,\n\t\t\t\t},\n\t\t\t\tpin,\n\t\t\t)\n\t\t\t.then(({ token, device }) => {\n\t\t\t\tlog(`paired device ${device.id} (${device.identity})`);\n\t\t\t\tres.cookie(DEVICE_COOKIE, token, {\n\t\t\t\t\thttpOnly: true,\n\t\t\t\t\tsameSite: \"strict\",\n\t\t\t\t\tsecure: false, // Tailscale already encrypts; the dashboard serves plain HTTP on the tailnet.\n\t\t\t\t\texpires: new Date(device.expiresAt),\n\t\t\t\t}).json({ device });\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconst status = typeof err?.status === \"number\" ? err.status : 500;\n\t\t\t\tlog(`pairing failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t\t\t});\n\t});\n\n\tapp.get(\"/api/devices\", (_req, res) => {\n\t\tauth\n\t\t\t.listDevices()\n\t\t\t.then((devices) => res.json({ devices }))\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/devices/:id\", (req, res) => {\n\t\tauth\n\t\t\t.unpair(req.params.id)\n\t\t\t.then((removed) => {\n\t\t\t\tif (!removed) {\n\t\t\t\t\tres.status(404).json({ error: `No paired device with id ${String(req.params.id)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlog(`unpaired device ${String(req.params.id)}`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- events (SSE) ----------------------------------------------------------\n\tapp.get(\"/api/events\", (req: AuthedRequest, res) => {\n\t\tconst connectionId = req.sseConnectionId ?? randomUUID();\n\t\tconst diagnostic = (kind: string, metadata: object = {}) =>\n\t\t\tlog(`sse ${JSON.stringify({ connectionId, kind, ...metadata })}`);\n\t\tres.writeHead(200, {\n\t\t\t\"content-type\": \"text/event-stream\",\n\t\t\t\"cache-control\": \"no-cache\",\n\t\t\tconnection: \"keep-alive\",\n\t\t});\n\n\t\tconst guardedWrite = (\n\t\t\tchunk: string,\n\t\t\tmetadata: SseWriteMetadata | { kind: \"handshake\" | \"heartbeat\" | \"connection\" },\n\t\t): boolean => {\n\t\t\tif (res.destroyed || res.writableEnded) {\n\t\t\t\tdiagnostic(\"write_closed\", { writeKind: metadata.kind });\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst accepted = res.write(chunk);\n\t\t\tconst details = {\n\t\t\t\twriteKind: metadata.kind,\n\t\t\t\t...(\"seq\" in metadata\n\t\t\t\t\t? { seq: metadata.seq, type: metadata.type, frameBytes: metadata.frameBytes, reason: metadata.reason }\n\t\t\t\t\t: {}),\n\t\t\t\twritableLength: res.writableLength,\n\t\t\t};\n\t\t\tdiagnostic(\"write\", details);\n\t\t\tif (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {\n\t\t\t\tdiagnostic(\"backpressure\", details);\n\t\t\t\tres.destroy();\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn true;\n\t\t};\n\n\t\tconst lastIdRaw = req.headers[\"last-event-id\"] ?? req.query.lastEventId;\n\t\tconst lastEventId =\n\t\t\ttypeof lastIdRaw === \"string\" && /^\\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;\n\t\tdiagnostic(\"connect\", { cursor: lastEventId });\n\t\tif (!guardedWrite(\":ok\\n\\n\", { kind: \"handshake\" })) return;\n\t\t// Unnumbered connection metadata lets a browser correlate optional,\n\t\t// payload-free diagnostics without mutating its application SSE cursor.\n\t\tconst issuedAt = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (issuedAt - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tdiagnosticConnections.set(connectionId, { issuedAt });\n\t\tif (!guardedWrite(`event: connection\\ndata: ${JSON.stringify({ connectionId })}\\n\\n`, { kind: \"connection\" }))\n\t\t\treturn;\n\t\tlet detach = () => {};\n\t\tlet keepAlive: ReturnType<typeof setInterval> | undefined;\n\t\tconst stop = () => {\n\t\t\tif (keepAlive) clearInterval(keepAlive);\n\t\t\tdetach();\n\t\t};\n\t\tlet usable = true;\n\t\tdetach = hub.attach(\n\t\t\t{\n\t\t\t\twrite: (chunk, metadata) => {\n\t\t\t\t\tif (!metadata) return false;\n\t\t\t\t\tusable = guardedWrite(chunk, metadata);\n\t\t\t\t\treturn usable;\n\t\t\t\t},\n\t\t\t},\n\t\t\tlastEventId,\n\t\t\t(replay) => diagnostic(replay.kind, replay),\n\t\t);\n\t\t// A rejected/destroyed replay must not leave a timer or live client behind.\n\t\tif (!usable) return;\n\t\t// Named heartbeats are visible to EventSource but have no id, so they do\n\t\t// not alter the application cursor or consume replay history.\n\t\tkeepAlive = setInterval(() => {\n\t\t\tif (!guardedWrite(formatHeartbeatFrame(), { kind: \"heartbeat\" })) stop();\n\t\t}, options.heartbeatIntervalMs ?? 25_000);\n\t\treq.on(\"close\", () => {\n\t\t\tdiagnostic(\"close\", { writableLength: res.writableLength });\n\t\t\tstop();\n\t\t});\n\t});\n\n\t// -- optional client stream diagnostics -----------------------------------\n\tapp.post(\"/api/events/diagnostic\", (req, res) => {\n\t\tconst declaredLength = Number(req.headers[\"content-length\"] ?? 0);\n\t\tconst encodedBytes = Buffer.byteLength(JSON.stringify(req.body ?? null));\n\t\tif (declaredLength > MAX_CLIENT_DIAGNOSTIC_BYTES || encodedBytes > MAX_CLIENT_DIAGNOSTIC_BYTES) {\n\t\t\tres.status(413).json({ error: \"Diagnostic summary exceeds the 4 KiB limit\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isClientDiagnostic(req.body)) {\n\t\t\tres.status(400).json({ error: \"Invalid diagnostic summary\" });\n\t\t\treturn;\n\t\t}\n\t\tconst now = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (now - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tconst record = diagnosticConnections.get(req.body.connectionId);\n\t\tif (!record) {\n\t\t\tres.status(400).json({ error: \"Unknown or expired SSE connection\" });\n\t\t\treturn;\n\t\t}\n\t\tif (record.lastAt !== undefined && now - record.lastAt < CLIENT_DIAGNOSTIC_RATE_LIMIT_MS) {\n\t\t\tres.status(429).json({ error: \"Diagnostic summary rate limited\" });\n\t\t\treturn;\n\t\t}\n\t\trecord.lastAt = now;\n\t\t// Never log the request body wholesale. The schema is intentionally only\n\t\t// connection metadata, and this explicit projection prevents future fields\n\t\t// from accidentally turning diagnostics into a payload side-channel.\n\t\tlog(\n\t\t\t`sse ${JSON.stringify({\n\t\t\t\tconnectionId: req.body.connectionId,\n\t\t\t\tkind: \"client_diagnostic\",\n\t\t\t\tstate: req.body.state,\n\t\t\t\tpreviousState: req.body.previousState,\n\t\t\t\tattempt: req.body.attempt,\n\t\t\t\tdelayMs: req.body.delayMs,\n\t\t\t\tvisibility: req.body.visibility,\n\t\t\t\tlastAppliedSeq: req.body.lastAppliedSeq,\n\t\t\t\theartbeatAgeMs: req.body.heartbeatAgeMs,\n\t\t\t\teventCount: req.body.eventCount,\n\t\t\t\teventRatePerMinute: req.body.eventRatePerMinute,\n\t\t\t\tprocessingLagTotalMs: req.body.processingLagTotalMs,\n\t\t\t\tprocessingLagMaxMs: req.body.processingLagMaxMs,\n\t\t\t})}`,\n\t\t);\n\t\tres.json({ ok: true });\n\t});\n\n\t// -- fleet -----------------------------------------------------------------\n\tconst listDiskSessions = async (): Promise<SessionInfoDto[]> =>\n\t\t((await options.listAllSessions()) as SessionInfoDto[]).filter((session) => existsSync(session.cwd));\n\n\tconst getFleet = async (): Promise<FleetDto> => {\n\t\tconst runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));\n\t\treturn { runtimes, diskSessions: await listDiskSessions() };\n\t};\n\n\t/** Map the one-RPC parent snapshot consistently for recovery and drill-in hydration. */\n\tconst toRuntimeHydration = (snapshot: DashboardRuntimeSnapshot): RuntimeHydrationDto => ({\n\t\tkey: snapshot.key,\n\t\tstate: snapshot.snapshot.state,\n\t\tmessages: snapshot.snapshot.messages,\n\t\tbackgroundAgents: snapshot.snapshot.backgroundAgents,\n\t\tbarrierSeq: snapshot.barrierSeq,\n\t});\n\n\tapp.get(\"/api/fleet\", (_req, res) => {\n\t\tconst startedAt = Date.now();\n\t\tgetFleet()\n\t\t\t.then((fleet) => {\n\t\t\t\t// Serialize once so the diagnostic reports the exact JSON response size\n\t\t\t\t// without retaining or logging any fleet payload fields.\n\t\t\t\tconst body = JSON.stringify(fleet);\n\t\t\t\tconst diagnostic = {\n\t\t\t\t\telapsedMs: Date.now() - startedAt,\n\t\t\t\t\tencodedBytes: Buffer.byteLength(body),\n\t\t\t\t\truntimeCount: fleet.runtimes.length,\n\t\t\t\t\tdiskSessionCount: fleet.diskSessions.length,\n\t\t\t\t};\n\t\t\t\tres.type(\"json\").send(body);\n\t\t\t\tlog(`fleet ${JSON.stringify(diagnostic)}`);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** On-disk inventory only; does not query or describe live runtimes. */\n\tapp.get(\"/api/sessions\", (_req, res) => {\n\t\tlistDiskSessions()\n\t\t\t.then((sessions) => {\n\t\t\t\tconst body: SessionInventoryDto = { sessions };\n\t\t\t\tres.json(body);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/**\n\t * Full recovery snapshot. For an active runtime, its RPC marker captures the\n\t * current EventHub sequence before the response; later publications have a\n\t * higher sequence. This is an ordering contract, not a timing heuristic.\n\t */\n\tapp.get(\"/api/resync\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst activeKey = typeof req.query.key === \"string\" ? req.query.key : undefined;\n\t\t\tconst activeAgentId = typeof req.query.agentId === \"string\" ? req.query.agentId : undefined;\n\t\t\tlet active: DashboardResyncDto[\"active\"];\n\t\t\tlet barrierSeq: number;\n\t\t\tif (activeKey) {\n\t\t\t\tconst handle = pool.get(activeKey);\n\t\t\t\tif (!handle) {\n\t\t\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), barrierSeq: hub.currentSequence };\n\t\t\t\t\tres.json(body);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// The disk transcript has its own sequence boundary because it is read\n\t\t\t\t// before the parent RPC snapshot. Relays between these two barriers must\n\t\t\t\t// be reapplied so a subagent delta cannot disappear during recovery.\n\t\t\t\tlet preBarrierSubagent: NonNullable<ActiveRuntimeSnapshotDto[\"subagent\"]> | undefined;\n\t\t\t\tif (activeAgentId) {\n\t\t\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\t\t\tconst agent = agents.find((candidate) => candidate.agentId === activeAgentId);\n\t\t\t\t\tif (!agent) throw new Error(`No background agent ${activeAgentId} in this runtime`);\n\t\t\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\t\t\tpreBarrierSubagent = {\n\t\t\t\t\t\tagentId: activeAgentId,\n\t\t\t\t\t\tagent,\n\t\t\t\t\t\tmessages,\n\t\t\t\t\t\tbarrierSeq: hub.currentSequence,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst snapshot = await pool.snapshotDashboard(handle);\n\t\t\t\tbarrierSeq = snapshot.barrierSeq;\n\t\t\t\tactive = {\n\t\t\t\t\t...toRuntimeHydration(snapshot),\n\t\t\t\t\t...(preBarrierSubagent ? { subagent: preBarrierSubagent } : {}),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbarrierSeq = hub.currentSequence;\n\t\t\t}\n\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), ...(active ? { active } : {}), barrierSeq };\n\t\t\tres.json(body);\n\t\t})().catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- runtimes ---------------------------------------------------------------\n\tapp.post(\"/api/runtimes\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst cwd = typeof req.body?.cwd === \"string\" ? req.body.cwd : \"\";\n\t\t\tif (!cwd || !existsSync(cwd)) {\n\t\t\t\tres.status(400).json({ error: `Working directory does not exist: ${cwd || \"(empty)\"}` });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : undefined;\n\t\t\tconst handle = await pool.create(cwd, sessionPath);\n\t\t\tlog(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : \"\"}`);\n\t\t\tconst firstPrompt = typeof req.body?.firstPrompt === \"string\" ? req.body.firstPrompt : undefined;\n\t\t\tif (firstPrompt) await handle.client.prompt(firstPrompt);\n\t\t\tres.status(201).json(await pool.describe(handle));\n\t\t})().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/runtimes/:key\", (req, res) => {\n\t\tpool\n\t\t\t.stop(req.params.key)\n\t\t\t.then((stopped) => {\n\t\t\t\tif (!stopped) {\n\t\t\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlog(`runtime ${String(req.params.key)} stopped`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** Helper: run an async op against a pooled runtime with uniform errors. */\n\tfunction withRuntime(\n\t\treq: Request,\n\t\tres: Response,\n\t\tfn: (handle: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t): void {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\tfn(handle)\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/runtimes/:key\", (req, res) => {\n\t\twithRuntime(req, res, (h) => pool.describe(h));\n\t});\n\n\t/**\n\t * Atomic drill-in snapshot. snapshotDashboard performs exactly one RPC and\n\t * consumes its marker barrier, so no independently-read runtime fields can\n\t * describe different moments in a live turn.\n\t */\n\tapp.get(\"/api/runtimes/:key/hydrate\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => toRuntimeHydration(await pool.snapshotDashboard(h)));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ messages: await h.client.getMessages() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/pending\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPendingMessages());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dequeue\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.clearPendingMessages());\n\t});\n\n\tfunction parseImages(body: unknown): ImageAttachmentDto[] | undefined | \"invalid\" {\n\t\tconst images = (body as { images?: unknown } | undefined)?.images;\n\t\tif (images === undefined) return undefined;\n\t\tif (!Array.isArray(images)) return \"invalid\";\n\t\tconst parsed: ImageAttachmentDto[] = [];\n\t\tfor (const image of images) {\n\t\t\tif (\n\t\t\t\t!image ||\n\t\t\t\ttypeof image !== \"object\" ||\n\t\t\t\ttypeof (image as { data?: unknown }).data !== \"string\" ||\n\t\t\t\ttypeof (image as { mimeType?: unknown }).mimeType !== \"string\"\n\t\t\t) {\n\t\t\t\treturn \"invalid\";\n\t\t\t}\n\t\t\tparsed.push({ data: (image as ImageAttachmentDto).data, mimeType: (image as ImageAttachmentDto).mimeType });\n\t\t}\n\t\treturn parsed;\n\t}\n\n\tapp.post(\"/api/runtimes/:key/prompt\", (req, res) => {\n\t\tconst { message, mode } = req.body ?? {};\n\t\tif (typeof message !== \"string\" || message.length === 0) {\n\t\t\tres.status(400).json({ error: \"message is required\" });\n\t\t\treturn;\n\t\t}\n\t\tconst images = parseImages(req.body);\n\t\tif (images === \"invalid\") {\n\t\t\tres.status(400).json({ error: \"images must be an array of {data, mimeType} objects\" });\n\t\t\treturn;\n\t\t}\n\t\tconst rpcImages = images?.map((image) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\tdata: image.data,\n\t\t\tmimeType: image.mimeType,\n\t\t}));\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tif (mode === \"steer\") await h.client.steer(message, rpcImages);\n\t\t\telse if (mode === \"follow_up\") await h.client.followUp(message, rpcImages);\n\t\t\telse await h.client.prompt(message, rpcImages);\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abort());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-compaction\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortCompaction());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-retry\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortRetry());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/model\", (req, res) => {\n\t\tconst { provider, modelId } = req.body ?? {};\n\t\tif (typeof provider !== \"string\" || typeof modelId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"provider and modelId are required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setModel(provider, modelId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/models\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/thinking\", (req, res) => {\n\t\tconst { level } = req.body ?? {};\n\t\tif (typeof level !== \"string\") {\n\t\t\tres.status(400).json({ error: \"level is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setThinkingLevel(level as never));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/compact\", (req, res) => {\n\t\tconst instructions = typeof req.body?.instructions === \"string\" ? req.body.instructions : undefined;\n\t\twithRuntime(req, res, (h) => h.client.compact(instructions));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/name\", (req, res) => {\n\t\tconst { name } = req.body ?? {};\n\t\tif (typeof name !== \"string\" || name.length === 0) {\n\t\t\tres.status(400).json({ error: \"name is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setSessionName(name));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/stats\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getSessionStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/performance\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPerformanceStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/resources\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getResources());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/commands\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ commands: await h.client.getCommands() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/branch\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ branch: await h.client.getGitBranch() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/fork-messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ messages: await h.client.getForkMessages() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/fork\", (req, res) => {\n\t\tconst { entryId } = req.body ?? {};\n\t\tif (typeof entryId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"entryId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.fork(entryId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/export-html\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\thandle.client\n\t\t\t.exportHtml()\n\t\t\t.then(({ path }) => {\n\t\t\t\tres.download(path);\n\t\t\t})\n\t\t\t.catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/background-agents\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ agents: await h.client.listBackgroundAgents() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/messages\", (req, res) => {\n\t\tconst agentId = String(req.params.agentId);\n\t\twithRuntime(req, res, async (h) => {\n\t\t\t// The runtime's registry is authoritative for status + log location.\n\t\t\tconst agents = await h.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((a) => a.agentId === agentId);\n\t\t\tif (!agent) throw new Error(`No background agent ${agentId} in this runtime`);\n\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\treturn { agent, messages };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/extension-ui-response\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\thandle.client.sendExtensionUIResponse(req.body);\n\t\t\tres.json({ ok: true });\n\t\t} catch (err) {\n\t\t\tres.status(502).json({ error: String((err as Error)?.message ?? err) });\n\t\t}\n\t});\n\n\t// -- disk sessions -----------------------------------------------------------\n\tapp.delete(\"/api/sessions\", (req, res) => {\n\t\tconst path = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!path) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\toptions\n\t\t\t.deleteSession(path)\n\t\t\t.then((result) => {\n\t\t\t\tlog(`session deleted: ${path}`);\n\t\t\t\thub.publish(\"\", { type: \"disk_sessions_changed\" });\n\t\t\t\tres.json(result ?? { ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- settings ------------------------------------------------------------------\n\t// Settings are process-global persistent defaults. They route through hidden\n\t// utility runtimes instead of whichever user session happened to open first.\n\t// Agent-definition discovery is cwd-sensitive, so callers may pass an explicit\n\t// project cwd for endpoints that need project-local .dreb/agents.\n\tfunction withAnyRuntime(\n\t\tres: Response,\n\t\tfn: (h: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t\tcwd?: string,\n\t) {\n\t\tpool\n\t\t\t.ensureUtilityRuntime(cwd)\n\t\t\t.then((handle) => fn(handle))\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/settings\", (_req, res) => {\n\t\twithAnyRuntime(res, (h) => h.client.getSettings());\n\t});\n\n\tapp.get(\"/api/settings/models\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.get(\"/api/settings/agent-types\", (req, res) => {\n\t\tconst cwd = typeof req.query.cwd === \"string\" && req.query.cwd.trim() ? req.query.cwd : undefined;\n\t\tif (cwd && !existsSync(cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${cwd}` });\n\t\t\treturn;\n\t\t}\n\t\twithAnyRuntime(res, async (h) => ({ agentTypes: await h.client.listAgentTypes() }), cwd);\n\t});\n\n\tapp.get(\"/api/daily-cost\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ cost: await h.client.getDailyCost() }));\n\t});\n\n\tapp.put(\"/api/settings\", (req, res) => {\n\t\twithAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}));\n\t});\n\n\tapp.get(\"/api/version\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ version: await h.client.getVersion() }));\n\t});\n\n\tapp.post(\"/api/settings/remove-trusted\", (req, res) => {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tpool\n\t\t\t.ensureUtilityRuntime()\n\t\t\t.then(async (handle) => {\n\t\t\t\tconst result = await handle.client.removeTrustedContextFolder(rawPath);\n\t\t\t\tlog(`context trust configured remove: ${rawPath}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- server lifecycle ----------------------------------------------------------\n\t// Build/version of the *server* process (distinct from a freshly-spawned RPC\n\t// child's version) so a stale long-running service is visible at a glance.\n\tapp.get(\"/api/server/info\", (_req, res) => {\n\t\tres.json({\n\t\t\tversion: options.serverVersion ?? null,\n\t\t\tstartedAt: serverStartedAt,\n\t\t\t// systemd sets INVOCATION_ID; other supervisors set LISTEN_PID. Best-effort.\n\t\t\tsupervised: Boolean(process.env.INVOCATION_ID || process.env.LISTEN_PID),\n\t\t\trestartable: Boolean(options.onRestart),\n\t\t});\n\t});\n\n\tapp.post(\"/api/server/restart\", (_req, res) => {\n\t\tif (!options.onRestart) {\n\t\t\tres.status(501).json({\n\t\t\t\terror: \"Restart is unavailable — the dashboard is not running under a supervisor that can respawn it\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tlog(\"restart requested via API\");\n\t\tres.json({ ok: true, restarting: true });\n\t\t// Defer so the HTTP response flushes before the process exits.\n\t\tsetTimeout(() => options.onRestart?.(), 100);\n\t});\n\n\t// -- files -----------------------------------------------------------------------\n\tapp.get(\"/api/files\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : homedir();\n\t\tfiles\n\t\t\t.list(path)\n\t\t\t.then(async (listing) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst contextTrust = await handle.client.evaluateContextTrust(listing.path);\n\t\t\t\tres.json({ ...listing, contextTrust });\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tfunction contextTrustMutation(\n\t\treq: Request,\n\t\tres: Response,\n\t\toperation: \"trustContextFolder\" | \"untrustContextFolder\",\n\t): void {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.resolveDirectory(rawPath)\n\t\t\t.then(async (path) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst result = await handle.client[operation](path);\n\t\t\t\tlog(`context trust ${operation === \"trustContextFolder\" ? \"add\" : \"remove\"}: ${path}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t}\n\n\tapp.post(\"/api/files/trust\", (req, res) => contextTrustMutation(req, res, \"trustContextFolder\"));\n\tapp.post(\"/api/files/untrust\", (req, res) => contextTrustMutation(req, res, \"untrustContextFolder\"));\n\n\tapp.get(\"/api/files/places\", (_req, res) => {\n\t\tconst roots = [...new Set(pool.list().map((h) => h.cwd))];\n\t\tres.json({ places: defaultPlaces(homedir(), roots) });\n\t});\n\n\tapp.get(\"/api/files/download\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : \"\";\n\t\tfiles\n\t\t\t.resolveDownload(path)\n\t\t\t.then(({ path: real }) => {\n\t\t\t\tres.download(real);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.post(\"/api/files/upload\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst dir = typeof req.query.dir === \"string\" ? req.query.dir : \"\";\n\t\t\tconst name = typeof req.query.name === \"string\" ? req.query.name : \"\";\n\t\t\tconst overwrite = req.query.overwrite === \"true\";\n\t\t\tconst upload = await files.prepareUpload(dir, name, overwrite);\n\t\t\ttry {\n\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\tlet settled = false;\n\t\t\t\t\tconst fail = (err: unknown) => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tupload.stream.destroy();\n\t\t\t\t\t\treject(err);\n\t\t\t\t\t};\n\t\t\t\t\treq.pipe(upload.stream);\n\t\t\t\t\tupload.stream.on(\"finish\", () => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t\tupload.stream.on(\"error\", fail);\n\t\t\t\t\treq.on(\"error\", fail);\n\t\t\t\t\treq.on(\"aborted\", () => fail(Object.assign(new Error(\"Upload aborted\"), { status: 499 })));\n\t\t\t\t});\n\t\t\t\tawait upload.commit();\n\t\t\t\tres.json({ path: upload.path });\n\t\t\t} catch (err) {\n\t\t\t\tawait upload.cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t})().catch((err) => {\n\t\t\tif (!res.headersSent) res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) });\n\t\t});\n\t});\n\n\tapp.post(\"/api/files/mkdir\", (req, res) => {\n\t\tconst { dir, name } = req.body ?? {};\n\t\tif (typeof dir !== \"string\" || typeof name !== \"string\") {\n\t\t\tres.status(400).json({ error: \"dir and name are required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.mkdir(dir, name)\n\t\t\t.then((path) => res.json({ path }))\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- static client -----------------------------------------------------------------\n\tif (options.staticDir) {\n\t\tapp.use(express.static(options.staticDir));\n\t\t// SPA fallback: serve index.html for non-API GETs (client-side routing).\n\t\tapp.get(/^\\/(?!api\\/).*/, (_req, res) => {\n\t\t\tres.sendFile(join(options.staticDir!, \"index.html\"));\n\t\t});\n\t}\n\n\treturn app;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,OAAO,MAAM,SAAS,CAAC;AAc9B,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAKN,qBAAqB,EACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,QAAQ,EAA+C,MAAM,gBAAgB,CAAC;AAGvF,OAAO,KAAK,EAA4B,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG/E,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,OAAO,GAAG;IAClD,mFAAmF;IACnF,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAuE;IACvE,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC1C,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sGAAsG;IACtG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2HAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2GAA2G;IAC3G,YAAY,CAAC,EAAE,qBAAqB,CAAC;IACrC,wDAAwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAGD,eAAO,MAAM,sBAAsB,QAAkB,CAAC;AACtD,eAAO,MAAM,+BAA+B,QAAS,CAAC;AA2CtD,oDAAoD;AACpD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQtF;AAQD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,kBAAkB,CAo6BzF","sourcesContent":["/**\n * Dashboard HTTP server — Express app wiring auth, the runtime pool, the SSE\n * hub, and the file API into the REST surface the browser client consumes.\n *\n * Bind address discipline: local mode binds 127.0.0.1 only. The\n * caller decides the bind address; `createDashboardServer` never listens by\n * itself. Remote mode still passes every request through DashboardAuth.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { NextFunction, Request, Response } from \"express\";\nimport express from \"express\";\nimport type {\n\tActiveRuntimeSnapshotDto,\n\tAuthStatusDto,\n\tClientConnectionDiagnosticDto,\n\tDashboardResyncDto,\n\tFleetDto,\n\tImageAttachmentDto,\n\tPairingCodeDto,\n\tRuntimeHydrationDto,\n\tSessionInfoDto,\n\tSessionInventoryDto,\n} from \"../shared/protocol.js\";\nimport { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES } from \"../shared/protocol.js\";\nimport type { AuthDecision, DashboardAuth } from \"./auth.js\";\nimport {\n\tDASHBOARD_IMAGE_ID_PATTERN,\n\tDashboardImageNotFoundError,\n\tDashboardImagePreviewError,\n\ttype DashboardImageScope,\n\tDashboardImageService,\n} from \"./dashboard-images.js\";\nimport { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from \"./event-hub.js\";\nimport { defaultPlaces, FileApi } from \"./files.js\";\nimport { ImagePreviewWorker } from \"./image-preview.js\";\nimport type { DashboardRuntimeSnapshot, RuntimePool } from \"./runtime-pool.js\";\nimport { readSubagentMessages } from \"./subagent-log.js\";\n\nexport type DashboardServerApp = express.Express & {\n\t/** Close dashboard-owned services. Safe to call more than once during shutdown. */\n\tcloseDashboard(): Promise<void>;\n};\n\nexport interface DashboardServerOptions {\n\tauth: DashboardAuth;\n\tpool: RuntimePool;\n\t/** Directory of built client assets; omit to skip static serving (tests). */\n\tstaticDir?: string;\n\t/** Session listing (cross-project) — injected so tests can stub it. */\n\tlistAllSessions: () => Promise<unknown[]>;\n\tdeleteSession: (path: string) => Promise<unknown>;\n\tlogger?: (line: string) => void;\n\t/** Build version of the running server process (for the settings footer / stale-server detection). */\n\tserverVersion?: string;\n\t/** Restart hook — when set, POST /api/server/restart invokes it (typically process exit for a supervisor to respawn). */\n\tonRestart?: () => void;\n\t/** Injectable only to make SSE limits deterministic in integration tests. */\n\teventHub?: EventHub;\n\t/** Injectable bounded image repository/preview service for deterministic tests and lifecycle ownership. */\n\timageService?: DashboardImageService;\n\t/** Named heartbeat interval; defaults to 25 seconds. */\n\theartbeatIntervalMs?: number;\n}\n\nconst DEVICE_COOKIE = \"dreb_dashboard_device\";\nexport const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;\nexport const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;\nconst CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;\n\nfunction isClientDiagnostic(value: unknown): value is ClientConnectionDiagnosticDto {\n\tif (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n\tconst body = value as Record<string, unknown>;\n\tconst allowed = new Set([\n\t\t\"connectionId\",\n\t\t\"state\",\n\t\t\"previousState\",\n\t\t\"attempt\",\n\t\t\"delayMs\",\n\t\t\"visibility\",\n\t\t\"lastAppliedSeq\",\n\t\t\"heartbeatAgeMs\",\n\t\t\"eventCount\",\n\t\t\"eventRatePerMinute\",\n\t\t\"processingLagTotalMs\",\n\t\t\"processingLagMaxMs\",\n\t]);\n\tif (Object.keys(body).some((key) => !allowed.has(key))) return false;\n\tconst states = new Set([\"connecting\", \"connected\", \"retrying\", \"resyncing\", \"disconnected\", \"auth_failed\"]);\n\tconst nonNegativeNumber = (item: unknown) => typeof item === \"number\" && Number.isFinite(item) && item >= 0;\n\tconst nonNegativeInteger = (item: unknown) => typeof item === \"number\" && Number.isSafeInteger(item) && item >= 0;\n\treturn (\n\t\ttypeof body.connectionId === \"string\" &&\n\t\t/^[0-9a-f-]{36}$/i.test(body.connectionId) &&\n\t\ttypeof body.state === \"string\" &&\n\t\tstates.has(body.state) &&\n\t\t(body.previousState === undefined ||\n\t\t\t(typeof body.previousState === \"string\" && states.has(body.previousState))) &&\n\t\tnonNegativeInteger(body.attempt) &&\n\t\tnonNegativeNumber(body.eventCount) &&\n\t\tnonNegativeNumber(body.eventRatePerMinute) &&\n\t\tnonNegativeNumber(body.processingLagTotalMs) &&\n\t\tnonNegativeNumber(body.processingLagMaxMs) &&\n\t\t(body.delayMs === undefined || nonNegativeNumber(body.delayMs)) &&\n\t\t(body.lastAppliedSeq === undefined || nonNegativeInteger(body.lastAppliedSeq)) &&\n\t\t(body.heartbeatAgeMs === undefined || nonNegativeNumber(body.heartbeatAgeMs)) &&\n\t\t(body.visibility === \"visible\" || body.visibility === \"hidden\")\n\t);\n}\n\n/** Parse the device cookie from a Cookie header. */\nexport function parseDeviceCookie(cookieHeader: string | undefined): string | undefined {\n\tif (!cookieHeader) return undefined;\n\tfor (const part of cookieHeader.split(\";\")) {\n\t\tconst eq = part.indexOf(\"=\");\n\t\tif (eq === -1) continue;\n\t\tif (part.slice(0, eq).trim() === DEVICE_COOKIE) return part.slice(eq + 1).trim();\n\t}\n\treturn undefined;\n}\n\ninterface AuthedRequest extends Request {\n\tauthDecision?: AuthDecision;\n\t/** Per-SSE-request opaque diagnostic correlation id. */\n\tsseConnectionId?: string;\n}\n\nexport function createDashboardServer(options: DashboardServerOptions): DashboardServerApp {\n\tconst { auth, pool } = options;\n\tconst serverStartedAt = new Date().toISOString();\n\tconst diagnosticConnections = new Map<string, { issuedAt: number; lastAt?: number }>();\n\tconst log = options.logger ?? ((line: string) => console.log(`[dashboard] ${line}`));\n\tconst files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : \"\"}`));\n\tconst hub = options.eventHub ?? new EventHub();\n\tconst images = options.imageService ?? new DashboardImageService(new ImagePreviewWorker());\n\thub.setEventProjector((key, event) => (key ? images.projectEvent(event, { runtimeKey: key }) : event));\n\tpool.onEvent((key, event) => {\n\t\tif (event.type === \"dashboard_snapshot_barrier\" && typeof event.snapshotId === \"string\") {\n\t\t\t// This RPC marker has no browser frame: its synchronous sequence capture\n\t\t\t// orders the HTTP snapshot before all later EventHub publications.\n\t\t\tpool.recordDashboardBarrier(key, event.snapshotId, hub.currentSequence);\n\t\t\treturn;\n\t\t}\n\t\thub.publish(key, event);\n\t\tif (event.type === \"runtime_removed\") images.removeRuntime(key);\n\t});\n\tpool.onFleetSnapshot((event) => hub.publish(\"\", { ...event }));\n\n\tconst app = express() as DashboardServerApp;\n\tlet closePromise: Promise<void> | undefined;\n\tapp.closeDashboard = () => {\n\t\tclosePromise ??= images.close();\n\t\treturn closePromise;\n\t};\n\tapp.disable(\"x-powered-by\");\n\n\t// -- auth middleware (every route, fail-closed) ---------------------------\n\tapp.use((req: AuthedRequest, res: Response, next: NextFunction) => {\n\t\tif (req.path === \"/api/events\") req.sseConnectionId = randomUUID();\n\t\tauth\n\t\t\t.authenticate({\n\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\thostHeader: req.headers.host,\n\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\tdeviceToken: parseDeviceCookie(req.headers.cookie),\n\t\t\t})\n\t\t\t.then((decision) => {\n\t\t\t\treq.authDecision = decision;\n\t\t\t\tif (decision.allowed) return next();\n\t\t\t\tconst canRenderAuthScreen = decision.needsPairing || Boolean(decision.identity);\n\t\t\t\tif (canRenderAuthScreen) {\n\t\t\t\t\t// The auth/pairing endpoints must be reachable by allowed-but-unpaired\n\t\t\t\t\t// identities, and /api/auth must also be reachable by rejected\n\t\t\t\t\t// Tailscale identities so the SPA denial screen can name them.\n\t\t\t\t\tif (req.path === \"/api/auth\" || (decision.needsPairing && req.path === \"/api/pair\")) return next();\n\t\t\t\t\t// Let the SPA shell + static assets load so the client-side pairing or\n\t\t\t\t\t// denial screen can render. No data exposure: every /api/* data route\n\t\t\t\t\t// below stays fail-closed — only non-API GETs (the app shell) are allowed.\n\t\t\t\t\tif (req.method === \"GET\" && !req.path.startsWith(\"/api/\")) return next();\n\t\t\t\t}\n\t\t\t\tif (req.sseConnectionId) {\n\t\t\t\t\tlog(\n\t\t\t\t\t\t`sse ${JSON.stringify({\n\t\t\t\t\t\t\tconnectionId: req.sseConnectionId,\n\t\t\t\t\t\t\tkind: \"auth_denial\",\n\t\t\t\t\t\t\tmethod: req.method,\n\t\t\t\t\t\t\tpath: req.path,\n\t\t\t\t\t\t\tstatus: decision.status,\n\t\t\t\t\t\t})}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tlog(`denied ${req.method} ${req.path}: ${decision.reason}`);\n\t\t\t\t}\n\t\t\t\tres.status(decision.status).json({\n\t\t\t\t\terror: decision.reason,\n\t\t\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\t\t\tidentity: decision.identity?.loginName,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\t// authenticate() already catches internally; this is belt-and-suspenders.\n\t\t\t\tlog(`auth middleware error — denying: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(500).json({ error: \"Auth subsystem error — denied\" });\n\t\t\t});\n\t});\n\n\t// Authenticate before consuming request bodies. Diagnostics have their own\n\t// small parser limit; the larger limit exists only for prompt image payloads.\n\tapp.use(\"/api/events/diagnostic\", express.json({ limit: MAX_CLIENT_DIAGNOSTIC_BYTES }));\n\tapp.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));\n\tapp.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {\n\t\tif ((err as { type?: string }).type === \"entity.too.large\") {\n\t\t\tres.status(413).json({ error: \"Request body is too large\" });\n\t\t\treturn;\n\t\t}\n\t\tnext(err);\n\t});\n\n\t// -- auth/pairing ----------------------------------------------------------\n\tapp.get(\"/api/auth\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (decision.allowed) {\n\t\t\tconst status: AuthStatusDto =\n\t\t\t\tdecision.mode === \"local\"\n\t\t\t\t\t? { mode: \"local\" }\n\t\t\t\t\t: { mode: \"remote\", identity: decision.identity.loginName, device: decision.identity.device };\n\t\t\tres.json({ ...status, needsPairing: false });\n\t\t\treturn;\n\t\t}\n\t\tres.status(decision.status).json({\n\t\t\terror: decision.reason,\n\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\tidentity: decision.identity?.loginName,\n\t\t});\n\t});\n\n\tapp.get(\"/api/pairing-code\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (!decision.allowed || decision.mode !== \"local\") {\n\t\t\tres.status(403).json({ error: \"Pairing code is only available from the host machine\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!auth.isRemoteEnabled) {\n\t\t\tconst body: PairingCodeDto = { enabled: false };\n\t\t\tres.json(body);\n\t\t\treturn;\n\t\t}\n\t\tconst body: PairingCodeDto = { enabled: true, ...auth.currentPairingCode() };\n\t\tres.json(body);\n\t});\n\n\tapp.post(\"/api/pair\", (req: AuthedRequest, res) => {\n\t\tconst pin = typeof req.body?.pin === \"string\" ? req.body.pin : \"\";\n\t\tauth\n\t\t\t.pair(\n\t\t\t\t{\n\t\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\t\thostHeader: req.headers.host,\n\t\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\t\tdeviceToken: undefined,\n\t\t\t\t},\n\t\t\t\tpin,\n\t\t\t)\n\t\t\t.then(({ token, device }) => {\n\t\t\t\tlog(`paired device ${device.id} (${device.identity})`);\n\t\t\t\tres.cookie(DEVICE_COOKIE, token, {\n\t\t\t\t\thttpOnly: true,\n\t\t\t\t\tsameSite: \"strict\",\n\t\t\t\t\tsecure: false, // Tailscale already encrypts; the dashboard serves plain HTTP on the tailnet.\n\t\t\t\t\texpires: new Date(device.expiresAt),\n\t\t\t\t}).json({ device });\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconst status = typeof err?.status === \"number\" ? err.status : 500;\n\t\t\t\tlog(`pairing failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t\t\t});\n\t});\n\n\tapp.get(\"/api/devices\", (_req, res) => {\n\t\tauth\n\t\t\t.listDevices()\n\t\t\t.then((devices) => res.json({ devices }))\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/devices/:id\", (req, res) => {\n\t\tauth\n\t\t\t.unpair(req.params.id)\n\t\t\t.then((removed) => {\n\t\t\t\tif (!removed) {\n\t\t\t\t\tres.status(404).json({ error: `No paired device with id ${String(req.params.id)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlog(`unpaired device ${String(req.params.id)}`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- events (SSE) ----------------------------------------------------------\n\tapp.get(\"/api/events\", (req: AuthedRequest, res) => {\n\t\tconst connectionId = req.sseConnectionId ?? randomUUID();\n\t\tconst diagnostic = (kind: string, metadata: object = {}) =>\n\t\t\tlog(`sse ${JSON.stringify({ connectionId, kind, ...metadata })}`);\n\t\tres.writeHead(200, {\n\t\t\t\"content-type\": \"text/event-stream\",\n\t\t\t\"cache-control\": \"no-cache\",\n\t\t\tconnection: \"keep-alive\",\n\t\t});\n\n\t\tconst guardedWrite = (\n\t\t\tchunk: string,\n\t\t\tmetadata: SseWriteMetadata | { kind: \"handshake\" | \"heartbeat\" | \"connection\" },\n\t\t): boolean => {\n\t\t\tif (res.destroyed || res.writableEnded) {\n\t\t\t\tdiagnostic(\"write_closed\", { writeKind: metadata.kind });\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst accepted = res.write(chunk);\n\t\t\tconst details = {\n\t\t\t\twriteKind: metadata.kind,\n\t\t\t\t...(\"seq\" in metadata\n\t\t\t\t\t? { seq: metadata.seq, type: metadata.type, frameBytes: metadata.frameBytes, reason: metadata.reason }\n\t\t\t\t\t: {}),\n\t\t\t\twritableLength: res.writableLength,\n\t\t\t};\n\t\t\tdiagnostic(\"write\", details);\n\t\t\tif (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {\n\t\t\t\tdiagnostic(\"backpressure\", details);\n\t\t\t\tres.destroy();\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn true;\n\t\t};\n\n\t\tconst lastIdRaw = req.headers[\"last-event-id\"] ?? req.query.lastEventId;\n\t\tconst lastEventId =\n\t\t\ttypeof lastIdRaw === \"string\" && /^\\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;\n\t\tdiagnostic(\"connect\", { cursor: lastEventId });\n\t\tif (!guardedWrite(\":ok\\n\\n\", { kind: \"handshake\" })) return;\n\t\t// Unnumbered connection metadata lets a browser correlate optional,\n\t\t// payload-free diagnostics without mutating its application SSE cursor.\n\t\tconst issuedAt = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (issuedAt - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tdiagnosticConnections.set(connectionId, { issuedAt });\n\t\tif (!guardedWrite(`event: connection\\ndata: ${JSON.stringify({ connectionId })}\\n\\n`, { kind: \"connection\" }))\n\t\t\treturn;\n\t\tlet detach = () => {};\n\t\tlet keepAlive: ReturnType<typeof setInterval> | undefined;\n\t\tconst stop = () => {\n\t\t\tif (keepAlive) clearInterval(keepAlive);\n\t\t\tdetach();\n\t\t};\n\t\tlet usable = true;\n\t\tdetach = hub.attach(\n\t\t\t{\n\t\t\t\twrite: (chunk, metadata) => {\n\t\t\t\t\tif (!metadata) return false;\n\t\t\t\t\tusable = guardedWrite(chunk, metadata);\n\t\t\t\t\treturn usable;\n\t\t\t\t},\n\t\t\t},\n\t\t\tlastEventId,\n\t\t\t(replay) => diagnostic(replay.kind, replay),\n\t\t);\n\t\t// A rejected/destroyed replay must not leave a timer or live client behind.\n\t\tif (!usable) return;\n\t\t// Named heartbeats are visible to EventSource but have no id, so they do\n\t\t// not alter the application cursor or consume replay history.\n\t\tkeepAlive = setInterval(() => {\n\t\t\tif (!guardedWrite(formatHeartbeatFrame(), { kind: \"heartbeat\" })) stop();\n\t\t}, options.heartbeatIntervalMs ?? 25_000);\n\t\treq.on(\"close\", () => {\n\t\t\tdiagnostic(\"close\", { writableLength: res.writableLength });\n\t\t\tstop();\n\t\t});\n\t});\n\n\t// -- optional client stream diagnostics -----------------------------------\n\tapp.post(\"/api/events/diagnostic\", (req, res) => {\n\t\tconst declaredLength = Number(req.headers[\"content-length\"] ?? 0);\n\t\tconst encodedBytes = Buffer.byteLength(JSON.stringify(req.body ?? null));\n\t\tif (declaredLength > MAX_CLIENT_DIAGNOSTIC_BYTES || encodedBytes > MAX_CLIENT_DIAGNOSTIC_BYTES) {\n\t\t\tres.status(413).json({ error: \"Diagnostic summary exceeds the 4 KiB limit\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isClientDiagnostic(req.body)) {\n\t\t\tres.status(400).json({ error: \"Invalid diagnostic summary\" });\n\t\t\treturn;\n\t\t}\n\t\tconst now = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (now - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tconst record = diagnosticConnections.get(req.body.connectionId);\n\t\tif (!record) {\n\t\t\tres.status(400).json({ error: \"Unknown or expired SSE connection\" });\n\t\t\treturn;\n\t\t}\n\t\tif (record.lastAt !== undefined && now - record.lastAt < CLIENT_DIAGNOSTIC_RATE_LIMIT_MS) {\n\t\t\tres.status(429).json({ error: \"Diagnostic summary rate limited\" });\n\t\t\treturn;\n\t\t}\n\t\trecord.lastAt = now;\n\t\t// Never log the request body wholesale. The schema is intentionally only\n\t\t// connection metadata, and this explicit projection prevents future fields\n\t\t// from accidentally turning diagnostics into a payload side-channel.\n\t\tlog(\n\t\t\t`sse ${JSON.stringify({\n\t\t\t\tconnectionId: req.body.connectionId,\n\t\t\t\tkind: \"client_diagnostic\",\n\t\t\t\tstate: req.body.state,\n\t\t\t\tpreviousState: req.body.previousState,\n\t\t\t\tattempt: req.body.attempt,\n\t\t\t\tdelayMs: req.body.delayMs,\n\t\t\t\tvisibility: req.body.visibility,\n\t\t\t\tlastAppliedSeq: req.body.lastAppliedSeq,\n\t\t\t\theartbeatAgeMs: req.body.heartbeatAgeMs,\n\t\t\t\teventCount: req.body.eventCount,\n\t\t\t\teventRatePerMinute: req.body.eventRatePerMinute,\n\t\t\t\tprocessingLagTotalMs: req.body.processingLagTotalMs,\n\t\t\t\tprocessingLagMaxMs: req.body.processingLagMaxMs,\n\t\t\t})}`,\n\t\t);\n\t\tres.json({ ok: true });\n\t});\n\n\t// -- fleet -----------------------------------------------------------------\n\tconst listDiskSessions = async (): Promise<SessionInfoDto[]> =>\n\t\t((await options.listAllSessions()) as SessionInfoDto[]).filter((session) => existsSync(session.cwd));\n\n\tconst getFleet = async (): Promise<FleetDto> => {\n\t\tconst runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));\n\t\treturn { runtimes, diskSessions: await listDiskSessions() };\n\t};\n\n\t/** Map the one-RPC parent snapshot consistently for recovery and drill-in hydration. */\n\tconst toRuntimeHydration = (snapshot: DashboardRuntimeSnapshot): RuntimeHydrationDto => ({\n\t\tkey: snapshot.key,\n\t\tstate: snapshot.snapshot.state,\n\t\tmessages: images.project(snapshot.snapshot.messages, { runtimeKey: snapshot.key }),\n\t\tbackgroundAgents: snapshot.snapshot.backgroundAgents,\n\t\tbarrierSeq: snapshot.barrierSeq,\n\t});\n\n\tapp.get(\"/api/fleet\", (_req, res) => {\n\t\tconst startedAt = Date.now();\n\t\tgetFleet()\n\t\t\t.then((fleet) => {\n\t\t\t\t// Serialize once so the diagnostic reports the exact JSON response size\n\t\t\t\t// without retaining or logging any fleet payload fields.\n\t\t\t\tconst body = JSON.stringify(fleet);\n\t\t\t\tconst diagnostic = {\n\t\t\t\t\telapsedMs: Date.now() - startedAt,\n\t\t\t\t\tencodedBytes: Buffer.byteLength(body),\n\t\t\t\t\truntimeCount: fleet.runtimes.length,\n\t\t\t\t\tdiskSessionCount: fleet.diskSessions.length,\n\t\t\t\t};\n\t\t\t\tres.type(\"json\").send(body);\n\t\t\t\tlog(`fleet ${JSON.stringify(diagnostic)}`);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** On-disk inventory only; does not query or describe live runtimes. */\n\tapp.get(\"/api/sessions\", (_req, res) => {\n\t\tlistDiskSessions()\n\t\t\t.then((sessions) => {\n\t\t\t\tconst body: SessionInventoryDto = { sessions };\n\t\t\t\tres.json(body);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/**\n\t * Full recovery snapshot. For an active runtime, its RPC marker captures the\n\t * current EventHub sequence before the response; later publications have a\n\t * higher sequence. This is an ordering contract, not a timing heuristic.\n\t */\n\tapp.get(\"/api/resync\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst activeKey = typeof req.query.key === \"string\" ? req.query.key : undefined;\n\t\t\tconst activeAgentId = typeof req.query.agentId === \"string\" ? req.query.agentId : undefined;\n\t\t\tlet active: DashboardResyncDto[\"active\"];\n\t\t\tlet barrierSeq: number;\n\t\t\tif (activeKey) {\n\t\t\t\tconst handle = pool.get(activeKey);\n\t\t\t\tif (!handle) {\n\t\t\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), barrierSeq: hub.currentSequence };\n\t\t\t\t\tres.json(body);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// The disk transcript has its own sequence boundary because it is read\n\t\t\t\t// before the parent RPC snapshot. Relays between these two barriers must\n\t\t\t\t// be reapplied so a subagent delta cannot disappear during recovery.\n\t\t\t\tlet preBarrierSubagent: NonNullable<ActiveRuntimeSnapshotDto[\"subagent\"]> | undefined;\n\t\t\t\tif (activeAgentId) {\n\t\t\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\t\t\tconst agent = agents.find((candidate) => candidate.agentId === activeAgentId);\n\t\t\t\t\tif (!agent) throw new Error(`No background agent ${activeAgentId} in this runtime`);\n\t\t\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\t\t\tpreBarrierSubagent = {\n\t\t\t\t\t\tagentId: activeAgentId,\n\t\t\t\t\t\tagent,\n\t\t\t\t\t\tmessages: images.project(messages, { runtimeKey: activeKey, agentId: activeAgentId }),\n\t\t\t\t\t\tbarrierSeq: hub.currentSequence,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst snapshot = await pool.snapshotDashboard(handle);\n\t\t\t\tbarrierSeq = snapshot.barrierSeq;\n\t\t\t\tactive = {\n\t\t\t\t\t...toRuntimeHydration(snapshot),\n\t\t\t\t\t...(preBarrierSubagent ? { subagent: preBarrierSubagent } : {}),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbarrierSeq = hub.currentSequence;\n\t\t\t}\n\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), ...(active ? { active } : {}), barrierSeq };\n\t\t\tres.json(body);\n\t\t})().catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- runtimes ---------------------------------------------------------------\n\tapp.post(\"/api/runtimes\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst cwd = typeof req.body?.cwd === \"string\" ? req.body.cwd : \"\";\n\t\t\tif (!cwd || !existsSync(cwd)) {\n\t\t\t\tres.status(400).json({ error: `Working directory does not exist: ${cwd || \"(empty)\"}` });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : undefined;\n\t\t\tconst handle = await pool.create(cwd, sessionPath);\n\t\t\tlog(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : \"\"}`);\n\t\t\tconst firstPrompt = typeof req.body?.firstPrompt === \"string\" ? req.body.firstPrompt : undefined;\n\t\t\tif (firstPrompt) await handle.client.prompt(firstPrompt);\n\t\t\tres.status(201).json(await pool.describe(handle));\n\t\t})().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/runtimes/:key\", (req, res) => {\n\t\tpool\n\t\t\t.stop(req.params.key)\n\t\t\t.then((stopped) => {\n\t\t\t\tif (!stopped) {\n\t\t\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\timages.removeRuntime(String(req.params.key));\n\t\t\t\tlog(`runtime ${String(req.params.key)} stopped`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** Helper: run an async op against a pooled runtime with uniform errors. */\n\tfunction withRuntime(\n\t\treq: Request,\n\t\tres: Response,\n\t\tfn: (handle: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t): void {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\tfn(handle)\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/runtimes/:key\", (req, res) => {\n\t\twithRuntime(req, res, (h) => pool.describe(h));\n\t});\n\n\t/**\n\t * Atomic drill-in snapshot. snapshotDashboard performs exactly one RPC and\n\t * consumes its marker barrier, so no independently-read runtime fields can\n\t * describe different moments in a live turn.\n\t */\n\tapp.get(\"/api/runtimes/:key/hydrate\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => toRuntimeHydration(await pool.snapshotDashboard(h)));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({\n\t\t\tmessages: images.project(await h.client.getMessages(), { runtimeKey: h.key }),\n\t\t}));\n\t});\n\n\tconst sendImage = async (\n\t\treq: Request,\n\t\tres: Response,\n\t\tscope: DashboardImageScope,\n\t\tvariant: \"preview\" | \"original\",\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<void> => {\n\t\tconst id = String(req.params.id);\n\t\tif (!DASHBOARD_IMAGE_ID_PATTERN.test(id)) {\n\t\t\tres.status(400).json({ error: \"Invalid dashboard image ID\" });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tconst image =\n\t\t\t\tvariant === \"preview\"\n\t\t\t\t\t? await images.preview(scope, id, loadAuthoritative)\n\t\t\t\t\t: await images.original(scope, id, loadAuthoritative);\n\t\t\tres.set({\n\t\t\t\t\"Content-Type\": image.mimeType,\n\t\t\t\t\"Content-Length\": String(image.bytes.byteLength),\n\t\t\t\t\"X-Content-Type-Options\": \"nosniff\",\n\t\t\t\t\"Cache-Control\": \"private, max-age=31536000, immutable\",\n\t\t\t});\n\t\t\tres.send(Buffer.from(image.bytes));\n\t\t} catch (error) {\n\t\t\tif (error instanceof DashboardImageNotFoundError) {\n\t\t\t\tres.status(404).json({ error: error.message });\n\t\t\t} else if (error instanceof DashboardImagePreviewError) {\n\t\t\t\tres.status(422).json({ error: error.message });\n\t\t\t} else {\n\t\t\t\tres.status(502).json({\n\t\t\t\t\terror: `Image source unavailable: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t};\n\n\tapp.get(\"/api/runtimes/:key/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key }, variant, () => handle.client.getMessages());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst agentId = String(req.params.agentId);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key, agentId }, variant, async () => {\n\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((candidate) => candidate.agentId === agentId);\n\t\t\tif (!agent) throw new DashboardImageNotFoundError(`No background agent ${agentId} in this runtime`);\n\t\t\treturn readSubagentMessages(agent);\n\t\t});\n\t});\n\n\tapp.get(\"/api/runtimes/:key/pending\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPendingMessages());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dequeue\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.clearPendingMessages());\n\t});\n\n\tfunction parseImages(body: unknown): ImageAttachmentDto[] | undefined | \"invalid\" {\n\t\tconst images = (body as { images?: unknown } | undefined)?.images;\n\t\tif (images === undefined) return undefined;\n\t\tif (!Array.isArray(images)) return \"invalid\";\n\t\tconst parsed: ImageAttachmentDto[] = [];\n\t\tfor (const image of images) {\n\t\t\tif (\n\t\t\t\t!image ||\n\t\t\t\ttypeof image !== \"object\" ||\n\t\t\t\ttypeof (image as { data?: unknown }).data !== \"string\" ||\n\t\t\t\ttypeof (image as { mimeType?: unknown }).mimeType !== \"string\"\n\t\t\t) {\n\t\t\t\treturn \"invalid\";\n\t\t\t}\n\t\t\tparsed.push({ data: (image as ImageAttachmentDto).data, mimeType: (image as ImageAttachmentDto).mimeType });\n\t\t}\n\t\treturn parsed;\n\t}\n\n\tapp.post(\"/api/runtimes/:key/prompt\", (req, res) => {\n\t\tconst { message, mode } = req.body ?? {};\n\t\tif (typeof message !== \"string\" || message.length === 0) {\n\t\t\tres.status(400).json({ error: \"message is required\" });\n\t\t\treturn;\n\t\t}\n\t\tconst images = parseImages(req.body);\n\t\tif (images === \"invalid\") {\n\t\t\tres.status(400).json({ error: \"images must be an array of {data, mimeType} objects\" });\n\t\t\treturn;\n\t\t}\n\t\tconst rpcImages = images?.map((image) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\tdata: image.data,\n\t\t\tmimeType: image.mimeType,\n\t\t}));\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tif (mode === \"steer\") await h.client.steer(message, rpcImages);\n\t\t\telse if (mode === \"follow_up\") await h.client.followUp(message, rpcImages);\n\t\t\telse await h.client.prompt(message, rpcImages);\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abort());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-compaction\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortCompaction());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-retry\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortRetry());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/model\", (req, res) => {\n\t\tconst { provider, modelId } = req.body ?? {};\n\t\tif (typeof provider !== \"string\" || typeof modelId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"provider and modelId are required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setModel(provider, modelId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/models\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/thinking\", (req, res) => {\n\t\tconst { level } = req.body ?? {};\n\t\tif (typeof level !== \"string\") {\n\t\t\tres.status(400).json({ error: \"level is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setThinkingLevel(level as never));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/compact\", (req, res) => {\n\t\tconst instructions = typeof req.body?.instructions === \"string\" ? req.body.instructions : undefined;\n\t\twithRuntime(req, res, (h) => h.client.compact(instructions));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/name\", (req, res) => {\n\t\tconst { name } = req.body ?? {};\n\t\tif (typeof name !== \"string\" || name.length === 0) {\n\t\t\tres.status(400).json({ error: \"name is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setSessionName(name));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/stats\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getSessionStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/performance\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPerformanceStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/resources\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getResources());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/commands\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ commands: await h.client.getCommands() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/branch\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ branch: await h.client.getGitBranch() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/fork-messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ messages: await h.client.getForkMessages() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/fork\", (req, res) => {\n\t\tconst { entryId } = req.body ?? {};\n\t\tif (typeof entryId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"entryId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.fork(entryId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/export-html\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\thandle.client\n\t\t\t.exportHtml()\n\t\t\t.then(({ path }) => {\n\t\t\t\tres.download(path);\n\t\t\t})\n\t\t\t.catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/background-agents\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ agents: await h.client.listBackgroundAgents() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/messages\", (req, res) => {\n\t\tconst agentId = String(req.params.agentId);\n\t\twithRuntime(req, res, async (h) => {\n\t\t\t// The runtime's registry is authoritative for status + log location.\n\t\t\tconst agents = await h.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((a) => a.agentId === agentId);\n\t\t\tif (!agent) throw new Error(`No background agent ${agentId} in this runtime`);\n\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\treturn { agent, messages: images.project(messages, { runtimeKey: h.key, agentId }) };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/extension-ui-response\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\thandle.client.sendExtensionUIResponse(req.body);\n\t\t\tres.json({ ok: true });\n\t\t} catch (err) {\n\t\t\tres.status(502).json({ error: String((err as Error)?.message ?? err) });\n\t\t}\n\t});\n\n\t// -- disk sessions -----------------------------------------------------------\n\tapp.delete(\"/api/sessions\", (req, res) => {\n\t\tconst path = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!path) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\toptions\n\t\t\t.deleteSession(path)\n\t\t\t.then((result) => {\n\t\t\t\tlog(`session deleted: ${path}`);\n\t\t\t\thub.publish(\"\", { type: \"disk_sessions_changed\" });\n\t\t\t\tres.json(result ?? { ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- settings ------------------------------------------------------------------\n\t// Settings are process-global persistent defaults. They route through hidden\n\t// utility runtimes instead of whichever user session happened to open first.\n\t// Agent-definition discovery is cwd-sensitive, so callers may pass an explicit\n\t// project cwd for endpoints that need project-local .dreb/agents.\n\tfunction withAnyRuntime(\n\t\tres: Response,\n\t\tfn: (h: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t\tcwd?: string,\n\t) {\n\t\tpool\n\t\t\t.ensureUtilityRuntime(cwd)\n\t\t\t.then((handle) => fn(handle))\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/settings\", (_req, res) => {\n\t\twithAnyRuntime(res, (h) => h.client.getSettings());\n\t});\n\n\tapp.get(\"/api/settings/models\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.get(\"/api/settings/agent-types\", (req, res) => {\n\t\tconst cwd = typeof req.query.cwd === \"string\" && req.query.cwd.trim() ? req.query.cwd : undefined;\n\t\tif (cwd && !existsSync(cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${cwd}` });\n\t\t\treturn;\n\t\t}\n\t\twithAnyRuntime(res, async (h) => ({ agentTypes: await h.client.listAgentTypes() }), cwd);\n\t});\n\n\tapp.get(\"/api/daily-cost\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ cost: await h.client.getDailyCost() }));\n\t});\n\n\tapp.put(\"/api/settings\", (req, res) => {\n\t\twithAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}));\n\t});\n\n\tapp.get(\"/api/version\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ version: await h.client.getVersion() }));\n\t});\n\n\tapp.post(\"/api/settings/remove-trusted\", (req, res) => {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tpool\n\t\t\t.ensureUtilityRuntime()\n\t\t\t.then(async (handle) => {\n\t\t\t\tconst result = await handle.client.removeTrustedContextFolder(rawPath);\n\t\t\t\tlog(`context trust configured remove: ${rawPath}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- server lifecycle ----------------------------------------------------------\n\t// Build/version of the *server* process (distinct from a freshly-spawned RPC\n\t// child's version) so a stale long-running service is visible at a glance.\n\tapp.get(\"/api/server/info\", (_req, res) => {\n\t\tres.json({\n\t\t\tversion: options.serverVersion ?? null,\n\t\t\tstartedAt: serverStartedAt,\n\t\t\t// systemd sets INVOCATION_ID; other supervisors set LISTEN_PID. Best-effort.\n\t\t\tsupervised: Boolean(process.env.INVOCATION_ID || process.env.LISTEN_PID),\n\t\t\trestartable: Boolean(options.onRestart),\n\t\t});\n\t});\n\n\tapp.post(\"/api/server/restart\", (_req, res) => {\n\t\tif (!options.onRestart) {\n\t\t\tres.status(501).json({\n\t\t\t\terror: \"Restart is unavailable — the dashboard is not running under a supervisor that can respawn it\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tlog(\"restart requested via API\");\n\t\tres.json({ ok: true, restarting: true });\n\t\t// Defer so the HTTP response flushes before the process exits.\n\t\tsetTimeout(() => options.onRestart?.(), 100);\n\t});\n\n\t// -- files -----------------------------------------------------------------------\n\tapp.get(\"/api/files\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : homedir();\n\t\tfiles\n\t\t\t.list(path)\n\t\t\t.then(async (listing) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst contextTrust = await handle.client.evaluateContextTrust(listing.path);\n\t\t\t\tres.json({ ...listing, contextTrust });\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tfunction contextTrustMutation(\n\t\treq: Request,\n\t\tres: Response,\n\t\toperation: \"trustContextFolder\" | \"untrustContextFolder\",\n\t): void {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.resolveDirectory(rawPath)\n\t\t\t.then(async (path) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst result = await handle.client[operation](path);\n\t\t\t\tlog(`context trust ${operation === \"trustContextFolder\" ? \"add\" : \"remove\"}: ${path}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t}\n\n\tapp.post(\"/api/files/trust\", (req, res) => contextTrustMutation(req, res, \"trustContextFolder\"));\n\tapp.post(\"/api/files/untrust\", (req, res) => contextTrustMutation(req, res, \"untrustContextFolder\"));\n\n\tapp.get(\"/api/files/places\", (_req, res) => {\n\t\tconst roots = [...new Set(pool.list().map((h) => h.cwd))];\n\t\tres.json({ places: defaultPlaces(homedir(), roots) });\n\t});\n\n\tapp.get(\"/api/files/download\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : \"\";\n\t\tfiles\n\t\t\t.resolveDownload(path)\n\t\t\t.then(({ path: real }) => {\n\t\t\t\tres.download(real);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.post(\"/api/files/upload\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst dir = typeof req.query.dir === \"string\" ? req.query.dir : \"\";\n\t\t\tconst name = typeof req.query.name === \"string\" ? req.query.name : \"\";\n\t\t\tconst overwrite = req.query.overwrite === \"true\";\n\t\t\tconst upload = await files.prepareUpload(dir, name, overwrite);\n\t\t\ttry {\n\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\tlet settled = false;\n\t\t\t\t\tconst fail = (err: unknown) => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tupload.stream.destroy();\n\t\t\t\t\t\treject(err);\n\t\t\t\t\t};\n\t\t\t\t\treq.pipe(upload.stream);\n\t\t\t\t\tupload.stream.on(\"finish\", () => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t\tupload.stream.on(\"error\", fail);\n\t\t\t\t\treq.on(\"error\", fail);\n\t\t\t\t\treq.on(\"aborted\", () => fail(Object.assign(new Error(\"Upload aborted\"), { status: 499 })));\n\t\t\t\t});\n\t\t\t\tawait upload.commit();\n\t\t\t\tres.json({ path: upload.path });\n\t\t\t} catch (err) {\n\t\t\t\tawait upload.cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t})().catch((err) => {\n\t\t\tif (!res.headersSent) res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) });\n\t\t});\n\t});\n\n\tapp.post(\"/api/files/mkdir\", (req, res) => {\n\t\tconst { dir, name } = req.body ?? {};\n\t\tif (typeof dir !== \"string\" || typeof name !== \"string\") {\n\t\t\tres.status(400).json({ error: \"dir and name are required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.mkdir(dir, name)\n\t\t\t.then((path) => res.json({ path }))\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- static client -----------------------------------------------------------------\n\tif (options.staticDir) {\n\t\tapp.use(express.static(options.staticDir));\n\t\t// SPA fallback: serve index.html for non-API GETs (client-side routing).\n\t\tapp.get(/^\\/(?!api\\/).*/, (_req, res) => {\n\t\t\tres.sendFile(join(options.staticDir!, \"index.html\"));\n\t\t});\n\t}\n\n\treturn app;\n}\n"]}
|