@narumitw/pi-webui 0.21.0 → 0.25.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/src/server.ts CHANGED
@@ -4,23 +4,43 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
4
4
  import type { Socket } from "node:net";
5
5
  import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import {
8
+ AttachmentError,
9
+ type AttachmentLimits,
10
+ AttachmentStore,
11
+ type PreparedAttachment,
12
+ type PublicAttachmentState,
13
+ } from "./attachments.js";
7
14
  import type { ConversationEvent, ConversationProjection } from "./conversation.js";
15
+ import { DraftError, DraftStore, type PublicDraftState } from "./drafts.js";
16
+ import { type ImageLimits, imageLimits, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
8
17
  import type { BrowserImageInput } from "./images.js";
18
+ import { SentImageError, type SentImageSettings, SentImageStore } from "./sent-images.js";
9
19
 
10
20
  const JSON_LIMIT = 64 * 1024 * 1024;
11
21
  const CLIENT_ID = /^[A-Za-z0-9_-]{1,80}$/;
12
22
  const REQUEST_ID = /^[A-Za-z0-9_-]{1,120}$/;
13
23
  const MAX_REQUESTS = 128;
24
+ const DEFAULT_MAX_DRAFT_TEXT_BYTES = 1024 * 1024;
14
25
  const SSE_FLUSH_TIMEOUT_MS = 250;
15
26
 
16
27
  export interface WebSendRequest {
17
28
  requestId: string;
18
29
  text: string;
30
+ attachmentRevision?: number;
31
+ attachmentIds?: string[];
19
32
  images: BrowserImageInput[];
33
+ retainedImageIds?: string[];
20
34
  delivery: "next" | "steer";
21
35
  signal?: AbortSignal;
22
36
  }
23
37
 
38
+ interface ParsedSendRequest {
39
+ requestId: string;
40
+ draftRevision: number;
41
+ delivery: "next" | "steer";
42
+ }
43
+
24
44
  export interface WebSendResult {
25
45
  delivery: "immediate" | "followUp" | "steer";
26
46
  }
@@ -28,6 +48,11 @@ export interface WebSendResult {
28
48
  export interface WebUIServerOptions {
29
49
  conversation: ConversationProjection;
30
50
  send: (request: WebSendRequest) => Promise<WebSendResult>;
51
+ processAttachment?: (source: Uint8Array, signal?: AbortSignal) => Promise<PreparedAttachment>;
52
+ attachmentLimits?: AttachmentLimits;
53
+ imageLimits?: Readonly<ImageLimits>;
54
+ sentImageSettings?: SentImageSettings;
55
+ maxDraftTextBytes?: number;
31
56
  maxRequestBytes?: number;
32
57
  }
33
58
 
@@ -60,6 +85,13 @@ export class WebUIServer {
60
85
  private readonly sseClients = new Set<SseClient>();
61
86
  private readonly requests = new Map<string, RequestRecord>();
62
87
  private readonly activeSendControllers = new Set<AbortController>();
88
+ private readonly activeAttachmentControllers = new Set<AbortController>();
89
+ private readonly attachments: AttachmentStore;
90
+ private readonly attachmentLimits: AttachmentLimits;
91
+ private readonly imageLimits: Readonly<ImageLimits>;
92
+ private readonly draft: DraftStore;
93
+ private readonly sentImages: SentImageStore;
94
+ private readonly imageResidentBudget: number;
63
95
  private activeClientId?: string;
64
96
  private leaseGeneration = 0;
65
97
  private closed = false;
@@ -72,6 +104,49 @@ export class WebUIServer {
72
104
  port: number,
73
105
  ) {
74
106
  this.origin = `http://127.0.0.1:${port}`;
107
+ this.imageLimits = options.imageLimits
108
+ ? imageLimits(options.imageLimits)
109
+ : imageLimits({
110
+ maxImages: options.attachmentLimits?.maxImages,
111
+ maxImageBytes: options.attachmentLimits?.maxImageBytes,
112
+ maxBatchBytes: options.attachmentLimits?.maxPromptBytes,
113
+ });
114
+ this.attachmentLimits = {
115
+ maxImages: this.imageLimits.maxImages,
116
+ maxImageBytes: this.imageLimits.maxImageBytes,
117
+ maxPromptBytes: this.imageLimits.maxBatchBytes,
118
+ maxPreparedImageBytes: PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
119
+ };
120
+ this.draft = new DraftStore({
121
+ maxTextBytes: options.maxDraftTextBytes ?? DEFAULT_MAX_DRAFT_TEXT_BYTES,
122
+ });
123
+ const sentImageSettings = options.sentImageSettings ?? {
124
+ enabled: false,
125
+ maxImages: 32,
126
+ maxBytes: 128 * 1024 * 1024,
127
+ };
128
+ this.sentImages = new SentImageStore(sentImageSettings);
129
+ this.imageResidentBudget = Math.max(
130
+ sentImageSettings.maxBytes,
131
+ this.attachmentLimits.maxPromptBytes +
132
+ Math.max(
133
+ this.attachmentLimits.maxImageBytes,
134
+ this.attachmentLimits.maxPreparedImageBytes ?? 0,
135
+ ) *
136
+ 2,
137
+ );
138
+ this.attachments = new AttachmentStore({
139
+ limits: this.attachmentLimits,
140
+ process:
141
+ options.processAttachment ??
142
+ (async () => {
143
+ throw new Error("Image processing is unavailable.");
144
+ }),
145
+ onChange: (state) => {
146
+ this.reconcileSentImageBytes(state);
147
+ this.broadcastControl("attachments", state);
148
+ },
149
+ });
75
150
  server.on("connection", (socket) => {
76
151
  this.sockets.add(socket);
77
152
  socket.once("close", () => this.sockets.delete(socket));
@@ -123,7 +198,12 @@ export class WebUIServer {
123
198
  this.bootstrapToken = undefined;
124
199
  this.unsubscribe();
125
200
  for (const controller of this.activeSendControllers) controller.abort();
201
+ for (const controller of this.activeAttachmentControllers) controller.abort();
126
202
  this.activeSendControllers.clear();
203
+ this.activeAttachmentControllers.clear();
204
+ this.attachments.close();
205
+ this.draft.close();
206
+ this.sentImages.close();
127
207
  this.requests.clear();
128
208
  const responses = [...this.sseClients].map((client) => client.response);
129
209
  this.broadcastControl("session-ended", { message: "Pi session ended" });
@@ -153,7 +233,9 @@ export class WebUIServer {
153
233
  }
154
234
  if (
155
235
  request.method === "GET" &&
156
- ["/app.js", "/state.js", "/markdown.js", "/transcript.js"].includes(url.pathname)
236
+ ["/app.js", "/state.js", "/markdown.js", "/transcript.js", "/image-drag.js"].includes(
237
+ url.pathname,
238
+ )
157
239
  ) {
158
240
  await this.asset(response, url.pathname.slice(1), "text/javascript; charset=utf-8");
159
241
  return;
@@ -166,7 +248,31 @@ export class WebUIServer {
166
248
  this.json(response, 200, {
167
249
  ...this.options.conversation.snapshot(),
168
250
  lease: this.leaseSnapshot(),
251
+ draft: this.draft.publicState(),
252
+ attachments: this.attachments.publicState(),
253
+ imageLimits: this.imageLimits,
254
+ sentImages: this.sentImages.publicState(),
255
+ });
256
+ return;
257
+ }
258
+ const sentPreviewId = sentImagePathId(url.pathname, "preview");
259
+ if (request.method === "GET" && sentPreviewId) {
260
+ const preview = this.sentImages.preview(sentPreviewId);
261
+ response.writeHead(200, {
262
+ "Content-Type": preview.mimeType,
263
+ "Content-Length": preview.bytes.byteLength,
169
264
  });
265
+ response.end(preview.bytes);
266
+ return;
267
+ }
268
+ const previewId = attachmentPathId(url.pathname, "preview");
269
+ if (request.method === "GET" && previewId) {
270
+ const preview = this.attachments.preview(previewId);
271
+ response.writeHead(200, {
272
+ "Content-Type": preview.mimeType,
273
+ "Content-Length": preview.bytes.byteLength,
274
+ });
275
+ response.end(preview.bytes);
170
276
  return;
171
277
  }
172
278
  if (request.method === "GET" && url.pathname === "/api/events") {
@@ -184,6 +290,8 @@ export class WebUIServer {
184
290
  this.activeClientId = clientId;
185
291
  this.leaseGeneration += 1;
186
292
  for (const controller of this.activeSendControllers) controller.abort();
293
+ for (const controller of this.activeAttachmentControllers) controller.abort();
294
+ this.attachments.cancelInFlight("Editing moved to another browser tab.");
187
295
  this.broadcastControl("lease", {
188
296
  activeClientId: clientId,
189
297
  generation: this.leaseGeneration,
@@ -195,17 +303,162 @@ export class WebUIServer {
195
303
  return;
196
304
  }
197
305
  const lease = this.assertActiveClient(request);
306
+ if (request.method === "POST" && url.pathname === "/api/draft") {
307
+ const body = requireRecord(
308
+ await readJson(
309
+ request,
310
+ (this.options.maxDraftTextBytes ?? DEFAULT_MAX_DRAFT_TEXT_BYTES) + 8192,
311
+ ),
312
+ "draft mutation",
313
+ );
314
+ this.assertLease(lease);
315
+ const state = this.draft.setText(
316
+ stringField(body, "text"),
317
+ numberField(body, "revision"),
318
+ stringField(body, "requestId"),
319
+ );
320
+ this.assertLease(lease);
321
+ this.broadcastDraft(state);
322
+ this.json(response, 200, state);
323
+ return;
324
+ }
325
+ if (request.method === "POST" && url.pathname === "/api/sent-images/reattach") {
326
+ const body = requireRecord(await readJson(request, 64 * 1024), "sent-image reattach");
327
+ this.assertLease(lease);
328
+ const items = parseSentImageReattachments(body.items);
329
+ const clones = this.sentImages.clone(items.map((item) => item.retainedId));
330
+ const attachments = this.attachments.attachPrepared(
331
+ clones.map((clone, index) => ({
332
+ id: items[index]?.id ?? "",
333
+ name: clone.name,
334
+ prepared: { bytes: clone.bytes, mimeType: clone.mimeType, notes: [] },
335
+ })),
336
+ numberField(body, "revision"),
337
+ );
338
+ this.assertLease(lease);
339
+ const draft = this.syncDraftAttachments(attachments);
340
+ this.json(response, 200, {
341
+ attachments,
342
+ draft,
343
+ sentImages: this.sentImages.publicState(),
344
+ });
345
+ return;
346
+ }
347
+ const sentDeleteId = sentImagePathId(url.pathname);
348
+ if (request.method === "DELETE" && sentDeleteId) {
349
+ const state = this.sentImages.remove(sentDeleteId, revisionParameter(url));
350
+ this.assertLease(lease);
351
+ this.broadcastSentImages(state);
352
+ this.json(response, 200, state);
353
+ return;
354
+ }
355
+ if (request.method === "POST" && url.pathname === "/api/sent-images/clear") {
356
+ const body = requireRecord(await readJson(request, 8 * 1024), "sent-image clear");
357
+ const state = this.sentImages.clear(numberField(body, "revision"));
358
+ this.assertLease(lease);
359
+ this.broadcastSentImages(state);
360
+ this.json(response, 200, state);
361
+ return;
362
+ }
363
+ if (request.method === "POST" && url.pathname === "/api/attachments/reserve") {
364
+ const body = requireRecord(await readJson(request, 64 * 1024), "attachment reservation");
365
+ this.assertLease(lease);
366
+ const state = this.attachments.reserve(
367
+ parseAttachmentReservations(body.items),
368
+ numberField(body, "revision"),
369
+ );
370
+ this.assertLease(lease);
371
+ this.syncDraftAttachments(state);
372
+ this.json(response, 201, state);
373
+ return;
374
+ }
375
+ const uploadId = attachmentPathId(url.pathname, "upload");
376
+ if (request.method === "POST" && uploadId) {
377
+ const revision = revisionParameter(url);
378
+ let source: Buffer;
379
+ try {
380
+ source = await readBytes(request, this.attachmentLimits.maxImageBytes);
381
+ } catch (error) {
382
+ this.assertLease(lease);
383
+ try {
384
+ this.attachments.failUpload(uploadId, `Upload failed: ${formatError(error)}`);
385
+ } catch (failure) {
386
+ if (!(failure instanceof AttachmentError) || failure.status !== 409) throw failure;
387
+ }
388
+ throw error;
389
+ }
390
+ this.assertLease(lease);
391
+ const state = await this.runAttachmentOperation(request, response, (signal) =>
392
+ this.attachments.upload(uploadId, source, revision, signal),
393
+ );
394
+ this.assertLease(lease);
395
+ this.json(response, 200, state);
396
+ return;
397
+ }
398
+ const retryId = attachmentPathId(url.pathname, "retry");
399
+ if (request.method === "POST" && retryId) {
400
+ const body = requireRecord(await readJson(request, 8 * 1024), "attachment retry");
401
+ this.assertLease(lease);
402
+ const state = await this.runAttachmentOperation(request, response, (signal) =>
403
+ this.attachments.retry(retryId, numberField(body, "revision"), signal),
404
+ );
405
+ this.assertLease(lease);
406
+ this.json(response, 200, state);
407
+ return;
408
+ }
409
+ const deleteId = attachmentPathId(url.pathname);
410
+ if (request.method === "DELETE" && deleteId) {
411
+ const state = this.attachments.remove(deleteId, revisionParameter(url));
412
+ this.assertLease(lease);
413
+ this.syncDraftAttachments(state);
414
+ this.json(response, 200, state);
415
+ return;
416
+ }
417
+ if (request.method === "POST" && url.pathname === "/api/attachments/reorder") {
418
+ const body = requireRecord(await readJson(request, 64 * 1024), "attachment reorder");
419
+ this.assertLease(lease);
420
+ const state = this.attachments.reorder(
421
+ stringArrayField(body, "ids"),
422
+ numberField(body, "revision"),
423
+ );
424
+ this.assertLease(lease);
425
+ this.syncDraftAttachments(state);
426
+ this.json(response, 200, state);
427
+ return;
428
+ }
429
+ if (request.method === "POST" && url.pathname === "/api/attachments/clear") {
430
+ const body = requireRecord(await readJson(request, 8 * 1024), "attachment clear");
431
+ this.assertLease(lease);
432
+ const state = this.attachments.clear(numberField(body, "revision"));
433
+ this.assertLease(lease);
434
+ this.syncDraftAttachments(state);
435
+ this.json(response, 200, state);
436
+ return;
437
+ }
198
438
  if (request.method === "POST" && url.pathname === "/api/messages") {
199
439
  const body = await readJson(request, this.options.maxRequestBytes ?? JSON_LIMIT);
200
440
  this.assertLease(lease);
201
441
  const message = parseSendRequest(body);
202
442
  const result = await this.sendDeduplicated(message, request, response);
203
- this.json(response, 202, { accepted: true, requestId: message.requestId, ...result });
443
+ this.json(response, 202, {
444
+ accepted: true,
445
+ requestId: message.requestId,
446
+ ...result,
447
+ draft: this.draft.publicState(),
448
+ attachments: this.attachments.publicState(),
449
+ sentImages: this.sentImages.publicState(),
450
+ });
204
451
  return;
205
452
  }
206
453
  throw new HttpError(404, "Not found");
207
454
  } catch (error) {
208
- const status = error instanceof HttpError ? error.status : 500;
455
+ const status =
456
+ error instanceof HttpError ||
457
+ error instanceof AttachmentError ||
458
+ error instanceof DraftError ||
459
+ error instanceof SentImageError
460
+ ? error.status
461
+ : 500;
209
462
  if (error instanceof HttpError && error.closeConnection)
210
463
  response.setHeader("Connection", "close");
211
464
  this.json(response, status, { error: formatError(error) });
@@ -253,8 +506,27 @@ export class WebUIServer {
253
506
  }
254
507
  }
255
508
 
509
+ private async runAttachmentOperation(
510
+ request: IncomingMessage,
511
+ response: ServerResponse,
512
+ operation: (signal: AbortSignal) => PublicAttachmentState | Promise<PublicAttachmentState>,
513
+ ): Promise<PublicAttachmentState> {
514
+ const controller = new AbortController();
515
+ this.activeAttachmentControllers.add(controller);
516
+ const abort = () => controller.abort();
517
+ request.once("aborted", abort);
518
+ response.once("close", abort);
519
+ try {
520
+ return await operation(controller.signal);
521
+ } finally {
522
+ request.off("aborted", abort);
523
+ response.off("close", abort);
524
+ this.activeAttachmentControllers.delete(controller);
525
+ }
526
+ }
527
+
256
528
  private async sendDeduplicated(
257
- message: WebSendRequest,
529
+ message: ParsedSendRequest,
258
530
  request: IncomingMessage,
259
531
  response: ServerResponse,
260
532
  ): Promise<WebSendResult> {
@@ -265,14 +537,75 @@ export class WebUIServer {
265
537
  throw new HttpError(409, "Request id was reused with different content");
266
538
  return current.promise;
267
539
  }
540
+ const draftReservation = this.draft.beginSend(message.draftRevision);
541
+ const attachmentState = this.attachments.publicState();
542
+ if (
543
+ !sameIds(
544
+ draftReservation.attachmentIds,
545
+ attachmentState.items.map((item) => item.id),
546
+ )
547
+ ) {
548
+ this.draft.finishSend(draftReservation.token, false);
549
+ throw new HttpError(409, "Draft attachment references are stale");
550
+ }
551
+ let attachmentReservation: ReturnType<AttachmentStore["beginSend"]> | undefined;
552
+ try {
553
+ attachmentReservation =
554
+ draftReservation.attachmentIds.length > 0
555
+ ? this.attachments.beginSend(draftReservation.attachmentIds, attachmentState.revision)
556
+ : undefined;
557
+ } catch (error) {
558
+ this.draft.finishSend(draftReservation.token, false);
559
+ throw error;
560
+ }
561
+ const retainedImageIds = this.sentImages.referencesFor(
562
+ message.requestId,
563
+ attachmentReservation?.images ?? [],
564
+ );
268
565
  const controller = new AbortController();
269
566
  this.activeSendControllers.add(controller);
270
567
  const abort = () => controller.abort();
271
568
  request.once("aborted", abort);
272
569
  response.once("close", abort);
570
+ let reservationsSettled = false;
273
571
  const promise = this.options
274
- .send({ ...message, signal: controller.signal })
572
+ .send({
573
+ requestId: message.requestId,
574
+ text: draftReservation.text,
575
+ attachmentRevision: attachmentState.revision,
576
+ attachmentIds: draftReservation.attachmentIds,
577
+ images: attachmentReservation?.images ?? [],
578
+ retainedImageIds,
579
+ delivery: message.delivery,
580
+ signal: controller.signal,
581
+ })
275
582
  .then((result) => {
583
+ if (retainedImageIds.length > 0) {
584
+ try {
585
+ const before = this.sentImages.publicState().revision;
586
+ this.sentImages.commit(
587
+ message.requestId,
588
+ attachmentReservation?.images ?? [],
589
+ retainedImageIds,
590
+ );
591
+ const retained = this.sentImages.reconcile(
592
+ attachmentState.totalResidentBytes,
593
+ this.imageResidentBudget,
594
+ );
595
+ if (retained.revision !== before) this.broadcastSentImages(retained);
596
+ } catch {
597
+ // Retention is optional and must not turn an accepted Pi message into a failure.
598
+ }
599
+ }
600
+ const nextAttachments = attachmentReservation
601
+ ? this.attachments.finishSend(attachmentReservation.token, true)
602
+ : this.attachments.publicState();
603
+ const nextDraft = this.draft.finishSend(draftReservation.token, true, {
604
+ revision: nextAttachments.revision,
605
+ ids: nextAttachments.items.map((item) => item.id),
606
+ });
607
+ this.broadcastDraft(nextDraft);
608
+ reservationsSettled = true;
276
609
  const record = this.requests.get(message.requestId);
277
610
  if (record?.promise === promise) {
278
611
  record.settled = true;
@@ -281,6 +614,17 @@ export class WebUIServer {
281
614
  return result;
282
615
  })
283
616
  .catch((error) => {
617
+ if (!reservationsSettled) {
618
+ try {
619
+ if (attachmentReservation) {
620
+ this.attachments.finishSend(attachmentReservation.token, false);
621
+ }
622
+ this.draft.finishSend(draftReservation.token, false);
623
+ } catch {
624
+ // Session shutdown may already have released the reservations.
625
+ }
626
+ reservationsSettled = true;
627
+ }
284
628
  if (this.requests.get(message.requestId)?.promise === promise) {
285
629
  this.requests.delete(message.requestId);
286
630
  }
@@ -326,6 +670,10 @@ export class WebUIServer {
326
670
  return;
327
671
  }
328
672
  this.writeSse(client, "lease", this.leaseSnapshot());
673
+ this.writeSse(client, "draft", this.draft.publicState());
674
+ this.writeSse(client, "attachments", this.attachments.publicState());
675
+ this.writeSse(client, "image-limits", this.imageLimits);
676
+ this.writeSse(client, "sent-images", this.sentImages.publicState());
329
677
  const replay = this.options.conversation.eventsAfter(since);
330
678
  if (replay === undefined) {
331
679
  this.writeSse(client, "snapshot", this.options.conversation.snapshot());
@@ -342,6 +690,40 @@ export class WebUIServer {
342
690
  };
343
691
  }
344
692
 
693
+ private syncDraftAttachments(attachments: PublicAttachmentState): PublicDraftState {
694
+ const before = this.draft.publicState().revision;
695
+ const next = this.draft.syncAttachments(
696
+ attachments.items.map((item) => item.id),
697
+ attachments.revision,
698
+ );
699
+ if (next.revision !== before) this.broadcastDraft(next);
700
+ return next;
701
+ }
702
+
703
+ private broadcastDraft(state: PublicDraftState): void {
704
+ this.broadcastControl("draft", state);
705
+ }
706
+
707
+ private broadcastSentImages(state: ReturnType<SentImageStore["publicState"]>): void {
708
+ this.broadcastControl("sent-images", state);
709
+ }
710
+
711
+ private reconcileSentImageBytes(attachments = this.attachments.publicState()): void {
712
+ const processingReserve =
713
+ attachments.phase === "processing"
714
+ ? Math.max(
715
+ this.attachmentLimits.maxImageBytes,
716
+ this.attachmentLimits.maxPreparedImageBytes ?? 0,
717
+ ) * 2
718
+ : 0;
719
+ const before = this.sentImages.publicState().revision;
720
+ const retained = this.sentImages.reconcile(
721
+ attachments.totalResidentBytes + processingReserve,
722
+ this.imageResidentBudget,
723
+ );
724
+ if (retained.revision !== before) this.broadcastSentImages(retained);
725
+ }
726
+
345
727
  private broadcastEvent(event: ConversationEvent): void {
346
728
  for (const client of [...this.sseClients]) this.writeSse(client, "conversation", event);
347
729
  }
@@ -421,46 +803,42 @@ async function readAsset(name: string): Promise<Buffer> {
421
803
  }
422
804
  }
423
805
 
424
- function messageDigest(message: WebSendRequest): string {
806
+ function messageDigest(message: ParsedSendRequest): string {
425
807
  const hash = createHash("sha256");
426
- for (const value of [message.requestId, message.delivery, message.text]) {
808
+ for (const value of [message.requestId, message.delivery, String(message.draftRevision)]) {
427
809
  hash
428
810
  .update(String(Buffer.byteLength(value)))
429
811
  .update(":")
430
812
  .update(value);
431
813
  }
432
- for (const image of message.images) {
433
- for (const value of [image.name ?? "", image.mimeType ?? "", image.data]) {
434
- hash
435
- .update(String(Buffer.byteLength(value)))
436
- .update(":")
437
- .update(value);
438
- }
439
- }
440
814
  return hash.digest("hex");
441
815
  }
442
816
 
443
- function parseSendRequest(value: unknown): WebSendRequest {
817
+ function parseSendRequest(value: unknown): ParsedSendRequest {
444
818
  if (!isRecord(value)) throw new HttpError(400, "Invalid message request");
445
819
  const requestId = stringField(value, "requestId");
446
820
  if (!REQUEST_ID.test(requestId)) throw new HttpError(400, "Invalid request id");
447
- const text = stringField(value, "text");
448
- const imagesValue = value.images;
449
- if (!Array.isArray(imagesValue)) throw new HttpError(400, "Invalid image list");
450
- if (!text.trim() && imagesValue.length === 0) throw new HttpError(400, "Message cannot be empty");
821
+ const draftRevision = numberField(value, "draftRevision");
451
822
  const delivery = value.delivery;
452
823
  if (delivery !== "next" && delivery !== "steer")
453
824
  throw new HttpError(400, "Invalid delivery mode");
454
- const images = imagesValue.map((image) => {
455
- if (!isRecord(image) || typeof image.data !== "string")
456
- throw new HttpError(400, "Invalid image");
457
- return {
458
- data: image.data,
459
- ...(typeof image.name === "string" ? { name: image.name } : {}),
460
- ...(typeof image.mimeType === "string" ? { mimeType: image.mimeType } : {}),
461
- };
462
- });
463
- return { requestId, text, images, delivery };
825
+ return { requestId, draftRevision, delivery };
826
+ }
827
+
828
+ async function readBytes(request: IncomingMessage, limit: number): Promise<Buffer> {
829
+ const length = Number(request.headers["content-length"] ?? "0");
830
+ if (Number.isFinite(length) && length > limit) {
831
+ throw new HttpError(413, "Request body is too large", true);
832
+ }
833
+ const chunks: Buffer[] = [];
834
+ let total = 0;
835
+ for await (const chunk of request) {
836
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
837
+ total += bytes.byteLength;
838
+ if (total > limit) throw new HttpError(413, "Request body is too large", true);
839
+ chunks.push(bytes);
840
+ }
841
+ return Buffer.concat(chunks);
464
842
  }
465
843
 
466
844
  async function readJson(request: IncomingMessage, limit: number): Promise<unknown> {
@@ -479,12 +857,94 @@ async function readJson(request: IncomingMessage, limit: number): Promise<unknow
479
857
  }
480
858
  }
481
859
 
860
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
861
+ if (!isRecord(value)) throw new HttpError(400, `Invalid ${label}`);
862
+ return value;
863
+ }
864
+
482
865
  function stringField(value: Record<string, unknown>, key: string): string {
483
866
  const field = value[key];
484
867
  if (typeof field !== "string") throw new HttpError(400, `Invalid ${key}`);
485
868
  return field;
486
869
  }
487
870
 
871
+ function numberField(value: Record<string, unknown>, key: string): number {
872
+ const field = value[key];
873
+ if (!Number.isSafeInteger(field) || (field as number) < 0) {
874
+ throw new HttpError(400, `Invalid ${key}`);
875
+ }
876
+ return field as number;
877
+ }
878
+
879
+ function stringArrayField(value: Record<string, unknown>, key: string): string[] {
880
+ const field = value[key];
881
+ if (!Array.isArray(field) || field.some((item) => typeof item !== "string")) {
882
+ throw new HttpError(400, `Invalid ${key}`);
883
+ }
884
+ return field;
885
+ }
886
+
887
+ function parseAttachmentReservations(value: unknown): Array<{
888
+ id: string;
889
+ name: string;
890
+ size: number;
891
+ mimeType?: string;
892
+ }> {
893
+ if (!Array.isArray(value)) throw new HttpError(400, "Invalid attachment list");
894
+ return value.map((entry) => {
895
+ const item = requireRecord(entry, "attachment");
896
+ return {
897
+ id: stringField(item, "id"),
898
+ name: stringField(item, "name"),
899
+ size: numberField(item, "size"),
900
+ ...(typeof item.mimeType === "string" ? { mimeType: item.mimeType } : {}),
901
+ };
902
+ });
903
+ }
904
+
905
+ function parseSentImageReattachments(value: unknown): Array<{
906
+ retainedId: string;
907
+ id: string;
908
+ }> {
909
+ if (!Array.isArray(value) || value.length === 0) {
910
+ throw new HttpError(400, "Invalid sent-image reattach list");
911
+ }
912
+ return value.map((entry) => {
913
+ const item = requireRecord(entry, "sent-image reattach item");
914
+ return {
915
+ retainedId: stringField(item, "retainedId"),
916
+ id: stringField(item, "id"),
917
+ };
918
+ });
919
+ }
920
+
921
+ function revisionParameter(url: URL): number {
922
+ const value = url.searchParams.get("revision");
923
+ if (!value || !/^\d+$/.test(value)) throw new HttpError(400, "Invalid attachment revision");
924
+ const revision = Number(value);
925
+ if (!Number.isSafeInteger(revision)) throw new HttpError(400, "Invalid attachment revision");
926
+ return revision;
927
+ }
928
+
929
+ function attachmentPathId(
930
+ pathname: string,
931
+ action?: "upload" | "retry" | "preview",
932
+ ): string | undefined {
933
+ const suffix = action ? `/${action}` : "";
934
+ const match = new RegExp(`^/api/attachments/([A-Za-z0-9_-]{1,128})${suffix}$`).exec(pathname);
935
+ return match?.[1];
936
+ }
937
+
938
+ function sentImagePathId(pathname: string, action?: "preview"): string | undefined {
939
+ const suffix = action ? `/${action}` : "";
940
+ const match = new RegExp(`^/api/sent-images/([A-Za-z0-9_-]{1,128})${suffix}$`).exec(pathname);
941
+ return match?.[1];
942
+ }
943
+
944
+ function sameIds(left: readonly string[], right: readonly string[]): boolean {
945
+ return left.length === right.length && left.every((id, index) => id === right[index]);
946
+ }
947
+
488
948
  function token(): string {
489
949
  return randomBytes(32).toString("base64url");
490
950
  }