@narumitw/pi-webui 0.20.2 → 0.22.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/runtime.ts CHANGED
@@ -8,11 +8,16 @@ import {
8
8
  getSettingsListTheme,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
11
+ import type { PreparedAttachment } from "./attachments.js";
11
12
  import { ConversationProjection, projectBranchMessages } from "./conversation.js";
13
+ import { DEFAULT_IMAGE_LIMITS, type ImageLimits, imageLimits } from "./image-limits.js";
12
14
  import {
13
15
  type BrowserImageInput,
14
16
  type ProcessBrowserImageOptions,
17
+ type ProcessedBrowserImage,
15
18
  processBrowserImages,
19
+ processStagedImage,
20
+ validateStagedBrowserImages,
16
21
  } from "./images.js";
17
22
  import { type EffectivePiImageSettings, readEffectivePiImageSettings } from "./pi-settings.js";
18
23
  import {
@@ -51,6 +56,12 @@ interface PendingBrowserInput {
51
56
  resolve(): void;
52
57
  reject(error: Error): void;
53
58
  text: string;
59
+ retainedImageIds: string[];
60
+ }
61
+
62
+ interface AcceptedBrowserInput {
63
+ text: string;
64
+ retainedImageIds: string[];
54
65
  }
55
66
 
56
67
  export interface RuntimeDependencies {
@@ -63,6 +74,10 @@ export interface RuntimeDependencies {
63
74
  inputs: BrowserImageInput[],
64
75
  options?: ProcessBrowserImageOptions,
65
76
  ): Promise<ImageContent[]>;
77
+ processAttachment(
78
+ source: Uint8Array,
79
+ options?: ProcessBrowserImageOptions,
80
+ ): Promise<ProcessedBrowserImage>;
66
81
  }
67
82
 
68
83
  const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
@@ -72,6 +87,7 @@ const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
72
87
  startServer: (options) => WebUIServer.start(options),
73
88
  readPiSettings: readEffectivePiImageSettings,
74
89
  processImages: processBrowserImages,
90
+ processAttachment: processStagedImage,
75
91
  };
76
92
 
77
93
  export class WebUIRuntime {
@@ -88,8 +104,9 @@ export class WebUIRuntime {
88
104
  private readonly activeMessageIds = new Map<string, string>();
89
105
  private readonly finalMessageTimers = new Set<ReturnType<typeof setTimeout>>();
90
106
  private readonly pendingBrowserInputs = new Map<string, PendingBrowserInput>();
91
- private readonly acceptedBrowserInputs = new Map<string, string>();
107
+ private readonly acceptedBrowserInputs = new Map<string, AcceptedBrowserInput>();
92
108
  private settings: WebUISettings = { ...DEFAULT_SETTINGS };
109
+ private effectiveImageLimits: Readonly<ImageLimits> = DEFAULT_IMAGE_LIMITS;
93
110
  private settingsDocument?: Record<string, unknown> = {};
94
111
  private settingsPath = "pi-webui.json";
95
112
  private settingsSource: SettingsLoadResult["source"] = "defaults";
@@ -164,7 +181,10 @@ export class WebUIRuntime {
164
181
  if (!wrapped) return;
165
182
  const pending = this.pendingBrowserInputs.get(wrapped.nonce);
166
183
  if (!pending) return;
167
- this.acceptedBrowserInputs.set(wrapped.nonce, pending.text);
184
+ this.acceptedBrowserInputs.set(wrapped.nonce, {
185
+ text: pending.text,
186
+ retainedImageIds: [...pending.retainedImageIds],
187
+ });
168
188
  this.settleBrowserInput(wrapped.nonce);
169
189
  });
170
190
  this.pi.on("message_start", async (event, ctx) => {
@@ -281,8 +301,11 @@ export class WebUIRuntime {
281
301
  id = `web-live:${++this.nextLiveMessageId}`;
282
302
  this.activeMessageIds.set(key, id);
283
303
  }
304
+ const retainedImageIds = Array.isArray(event.retainedImageIds)
305
+ ? event.retainedImageIds.filter((value): value is string => typeof value === "string")
306
+ : [];
284
307
  if (phase !== "end") {
285
- this.recordProjectedMessage(event.message, false, id);
308
+ this.recordProjectedMessage(event.message, false, id, retainedImageIds);
286
309
  return;
287
310
  }
288
311
  this.activeMessageIds.delete(key);
@@ -290,14 +313,19 @@ export class WebUIRuntime {
290
313
  const timer = setTimeout(() => {
291
314
  this.finalMessageTimers.delete(timer);
292
315
  if (generation !== this.generation || this.closed) return;
293
- this.recordProjectedMessage(event.message, true, id);
316
+ this.recordProjectedMessage(event.message, true, id, retainedImageIds);
294
317
  }, 0);
295
318
  this.finalMessageTimers.add(timer);
296
319
  }
297
320
 
298
- private recordProjectedMessage(message: unknown, final: boolean, id: string): void {
321
+ private recordProjectedMessage(
322
+ message: unknown,
323
+ final: boolean,
324
+ id: string,
325
+ retainedImageIds: readonly string[] = [],
326
+ ): void {
299
327
  try {
300
- this.conversation?.recordMessage(message, final, id);
328
+ this.conversation?.recordMessage(message, final, id, retainedImageIds);
301
329
  } catch {
302
330
  // Unknown custom message shapes do not block the supported transcript.
303
331
  }
@@ -369,7 +397,7 @@ export class WebUIRuntime {
369
397
  if (!this.settingsDocument) {
370
398
  throw new Error("the invalid settings file must be repaired manually first");
371
399
  }
372
- const next = { startOnSessionStart: requested };
400
+ const next = { ...this.settings, startOnSessionStart: requested };
373
401
  const document = await this.dependencies.saveSettings(
374
402
  next,
375
403
  this.settingsDocument,
@@ -409,6 +437,7 @@ export class WebUIRuntime {
409
437
  [
410
438
  "Pi WebUI status",
411
439
  `startOnSessionStart: ${this.settings.startOnSessionStart} (${source})`,
440
+ `Image limits (${source}): ${this.effectiveImageLimits.maxImages} images, ${formatMib(this.effectiveImageLimits.maxImageBytes)}/image, ${formatMib(this.effectiveImageLimits.maxBatchBytes)}/batch, ${this.effectiveImageLimits.maxImagePixels.toLocaleString("en-US")} pixels/image`,
412
441
  `Settings: ${this.settingsPath}`,
413
442
  `Server: ${this.server ? "running" : "stopped"}`,
414
443
  ].join("\n"),
@@ -425,9 +454,10 @@ export class WebUIRuntime {
425
454
  "settings: edit WebUI settings in TUI mode",
426
455
  "status: show effective settings, source, path, and current server state",
427
456
  "init: create the defaults file without overwriting existing content",
428
- 'Accepted JSON: { "startOnSessionStart": false }',
457
+ 'Accepted JSON starts with { "startOnSessionStart": false } and may include advanced maxImages, maxImageBytes, maxBatchBytes, and maxImagePixels fields.',
429
458
  `Settings path: ${this.settingsPath}`,
430
- "The default is false. Changes apply on the next session initialization or reload.",
459
+ "Image byte/pixel limits stay in Advanced JSON; Pi provider-ready dimension/Base64 limits are fixed.",
460
+ "Changes apply on the next session initialization or reload.",
431
461
  ].join("\n"),
432
462
  "info",
433
463
  );
@@ -451,6 +481,7 @@ export class WebUIRuntime {
451
481
 
452
482
  private applySettingsResult(result: SettingsLoadResult): void {
453
483
  this.settings = { ...result.settings };
484
+ this.effectiveImageLimits = imageLimits(result.settings);
454
485
  this.settingsDocument = result.kind === "invalid" ? undefined : { ...(result.document ?? {}) };
455
486
  this.settingsPath = result.path;
456
487
  this.settingsSource = result.source;
@@ -465,6 +496,14 @@ export class WebUIRuntime {
465
496
  const starting = this.dependencies.startServer({
466
497
  conversation,
467
498
  send: (request) => this.sendBrowserMessage(request, generation),
499
+ imageLimits: this.effectiveImageLimits,
500
+ sentImageSettings: {
501
+ enabled: this.settings.retainSentImages,
502
+ maxImages: this.settings.maxRetainedImages,
503
+ maxBytes: this.settings.maxRetainedBytes,
504
+ },
505
+ processAttachment: (source, signal) =>
506
+ this.processStagedAttachment(source, generation, signal),
468
507
  });
469
508
  this.serverStarting = starting.then(async (server) => {
470
509
  if (generation !== this.generation || this.closed || conversation !== this.conversation) {
@@ -483,6 +522,37 @@ export class WebUIRuntime {
483
522
  }
484
523
  }
485
524
 
525
+ private async processStagedAttachment(
526
+ source: Uint8Array,
527
+ generation: number,
528
+ signal?: AbortSignal,
529
+ ): Promise<PreparedAttachment> {
530
+ const ctx = this.context;
531
+ if (!ctx || this.closed || generation !== this.generation) {
532
+ throw new Error("The Pi session has ended.");
533
+ }
534
+ const combinedSignal = signal
535
+ ? AbortSignal.any([this.sessionAbort.signal, signal])
536
+ : this.sessionAbort.signal;
537
+ const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
538
+ this.notifySettingsWarnings(ctx, settings.warnings);
539
+ const image = await this.dependencies.processAttachment(source, {
540
+ maxImageBytes: this.effectiveImageLimits.maxImageBytes,
541
+ maxPixels: this.effectiveImageLimits.maxImagePixels,
542
+ autoResize: settings.autoResize,
543
+ blockImages: settings.blockImages,
544
+ supportsImages: ctx.model?.input.includes("image") ?? false,
545
+ signal: combinedSignal,
546
+ });
547
+ if (combinedSignal.aborted || generation !== this.generation || this.closed) {
548
+ throw new Error("Image processing was cancelled.");
549
+ }
550
+ return {
551
+ ...image,
552
+ notes: attachmentNotes(image),
553
+ };
554
+ }
555
+
486
556
  private async sendBrowserMessage(
487
557
  request: WebSendRequest,
488
558
  generation: number,
@@ -499,15 +569,16 @@ export class WebUIRuntime {
499
569
  await this.preflightIdlePrompt(ctx, request, generation, signal);
500
570
  let images: ImageContent[] = [];
501
571
  if (request.images.length > 0) {
572
+ validateStagedBrowserImages(request.images, this.effectiveImageLimits);
502
573
  const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
503
574
  this.notifySettingsWarnings(ctx, settings.warnings);
504
- images = await this.dependencies.processImages(request.images, {
505
- autoResize: settings.autoResize,
506
- blockImages: settings.blockImages,
507
- supportsImages: ctx.model?.input.includes("image") ?? false,
508
- signal,
509
- });
575
+ if (settings.blockImages) throw new Error("Pi image sending is disabled.");
510
576
  await this.validateCurrentModel(ctx, generation, signal, true);
577
+ images = request.images.map((image) => ({
578
+ type: "image" as const,
579
+ data: image.data,
580
+ mimeType: image.mimeType ?? "image/png",
581
+ }));
511
582
  }
512
583
  if (signal.aborted) throw new Error("The browser message was cancelled.");
513
584
  if (!this.context || this.closed || generation !== this.generation) {
@@ -519,7 +590,7 @@ export class WebUIRuntime {
519
590
  images.length === 0
520
591
  ? text
521
592
  : [...(text.trim() ? ([{ type: "text", text }] satisfies TextContent[]) : []), ...images];
522
- const wrapped = this.createBrowserInput(content);
593
+ const wrapped = this.createBrowserInput(content, request.retainedImageIds ?? []);
523
594
  const delivery =
524
595
  request.delivery === "steer"
525
596
  ? "steer"
@@ -574,7 +645,10 @@ export class WebUIRuntime {
574
645
  if (ctx.model !== model) throw new Error("The Pi model changed; retry the browser message.");
575
646
  }
576
647
 
577
- private createBrowserInput(content: string | Array<TextContent | ImageContent>): {
648
+ private createBrowserInput(
649
+ content: string | Array<TextContent | ImageContent>,
650
+ retainedImageIds: readonly string[],
651
+ ): {
578
652
  nonce: string;
579
653
  content: string | Array<TextContent | ImageContent>;
580
654
  accepted: Promise<void>;
@@ -590,7 +664,12 @@ export class WebUIRuntime {
590
664
  ...content.filter((part): part is ImageContent => part.type === "image"),
591
665
  ];
592
666
  const accepted = new Promise<void>((resolve, reject) => {
593
- this.pendingBrowserInputs.set(nonce, { resolve, reject, text });
667
+ this.pendingBrowserInputs.set(nonce, {
668
+ resolve,
669
+ reject,
670
+ text,
671
+ retainedImageIds: [...retainedImageIds],
672
+ });
594
673
  });
595
674
  return { nonce, content: wrappedContent, accepted };
596
675
  }
@@ -653,35 +732,56 @@ function contentText(content: Array<TextContent | ImageContent>): string {
653
732
 
654
733
  function sanitizeBrowserMessageEvent(
655
734
  event: unknown,
656
- acceptedNonces: Map<string, string>,
735
+ acceptedNonces: Map<string, AcceptedBrowserInput>,
657
736
  consume: boolean,
658
737
  ): unknown {
659
738
  if (!isRecord(event) || !isRecord(event.message) || event.message.role !== "user") return event;
660
739
  const content = event.message.content;
661
740
  if (typeof content === "string") {
662
741
  const wrapped = parseBrowserInput(content);
663
- if (!wrapped || !acceptedNonces.has(wrapped.nonce)) return event;
664
- const text = acceptedNonces.get(wrapped.nonce) ?? "";
742
+ const accepted = wrapped ? acceptedNonces.get(wrapped.nonce) : undefined;
743
+ if (!wrapped || !accepted) return event;
665
744
  if (consume) acceptedNonces.delete(wrapped.nonce);
666
- return { ...event, message: { ...event.message, content: text } };
745
+ return {
746
+ ...event,
747
+ retainedImageIds: [...accepted.retainedImageIds],
748
+ message: { ...event.message, content: accepted.text },
749
+ };
667
750
  }
668
751
  if (!Array.isArray(content)) return event;
669
- let changed = false;
670
- const consumed = new Set<string>();
671
- const sanitized = content.flatMap((part) => {
752
+ let accepted: AcceptedBrowserInput | undefined;
753
+ let acceptedNonce: string | undefined;
754
+ const sanitizedText = content.flatMap((part) => {
672
755
  if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return [part];
673
756
  const wrapped = parseBrowserInput(part.text);
674
- if (!wrapped || !acceptedNonces.has(wrapped.nonce)) return [part];
675
- const text = acceptedNonces.get(wrapped.nonce) ?? "";
676
- changed = true;
677
- consumed.add(wrapped.nonce);
678
- return text ? [{ ...part, text }] : [];
757
+ const candidate = wrapped ? acceptedNonces.get(wrapped.nonce) : undefined;
758
+ if (!wrapped || !candidate) return [part];
759
+ accepted = candidate;
760
+ acceptedNonce = wrapped.nonce;
761
+ return candidate.text ? [{ ...part, text: candidate.text }] : [];
679
762
  });
680
- if (!changed) return event;
681
- if (consume) {
682
- for (const nonce of consumed) acceptedNonces.delete(nonce);
763
+ if (!accepted) return event;
764
+ if (consume && acceptedNonce) acceptedNonces.delete(acceptedNonce);
765
+ return {
766
+ ...event,
767
+ retainedImageIds: [...accepted.retainedImageIds],
768
+ message: { ...event.message, content: sanitizedText },
769
+ };
770
+ }
771
+
772
+ function attachmentNotes(image: ProcessedBrowserImage): string[] {
773
+ const notes: string[] = [];
774
+ if (image.sourceFormat !== image.outputFormat) {
775
+ notes.push(
776
+ `Converted ${image.sourceFormat.toUpperCase()} to ${image.outputFormat.toUpperCase()}`,
777
+ );
778
+ }
779
+ if (image.resized) {
780
+ notes.push(
781
+ `Resized ${image.originalWidth}×${image.originalHeight} to ${image.width}×${image.height}`,
782
+ );
683
783
  }
684
- return { ...event, message: { ...event.message, content: sanitized } };
784
+ return notes;
685
785
  }
686
786
 
687
787
  function messageLifecycleKey(message: Record<string, unknown>): string {
@@ -692,6 +792,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
692
792
  return typeof value === "object" && value !== null && !Array.isArray(value);
693
793
  }
694
794
 
795
+ function formatMib(bytes: number): string {
796
+ return `${bytes / (1024 * 1024)} MiB`;
797
+ }
798
+
695
799
  function formatError(error: unknown): string {
696
800
  return error instanceof Error ? error.message : String(error);
697
801
  }
@@ -0,0 +1,279 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+
3
+ export interface SentImageSettings {
4
+ enabled: boolean;
5
+ maxImages: number;
6
+ maxBytes: number;
7
+ }
8
+
9
+ export interface RetainedImageInput {
10
+ name?: string;
11
+ mimeType?: string;
12
+ data: string;
13
+ }
14
+
15
+ export interface RetainedImageClone {
16
+ retainedId: string;
17
+ name: string;
18
+ bytes: Buffer;
19
+ mimeType: string;
20
+ }
21
+
22
+ export interface PublicSentImage {
23
+ id: string;
24
+ name: string;
25
+ mimeType: string;
26
+ size: number;
27
+ }
28
+
29
+ export interface PublicSentImageState {
30
+ revision: number;
31
+ enabled: boolean;
32
+ items: PublicSentImage[];
33
+ totalBytes: number;
34
+ maxImages: number;
35
+ maxBytes: number;
36
+ }
37
+
38
+ interface SentImageItem {
39
+ id: string;
40
+ name: string;
41
+ mimeType: string;
42
+ bytes: Buffer;
43
+ associations: Set<string>;
44
+ }
45
+
46
+ export class SentImageError extends Error {
47
+ constructor(
48
+ message: string,
49
+ readonly status = 409,
50
+ ) {
51
+ super(message);
52
+ this.name = "SentImageError";
53
+ }
54
+ }
55
+
56
+ export class SentImageStore {
57
+ private readonly key = randomBytes(32);
58
+ private readonly items: SentImageItem[] = [];
59
+ private revision = 0;
60
+ private closed = false;
61
+
62
+ constructor(private readonly settings: SentImageSettings) {
63
+ for (const value of [settings.maxImages, settings.maxBytes]) {
64
+ if (!Number.isSafeInteger(value) || value <= 0) {
65
+ throw new Error("Sent-image limits must be positive integers.");
66
+ }
67
+ }
68
+ }
69
+
70
+ publicState(): PublicSentImageState {
71
+ return {
72
+ revision: this.revision,
73
+ enabled: this.settings.enabled,
74
+ items: this.items.map((item) => ({
75
+ id: item.id,
76
+ name: item.name,
77
+ mimeType: item.mimeType,
78
+ size: item.bytes.byteLength,
79
+ })),
80
+ totalBytes: this.residentBytes(),
81
+ maxImages: this.settings.maxImages,
82
+ maxBytes: this.settings.maxBytes,
83
+ };
84
+ }
85
+
86
+ residentBytes(): number {
87
+ return this.items.reduce((total, item) => total + item.bytes.byteLength, 0);
88
+ }
89
+
90
+ referencesFor(messageId: string, images: readonly RetainedImageInput[]): string[] {
91
+ this.assertOpen();
92
+ if (!this.settings.enabled) return [];
93
+ normalizeMessageId(messageId);
94
+ return images.map((image, index) => {
95
+ const normalized = normalizeImage(image, index);
96
+ return this.idFor(normalized.bytes);
97
+ });
98
+ }
99
+
100
+ commit(
101
+ messageId: string,
102
+ images: readonly RetainedImageInput[],
103
+ expectedReferences: readonly string[],
104
+ ): PublicSentImageState {
105
+ this.assertOpen();
106
+ if (!this.settings.enabled) {
107
+ if (expectedReferences.length !== 0) {
108
+ throw new SentImageError("Sent-image retention is disabled.");
109
+ }
110
+ return this.publicState();
111
+ }
112
+ normalizeMessageId(messageId);
113
+ const normalized = images.map(normalizeImage);
114
+ const references = normalized.map((image) => this.idFor(image.bytes));
115
+ if (!sameIds(references, expectedReferences)) {
116
+ throw new SentImageError("Sent-image references are stale.");
117
+ }
118
+ let changed = false;
119
+ for (const [index, image] of normalized.entries()) {
120
+ const id = references[index];
121
+ if (!id) throw new SentImageError("Sent-image reference is missing.");
122
+ const existing = this.items.find((item) => item.id === id);
123
+ if (existing) {
124
+ existing.associations.add(messageId);
125
+ continue;
126
+ }
127
+ this.items.push({
128
+ id,
129
+ name: image.name,
130
+ mimeType: image.mimeType,
131
+ bytes: Buffer.from(image.bytes),
132
+ associations: new Set([messageId]),
133
+ });
134
+ changed = true;
135
+ }
136
+ if (this.evict()) changed = true;
137
+ if (changed) this.revision += 1;
138
+ return this.publicState();
139
+ }
140
+
141
+ preview(id: string): { bytes: Buffer; mimeType: string } {
142
+ const item = this.item(id);
143
+ return { bytes: Buffer.from(item.bytes), mimeType: item.mimeType };
144
+ }
145
+
146
+ clone(ids: readonly string[]): RetainedImageClone[] {
147
+ this.assertOpen();
148
+ if (!Array.isArray(ids) || ids.length === 0) {
149
+ throw new SentImageError("No retained images were selected.", 400);
150
+ }
151
+ if (new Set(ids).size !== ids.length) {
152
+ throw new SentImageError("Retained image selection contains a duplicate.", 400);
153
+ }
154
+ return ids.map((id) => {
155
+ const item = this.item(id);
156
+ return {
157
+ retainedId: item.id,
158
+ name: item.name,
159
+ bytes: Buffer.from(item.bytes),
160
+ mimeType: item.mimeType,
161
+ };
162
+ });
163
+ }
164
+
165
+ reconcile(externalBytes: number, totalBudget = this.settings.maxBytes): PublicSentImageState {
166
+ this.assertOpen();
167
+ if (
168
+ !Number.isSafeInteger(externalBytes) ||
169
+ externalBytes < 0 ||
170
+ !Number.isSafeInteger(totalBudget) ||
171
+ totalBudget <= 0
172
+ ) {
173
+ throw new SentImageError("Image resident-byte accounting is invalid.", 500);
174
+ }
175
+ if (!this.evict(externalBytes, totalBudget)) return this.publicState();
176
+ this.revision += 1;
177
+ return this.publicState();
178
+ }
179
+
180
+ remove(id: string, expectedRevision: number): PublicSentImageState {
181
+ this.assertRevision(expectedRevision);
182
+ const index = this.items.findIndex((item) => item.id === id);
183
+ if (index === -1) throw new SentImageError("Retained image was not found.", 404);
184
+ this.items.splice(index, 1);
185
+ this.revision += 1;
186
+ return this.publicState();
187
+ }
188
+
189
+ clear(expectedRevision: number): PublicSentImageState {
190
+ this.assertRevision(expectedRevision);
191
+ if (this.items.length === 0) return this.publicState();
192
+ this.items.length = 0;
193
+ this.revision += 1;
194
+ return this.publicState();
195
+ }
196
+
197
+ close(): void {
198
+ if (this.closed) return;
199
+ this.closed = true;
200
+ this.items.length = 0;
201
+ this.revision += 1;
202
+ }
203
+
204
+ private item(id: string): SentImageItem {
205
+ this.assertOpen();
206
+ if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
207
+ throw new SentImageError("Retained image id is invalid.", 400);
208
+ }
209
+ const item = this.items.find((candidate) => candidate.id === id);
210
+ if (!item) throw new SentImageError("Retained image has expired.", 404);
211
+ return item;
212
+ }
213
+
214
+ private idFor(bytes: Buffer): string {
215
+ return `sent_${createHmac("sha256", this.key).update(bytes).digest("base64url").slice(0, 32)}`;
216
+ }
217
+
218
+ private evict(externalBytes = 0, totalBudget = this.settings.maxBytes): boolean {
219
+ let changed = false;
220
+ while (
221
+ this.items.length > 0 &&
222
+ (this.items.length > this.settings.maxImages ||
223
+ this.residentBytes() > this.settings.maxBytes ||
224
+ this.residentBytes() + externalBytes > totalBudget)
225
+ ) {
226
+ this.items.shift();
227
+ changed = true;
228
+ }
229
+ return changed;
230
+ }
231
+
232
+ private assertRevision(expectedRevision: number): void {
233
+ this.assertOpen();
234
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== this.revision) {
235
+ throw new SentImageError(
236
+ `Sent-image revision mismatch; current revision is ${this.revision}.`,
237
+ );
238
+ }
239
+ }
240
+
241
+ private assertOpen(): void {
242
+ if (this.closed) throw new SentImageError("Sent-image store is closed.", 410);
243
+ }
244
+ }
245
+
246
+ function normalizeImage(
247
+ input: RetainedImageInput,
248
+ index = 0,
249
+ ): {
250
+ name: string;
251
+ mimeType: string;
252
+ bytes: Buffer;
253
+ } {
254
+ if (!input || typeof input.data !== "string" || !validBase64(input.data)) {
255
+ throw new SentImageError(`Sent image ${index + 1} has invalid data.`, 400);
256
+ }
257
+ const bytes = Buffer.from(input.data, "base64");
258
+ if (bytes.length === 0) throw new SentImageError(`Sent image ${index + 1} is empty.`, 400);
259
+ const mimeType = input.mimeType;
260
+ if (typeof mimeType !== "string" || !/^image\/[a-z0-9.+-]{1,64}$/i.test(mimeType)) {
261
+ throw new SentImageError(`Sent image ${index + 1} has an invalid MIME type.`, 400);
262
+ }
263
+ const name = typeof input.name === "string" && input.name ? input.name.slice(0, 255) : "Image";
264
+ return { name, mimeType, bytes };
265
+ }
266
+
267
+ function normalizeMessageId(value: string): void {
268
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,120}$/.test(value)) {
269
+ throw new SentImageError("Message association is invalid.", 400);
270
+ }
271
+ }
272
+
273
+ function validBase64(value: string): boolean {
274
+ return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
275
+ }
276
+
277
+ function sameIds(left: readonly string[], right: readonly string[]): boolean {
278
+ return left.length === right.length && left.every((id, index) => id === right[index]);
279
+ }