@dev.fast/whiteboard 0.5.1 → 0.5.2-preview.20261104.146

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.
Files changed (117) hide show
  1. package/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
  2. package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
  3. package/dist/agent-cli-DLibAL4u.js +2 -0
  4. package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
  5. package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
  6. package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
  7. package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
  10. package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
  11. package/dist/cli.js +2 -2
  12. package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
  13. package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
  14. package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
  15. package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
  16. package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
  17. package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
  18. package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
  19. package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
  20. package/dist/profile-BxdzBxHW.js +2 -0
  21. package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
  22. package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
  23. package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
  24. package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
  25. package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
  26. package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
  27. package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
  28. package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
  29. package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
  30. package/dist/server/desktop-host.js +26 -38
  31. package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
  32. package/dist/sharing/index.d.ts +137 -14
  33. package/dist/sharing/index.js +1 -1
  34. package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
  35. package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
  36. package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
  37. package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
  38. package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
  39. package/instructions/animation.md +69 -5
  40. package/instructions/animations/before-after-structure/README.md +27 -0
  41. package/instructions/animations/before-after-structure/animation.html +89 -0
  42. package/instructions/animations/before-after-structure/block.json +10 -0
  43. package/instructions/animations/before-after-structure/frames.json +313 -0
  44. package/instructions/animations/cache-fill-evict/README.md +24 -0
  45. package/instructions/animations/cache-fill-evict/animation.html +81 -0
  46. package/instructions/animations/cache-fill-evict/block.json +13 -0
  47. package/instructions/animations/cache-fill-evict/frames.json +79 -0
  48. package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
  49. package/instructions/animations/log-replay/README.md +25 -0
  50. package/instructions/animations/log-replay/animation.html +86 -0
  51. package/instructions/animations/log-replay/block.json +10 -0
  52. package/instructions/animations/log-replay/frames.json +143 -0
  53. package/instructions/animations/log-replay/scenarios.json +18 -0
  54. package/instructions/animations/queue-workers/README.md +23 -0
  55. package/instructions/animations/queue-workers/animation.html +107 -0
  56. package/instructions/animations/queue-workers/block.json +13 -0
  57. package/instructions/animations/queue-workers/frames.json +119 -0
  58. package/instructions/animations/queue-workers/scenarios.json +18 -0
  59. package/instructions/animations/race-two-lanes/README.md +22 -0
  60. package/instructions/animations/race-two-lanes/animation.html +97 -0
  61. package/instructions/animations/race-two-lanes/block.json +1 -0
  62. package/instructions/animations/race-two-lanes/frames.json +234 -0
  63. package/instructions/animations/race-two-lanes/scenarios.json +19 -0
  64. package/instructions/animations/request-across-layers/README.md +24 -0
  65. package/instructions/animations/request-across-layers/animation.html +85 -0
  66. package/instructions/animations/request-across-layers/block.json +17 -0
  67. package/instructions/animations/request-across-layers/frames.json +58 -0
  68. package/instructions/animations/request-across-layers/scenarios.json +12 -0
  69. package/instructions/animations/request-timing/README.md +22 -0
  70. package/instructions/animations/request-timing/animation.html +120 -0
  71. package/instructions/animations/request-timing/block.json +21 -0
  72. package/instructions/animations/request-timing/markers.json +32 -0
  73. package/instructions/animations/request-timing/scenarios.json +4 -0
  74. package/instructions/animations/retry-backoff/README.md +22 -0
  75. package/instructions/animations/retry-backoff/animation.html +122 -0
  76. package/instructions/animations/retry-backoff/block.json +10 -0
  77. package/instructions/animations/retry-backoff/frames.json +65 -0
  78. package/instructions/animations/retry-backoff/scenarios.json +12 -0
  79. package/instructions/animations/state-machine/README.md +24 -0
  80. package/instructions/animations/state-machine/animation.html +95 -0
  81. package/instructions/animations/state-machine/block.json +9 -0
  82. package/instructions/animations/state-machine/frames.json +79 -0
  83. package/instructions/animations/state-machine/scenarios.json +12 -0
  84. package/instructions/animations/video-platform-architecture/README.md +24 -0
  85. package/instructions/animations/video-platform-architecture/animation.html +76 -0
  86. package/instructions/animations/video-platform-architecture/block.json +17 -0
  87. package/instructions/animations/video-platform-architecture/frames.json +97 -0
  88. package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
  89. package/instructions/whiteboard.d.ts +168 -14
  90. package/package.json +4 -4
  91. package/src/animation-stage-kit.ts +150 -10
  92. package/src/cli-runner.ts +4 -1
  93. package/src/fixtures/blocks/clip.json +19 -0
  94. package/src/review-api/README.md +1 -0
  95. package/src/review-api/agent-cli.ts +3 -2
  96. package/src/review-api/animation-drafts.ts +151 -47
  97. package/src/review-api/animation-references.ts +92 -0
  98. package/src/review-api/animation-render.ts +106 -0
  99. package/src/review-api/authoring-tools.ts +10 -3
  100. package/src/review-api/document-text.ts +27 -12
  101. package/src/review-api/http.ts +52 -6
  102. package/src/review-api/instructions.ts +1 -1
  103. package/src/review-api/mcp.ts +10 -5
  104. package/src/review-api/public-tools.ts +71 -2
  105. package/src/review-api/store.ts +89 -12
  106. package/src/server/desktop-server.ts +17 -5
  107. package/src/server/headless-host.ts +4 -0
  108. package/src/server/review-gateway.ts +1 -0
  109. package/src/server/review-server-core.ts +71 -12
  110. package/src/sharing/client.ts +129 -32
  111. package/src/sharing/export.ts +42 -5
  112. package/src/sharing/file.ts +8 -0
  113. package/src/sharing/host.ts +202 -73
  114. package/src/sharing/import.ts +125 -33
  115. package/src/ui-telemetry-events.ts +25 -1
  116. package/dist/agent-cli-B8KBHuh_.js +0 -2
  117. package/dist/profile-BjcXi-oH.js +0 -2
