@yagejs-tools/feedback 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +890 -0
- package/dist/gallery.d.ts +2 -0
- package/dist/gallery.js +288 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +1057 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +735 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +755 -0
- package/package.json +84 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
// src/server/http/server.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
|
|
4
|
+
// src/shared/protocol.ts
|
|
5
|
+
var validId = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id);
|
|
6
|
+
function copyJson(value) {
|
|
7
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8
|
+
function visit(item) {
|
|
9
|
+
if (item === null || typeof item === "string" || typeof item === "boolean")
|
|
10
|
+
return item;
|
|
11
|
+
if (typeof item === "number" && Number.isFinite(item)) return item;
|
|
12
|
+
if (typeof item !== "object" || seen.has(item))
|
|
13
|
+
throw new Error(
|
|
14
|
+
"Feedback context must contain finite, acyclic JSON values."
|
|
15
|
+
);
|
|
16
|
+
seen.add(item);
|
|
17
|
+
let result;
|
|
18
|
+
if (Array.isArray(item)) result = item.map(visit);
|
|
19
|
+
else {
|
|
20
|
+
if (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
|
|
21
|
+
throw new Error("Feedback context must contain plain JSON objects.");
|
|
22
|
+
result = Object.fromEntries(
|
|
23
|
+
Object.entries(item).map(([key, entry]) => [key, visit(entry)])
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
seen.delete(item);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
return visit(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/shared/canonicalJson.ts
|
|
33
|
+
function canonicalJson(value) {
|
|
34
|
+
function sort(value2) {
|
|
35
|
+
if (Array.isArray(value2)) return value2.map(sort);
|
|
36
|
+
if (value2 !== null && typeof value2 === "object")
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.keys(value2).sort().map((key) => [key, sort(value2[key])])
|
|
39
|
+
);
|
|
40
|
+
return value2;
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(sort(copyJson(value)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/server/files/FeedbackFiles.ts
|
|
46
|
+
import {
|
|
47
|
+
mkdir,
|
|
48
|
+
readFile,
|
|
49
|
+
readdir,
|
|
50
|
+
rename,
|
|
51
|
+
rm,
|
|
52
|
+
writeFile
|
|
53
|
+
} from "fs/promises";
|
|
54
|
+
import path from "path";
|
|
55
|
+
import { randomUUID } from "crypto";
|
|
56
|
+
|
|
57
|
+
// src/shared/errors.ts
|
|
58
|
+
var FeedbackError = class extends Error {
|
|
59
|
+
constructor(code, message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.code = code;
|
|
62
|
+
this.name = "FeedbackError";
|
|
63
|
+
}
|
|
64
|
+
code;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/shared/workflow.ts
|
|
68
|
+
var destinations = {
|
|
69
|
+
ingest: "ingested",
|
|
70
|
+
address: "addressed",
|
|
71
|
+
resolve: "resolved",
|
|
72
|
+
reopen: "open"
|
|
73
|
+
};
|
|
74
|
+
var predecessors = {
|
|
75
|
+
ingest: ["open"],
|
|
76
|
+
address: ["ingested"],
|
|
77
|
+
resolve: ["addressed"],
|
|
78
|
+
reopen: ["ingested", "addressed", "resolved"]
|
|
79
|
+
};
|
|
80
|
+
function isStatus(value) {
|
|
81
|
+
return ["open", "ingested", "addressed", "resolved"].includes(String(value));
|
|
82
|
+
}
|
|
83
|
+
function isAction(value) {
|
|
84
|
+
return typeof value === "string" && Object.hasOwn(destinations, value);
|
|
85
|
+
}
|
|
86
|
+
function parseTransition(value) {
|
|
87
|
+
const data = copyJson(value);
|
|
88
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || typeof data.requestId !== "string" || !validId(data.requestId) || typeof data.expectedRevision !== "number" || !Number.isSafeInteger(data.expectedRevision) || data.expectedRevision < 0 || !isAction(data.action) || typeof data.actor !== "string" || !data.actor.trim() || data.actor.length > 200 || data.note !== void 0 && (typeof data.note !== "string" || data.note.length > 1e4)) {
|
|
89
|
+
throw new FeedbackError(
|
|
90
|
+
"invalid",
|
|
91
|
+
"Expected a request UUID, nonnegative revision, action, actor (1\u2013200 characters), and optional note (up to 10000 characters)."
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
requestId: data.requestId,
|
|
96
|
+
expectedRevision: data.expectedRevision,
|
|
97
|
+
action: data.action,
|
|
98
|
+
actor: data.actor,
|
|
99
|
+
...typeof data.note === "string" ? { note: data.note } : {}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function authoredComment(comment) {
|
|
103
|
+
return {
|
|
104
|
+
id: comment.id,
|
|
105
|
+
captureId: comment.captureId,
|
|
106
|
+
text: comment.text,
|
|
107
|
+
created: comment.created,
|
|
108
|
+
target: comment.target,
|
|
109
|
+
status: "open"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function readComment(value) {
|
|
113
|
+
const data = copyJson(value);
|
|
114
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || typeof data.id !== "string" || !validId(data.id) || typeof data.captureId !== "string" || !validId(data.captureId) || typeof data.text !== "string" || !data.text.trim() || data.text.length > 1e4 || typeof data.created !== "string" || !Number.isFinite(Date.parse(data.created)) || !data.target || typeof data.target !== "object" || Array.isArray(data.target) || !["global", "entities", "area"].includes(String(data.target.kind))) {
|
|
115
|
+
throw new FeedbackError("invalid", "Invalid stored feedback comment.");
|
|
116
|
+
}
|
|
117
|
+
const authored = data;
|
|
118
|
+
let comment = {
|
|
119
|
+
...authoredComment({ ...authored, revision: 0, history: [] }),
|
|
120
|
+
status: "open",
|
|
121
|
+
revision: 0,
|
|
122
|
+
history: []
|
|
123
|
+
};
|
|
124
|
+
if (data.revision === void 0 && data.history === void 0 && data.status === "open")
|
|
125
|
+
return comment;
|
|
126
|
+
if (!Array.isArray(data.history))
|
|
127
|
+
throw new FeedbackError("invalid", "Invalid feedback history.");
|
|
128
|
+
for (const value2 of data.history) {
|
|
129
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2) || typeof value2.created !== "string" || !Number.isFinite(Date.parse(value2.created)))
|
|
130
|
+
throw new FeedbackError("invalid", "Invalid feedback history entry.");
|
|
131
|
+
const request = parseTransition(value2);
|
|
132
|
+
const previous = comment;
|
|
133
|
+
comment = transitionComment(comment, request, value2.created);
|
|
134
|
+
if (comment === previous || value2.revision !== comment.revision || value2.from !== previous.status || value2.to !== comment.status)
|
|
135
|
+
throw new FeedbackError("invalid", "Invalid feedback history sequence.");
|
|
136
|
+
}
|
|
137
|
+
if (data.status !== comment.status || data.revision !== comment.revision)
|
|
138
|
+
throw new FeedbackError(
|
|
139
|
+
"invalid",
|
|
140
|
+
"Feedback status and revision must match history."
|
|
141
|
+
);
|
|
142
|
+
return comment;
|
|
143
|
+
}
|
|
144
|
+
function transitionComment(comment, request, created) {
|
|
145
|
+
const prior = comment.history.find(
|
|
146
|
+
(entry) => entry.requestId === request.requestId
|
|
147
|
+
);
|
|
148
|
+
if (prior) {
|
|
149
|
+
if (prior.expectedRevision !== request.expectedRevision || prior.action !== request.action || prior.actor !== request.actor || prior.note !== request.note)
|
|
150
|
+
throw new FeedbackError(
|
|
151
|
+
"conflict",
|
|
152
|
+
"Request ID conflict: retry the original command unchanged."
|
|
153
|
+
);
|
|
154
|
+
return comment;
|
|
155
|
+
}
|
|
156
|
+
if (request.expectedRevision !== comment.revision)
|
|
157
|
+
throw new FeedbackError(
|
|
158
|
+
"stale",
|
|
159
|
+
`Expected revision ${request.expectedRevision}, current revision is ${comment.revision}. Read the comment again.`
|
|
160
|
+
);
|
|
161
|
+
if (!predecessors[request.action].includes(comment.status))
|
|
162
|
+
throw new FeedbackError(
|
|
163
|
+
"conflict",
|
|
164
|
+
`Cannot ${request.action} feedback with status ${comment.status}.`
|
|
165
|
+
);
|
|
166
|
+
const status = destinations[request.action];
|
|
167
|
+
const revision = comment.revision + 1;
|
|
168
|
+
return {
|
|
169
|
+
...comment,
|
|
170
|
+
status,
|
|
171
|
+
revision,
|
|
172
|
+
history: [
|
|
173
|
+
...comment.history,
|
|
174
|
+
{ ...request, created, revision, from: comment.status, to: status }
|
|
175
|
+
]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/server/files/FeedbackFiles.ts
|
|
180
|
+
async function optionalFile(file) {
|
|
181
|
+
try {
|
|
182
|
+
return await readFile(file);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code === "ENOENT") return void 0;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
var FeedbackFiles = class {
|
|
189
|
+
directory;
|
|
190
|
+
constructor(directory) {
|
|
191
|
+
this.directory = path.resolve(directory);
|
|
192
|
+
}
|
|
193
|
+
async save(data, bytes) {
|
|
194
|
+
const { capture, comment } = data;
|
|
195
|
+
const captureRoot = path.join(this.directory, "captures");
|
|
196
|
+
const commentsRoot = path.join(this.directory, "comments");
|
|
197
|
+
await mkdir(captureRoot, { recursive: true });
|
|
198
|
+
await mkdir(commentsRoot, { recursive: true });
|
|
199
|
+
const destination = path.join(captureRoot, capture.id);
|
|
200
|
+
const captureJson = JSON.stringify(capture, null, 2) + "\n";
|
|
201
|
+
const commentJson = JSON.stringify(readComment(comment), null, 2) + "\n";
|
|
202
|
+
const commentPath = path.join(commentsRoot, `${comment.id}.json`);
|
|
203
|
+
const previousComment = await optionalFile(commentPath);
|
|
204
|
+
if (previousComment && canonicalJson(
|
|
205
|
+
authoredComment(readComment(JSON.parse(previousComment.toString())))
|
|
206
|
+
) !== canonicalJson(authoredComment(readComment(comment))))
|
|
207
|
+
throw new FeedbackError("conflict", "Comment ID conflict.");
|
|
208
|
+
const previous = await optionalFile(path.join(destination, "capture.json"));
|
|
209
|
+
if (previous) {
|
|
210
|
+
if (canonicalJson(JSON.parse(previous.toString())) !== canonicalJson(capture) || !(await readFile(path.join(destination, "screenshot.png"))).equals(
|
|
211
|
+
bytes
|
|
212
|
+
))
|
|
213
|
+
throw new FeedbackError("conflict", "Capture ID conflict.");
|
|
214
|
+
} else {
|
|
215
|
+
const staging = path.join(captureRoot, `.pending-${randomUUID()}`);
|
|
216
|
+
await mkdir(staging);
|
|
217
|
+
try {
|
|
218
|
+
await writeFile(path.join(staging, "screenshot.png"), bytes);
|
|
219
|
+
await writeFile(path.join(staging, "capture.json"), captureJson);
|
|
220
|
+
await rename(staging, destination);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await rm(staging, { recursive: true, force: true });
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!previousComment) {
|
|
227
|
+
const temporary = path.join(commentsRoot, `.pending-${randomUUID()}`);
|
|
228
|
+
try {
|
|
229
|
+
await writeFile(temporary, commentJson);
|
|
230
|
+
await rename(temporary, commentPath);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await rm(temporary, { force: true });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return this.show(comment.id);
|
|
237
|
+
}
|
|
238
|
+
async list() {
|
|
239
|
+
const root = path.join(this.directory, "comments");
|
|
240
|
+
await mkdir(root, { recursive: true });
|
|
241
|
+
const files = (await readdir(root)).filter((file) => file.endsWith(".json") && validId(file.slice(0, -5))).sort();
|
|
242
|
+
const comments = await Promise.all(
|
|
243
|
+
files.map(
|
|
244
|
+
async (file) => readComment(JSON.parse(await readFile(path.join(root, file), "utf8")))
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
return comments.sort(
|
|
248
|
+
(a, b) => a.created.localeCompare(b.created) || a.id.localeCompare(b.id)
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
async show(id) {
|
|
252
|
+
const comment = readComment(
|
|
253
|
+
JSON.parse(
|
|
254
|
+
await readFile(
|
|
255
|
+
path.join(this.directory, "comments", `${id}.json`),
|
|
256
|
+
"utf8"
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
);
|
|
260
|
+
const captureRoot = path.join(
|
|
261
|
+
this.directory,
|
|
262
|
+
"captures",
|
|
263
|
+
comment.captureId
|
|
264
|
+
);
|
|
265
|
+
const capture = JSON.parse(
|
|
266
|
+
await readFile(path.join(captureRoot, "capture.json"), "utf8")
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
comment,
|
|
270
|
+
capture,
|
|
271
|
+
screenshotPath: path.join(captureRoot, "screenshot.png"),
|
|
272
|
+
screenshotUrl: `/captures/${comment.captureId}/screenshot.png`
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async writeComment(comment) {
|
|
276
|
+
const root = path.join(this.directory, "comments");
|
|
277
|
+
const temporary = path.join(root, `.pending-${randomUUID()}`);
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(temporary, JSON.stringify(comment, null, 2) + "\n");
|
|
280
|
+
await rename(temporary, path.join(root, `${comment.id}.json`));
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await rm(temporary, { force: true });
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async image(id) {
|
|
287
|
+
return readFile(
|
|
288
|
+
path.join(this.directory, "captures", id, "screenshot.png")
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/server/service/validatePng.ts
|
|
294
|
+
import { PNG } from "pngjs";
|
|
295
|
+
var MAX_PIXELS = 16777216;
|
|
296
|
+
function validatePng(bytes, width, height) {
|
|
297
|
+
if (bytes.length < 33 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.readUInt32BE(8) !== 13 || bytes.toString("ascii", 12, 16) !== "IHDR")
|
|
298
|
+
throw new Error("Invalid PNG header.");
|
|
299
|
+
if (bytes.readUInt32BE(16) !== width || bytes.readUInt32BE(20) !== height)
|
|
300
|
+
throw new Error("PNG dimensions do not match capture dimensions.");
|
|
301
|
+
if (width * height > MAX_PIXELS || bytes[28] !== 0)
|
|
302
|
+
throw new Error(
|
|
303
|
+
"Screenshots must be non-interlaced PNGs of at most 16777216 pixels."
|
|
304
|
+
);
|
|
305
|
+
let offset = 8;
|
|
306
|
+
let ended = false;
|
|
307
|
+
while (offset + 12 <= bytes.length) {
|
|
308
|
+
const length = bytes.readUInt32BE(offset);
|
|
309
|
+
const next = offset + length + 12;
|
|
310
|
+
if (next > bytes.length) throw new Error("Truncated PNG chunk.");
|
|
311
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
312
|
+
if (type === "IHDR" && offset !== 8)
|
|
313
|
+
throw new Error("PNG contains multiple headers.");
|
|
314
|
+
if (type === "IEND") {
|
|
315
|
+
if (length !== 0 || next !== bytes.length)
|
|
316
|
+
throw new Error("Invalid PNG end chunk.");
|
|
317
|
+
ended = true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
offset = next;
|
|
321
|
+
}
|
|
322
|
+
if (!ended) throw new Error("PNG is missing its end chunk.");
|
|
323
|
+
try {
|
|
324
|
+
PNG.sync.read(bytes, { checkCRC: true });
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw new Error("PNG pixels could not be decoded.", { cause: error });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/server/service/validateUpload.ts
|
|
331
|
+
function requireId(id) {
|
|
332
|
+
if (typeof id !== "string" || !validId(id))
|
|
333
|
+
throw new FeedbackError("invalid", "Invalid feedback ID.");
|
|
334
|
+
}
|
|
335
|
+
function rectValid(rect, width, height) {
|
|
336
|
+
return [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.x >= 0 && rect.y >= 0 && rect.width > 0 && rect.height > 0 && rect.x + rect.width <= width + 0.01 && rect.y + rect.height <= height + 0.01;
|
|
337
|
+
}
|
|
338
|
+
function validateUpload(data) {
|
|
339
|
+
copyJson(data);
|
|
340
|
+
const { capture, comment, image } = data;
|
|
341
|
+
if ("revision" in comment || "history" in comment)
|
|
342
|
+
throw new Error("Uploads cannot supply workflow history.");
|
|
343
|
+
copyJson(capture.context);
|
|
344
|
+
requireId(capture.id);
|
|
345
|
+
requireId(capture.sessionId);
|
|
346
|
+
requireId(comment.id);
|
|
347
|
+
if (capture.version !== 1 || comment.captureId !== capture.id || comment.status !== "open")
|
|
348
|
+
throw new Error("Invalid feedback version or capture reference.");
|
|
349
|
+
if (typeof comment.text !== "string" || !comment.text.trim() || comment.text.length > 1e4)
|
|
350
|
+
throw new Error("Feedback text must contain 1\u201310000 characters.");
|
|
351
|
+
if (!Number.isSafeInteger(capture.frame) || capture.frame < 0 || !Number.isSafeInteger(capture.width) || !Number.isSafeInteger(capture.height) || capture.width <= 0 || capture.height <= 0)
|
|
352
|
+
throw new Error("Invalid capture frame or dimensions.");
|
|
353
|
+
if (typeof capture.snapshot !== "object" || capture.snapshot === null || Array.isArray(capture.snapshot) || capture.snapshot["frame"] !== capture.frame)
|
|
354
|
+
throw new Error("Inspector snapshot frame does not match capture frame.");
|
|
355
|
+
if (typeof capture.url !== "string" || typeof capture.created !== "string" || typeof comment.created !== "string" || !Number.isFinite(Date.parse(capture.created)) || !Number.isFinite(Date.parse(comment.created)))
|
|
356
|
+
throw new Error("Invalid feedback URL or timestamp.");
|
|
357
|
+
if (!Array.isArray(capture.entities) || capture.entities.some(
|
|
358
|
+
(entity) => typeof entity.id !== "string" || typeof entity.sceneId !== "string" || typeof entity.name !== "string" || !Number.isSafeInteger(entity.generation) || entity.generation < 0 || !rectValid(entity.bounds, capture.width, capture.height)
|
|
359
|
+
))
|
|
360
|
+
throw new Error("Invalid capture entities.");
|
|
361
|
+
const target = comment.target;
|
|
362
|
+
if (target.kind === "area") {
|
|
363
|
+
if (!rectValid(target.rect, capture.width, capture.height))
|
|
364
|
+
throw new Error("Invalid feedback area.");
|
|
365
|
+
} else if (target.kind === "entities") {
|
|
366
|
+
if (!Array.isArray(target.entities) || target.entities.length === 0 || target.entities.some(
|
|
367
|
+
(entity) => !capture.entities.some(
|
|
368
|
+
(candidate) => canonicalJson(candidate) === canonicalJson(entity)
|
|
369
|
+
)
|
|
370
|
+
))
|
|
371
|
+
throw new Error("Invalid feedback entities.");
|
|
372
|
+
} else if (target.kind !== "global")
|
|
373
|
+
throw new Error("Invalid feedback target.");
|
|
374
|
+
if (typeof image !== "string" || !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(image))
|
|
375
|
+
throw new Error("Expected PNG data URL.");
|
|
376
|
+
const bytes = Buffer.from(image.slice(image.indexOf(",") + 1), "base64");
|
|
377
|
+
validatePng(bytes, capture.width, capture.height);
|
|
378
|
+
return bytes;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/server/service/FeedbackService.ts
|
|
382
|
+
var FeedbackService = class {
|
|
383
|
+
files;
|
|
384
|
+
tail = Promise.resolve();
|
|
385
|
+
constructor(directory) {
|
|
386
|
+
this.files = new FeedbackFiles(directory);
|
|
387
|
+
}
|
|
388
|
+
async idle() {
|
|
389
|
+
await this.tail;
|
|
390
|
+
}
|
|
391
|
+
get directory() {
|
|
392
|
+
return this.files.directory;
|
|
393
|
+
}
|
|
394
|
+
run(work) {
|
|
395
|
+
const result = this.tail.then(work);
|
|
396
|
+
this.tail = result.then(
|
|
397
|
+
() => void 0,
|
|
398
|
+
() => void 0
|
|
399
|
+
);
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
save(value) {
|
|
403
|
+
return this.run(async () => {
|
|
404
|
+
let data;
|
|
405
|
+
let bytes;
|
|
406
|
+
try {
|
|
407
|
+
data = copyJson(value);
|
|
408
|
+
bytes = validateUpload(data);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
throw new FeedbackError(
|
|
411
|
+
"invalid",
|
|
412
|
+
error instanceof Error ? error.message : String(error)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return this.files.save(data, bytes);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async list(status) {
|
|
419
|
+
if (status !== void 0 && !isStatus(status))
|
|
420
|
+
throw new FeedbackError("invalid", "Unknown feedback status.");
|
|
421
|
+
const comments = await this.files.list();
|
|
422
|
+
return status ? comments.filter((comment) => comment.status === status) : comments;
|
|
423
|
+
}
|
|
424
|
+
async show(id) {
|
|
425
|
+
requireId(id);
|
|
426
|
+
return this.files.show(id);
|
|
427
|
+
}
|
|
428
|
+
async image(id) {
|
|
429
|
+
requireId(id);
|
|
430
|
+
return this.files.image(id);
|
|
431
|
+
}
|
|
432
|
+
transition(id, value) {
|
|
433
|
+
return this.run(async () => {
|
|
434
|
+
requireId(id);
|
|
435
|
+
const request = parseTransition(value);
|
|
436
|
+
const detail = await this.files.show(id);
|
|
437
|
+
const comment = transitionComment(
|
|
438
|
+
detail.comment,
|
|
439
|
+
request,
|
|
440
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
441
|
+
);
|
|
442
|
+
if (comment !== detail.comment) await this.files.writeComment(comment);
|
|
443
|
+
return { ...detail, comment };
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/server/files/DirectoryLock.ts
|
|
449
|
+
import {
|
|
450
|
+
mkdirSync,
|
|
451
|
+
openSync,
|
|
452
|
+
writeFileSync,
|
|
453
|
+
closeSync,
|
|
454
|
+
unlinkSync
|
|
455
|
+
} from "fs";
|
|
456
|
+
import path2 from "path";
|
|
457
|
+
function acquireDirectoryLock(directory) {
|
|
458
|
+
mkdirSync(directory, { recursive: true });
|
|
459
|
+
const file = path2.join(directory, ".server.lock");
|
|
460
|
+
let descriptor;
|
|
461
|
+
try {
|
|
462
|
+
descriptor = openSync(file, "wx");
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error.code === "EEXIST")
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Feedback directory is locked: ${file}. Stop its server first. After a crash, confirm the recorded PID is no longer running before removing the lock.`,
|
|
467
|
+
{ cause: error }
|
|
468
|
+
);
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
writeFileSync(
|
|
473
|
+
descriptor,
|
|
474
|
+
JSON.stringify({ pid: process.pid, created: (/* @__PURE__ */ new Date()).toISOString() }) + "\n"
|
|
475
|
+
);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
closeSync(descriptor);
|
|
478
|
+
unlinkSync(file);
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
closeSync(descriptor);
|
|
482
|
+
let released = false;
|
|
483
|
+
return () => {
|
|
484
|
+
if (!released) {
|
|
485
|
+
unlinkSync(file);
|
|
486
|
+
released = true;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/shared/endpoint.ts
|
|
492
|
+
function normalizeBasePath(value) {
|
|
493
|
+
if (!/^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]*\/?$/.test(value))
|
|
494
|
+
throw new Error(
|
|
495
|
+
"Feedback base path must contain only slash-separated letters, digits, underscores, or hyphens."
|
|
496
|
+
);
|
|
497
|
+
return value.endsWith("/") ? value : value + "/";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/server/files/GalleryAssets.ts
|
|
501
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
502
|
+
function readGalleryScript() {
|
|
503
|
+
return readFile2(new URL("./gallery.js", import.meta.url));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/gallery/gallery.html?raw
|
|
507
|
+
var gallery_default = '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width,initial-scale=1" />\n <meta name="feedback-api" content="FEEDBACK_API_PATH" />\n <title>YAGE feedback</title>\n <style>\n :root {\n color-scheme: dark;\n font:\n 15px/1.5 system-ui,\n sans-serif;\n color: #e4edf4;\n background: #101820;\n }\n * {\n box-sizing: border-box;\n }\n body {\n margin: 0;\n }\n main {\n max-width: 1440px;\n margin: auto;\n padding: 32px;\n }\n header {\n display: flex;\n align-items: start;\n justify-content: space-between;\n gap: 20px;\n }\n h1 {\n font-size: 28px;\n margin: 0;\n }\n h2 {\n margin: 0 0 12px;\n }\n p {\n margin: 8px 0;\n }\n small,\n .muted {\n color: #a6b7c6;\n }\n .project {\n overflow-wrap: anywhere;\n }\n .toolbar {\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n margin: 24px 0 12px;\n position: sticky;\n top: 0;\n background: #101820;\n padding: 12px 0;\n z-index: 1;\n }\n button,\n select,\n input,\n textarea {\n font: inherit;\n color: inherit;\n background: #213140;\n border: 1px solid #4b6375;\n border-radius: 7px;\n padding: 9px 12px;\n }\n button {\n cursor: pointer;\n }\n button:hover {\n background: #30495d;\n }\n button:disabled {\n opacity: 0.45;\n cursor: default;\n }\n button:focus-visible,\n a:focus-visible,\n input:focus-visible,\n select:focus-visible {\n outline: 2px solid #79d8d1;\n outline-offset: 3px;\n }\n input[type="search"] {\n min-width: 200px;\n flex: 1;\n }\n input[type="checkbox"] {\n accent-color: #79d8d1;\n width: 18px;\n height: 18px;\n }\n label {\n display: flex;\n gap: 8px;\n align-items: center;\n }\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n gap: 18px;\n }\n .card {\n border: 1px solid #324858;\n border-radius: 10px;\n overflow: hidden;\n background: #192631;\n }\n .card:has(input:checked) {\n border-color: #79d8d1;\n }\n .preview {\n padding: 0;\n border: 0;\n border-radius: 0;\n display: block;\n width: 100%;\n background: #0b1218;\n }\n .preview img {\n display: block;\n width: 100%;\n aspect-ratio: 16/9;\n object-fit: contain;\n }\n .content {\n padding: 16px;\n }\n .comment {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n margin: 12px 0;\n }\n .card .comment {\n display: -webkit-box;\n -webkit-line-clamp: 4;\n -webkit-box-orient: vertical;\n overflow: hidden;\n }\n .badge {\n font-size: 12px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: #79d8d1;\n margin-left: auto;\n }\n .meta {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .pagination {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 16px;\n margin: 24px;\n }\n dialog {\n color: inherit;\n background: #15222c;\n border: 1px solid #4b6375;\n border-radius: 12px;\n width: min(1200px, 95vw);\n max-height: 92vh;\n padding: 24px;\n }\n dialog::backdrop {\n background: #000a;\n }\n .detail-grid {\n display: grid;\n grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr);\n gap: 24px;\n margin-top: 20px;\n }\n .capture {\n position: relative;\n width: 100%;\n align-self: start;\n }\n .capture img {\n display: block;\n width: 100%;\n }\n .outline {\n position: absolute;\n border: 2px solid #ffca66;\n background: #ffca661a;\n pointer-events: none;\n }\n pre {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 12px;\n max-height: 350px;\n overflow: auto;\n background: #0d1720;\n padding: 12px;\n }\n summary {\n cursor: pointer;\n padding: 8px 0;\n }\n .error {\n color: #ffafab;\n }\n textarea {\n width: 100%;\n min-height: 130px;\n }\n a {\n color: #79d8d1;\n }\n @media (max-width: 720px) {\n main {\n padding: 16px;\n }\n header {\n flex-direction: column;\n }\n .detail-grid {\n grid-template-columns: 1fr;\n }\n .toolbar {\n position: static;\n }\n }\n </style>\n </head>\n <body>\n <main>\n <header>\n <div>\n <h1>Feedback</h1>\n <p class="muted project" id="project">Loading project\u2026</p>\n </div>\n <button id="refresh">Refresh</button>\n </header>\n <div class="toolbar">\n <label\n >Status\n <select id="status">\n <option value="pending">Pending</option>\n <option value="open">Open</option>\n <option value="ingested">Ingested</option>\n <option value="addressed">Addressed</option>\n <option value="resolved">Resolved</option>\n <option value="all">All</option>\n </select></label\n >\n <input\n type="search"\n id="search"\n aria-label="Search comments"\n placeholder="Search comments or entity names"\n />\n <button id="select-page">Select page</button\n ><button id="clear">Clear selection</button>\n <button id="codex" disabled>Copy for Codex</button\n ><button id="claude" disabled>Copy for Claude</button>\n </div>\n <p id="summary" class="muted"></p>\n <p id="message" role="status"></p>\n <div class="grid" id="cards"></div>\n <div class="pagination">\n <button id="previous">Previous</button><span id="page"></span\n ><button id="next">Next</button>\n </div>\n </main>\n <dialog id="detail" aria-labelledby="detail-title">\n <header>\n <h2 id="detail-title">Comment evidence</h2>\n <button id="close-detail">Close</button>\n </header>\n <div id="evidence"></div>\n </dialog>\n <dialog id="copy" aria-labelledby="copy-title">\n <header>\n <h2 id="copy-title">Paste into your agent session</h2>\n <button id="close-copy">Close</button>\n </header>\n <p>Clipboard access was unavailable. Copy this instruction:</p>\n <textarea\n id="instruction"\n readonly\n aria-label="Agent instruction"\n ></textarea>\n </dialog>\n <script type="module" src="./gallery.js"></script>\n </body>\n</html>\n';
|
|
508
|
+
|
|
509
|
+
// src/server/http/application.ts
|
|
510
|
+
function createFeedbackApplication(options) {
|
|
511
|
+
const basePath = normalizeBasePath(options.basePath ?? "/");
|
|
512
|
+
const galleryPath = normalizeBasePath(
|
|
513
|
+
options.galleryPath ?? basePath + "gallery/"
|
|
514
|
+
);
|
|
515
|
+
const store = new FeedbackService(options.directory);
|
|
516
|
+
const release = acquireDirectoryLock(store.directory);
|
|
517
|
+
const active = /* @__PURE__ */ new Set();
|
|
518
|
+
let closing;
|
|
519
|
+
const send = (response, status, body) => {
|
|
520
|
+
response.writeHead(status, {
|
|
521
|
+
"Content-Type": "application/json",
|
|
522
|
+
"Cache-Control": "no-store"
|
|
523
|
+
});
|
|
524
|
+
response.end(JSON.stringify(body));
|
|
525
|
+
};
|
|
526
|
+
async function route(request, response) {
|
|
527
|
+
const remote = request.socket.remoteAddress;
|
|
528
|
+
if (!remote || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) {
|
|
529
|
+
send(response, 403, {
|
|
530
|
+
error: "Feedback is only available on this machine."
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const host = request.headers.host;
|
|
535
|
+
if (!host || !/^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/i.test(host)) {
|
|
536
|
+
send(response, 403, {
|
|
537
|
+
code: "invalid-host",
|
|
538
|
+
error: "Expected a loopback Host header."
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (closing) {
|
|
543
|
+
send(response, 503, { error: "Feedback is shutting down." });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const origin = request.headers.origin;
|
|
547
|
+
if (origin && origin !== `http://${host}` && !options.origins?.includes(origin)) {
|
|
548
|
+
send(response, 403, {
|
|
549
|
+
error: "Origin is not allowed. Pass --origin to the feedback server."
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (origin) response.setHeader("Access-Control-Allow-Origin", origin);
|
|
554
|
+
response.setHeader("Vary", "Origin");
|
|
555
|
+
if (request.method === "OPTIONS") {
|
|
556
|
+
response.writeHead(204, {
|
|
557
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
558
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
559
|
+
});
|
|
560
|
+
response.end();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
564
|
+
if (request.method === "GET" && (url.pathname === galleryPath.slice(0, -1) || url.pathname === basePath + "gallery/" && galleryPath !== basePath + "gallery/")) {
|
|
565
|
+
response.writeHead(302, { Location: galleryPath });
|
|
566
|
+
response.end();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (request.method === "GET" && url.pathname === galleryPath + "gallery.js") {
|
|
570
|
+
const script = await readGalleryScript();
|
|
571
|
+
response.writeHead(200, {
|
|
572
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
573
|
+
"Cache-Control": "no-store",
|
|
574
|
+
"X-Content-Type-Options": "nosniff"
|
|
575
|
+
});
|
|
576
|
+
response.end(script);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (request.method === "GET" && url.pathname === galleryPath) {
|
|
580
|
+
response.writeHead(200, {
|
|
581
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
582
|
+
"Cache-Control": "no-store",
|
|
583
|
+
"X-Content-Type-Options": "nosniff"
|
|
584
|
+
});
|
|
585
|
+
response.end(gallery_default.replace("FEEDBACK_API_PATH", basePath));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (!url.pathname.startsWith(basePath)) {
|
|
589
|
+
send(response, 404, { error: "Unknown feedback route." });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
url.pathname = "/" + url.pathname.slice(basePath.length);
|
|
593
|
+
if (request.method === "GET" && url.pathname === "/session") {
|
|
594
|
+
send(response, 200, {
|
|
595
|
+
directory: store.directory,
|
|
596
|
+
project: options.project ?? process.cwd(),
|
|
597
|
+
galleryPath
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const detail = (value) => ({
|
|
602
|
+
...value,
|
|
603
|
+
screenshotUrl: basePath + value.screenshotUrl.replace(/^\//, "")
|
|
604
|
+
});
|
|
605
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
606
|
+
send(response, 200, { ok: true });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (request.method === "GET" && url.pathname === "/comments") {
|
|
610
|
+
send(
|
|
611
|
+
response,
|
|
612
|
+
200,
|
|
613
|
+
await store.list(url.searchParams.get("status") ?? void 0)
|
|
614
|
+
);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (request.method === "GET" && url.pathname.startsWith("/comments/")) {
|
|
618
|
+
send(response, 200, detail(await store.show(url.pathname.slice(10))));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const image = /^\/captures\/([^/]+)\/screenshot\.png$/.exec(url.pathname);
|
|
622
|
+
if (request.method === "GET" && image?.[1]) {
|
|
623
|
+
const bytes = await store.image(image[1]);
|
|
624
|
+
response.writeHead(200, {
|
|
625
|
+
"Content-Type": "image/png",
|
|
626
|
+
"Cache-Control": "no-store"
|
|
627
|
+
});
|
|
628
|
+
response.end(bytes);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const transition = /^\/comments\/([^/]+)\/transitions$/.exec(url.pathname);
|
|
632
|
+
if (request.method === "POST" && (url.pathname === "/comments" || transition)) {
|
|
633
|
+
if (!request.headers["content-type"]?.startsWith("application/json")) {
|
|
634
|
+
send(response, 415, { error: "Expected application/json." });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
let size = 0;
|
|
638
|
+
const chunks = [];
|
|
639
|
+
for await (const chunk of request) {
|
|
640
|
+
const bytes = Buffer.from(chunk);
|
|
641
|
+
size += bytes.length;
|
|
642
|
+
if (size > 24 * 1024 * 1024) {
|
|
643
|
+
send(response, 413, { error: "Capture exceeds 24 MiB." });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
chunks.push(bytes);
|
|
647
|
+
}
|
|
648
|
+
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
649
|
+
send(
|
|
650
|
+
response,
|
|
651
|
+
200,
|
|
652
|
+
detail(
|
|
653
|
+
transition?.[1] ? await store.transition(transition[1], data) : await store.save(data)
|
|
654
|
+
)
|
|
655
|
+
);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
send(response, 404, { error: "Unknown feedback route." });
|
|
659
|
+
}
|
|
660
|
+
const handle = (request, response) => {
|
|
661
|
+
const operation = route(request, response).catch((error) => {
|
|
662
|
+
if (response.headersSent) {
|
|
663
|
+
response.destroy();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
667
|
+
const code = error.code;
|
|
668
|
+
const status = error instanceof FeedbackError ? { invalid: 400, conflict: 409, stale: 409, "not-found": 404 }[error.code] : code === "ENOENT" ? 404 : error instanceof SyntaxError ? 400 : 500;
|
|
669
|
+
send(response, status, {
|
|
670
|
+
error: message,
|
|
671
|
+
code: error instanceof FeedbackError ? error.code : code === "ENOENT" ? "not-found" : status === 400 ? "invalid" : "internal"
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
active.add(operation);
|
|
675
|
+
void operation.finally(() => active.delete(operation));
|
|
676
|
+
};
|
|
677
|
+
return {
|
|
678
|
+
handle,
|
|
679
|
+
close: () => {
|
|
680
|
+
closing ??= (async () => {
|
|
681
|
+
await Promise.all([...active]);
|
|
682
|
+
await store.idle();
|
|
683
|
+
release();
|
|
684
|
+
})();
|
|
685
|
+
return closing;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/server/http/server.ts
|
|
691
|
+
async function startFeedbackServer(options) {
|
|
692
|
+
const port = options.port ?? 5212;
|
|
693
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
694
|
+
throw new Error("Feedback port must be an integer from 0 to 65535.");
|
|
695
|
+
const app = createFeedbackApplication({
|
|
696
|
+
...options,
|
|
697
|
+
// Vite's default dev origins, so a game on `vite` needs no --origin.
|
|
698
|
+
origins: options.origins ?? [
|
|
699
|
+
"http://localhost:5173",
|
|
700
|
+
"http://127.0.0.1:5173"
|
|
701
|
+
]
|
|
702
|
+
});
|
|
703
|
+
const server = createServer(app.handle);
|
|
704
|
+
try {
|
|
705
|
+
await new Promise((resolve, reject) => {
|
|
706
|
+
server.once("error", reject);
|
|
707
|
+
server.listen(port, "127.0.0.1", () => {
|
|
708
|
+
server.off("error", reject);
|
|
709
|
+
resolve();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
await app.close();
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
const address = server.address();
|
|
717
|
+
if (!address || typeof address === "string")
|
|
718
|
+
throw new Error("Feedback server has no TCP address.");
|
|
719
|
+
let closing;
|
|
720
|
+
return {
|
|
721
|
+
url: `http://127.0.0.1:${address.port}${normalizeBasePath(options.basePath ?? "/").replace(/\/$/, "")}`,
|
|
722
|
+
close: () => {
|
|
723
|
+
closing ??= (async () => {
|
|
724
|
+
await new Promise(
|
|
725
|
+
(resolve, reject) => server.close((error) => error ? reject(error) : resolve())
|
|
726
|
+
);
|
|
727
|
+
await app.close();
|
|
728
|
+
})();
|
|
729
|
+
return closing;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
export {
|
|
734
|
+
startFeedbackServer
|
|
735
|
+
};
|