@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
@@ -362,6 +362,11 @@ export interface SharedProvenance {
362
362
  sharedAt?: number;
363
363
  cloneUrl?: string;
364
364
  key?: string;
365
+ revisionId?: string;
366
+ /** The version import wrote; anything later is the receiver's edit. */
367
+ importedVersion?: number;
368
+ /** Opened from a link naming a revision: never refreshed. */
369
+ pinned?: boolean;
365
370
  }
366
371
 
367
372
  /** Content a new board starts with, as another board left it. */
@@ -1110,16 +1115,24 @@ export class ReviewStore {
1110
1115
 
1111
1116
  return run;
1112
1117
  }
1113
- /** The board copied from a share link, until it is deleted. */
1114
- copyOf(key: string): string | undefined {
1115
- const row = this.db
1118
+ /** The boards copied from a share link, newest first, until each is deleted. */
1119
+ copiesOf(
1120
+ key: string,
1121
+ ): { reviewId: string; version: number; shared: SharedProvenance }[] {
1122
+ return this.db
1116
1123
  .prepare(
1117
- `SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
1118
- WHERE CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.shared.key') END=?`,
1124
+ `SELECT reviews.id, reviews.version, json_extract(versions.snapshot,'$.shared') AS shared
1125
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
1126
+ WHERE CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.shared.key') END=?
1127
+ ORDER BY reviews.rowid DESC`,
1119
1128
  )
1120
- .get(key);
1121
-
1122
- return row && String(row.id);
1129
+ .all(key)
1130
+ .map((row) => ({
1131
+ reviewId: String(row.id),
1132
+ version: Number(row.version),
1133
+ // SAFETY: the key matched this row's seed.
1134
+ shared: JSON.parse(String(row.shared)) as SharedProvenance,
1135
+ }));
1123
1136
  }
1124
1137
  /** Checkouts whose remote is cloneUrl, most recently used first. */
1125
1138
  checkoutsOf(cloneUrl: string): string[] {
@@ -1600,6 +1613,50 @@ export class ReviewStore {
1600
1613
  author,
1601
1614
  ).then((source) => this.applyPreparedCommand(source, initial, author));
1602
1615
 
1616
+ return this.track(prepared);
1617
+ }
1618
+
1619
+ /** Replaces a share copy's content under the same board id; 409 once it moved past `baseVersion`. */
1620
+ refreshCopy(
1621
+ reviewId: string,
1622
+ baseVersion: number,
1623
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
1624
+ create: unknown,
1625
+ seed: Seed,
1626
+ ): Promise<Result> {
1627
+ if (this.closing)
1628
+ return Promise.reject(new Error("Review store is closing."));
1629
+ const command = commandSchema.parse(create);
1630
+ const op = command.operation;
1631
+
1632
+ if (
1633
+ op.type !== "create" ||
1634
+ op.pullRequestUrl ||
1635
+ op.parentId ||
1636
+ op.dependsOn ||
1637
+ op.projectId
1638
+ )
1639
+ throw new ReviewInputError(
1640
+ "A refresh takes a create command with a title and target alone.",
1641
+ );
1642
+ const person: Author = { kind: "person" };
1643
+
1644
+ return this.track(
1645
+ this.prepareCommand(
1646
+ command,
1647
+ undefined,
1648
+ Promise.resolve(undefined),
1649
+ person,
1650
+ ).then((source) =>
1651
+ this.applyPreparedCommand(source, seed, person, {
1652
+ reviewId,
1653
+ baseVersion,
1654
+ }),
1655
+ ),
1656
+ );
1657
+ }
1658
+
1659
+ private track(prepared: Promise<Result>) {
1603
1660
  this.preparing.add(prepared);
1604
1661
  void prepared
1605
1662
  .finally(() => this.preparing.delete(prepared))
@@ -1690,6 +1747,7 @@ export class ReviewStore {
1690
1747
  prepared: PreparedCommand,
1691
1748
  initial: Seed | undefined,
1692
1749
  author: Author,
1750
+ into?: { reviewId: string; baseVersion: number },
1693
1751
  ) {
1694
1752
  const {
1695
1753
  op,
@@ -1703,6 +1761,7 @@ export class ReviewStore {
1703
1761
 
1704
1762
  return this.enqueue(async () => {
1705
1763
  if (
1764
+ !into &&
1706
1765
  op.type === "create" &&
1707
1766
  (op.pullRequestUrl
1708
1767
  ? op.reuseExisting !== false
@@ -1774,9 +1833,19 @@ export class ReviewStore {
1774
1833
  fromTrash: true,
1775
1834
  });
1776
1835
 
1777
- const id = op.type === "create" ? randomUUID() : op.reviewId;
1836
+ const id =
1837
+ into?.reviewId ?? (op.type === "create" ? randomUUID() : op.reviewId);
1778
1838
 
1779
- const previous = op.type === "create" ? undefined : this.read(id);
1839
+ const previous =
1840
+ op.type === "create" && !into ? undefined : this.read(id);
1841
+
1842
+ if (
1843
+ into &&
1844
+ (!previous?.shared?.key || previous.shared.key !== initial?.shared?.key)
1845
+ )
1846
+ throw new ReviewInputError(
1847
+ "Only a copy of the same share link refreshes in place.",
1848
+ );
1780
1849
 
1781
1850
  let snapshot: Snapshot =
1782
1851
  op.type === "create"
@@ -1994,9 +2063,11 @@ export class ReviewStore {
1994
2063
  restore: op.type === "restore",
1995
2064
  repin: op.type === "set_target",
1996
2065
  copied: initial !== undefined,
2066
+ inPlace: into !== undefined,
2067
+ baseVersion: into?.baseVersion,
1997
2068
  projectLive: op.type !== "restore" && !resolvedTarget,
1998
2069
  result: {
1999
- ...(op.type === "create" && { created: true }),
2070
+ ...(op.type === "create" && !into && { created: true }),
2000
2071
  targetId: applied?.targetId ?? lensTarget?.targetId,
2001
2072
  ...(applied && { type: applied.type }),
2002
2073
  ...(lensTarget && { type: lensTarget.type }),
@@ -2039,6 +2110,8 @@ export class ReviewStore {
2039
2110
  repin?: boolean;
2040
2111
  /** A board made from another's content, whose images stayed behind. */
2041
2112
  copied?: boolean;
2113
+ /** A share copy refreshed in place, in Trash or not. */
2114
+ inPlace?: boolean;
2042
2115
  projectLive: boolean;
2043
2116
  /** The ids a person's save gave new blocks, by client key. */
2044
2117
  personIds?: Record<string, string>;
@@ -2104,6 +2177,9 @@ export class ReviewStore {
2104
2177
  }));
2105
2178
  warnings = [...(change.warnings ?? []), ...warnings];
2106
2179
 
2180
+ if (change.copied && snapshot.shared)
2181
+ snapshot.shared.importedVersion = snapshot.version;
2182
+
2107
2183
  const result: Result = {
2108
2184
  ...change.result,
2109
2185
  reviewId: id,
@@ -2156,7 +2232,8 @@ export class ReviewStore {
2156
2232
  previous
2157
2233
  ? () => {
2158
2234
  // A board in Trash is read-only, whoever writes.
2159
- this.assertWritable(id);
2235
+ if (change.inPlace) this.assertExists(id);
2236
+ else this.assertWritable(id);
2160
2237
 
2161
2238
  if (change.baseVersion !== undefined)
2162
2239
  assertBaseVersion(change.baseVersion, this.currentVersion(id));
@@ -54,6 +54,7 @@ import {
54
54
  ReviewTelemetry,
55
55
  type ReviewTelemetryContext,
56
56
  } from "@review/review-telemetry";
57
+ import { remoteShare } from "@review/sharing/host.js";
57
58
  import { Hono } from "hono";
58
59
  import { z } from "zod";
59
60
 
@@ -204,6 +205,12 @@ export function createGlobalReviewServer(
204
205
  }
205
206
  }
206
207
 
208
+ const hooks = reviewLifecycleTelemetry(
209
+ telemetry,
210
+ (reviewId) => reviewStore.summary(reviewId)?.firstCreatedAt,
211
+ () => aliasInstallationToAccount(telemetry),
212
+ );
213
+
207
214
  const core = createWhiteboardCore({
208
215
  profile: {
209
216
  store: reviewStore,
@@ -229,11 +236,16 @@ export function createGlobalReviewServer(
229
236
  home: devReviewHome(),
230
237
  };
231
238
  },
232
- hooks: reviewLifecycleTelemetry(
233
- telemetry,
234
- (reviewId) => reviewStore.summary(reviewId)?.firstCreatedAt,
235
- () => aliasInstallationToAccount(telemetry),
236
- ),
239
+ hooks: {
240
+ ...hooks,
241
+ sharing: {
242
+ ...hooks.sharing,
243
+ remoteShare: remoteShare(
244
+ (reviewId) => gateway.isRemote(reviewId),
245
+ (request) => gateway.fetch(request),
246
+ ),
247
+ },
248
+ },
237
249
  ask: { tools: askTools },
238
250
  });
239
251
 
@@ -18,6 +18,7 @@ import { cliAskTools } from "@review/ask/threads.js";
18
18
  import { desktopUsesHome } from "@review/desktop-discovery.js";
19
19
  import { findReviewPackageRoot } from "@review/package-paths.js";
20
20
  import { openReviewProfile } from "@review/review-api/profile.js";
21
+ import { readAnimationsEnabled } from "@review/review-preferences.js";
21
22
  import {
22
23
  DESKTOP_TAKEOVER_SIGNAL,
23
24
  type ReviewServerDiscovery,
@@ -198,6 +199,8 @@ async function serve(input: HeadlessServerInput) {
198
199
  manageWorkspaces: true,
199
200
  });
200
201
 
202
+ const animationsEnabled = await readAnimationsEnabled(input.stateDir);
203
+
201
204
  const discovery: ReviewServerDiscovery = {
202
205
  version: 1,
203
206
  instanceId: randomUUID(),
@@ -221,6 +224,7 @@ async function serve(input: HeadlessServerInput) {
221
224
  token: discovery.token,
222
225
  instanceId: discovery.instanceId,
223
226
  softwareMapEnabled: input.softwareMapEnabled,
227
+ animations: () => animationsEnabled,
224
228
  status: () => ({ key: "headless", home: input.stateDir }),
225
229
  ask: {
226
230
  tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
@@ -69,6 +69,7 @@ const REMOTE_ROUTES: readonly RemoteRoute[] = [
69
69
  { method: "GET", path: "maps/:name" },
70
70
  { method: "POST", path: "navigator" },
71
71
  { method: "POST", path: "copy-context" },
72
+ { method: "POST", path: "share-bundle", slow: true },
72
73
  { method: "GET", path: "language-context", slow: true },
73
74
  { method: "GET", path: "ask/agents" },
74
75
  { method: "GET", path: "ask/agents/:agent/offer", slow: true },
@@ -16,6 +16,11 @@ import {
16
16
  readBuildCommit,
17
17
  readReviewPackageVersion,
18
18
  } from "@review/package-paths.js";
19
+ import {
20
+ type RenderRequest,
21
+ type RenderedFrame,
22
+ renderedFrameSchema,
23
+ } from "@review/review-api/animation-render.js";
19
24
  import {
20
25
  type AuthoringCapabilities,
21
26
  type ReviewApiHooks,
@@ -95,7 +100,8 @@ export function createReviewServerApp(input: {
95
100
  mountFeedback(app);
96
101
  app.post("/control/result", async (context) => {
97
102
  const accepted = input.relay.acceptResult(
98
- await readBoundedRequestJson(context.req.raw),
103
+ // A rendered frame's PNG rides back in a result.
104
+ await readBoundedRequestJson(context.req.raw, VERB_RESULT_MAX_BYTES),
99
105
  );
100
106
 
101
107
  return serverJson(accepted ? 200 : 404, { ok: accepted });
@@ -144,6 +150,10 @@ export interface WhiteboardCoreInput {
144
150
  };
145
151
  }
146
152
 
153
+ /** A verb result may carry a captured frame: a PNG of up to 1600 pixels on
154
+ * its longest edge, base64. */
155
+ const VERB_RESULT_MAX_BYTES = 16 * 1024 * 1024;
156
+
147
157
  export function createWhiteboardCore(input: WhiteboardCoreInput) {
148
158
  const { store, data } = input.profile;
149
159
 
@@ -154,7 +164,11 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
154
164
  relay: input.relay,
155
165
  });
156
166
 
157
- const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
167
+ const callbacks = relayReviewCallbacks(
168
+ input.relay,
169
+ input.softwareMapEnabled,
170
+ input.animations,
171
+ );
158
172
 
159
173
  const { ask } = input;
160
174
 
@@ -195,7 +209,8 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
195
209
  threads: askThreads,
196
210
  agents: async () => detectAskAgents(await ask?.env),
197
211
  },
198
- input.animations,
212
+ callbacks.animations,
213
+ callbacks.render,
199
214
  );
200
215
 
201
216
  // Desktop's sharing host mounts the publisher with the rest of sharing.
@@ -205,6 +220,7 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
205
220
  app,
206
221
  api,
207
222
  close: () => {
223
+ callbacks.close();
208
224
  stopLocal?.();
209
225
  closeDeletedThreads();
210
226
  askThreads?.closeAll();
@@ -212,12 +228,53 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
212
228
  };
213
229
  }
214
230
 
215
- /** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
231
+ const capabilitiesAnswerSchema = z.object({
232
+ softwareMapEnabled: z.boolean(),
233
+ // Older Desktops omit it.
234
+ animationsEnabled: z.boolean().optional(),
235
+ });
236
+
237
+ /**
238
+ * The Desktop callbacks `createReviewApi` takes, answered over the relay.
239
+ * `animations` never asks Desktop: it is the last answer while one is
240
+ * attached, else the host's own.
241
+ */
216
242
  export function relayReviewCallbacks(
217
243
  relay: ReviewDesktopVerbRelay,
218
244
  softwareMapEnabled = false,
245
+ animations: () => boolean = () => false,
219
246
  ) {
247
+ let desktopAnimations: boolean | undefined;
248
+
249
+ const capabilities = async (): Promise<AuthoringCapabilities> => {
250
+ if (!relay.attached) return { desktopAvailable: false, softwareMapEnabled };
251
+
252
+ const result = await relay.dispatch({
253
+ name: "authoringCapabilities",
254
+ args: {},
255
+ });
256
+
257
+ if (!result.ok) throw new ReviewInputError(result.error, 409);
258
+
259
+ const answer = capabilitiesAnswerSchema.parse(result.result);
260
+ desktopAnimations = answer.animationsEnabled;
261
+
262
+ return {
263
+ desktopAvailable: true,
264
+ softwareMapEnabled: answer.softwareMapEnabled,
265
+ };
266
+ };
267
+
268
+ const stop = relay.onAttachedChange(() => {
269
+ desktopAnimations = undefined;
270
+
271
+ if (relay.attached) capabilities().catch(() => {});
272
+ });
273
+
220
274
  return {
275
+ capabilities,
276
+ animations: () => desktopAnimations ?? animations(),
277
+ close: stop,
221
278
  async open(review: {
222
279
  reviewId: string;
223
280
  title: string;
@@ -231,21 +288,23 @@ export function relayReviewCallbacks(
231
288
 
232
289
  return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
233
290
  },
234
- async capabilities(): Promise<AuthoringCapabilities> {
291
+ async render(request: RenderRequest): Promise<RenderedFrame> {
235
292
  if (!relay.attached)
236
- return { desktopAvailable: false, softwareMapEnabled };
293
+ throw new ReviewInputError(
294
+ "No Whiteboard Desktop is attached, so nothing can show the animation. Open the whiteboard in Desktop and render again.",
295
+ 409,
296
+ );
297
+
298
+ const { reviewId, targetId, frame } = request;
237
299
 
238
300
  const result = await relay.dispatch({
239
- name: "authoringCapabilities",
240
- args: {},
301
+ name: "renderAnimation",
302
+ args: { reviewId, targetId, frame },
241
303
  });
242
304
 
243
305
  if (!result.ok) throw new ReviewInputError(result.error, 409);
244
306
 
245
- return {
246
- desktopAvailable: true,
247
- ...z.object({ softwareMapEnabled: z.boolean() }).parse(result.result),
248
- };
307
+ return renderedFrameSchema.parse(result.result);
249
308
  },
250
309
  };
251
310
  }
@@ -4,8 +4,13 @@ import { type JsonValue, parseJsonText } from "@dev.fast/json";
4
4
  import {
5
5
  MAX_SHARE_BYTES,
6
6
  type ShareBundle,
7
+ type ShareRevisions,
8
+ type SharedMeta,
7
9
  capabilitySchema,
8
- shareBundleSchema,
10
+ shareCreatedSchema,
11
+ shareIdSchema,
12
+ shareRevisionsSchema,
13
+ sharedMetaSchema,
9
14
  } from "@dev.fast/review-share-protocol";
10
15
  import {
11
16
  StreamLimitError,
@@ -15,6 +20,10 @@ import { Agent } from "undici";
15
20
  import { z } from "zod";
16
21
 
17
22
  import { shareRequestBody } from "./export.js";
23
+ import {
24
+ WHITEBOARD_UPGRADE_REQUIRED_MESSAGE,
25
+ parseWhiteboardFile,
26
+ } from "./file.js";
18
27
 
19
28
  const MAX_RESPONSE_BYTES = MAX_SHARE_BYTES;
20
29
 
@@ -37,14 +46,6 @@ const jwtPayloadSchema = z.object({ exp: z.number() });
37
46
 
38
47
  const bearers = new Map<string, Promise<{ bearer: string; until: number }>>();
39
48
 
40
- const linkSchema = z.strictObject({ shareId: z.uuid(), url: z.url() });
41
-
42
- const receivedSchema = z.object({
43
- sender: z.strictObject({ login: z.string().min(1).max(256) }),
44
- sharedAt: z.number(),
45
- size: z.number().int().positive().max(MAX_SHARE_BYTES),
46
- });
47
-
48
49
  /** Size is checked while reading, even if Content-Length is absent or false. */
49
50
  export async function readBoundedBytes(
50
51
  response: Response,
@@ -76,6 +77,79 @@ export class ShareAuthError extends Error {
76
77
  }
77
78
  }
78
79
 
80
+ export class ShareRequestError extends Error {
81
+ constructor(
82
+ readonly status: number,
83
+ message: string,
84
+ ) {
85
+ super(message);
86
+ this.name = "ShareRequestError";
87
+ }
88
+ }
89
+
90
+ const errorSchema = z.object({ error: z.object({ code: z.string() }) });
91
+
92
+ async function errorCode(response: Response) {
93
+ if (!response.body) return undefined;
94
+
95
+ if (!response.headers.get("content-type")?.includes("application/json")) {
96
+ await response.body.cancel();
97
+
98
+ return undefined;
99
+ }
100
+
101
+ try {
102
+ const bytes = await readBoundedStream(response.body, 16 * 1024);
103
+
104
+ return errorSchema.safeParse(parseJsonText(bytes.toString())).data?.error
105
+ .code;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function requestErrorMessage(response: Response, code: string | undefined) {
112
+ const { status, headers } = response;
113
+
114
+ if (status === 426 || code === "upgrade_required")
115
+ return WHITEBOARD_UPGRADE_REQUIRED_MESSAGE;
116
+
117
+ if (
118
+ status === 403 &&
119
+ headers.get("content-type")?.includes("text/html") &&
120
+ headers.get("server")?.toLowerCase() === "cloudflare"
121
+ )
122
+ return "The sharing service's security filter blocked this request. Please report this issue.";
123
+
124
+ if (status === 413)
125
+ return "This review is too large to share. Remove large resources, then retry.";
126
+
127
+ if (status === 429)
128
+ return code === "share_quota_exceeded"
129
+ ? "Your sharing storage limit has been reached. Please contact support."
130
+ : "Too many sharing requests. Wait a moment, then retry.";
131
+
132
+ if (status === 400 && code === "invalid_request")
133
+ return "The sharing service could not accept this review. Update Whiteboard or report this issue.";
134
+
135
+ if (status >= 500)
136
+ return `The sharing service is temporarily unavailable (HTTP ${status}). Try again shortly.`;
137
+
138
+ return `The sharing service refused this request (HTTP ${status}). Please report this issue.`;
139
+ }
140
+
141
+ async function requestError(response: Response) {
142
+ const message = requestErrorMessage(response, await errorCode(response));
143
+ const ray = response.headers.get("cf-ray");
144
+
145
+ const reference =
146
+ ray && /^[a-f0-9]{16}(?:-[a-z]{3})?$/i.test(ray)
147
+ ? ` Reference: ${ray}.`
148
+ : "";
149
+
150
+ return new ShareRequestError(response.status, message + reference);
151
+ }
152
+
79
153
  /** Account tokens go only to the configured API origin. */
80
154
  export class ShareClient {
81
155
  private readonly origin: string;
@@ -132,10 +206,12 @@ export class ShareClient {
132
206
  const response = await this.send(this.origin + route, init);
133
207
 
134
208
  if (!response.ok) {
135
- await response.body?.cancel();
209
+ if (response.status === 401 && !anonymous) {
210
+ await response.body?.cancel();
211
+ throw new ShareAuthError();
212
+ }
136
213
 
137
- if (response.status === 401 && !anonymous) throw new ShareAuthError();
138
- throw new Error(`Share request failed (${response.status}).`);
214
+ throw await requestError(response);
139
215
  }
140
216
 
141
217
  return readBoundedBytes(response, maxBytes);
@@ -169,14 +245,19 @@ export class ShareClient {
169
245
  });
170
246
 
171
247
  if (!response.ok) {
172
- await response.body?.cancel();
173
-
174
248
  // A service without the JWT plugin still takes the device token.
175
- if (response.status === 404 || response.status === 405)
249
+ if (response.status === 404 || response.status === 405) {
250
+ await response.body?.cancel();
251
+
176
252
  return { bearer: token, until: Date.now() + 10 * 60_000 };
253
+ }
177
254
 
178
- if (response.status === 401) throw new ShareAuthError();
179
- throw new Error(`Share request failed (${response.status}).`);
255
+ if (response.status === 401) {
256
+ await response.body?.cancel();
257
+ throw new ShareAuthError();
258
+ }
259
+
260
+ throw await requestError(response);
180
261
  }
181
262
 
182
263
  const { token: jwt } = jwtSchema.parse(
@@ -206,7 +287,7 @@ export class ShareClient {
206
287
  }
207
288
 
208
289
  async create(bundle: ShareBundle, requestId: string = randomUUID()) {
209
- return linkSchema.parse(
290
+ return shareCreatedSchema.parse(
210
291
  await this.api(
211
292
  "/api/v2/shares",
212
293
  "POST",
@@ -222,7 +303,7 @@ export class ShareClient {
222
303
  }
223
304
 
224
305
  async recoverLink(shareId: string) {
225
- return linkSchema.parse(
306
+ return shareCreatedSchema.parse(
226
307
  await this.api(`/api/v2/shares/${z.uuid().parse(shareId)}/link`),
227
308
  );
228
309
  }
@@ -231,16 +312,35 @@ export class ShareClient {
231
312
  return this.api(`/api/v2/shares/${z.uuid().parse(shareId)}`, "DELETE");
232
313
  }
233
314
 
234
- /** The bundle and who shared it, read with the link's capability. */
235
- async download(capability: string) {
236
- const route = `/api/v2/shared/${capabilitySchema.parse(capability)}`;
315
+ async revisions(shareId: string): Promise<ShareRevisions> {
316
+ return shareRevisionsSchema.parse(
317
+ await this.api(
318
+ `/api/v2/shares/${shareIdSchema.parse(shareId)}/revisions`,
319
+ ),
320
+ );
321
+ }
322
+
323
+ /** Who shared a link's revision, and which revision is its latest. */
324
+ async shared(capability: string, revision?: string): Promise<SharedMeta> {
325
+ const query =
326
+ revision === undefined ? "" : `?rev=${shareIdSchema.parse(revision)}`;
237
327
 
238
- const received = receivedSchema.parse(
239
- await this.api(route, "GET", undefined, true),
328
+ return sharedMetaSchema.parse(
329
+ await this.api(
330
+ `/api/v2/shared/${capabilitySchema.parse(capability)}${query}`,
331
+ "GET",
332
+ undefined,
333
+ true,
334
+ ),
240
335
  );
336
+ }
337
+
338
+ /** The bundle of the revision the meta names, and who shared it. */
339
+ async download(capability: string, revision?: string) {
340
+ const meta = await this.shared(capability, revision);
241
341
 
242
342
  const bytes = await this.request(
243
- `${route}/bundle`,
343
+ `/api/v2/shared/${capability}/bundle?rev=${meta.revision.id}`,
244
344
  "GET",
245
345
  undefined,
246
346
  true,
@@ -248,13 +348,10 @@ export class ShareClient {
248
348
  );
249
349
 
250
350
  return {
251
- bundle: shareBundleSchema.parse(
252
- parseJsonText(new TextDecoder().decode(bytes)),
253
- ),
254
- attribution: {
255
- login: received.sender.login,
256
- sharedAt: received.sharedAt,
257
- },
351
+ bundle: parseWhiteboardFile(new TextDecoder().decode(bytes)),
352
+ attribution: { login: meta.sender.login, sharedAt: meta.sharedAt },
353
+ revision: meta.revision,
354
+ latest: meta.latest,
258
355
  };
259
356
  }
260
357
  }