@@ -156,8 +156,16 @@ export async function exportShare(input: {
156
156
  ...snapshot
157
157
  } = stored;
158
158
 
159
- if (target?.kind === "worktree")
160
- throw new ReviewInputError("Pin this review to commits before sharing it.");
159
+ // A worktree board reads its checkout as it is now, and shares at HEAD.
160
+ const live =
161
+ target?.kind === "worktree"
162
+ ? await input.data.sourcePins(stored)
163
+ : undefined;
164
+
165
+ if (live) {
166
+ const { worktreeRevision: _worktreeRevision, ...committed } = live;
167
+ snapshot.pins = committed;
168
+ }
161
169
 
162
170
  // A viewer has no PR state to read the base branch from.
163
171
  const pullRequestUrl = snapshot.origin?.pullRequestUrl;
@@ -184,7 +192,7 @@ export async function exportShare(input: {
184
192
  );
185
193
  }
186
194
 
187
- const pins = snapshot.pins;
195
+ const pins = live ?? snapshot.pins;
188
196
 
189
197
  // Lens ranges travel with the snapshot and must resolve at its pins too.
190
198
  const references: {
@@ -274,7 +282,32 @@ export async function exportShare(input: {
274
282
  toLine,
275
283
  }));
276
284
 
277
- return {
285
+ // Quoting uncommitted lines makes the share view only.
286
+ let frozen = false;
287
+
288
+ const sources = await cutExcerpts(
289
+ {
290
+ async quote(at, range) {
291
+ const quote = await input.data.quote(at, range);
292
+
293
+ if (at.worktreeRevision && range.side === "head" && !frozen) {
294
+ const { worktreeRevision: _worktreeRevision, ...committed } = at;
295
+
296
+ const changed = await input.data.quote(committed, range).then(
297
+ (head) => head.text !== quote.text,
298
+ () => true,
299
+ );
300
+
301
+ if (changed) frozen = true;
302
+ }
303
+
304
+ return quote;
305
+ },
306
+ },
307
+ excerpts,
308
+ );
309
+
310
+ const bundle: ShareBundle = {
278
311
  format: SHARE_FORMAT,
279
312
  reviewId: snapshot.reviewId,
280
313
  version: snapshot.version,
@@ -287,7 +320,11 @@ export async function exportShare(input: {
287
320
  ? await input.data.commits((await input.data.resolveSource(stored)).pins)
288
321
  : [],
289
322
  selections,
290
- sources: await cutExcerpts(input.data, excerpts),
323
+ sources,
291
324
  ...(input.repository && { repository: input.repository }),
292
325
  };
326
+
327
+ if (frozen) bundle.frozen = true;
328
+
329
+ return bundle;
293
330
  }
@@ -11,6 +11,9 @@ import { z } from "zod";
11
11
 
12
12
  export const WHITEBOARD_FILE_EXTENSION = ".whiteboard";
13
13
 
14
+ export const WHITEBOARD_UPGRADE_REQUIRED_MESSAGE =
15
+ "This action requires a newer version of Whiteboard. Use Check for Updates, then try again.";
16
+
14
17
  const headerSchema = z.looseObject({ format: z.string() });
15
18
 
16
19
  export function whiteboardFileName(title: string): string {
@@ -35,6 +38,11 @@ export function parseWhiteboardFile(text: string): ShareBundle {
35
38
  if (!header.success)
36
39
  throw new ReviewInputError("This is not a Whiteboard file.", 400);
37
40
 
41
+ const version = /^review-share\/([1-9]\d*)$/.exec(header.data.format);
42
+
43
+ if (version && Number(version[1]) > Number(SHARE_FORMAT.split("/")[1]))
44
+ throw new ReviewInputError(WHITEBOARD_UPGRADE_REQUIRED_MESSAGE, 400);
45
+
38
46
  if (header.data.format !== SHARE_FORMAT)
39
47
  throw new ReviewInputError(
40
48
  "This file needs a different Whiteboard version.",
@@ -4,8 +4,9 @@ import path from "node:path";
4
4
  import { Writable } from "node:stream";
5
5
 
6
6
  import {
7
- type ShareBundle,
7
+ type SharedMeta,
8
8
  parseShareLink,
9
+ shareBundleSchema,
9
10
  shareIdSchema,
10
11
  } from "@dev.fast/review-share-protocol";
11
12
  import {
@@ -17,13 +18,13 @@ import {
17
18
  } from "@dev.fast/trace-core";
18
19
  import { ReviewInputError } from "@dev.fast/whiteboard-document";
19
20
  import type { LocalReviewData } from "@review/review-api/local-data.js";
20
- import type { ReviewStore, Snapshot } from "@review/review-api/store.js";
21
+ import type { ReviewStore } from "@review/review-api/store.js";
21
22
  import { readBoundedRequestJson } from "@review/server/hono-http.js";
22
23
  import type { Hono } from "hono";
23
24
  import { z } from "zod";
24
25
 
25
26
  import { readSharingAuth } from "./auth.js";
26
- import { ShareAuthError, ShareClient } from "./client.js";
27
+ import { ShareAuthError, ShareClient, ShareRequestError } from "./client.js";
27
28
  import { exportShare, validateShareSources } from "./export.js";
28
29
  import {
29
30
  parseWhiteboardFile,
@@ -32,9 +33,11 @@ import {
32
33
  whiteboardFileText,
33
34
  } from "./file.js";
34
35
  import {
35
- type ShareAttribution,
36
+ type ShareImport,
36
37
  importShare,
37
38
  needsRepository,
39
+ planShareCopy,
40
+ receiveShare,
38
41
  shareKey,
39
42
  } from "./import.js";
40
43
  import {
@@ -73,6 +76,7 @@ interface SharingHostOptions extends SharingHostEvents {
73
76
  login?: typeof runStoreLogin;
74
77
  openUrl?: typeof openUrlInBrowser;
75
78
  fetch?: typeof fetch;
79
+ remoteShare?: RemoteShare;
76
80
  }
77
81
 
78
82
  const importSchema = z.union([
@@ -181,9 +185,20 @@ export function mountSharingHost(
181
185
 
182
186
  const imports = new Map<string, ImportStatus>();
183
187
 
184
- // One import at a time, so two links of one repository clone it once.
188
+ // One download at a time, so two links of one repository clone it once.
185
189
  let importing = Promise.resolve();
186
190
 
191
+ const serially = (task: () => Promise<string>) => {
192
+ const run = importing.then(task);
193
+
194
+ importing = run.then(
195
+ () => {},
196
+ () => {},
197
+ );
198
+
199
+ return run;
200
+ };
201
+
187
202
  app.get("/sharing/import/:id", (context) => {
188
203
  const id = context.req.param("id");
189
204
 
@@ -199,54 +214,41 @@ export function mountSharingHost(
199
214
  });
200
215
  });
201
216
 
217
+ const shareInput = (onStage: ShareImport["onStage"]): ShareImport => ({
218
+ store,
219
+ data,
220
+ fetchRepository: options.fetchRepository,
221
+ cloneRepository: options.cloneRepository,
222
+ onStage,
223
+ });
224
+
202
225
  const startImport = (
203
226
  id: string,
204
227
  initial: "downloading" | "reading",
205
- load: () => Promise<{
206
- bundle: ShareBundle;
207
- attribution?: ShareAttribution;
208
- }>,
228
+ receive: (onStage: ShareImport["onStage"]) => Promise<string>,
209
229
  ) => {
210
230
  const known = imports.get(id)?.stage;
211
231
 
212
- // A failed import retries, and a deleted copy imports again.
232
+ // A finished import asks again: its link may serve a newer revision.
213
233
  if (!known || known === "ready" || known === "error") {
214
- const copy = store.copyOf(id);
215
-
216
- const status: ImportStatus = copy
217
- ? { stage: "ready", reviewId: copy }
218
- : { stage: initial };
234
+ const status: ImportStatus = { stage: initial };
219
235
 
220
236
  imports.set(id, status);
221
-
222
- if (!copy)
223
- importing = importing
224
- .then(async () => {
225
- const { bundle, attribution } = await load();
226
-
227
- status.reviewId = await importShare(
228
- {
229
- store,
230
- data,
231
- fetchRepository: options.fetchRepository,
232
- cloneRepository: options.cloneRepository,
233
- onStage: (stage) => {
234
- status.stage = stage;
235
- },
236
- },
237
- id,
238
- bundle,
239
- attribution,
240
- );
241
- status.stage = "ready";
242
- })
243
- .catch((error) => {
244
- status.stage = "error";
245
- status.error =
246
- error instanceof ReviewInputError
247
- ? error.message
248
- : "This share is unavailable, revoked, or needs a newer Whiteboard version.";
249
- });
237
+ receive((stage) => {
238
+ status.stage = stage;
239
+ })
240
+ .then((reviewId) => {
241
+ status.reviewId = reviewId;
242
+ status.stage = "ready";
243
+ })
244
+ .catch((error) => {
245
+ status.stage = "error";
246
+ status.error =
247
+ error instanceof ReviewInputError ||
248
+ (error instanceof ShareRequestError && error.status !== 404)
249
+ ? error.message
250
+ : "This share is unavailable, revoked, or could not be read.";
251
+ });
250
252
  }
251
253
 
252
254
  const { reviewId: _reviewId, ...status } = imports.get(id)!;
@@ -270,10 +272,25 @@ export function mountSharingHost(
270
272
  );
271
273
  });
272
274
 
275
+ const key = whiteboardFileKey(text);
276
+ const copy = () => store.copiesOf(key)[0]?.reviewId;
277
+
273
278
  return context.json(
274
- startImport(whiteboardFileKey(text), "reading", async () => ({
275
- bundle: parseWhiteboardFile(text),
276
- })),
279
+ startImport(
280
+ key,
281
+ "reading",
282
+ async (onStage) =>
283
+ copy() ??
284
+ serially(
285
+ async () =>
286
+ copy() ??
287
+ importShare(
288
+ shareInput(onStage),
289
+ key,
290
+ parseWhiteboardFile(text),
291
+ ),
292
+ ),
293
+ ),
277
294
  202,
278
295
  );
279
296
  }
@@ -290,17 +307,101 @@ export function mountSharingHost(
290
307
  400,
291
308
  );
292
309
 
310
+ const { origin, capability, revision } = parsed;
311
+ const key = shareKey(origin, capability);
312
+ const client = new ShareClient(origin, undefined, options.fetch);
313
+
293
314
  return context.json(
294
315
  startImport(
295
- shareKey(parsed.origin, parsed.capability),
316
+ revision ? `${key}-${revision}` : key,
296
317
  "downloading",
297
- () => new ShareClient(parsed.origin).download(parsed.capability),
318
+ async (onStage) => {
319
+ let meta: SharedMeta;
320
+
321
+ try {
322
+ meta = await client.shared(capability, revision);
323
+ } catch (error) {
324
+ // Offline, the copy the link prefers still opens.
325
+ const copies = store.copiesOf(key);
326
+
327
+ const copy =
328
+ copies.find(({ shared }) =>
329
+ revision ? shared.revisionId === revision : !shared.pinned,
330
+ ) ?? copies[0];
331
+
332
+ if (!copy) throw error;
333
+
334
+ return copy.reviewId;
335
+ }
336
+
337
+ const pinned = revision !== undefined;
338
+ const plan = planShareCopy(store.copiesOf(key), meta, pinned);
339
+
340
+ // receiveShare plans again: the queue may have made a copy since.
341
+ return plan.kind === "open"
342
+ ? plan.reviewId
343
+ : serially(() =>
344
+ receiveShare(
345
+ shareInput(onStage),
346
+ key,
347
+ meta,
348
+ () => client.download(capability, meta.revision.id),
349
+ pinned,
350
+ ),
351
+ );
352
+ },
298
353
  ),
299
354
  202,
300
355
  );
301
356
  });
302
357
  }
303
358
 
359
+ const preparedShareSchema = z.strictObject({
360
+ title: z.string(),
361
+ version: z.number().int().nonnegative(),
362
+ bundle: shareBundleSchema,
363
+ needsRepository: z.boolean(),
364
+ });
365
+
366
+ type PreparedShare = z.infer<typeof preparedShareSchema>;
367
+
368
+ /** `undefined` for a board on this machine. */
369
+ export type RemoteShare = (
370
+ input: z.infer<typeof publishSchema>,
371
+ ) => Promise<PreparedShare> | undefined;
372
+
373
+ export function remoteShare(
374
+ isRemote: (reviewId: string) => boolean,
375
+ fetch: (request: Request) => Response | Promise<Response>,
376
+ ): RemoteShare {
377
+ return ({ reviewId, version }) =>
378
+ isRemote(reviewId)
379
+ ? (async () => {
380
+ const response = await fetch(
381
+ new Request(
382
+ `http://whiteboard/reviews-api/${encodeURIComponent(reviewId)}/share-bundle`,
383
+ {
384
+ method: "POST",
385
+ headers: { "content-type": "application/json" },
386
+ body: JSON.stringify({ version }),
387
+ },
388
+ ),
389
+ );
390
+
391
+ const body: unknown = await response.json().catch(() => undefined);
392
+
393
+ if (!response.ok)
394
+ throw new ReviewInputError(
395
+ z.object({ error: z.string() }).safeParse(body).data?.error ??
396
+ "Could not read the board from its host.",
397
+ response.status === 400 ? 400 : 409,
398
+ );
399
+
400
+ return preparedShareSchema.parse(body);
401
+ })()
402
+ : undefined;
403
+ }
404
+
304
405
  /** Publishing needs only an authored store, never Desktop or recipient workspaces. */
305
406
  export function mountSharingPublisher(
306
407
  app: Hono,
@@ -308,23 +409,16 @@ export function mountSharingPublisher(
308
409
  data: LocalReviewData,
309
410
  options: Pick<
310
411
  SharingHostOptions,
311
- "readRepository" | "fetch" | "onPublished" | "onRevoked"
412
+ "readRepository" | "fetch" | "onPublished" | "onRevoked" | "remoteShare"
312
413
  > = {},
313
414
  ) {
314
- const shareable = (input: z.infer<typeof publishSchema>) => {
415
+ const prepareLocal = async (
416
+ input: z.infer<typeof publishSchema>,
417
+ ): Promise<PreparedShare> => {
315
418
  const snapshot = store.read(input.reviewId, input.version);
316
419
 
317
- if (snapshot.target?.kind === "worktree")
318
- throw new ReviewInputError(
319
- "Pin this review to commits before sharing it.",
320
- );
420
+ if (snapshot.target?.kind !== "worktree") validateShareSources(snapshot);
321
421
 
322
- validateShareSources(snapshot);
323
-
324
- return snapshot;
325
- };
326
-
327
- const shareBundle = async (snapshot: Snapshot) => {
328
422
  const repositoryId =
329
423
  snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
330
424
 
@@ -348,24 +442,44 @@ export function mountSharingPublisher(
348
442
 
349
443
  if (verified) bundle.repository = verified;
350
444
 
351
- return bundle;
445
+ return {
446
+ title: snapshot.title,
447
+ version: snapshot.version,
448
+ bundle,
449
+ needsRepository: needsRepository(bundle, snapshot),
450
+ };
352
451
  };
353
452
 
453
+ const prepare = (input: z.infer<typeof publishSchema>) =>
454
+ options.remoteShare?.(input) ?? prepareLocal(input);
455
+
456
+ // Desktop uploads it with its own login.
457
+ app.post("/:id/share-bundle", async (context) => {
458
+ const { version } = publishSchema
459
+ .pick({ version: true })
460
+ .parse(await readBoundedRequestJson(context.req.raw));
461
+
462
+ return context.json(
463
+ await prepareLocal({ reviewId: context.req.param("id"), version }),
464
+ );
465
+ });
354
466
  app.post("/sharing/export", async (context) => {
355
- const snapshot = shareable(
467
+ const { title, bundle, needsRepository } = await prepare(
356
468
  publishSchema.parse(await readBoundedRequestJson(context.req.raw)),
357
469
  );
358
470
 
359
- const bundle = await shareBundle(snapshot);
360
-
361
471
  // A link falls back to the browser viewer; a file has no such reader.
362
- if (!bundle.repository && needsRepository(bundle, snapshot))
472
+ if (bundle.frozen)
473
+ throw new ReviewInputError(
474
+ "Commit your changes to export this whiteboard as a file.",
475
+ );
476
+ else if (!bundle.repository && needsRepository)
363
477
  throw new ReviewInputError(
364
478
  "Exporting pinned code needs a GitHub remote the file's reader can fetch from.",
365
479
  );
366
480
 
367
481
  return context.json({
368
- filename: whiteboardFileName(snapshot.title),
482
+ filename: whiteboardFileName(title),
369
483
  text: whiteboardFileText(bundle),
370
484
  });
371
485
  });
@@ -374,7 +488,6 @@ export function mountSharingPublisher(
374
488
  await readBoundedRequestJson(context.req.raw),
375
489
  );
376
490
 
377
- const snapshot = shareable(input);
378
491
  const account = await readSharingAuth();
379
492
 
380
493
  if (!account)
@@ -383,7 +496,7 @@ export function mountSharingPublisher(
383
496
  409,
384
497
  );
385
498
 
386
- const bundle = await shareBundle(snapshot);
499
+ const { bundle, version } = await prepare(input);
387
500
 
388
501
  try {
389
502
  const result = await new ShareClient(
@@ -402,17 +515,20 @@ export function mountSharingPublisher(
402
515
 
403
516
  options.onPublished?.({
404
517
  reviewId: input.reviewId,
405
- version: snapshot.version,
518
+ version,
406
519
  });
407
520
 
408
521
  return context.json({
409
522
  ...result,
410
523
  url: url.href,
411
- version: snapshot.version,
524
+ version,
412
525
  });
413
526
  } catch (error) {
414
527
  if (error instanceof ReviewInputError) throw error;
415
528
 
529
+ if (error instanceof ShareRequestError)
530
+ throw new ReviewInputError(error.message, 409);
531
+
416
532
  if (error instanceof ShareAuthError) {
417
533
  // A CI token lives in the environment; only the saved login can go stale.
418
534
  if (process.env.DEV_REVIEW_SHARE_TOKEN === undefined)
@@ -423,8 +539,19 @@ export function mountSharingPublisher(
423
539
  );
424
540
  }
425
541
 
542
+ if (
543
+ error instanceof DOMException &&
544
+ (error.name === "TimeoutError" || error.name === "AbortError")
545
+ )
546
+ throw new ReviewInputError(
547
+ "The sharing request timed out. Please retry.",
548
+ 409,
549
+ );
550
+
426
551
  throw new ReviewInputError(
427
- "Sharing failed. Check your connection and login, then retry.",
552
+ error instanceof TypeError
553
+ ? "Could not reach the sharing service. Check your connection, then retry."
554
+ : "The sharing service returned an unexpected response. Update Whiteboard or report this issue.",
428
555
  409,
429
556
  );
430
557
  }
@@ -441,7 +568,9 @@ export function mountSharingPublisher(
441
568
  "Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.",
442
569
  409,
443
570
  );
444
- await new ShareClient(account.origin, account.token).revoke(shareId);
571
+ await new ShareClient(account.origin, account.token, options.fetch).revoke(
572
+ shareId,
573
+ );
445
574
  options.onRevoked?.({ shareId });
446
575
 
447
576
  return context.json({ shareId, revoked: true });