@narumitw/pi-image-drop 0.18.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-image-drop",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pi extension for staging browser images and attaching them to the next message.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/batch.ts CHANGED
@@ -142,6 +142,9 @@ export class BatchStore {
142
142
  this.assertOpen();
143
143
  if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
144
144
  const item = this.item(id);
145
+ if (item.status !== "uploading" && item.status !== "error") {
146
+ throw new BatchError("Image is already processing or ready", "not-ready");
147
+ }
145
148
  if (source.byteLength !== item.size) {
146
149
  throw new BatchError("Uploaded size does not match the reservation", "invalid");
147
150
  }
@@ -207,6 +210,23 @@ export class BatchStore {
207
210
  return this.bump();
208
211
  }
209
212
 
213
+ cancelInFlight(error: string): boolean {
214
+ this.assertOpen();
215
+ if (this.reservation) return false;
216
+ const inFlight = this.items.filter(
217
+ (item) => item.status === "uploading" || item.status === "processing",
218
+ );
219
+ if (inFlight.length === 0) return false;
220
+ for (const item of inFlight) {
221
+ item.status = "error";
222
+ item.error = sanitizeError(error);
223
+ item.processed = undefined;
224
+ item.processedAutoResize = undefined;
225
+ }
226
+ this.bump();
227
+ return true;
228
+ }
229
+
210
230
  beginAutoResizeReprocessing(autoResize: boolean): Array<{ id: string; source: Buffer }> {
211
231
  this.assertOpen();
212
232
  if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
package/src/runtime.ts CHANGED
@@ -83,17 +83,21 @@ export class ImageDropRuntime {
83
83
  this.pi.on("before_agent_start", async () => this.batch?.markPreflightStarted());
84
84
  this.pi.on("message_start", async (event, ctx) => this.handleMessageStart(event, ctx));
85
85
  (this.pi as LatestExtensionAPI).on("agent_settled", async (_event, ctx) => {
86
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
86
87
  await this.recoverReservation(ctx, "Queued image message was not delivered; restored it.");
87
88
  });
88
89
  }
89
90
 
90
91
  async start(ctx: ExtensionContext): Promise<void> {
91
- this.generation += 1;
92
+ const generation = ++this.generation;
93
+ const previousBatch = this.batch;
92
94
  this.closed = true;
93
95
  this.sessionAbort.abort();
94
96
  await this.releaseServer();
95
- this.batch?.close();
97
+ previousBatch?.close();
98
+ if (generation !== this.generation) return;
96
99
  const result = await this.dependencies.loadSettings();
100
+ if (generation !== this.generation) return;
97
101
  this.settings = result.settings;
98
102
  this.batch = new BatchStore(result.settings);
99
103
  this.processor = this.dependencies.createProcessor();
@@ -109,11 +113,13 @@ export class ImageDropRuntime {
109
113
  }
110
114
 
111
115
  async shutdown(ctx: ExtensionContext): Promise<void> {
112
- this.generation += 1;
116
+ const generation = ++this.generation;
117
+ const previousBatch = this.batch;
113
118
  this.closed = true;
114
119
  this.sessionAbort.abort();
115
120
  await this.releaseServer();
116
- this.batch?.close();
121
+ previousBatch?.close();
122
+ if (generation !== this.generation) return;
117
123
  this.batch = undefined;
118
124
  this.settings = undefined;
119
125
  this.processor = undefined;
@@ -150,7 +156,12 @@ export class ImageDropRuntime {
150
156
  ctx.ui.notify("The current model does not support image input.", "warning");
151
157
  return { action: "handled" };
152
158
  }
159
+ const generation = this.generation;
160
+ const batch = this.batch;
153
161
  const piSettings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
162
+ if (generation !== this.generation || batch !== this.batch || this.closed) {
163
+ return { action: "handled" };
164
+ }
154
165
  this.notifyPiSettingsWarnings(ctx, piSettings.warnings);
155
166
  if (piSettings.blockImages) {
156
167
  this.restoreEditor(ctx, event.text);
@@ -162,7 +173,7 @@ export class ImageDropRuntime {
162
173
  }
163
174
 
164
175
  try {
165
- const reservation = this.batch.reserveMessage(event.text, event.streamingBehavior);
176
+ const reservation = batch.reserveMessage(event.text, event.streamingBehavior);
166
177
  this.server?.broadcastState();
167
178
  this.updateWidget(ctx);
168
179
  return {
@@ -230,19 +241,18 @@ export class ImageDropRuntime {
230
241
  if (!(failure instanceof BatchError) || failure.code !== "not-found") throw failure;
231
242
  }
232
243
  } finally {
233
- this.server?.broadcastState();
244
+ if (generation === this.generation) this.server?.broadcastState();
234
245
  }
235
246
  }),
236
247
  );
237
- if (generation !== this.generation || batch.publicState().phase !== "ready") {
248
+ if (generation !== this.generation) return false;
249
+ if (batch.publicState().phase !== "ready") {
238
250
  this.restoreEditor(ctx, text);
239
- if (generation === this.generation) {
240
- ctx.ui.notify(
241
- "Images could not be updated for the current auto-resize setting.",
242
- "warning",
243
- );
244
- this.updateWidget(ctx);
245
- }
251
+ ctx.ui.notify(
252
+ "Images could not be updated for the current auto-resize setting.",
253
+ "warning",
254
+ );
255
+ this.updateWidget(ctx);
246
256
  return false;
247
257
  }
248
258
  this.updateWidget(ctx);
@@ -300,10 +310,10 @@ export class ImageDropRuntime {
300
310
 
301
311
  private async releaseServer(): Promise<void> {
302
312
  const server = this.server;
303
- this.server = undefined;
304
- if (server) await server.close();
305
313
  const starting = this.serverStarting;
314
+ this.server = undefined;
306
315
  this.serverStarting = undefined;
316
+ if (server) await server.close();
307
317
  if (starting) {
308
318
  try {
309
319
  await (await starting).close();
@@ -318,9 +328,7 @@ export class ImageDropRuntime {
318
328
  const reservation = this.batch?.currentReservation();
319
329
  if (!reservation) return;
320
330
  const images = userMessageImages(event);
321
- if (images.length < reservation.images.length) return;
322
- const attached = images.slice(-reservation.images.length);
323
- if (digestImages(attached) !== reservation.digest) return;
331
+ if (!containsImageSequence(images, reservation.images.length, reservation.digest)) return;
324
332
  this.batch?.commitReservation(reservation.digest);
325
333
  this.server?.broadcastState();
326
334
  this.updateWidget(ctx);
@@ -398,6 +406,13 @@ function userMessageImages(event: unknown): ImageContent[] {
398
406
  return content.filter(isImageContent);
399
407
  }
400
408
 
409
+ function containsImageSequence(images: ImageContent[], length: number, digest: string): boolean {
410
+ for (let start = 0; start + length <= images.length; start += 1) {
411
+ if (digestImages(images.slice(start, start + length)) === digest) return true;
412
+ }
413
+ return false;
414
+ }
415
+
401
416
  function isImageContent(value: unknown): value is ImageContent {
402
417
  return (
403
418
  isRecord(value) &&
package/src/server.ts CHANGED
@@ -28,6 +28,11 @@ interface SseClient {
28
28
  response: ServerResponse;
29
29
  }
30
30
 
31
+ interface ClientLease {
32
+ clientId: string;
33
+ generation: number;
34
+ }
35
+
31
36
  class HttpError extends Error {
32
37
  constructor(
33
38
  readonly status: number,
@@ -47,6 +52,7 @@ export class ImageDropServer {
47
52
  private readonly sockets = new Set<Socket>();
48
53
  private readonly sseClients = new Set<SseClient>();
49
54
  private activeClientId?: string;
55
+ private activeClientGeneration = 0;
50
56
  private closed = false;
51
57
  private closePromise?: Promise<void>;
52
58
 
@@ -183,13 +189,15 @@ export class ImageDropServer {
183
189
  throw new HttpError(400, "Invalid client id");
184
190
  }
185
191
  this.takeLease(clientId);
192
+ this.broadcastState();
186
193
  this.json(response, 200, this.statePayload());
187
194
  return;
188
195
  }
189
196
  this.assertMutation(request);
190
- this.assertActiveClient(request);
197
+ const lease = this.assertActiveClient(request);
191
198
  if (request.method === "POST" && url.pathname === "/api/items") {
192
199
  const body = await readJson(request, JSON_LIMIT);
200
+ this.assertLease(lease);
193
201
  const revision = integerField(body, "revision");
194
202
  const inputs = arrayField(body, "items").map((item) => {
195
203
  if (!isRecord(item)) throw new HttpError(400, "Invalid item reservation");
@@ -210,6 +218,7 @@ export class ImageDropServer {
210
218
  try {
211
219
  source = await readBody(request, this.options.settings.maxImageBytes);
212
220
  } catch (error) {
221
+ this.assertLease(lease);
213
222
  try {
214
223
  this.options.batch.failUpload(id, `Upload failed: ${formatError(error)}`);
215
224
  } catch (failure) {
@@ -218,9 +227,10 @@ export class ImageDropServer {
218
227
  this.broadcastState();
219
228
  throw error;
220
229
  }
230
+ this.assertLease(lease);
221
231
  this.options.batch.startProcessing(id, source);
222
232
  this.broadcastState();
223
- const completed = await this.processItem(id, source, request);
233
+ const completed = await this.processItem(id, source, request, lease);
224
234
  this.broadcastState();
225
235
  this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
226
236
  return;
@@ -228,6 +238,7 @@ export class ImageDropServer {
228
238
  const failureMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/fail$/.exec(url.pathname);
229
239
  if (request.method === "POST" && failureMatch) {
230
240
  const body = await readJson(request, JSON_LIMIT);
241
+ this.assertLease(lease);
231
242
  this.options.batch.failUpload(failureMatch[1] as string, stringField(body, "error"));
232
243
  this.respondWithState(response);
233
244
  return;
@@ -237,7 +248,7 @@ export class ImageDropServer {
237
248
  const id = retryMatch[1] as string;
238
249
  const source = this.options.batch.retrySource(id);
239
250
  this.broadcastState();
240
- const completed = await this.processItem(id, source, request);
251
+ const completed = await this.processItem(id, source, request, lease);
241
252
  this.broadcastState();
242
253
  this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
243
254
  return;
@@ -250,6 +261,7 @@ export class ImageDropServer {
250
261
  }
251
262
  if (request.method === "PUT" && url.pathname === "/api/order") {
252
263
  const body = await readJson(request, JSON_LIMIT);
264
+ this.assertLease(lease);
253
265
  const ids = arrayField(body, "ids").map((id) => {
254
266
  if (typeof id !== "string") throw new HttpError(400, "Invalid image order");
255
267
  return id;
@@ -260,6 +272,7 @@ export class ImageDropServer {
260
272
  }
261
273
  if (request.method === "POST" && url.pathname === "/api/clear") {
262
274
  const body = await readJson(request, JSON_LIMIT);
275
+ this.assertLease(lease);
263
276
  this.options.batch.clear(integerField(body, "revision"));
264
277
  this.respondWithState(response);
265
278
  return;
@@ -295,17 +308,31 @@ export class ImageDropServer {
295
308
  if (request.headers.origin !== this.origin) throw new HttpError(403, "Unexpected Origin");
296
309
  }
297
310
 
298
- private assertActiveClient(request: IncomingMessage): void {
299
- const client = request.headers["x-image-drop-client"];
300
- if (typeof client !== "string" || client !== this.activeClientId) {
311
+ private assertActiveClient(request: IncomingMessage): ClientLease {
312
+ const clientId = request.headers["x-image-drop-client"];
313
+ if (typeof clientId !== "string" || clientId !== this.activeClientId) {
314
+ throw new HttpError(409, "This page no longer owns the editing lease");
315
+ }
316
+ return { clientId, generation: this.activeClientGeneration };
317
+ }
318
+
319
+ private assertLease(lease: ClientLease): void {
320
+ if (
321
+ lease.clientId !== this.activeClientId ||
322
+ lease.generation !== this.activeClientGeneration
323
+ ) {
301
324
  throw new HttpError(409, "This page no longer owns the editing lease");
302
325
  }
303
326
  }
304
327
 
305
328
  private takeLease(clientId: string): void {
306
329
  const previous = this.activeClientId;
330
+ if (previous !== clientId) this.activeClientGeneration += 1;
307
331
  this.activeClientId = clientId;
308
332
  if (previous && previous !== clientId) {
333
+ this.options.batch.cancelInFlight(
334
+ "Upload cancelled because the Image Drop page was replaced.",
335
+ );
309
336
  for (const client of [...this.sseClients]) {
310
337
  if (client.id !== previous) continue;
311
338
  writeSse(client.response, "stale", { message: "Image Drop opened in another tab" });
@@ -335,21 +362,26 @@ export class ImageDropServer {
335
362
  id: string,
336
363
  source: Buffer,
337
364
  request: IncomingMessage,
365
+ lease: ClientLease,
338
366
  ): Promise<{ duplicateOf?: string }> {
339
367
  const requestAbort = new AbortController();
340
368
  request.once("aborted", () => requestAbort.abort());
341
369
  const signal = AbortSignal.any([this.abortController.signal, requestAbort.signal]);
342
370
  try {
343
371
  const autoResize = await this.options.getAutoResize();
372
+ this.assertLease(lease);
344
373
  const processed = await this.options.process(source, {
345
374
  autoResize,
346
375
  maxImagePixels: this.options.settings.maxImagePixels,
347
376
  signal,
348
377
  });
378
+ this.assertLease(lease);
349
379
  const completion = this.options.batch.complete(id, processed, autoResize);
350
380
  return completion.kind === "duplicate" ? { duplicateOf: completion.existingId } : {};
351
381
  } catch (error) {
382
+ if (error instanceof HttpError) throw error;
352
383
  if (this.closed || signal.aborted || isDiscardedItemError(error)) return {};
384
+ this.assertLease(lease);
353
385
  if (!this.failItem(id, formatError(error))) return {};
354
386
  throw new HttpError(422, formatError(error));
355
387
  } finally {
package/src/web/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ attemptMutation,
2
3
  canMutate,
3
4
  formatBytes,
4
5
  moveItem,
@@ -198,8 +199,9 @@ async function retry(id) {
198
199
  }
199
200
 
200
201
  async function remove(id) {
201
- await mutate(`/api/items/${id}?revision=${state.batch.revision}`, { method: "DELETE" });
202
- pendingFiles.delete(id);
202
+ if (await mutate(`/api/items/${id}?revision=${state.batch.revision}`, { method: "DELETE" })) {
203
+ pendingFiles.delete(id);
204
+ }
203
205
  }
204
206
 
205
207
  async function reorder(ids) {
@@ -208,18 +210,21 @@ async function reorder(ids) {
208
210
  }
209
211
 
210
212
  async function clearAll() {
211
- await mutate("/api/clear", { method: "POST", json: { revision: state.batch.revision } });
212
- pendingFiles.clear();
213
+ if (await mutate("/api/clear", { method: "POST", json: { revision: state.batch.revision } })) {
214
+ pendingFiles.clear();
215
+ }
213
216
  }
214
217
 
215
218
  async function mutate(path, options) {
216
- try {
217
- applyState(await request(path, options));
218
- clearError();
219
- render();
220
- } catch (error) {
221
- showError(errorMessage(error));
219
+ const result = await attemptMutation(() => request(path, options));
220
+ if (!result.ok) {
221
+ showError(errorMessage(result.error));
222
+ return false;
222
223
  }
224
+ applyState(result.value);
225
+ clearError();
226
+ render();
227
+ return true;
223
228
  }
224
229
 
225
230
  function render() {
package/src/web/state.js CHANGED
@@ -49,6 +49,14 @@ export function preferNewestState(current, next) {
49
49
  return current;
50
50
  }
51
51
 
52
+ export async function attemptMutation(operation) {
53
+ try {
54
+ return { ok: true, value: await operation() };
55
+ } catch (error) {
56
+ return { ok: false, error };
57
+ }
58
+ }
59
+
52
60
  export function formatBytes(value) {
53
61
  const bytes = Math.max(0, Number(value) || 0);
54
62
  if (bytes < 1024) return `${bytes} B`;