@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.
- package/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
- package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
- package/dist/agent-cli-DLibAL4u.js +2 -0
- package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
- package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
- package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
- package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
- package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
- package/dist/cli.js +2 -2
- package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
- package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
- package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
- package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
- package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
- package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
- package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
- package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
- package/dist/profile-BxdzBxHW.js +2 -0
- package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
- package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
- package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
- package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
- package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
- package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
- package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
- package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
- package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
- package/dist/server/desktop-host.js +26 -38
- package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
- package/dist/sharing/index.d.ts +137 -14
- package/dist/sharing/index.js +1 -1
- package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
- package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
- package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
- package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
- package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
- package/instructions/animation.md +69 -5
- package/instructions/animations/before-after-structure/README.md +27 -0
- package/instructions/animations/before-after-structure/animation.html +89 -0
- package/instructions/animations/before-after-structure/block.json +10 -0
- package/instructions/animations/before-after-structure/frames.json +313 -0
- package/instructions/animations/cache-fill-evict/README.md +24 -0
- package/instructions/animations/cache-fill-evict/animation.html +81 -0
- package/instructions/animations/cache-fill-evict/block.json +13 -0
- package/instructions/animations/cache-fill-evict/frames.json +79 -0
- package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
- package/instructions/animations/log-replay/README.md +25 -0
- package/instructions/animations/log-replay/animation.html +86 -0
- package/instructions/animations/log-replay/block.json +10 -0
- package/instructions/animations/log-replay/frames.json +143 -0
- package/instructions/animations/log-replay/scenarios.json +18 -0
- package/instructions/animations/queue-workers/README.md +23 -0
- package/instructions/animations/queue-workers/animation.html +107 -0
- package/instructions/animations/queue-workers/block.json +13 -0
- package/instructions/animations/queue-workers/frames.json +119 -0
- package/instructions/animations/queue-workers/scenarios.json +18 -0
- package/instructions/animations/race-two-lanes/README.md +22 -0
- package/instructions/animations/race-two-lanes/animation.html +97 -0
- package/instructions/animations/race-two-lanes/block.json +1 -0
- package/instructions/animations/race-two-lanes/frames.json +234 -0
- package/instructions/animations/race-two-lanes/scenarios.json +19 -0
- package/instructions/animations/request-across-layers/README.md +24 -0
- package/instructions/animations/request-across-layers/animation.html +85 -0
- package/instructions/animations/request-across-layers/block.json +17 -0
- package/instructions/animations/request-across-layers/frames.json +58 -0
- package/instructions/animations/request-across-layers/scenarios.json +12 -0
- package/instructions/animations/request-timing/README.md +22 -0
- package/instructions/animations/request-timing/animation.html +120 -0
- package/instructions/animations/request-timing/block.json +21 -0
- package/instructions/animations/request-timing/markers.json +32 -0
- package/instructions/animations/request-timing/scenarios.json +4 -0
- package/instructions/animations/retry-backoff/README.md +22 -0
- package/instructions/animations/retry-backoff/animation.html +122 -0
- package/instructions/animations/retry-backoff/block.json +10 -0
- package/instructions/animations/retry-backoff/frames.json +65 -0
- package/instructions/animations/retry-backoff/scenarios.json +12 -0
- package/instructions/animations/state-machine/README.md +24 -0
- package/instructions/animations/state-machine/animation.html +95 -0
- package/instructions/animations/state-machine/block.json +9 -0
- package/instructions/animations/state-machine/frames.json +79 -0
- package/instructions/animations/state-machine/scenarios.json +12 -0
- package/instructions/animations/video-platform-architecture/README.md +24 -0
- package/instructions/animations/video-platform-architecture/animation.html +76 -0
- package/instructions/animations/video-platform-architecture/block.json +17 -0
- package/instructions/animations/video-platform-architecture/frames.json +97 -0
- package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
- package/instructions/whiteboard.d.ts +168 -14
- package/package.json +4 -4
- package/src/animation-stage-kit.ts +150 -10
- package/src/cli-runner.ts +4 -1
- package/src/fixtures/blocks/clip.json +19 -0
- package/src/review-api/README.md +1 -0
- package/src/review-api/agent-cli.ts +3 -2
- package/src/review-api/animation-drafts.ts +151 -47
- package/src/review-api/animation-references.ts +92 -0
- package/src/review-api/animation-render.ts +106 -0
- package/src/review-api/authoring-tools.ts +10 -3
- package/src/review-api/document-text.ts +27 -12
- package/src/review-api/http.ts +52 -6
- package/src/review-api/instructions.ts +1 -1
- package/src/review-api/mcp.ts +10 -5
- package/src/review-api/public-tools.ts +71 -2
- package/src/review-api/store.ts +89 -12
- package/src/server/desktop-server.ts +17 -5
- package/src/server/headless-host.ts +4 -0
- package/src/server/review-gateway.ts +1 -0
- package/src/server/review-server-core.ts +71 -12
- package/src/sharing/client.ts +129 -32
- package/src/sharing/export.ts +42 -5
- package/src/sharing/file.ts +8 -0
- package/src/sharing/host.ts +202 -73
- package/src/sharing/import.ts +125 -33
- package/src/ui-telemetry-events.ts +25 -1
- package/dist/agent-cli-B8KBHuh_.js +0 -2
- package/dist/profile-BjcXi-oH.js +0 -2
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { b as resolveRepoContext, d as git, r as defaultBranch, w as runVcs } from "./src-Y30gweiK.js";
|
|
2
2
|
import { c as jsonString, m as isStringValue, n as isJsonObject, p as isObjectValue, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
3
|
-
import { _ as
|
|
4
|
-
import { I as stepSources, T as selectionReferences, U as animationCode, et as formatAnchor, n as anchorPins, o as boardRepositoryId, ot as ReviewInputError, rt as selectionKey, u as elements } from "./src-
|
|
5
|
-
import { c as invalidateStructuralComparisons, d as readGitHubPullRequests, i as inspectSnapshot, l as diffrExecutable, r as commandSchema, u as diffrMissingError } from "./local-data-
|
|
6
|
-
import {
|
|
7
|
-
import { B as TraceStorageDeniedError, D as listReviewTraceSessions, I as isTraceStorageConfigured, O as loadReviewAgentTrace, R as resolveTraceStorage, T as traceMachineEnabled, at as readStoreAuth, et as shellQuote, it as clearStoreAuth, ot as runStoreLogin, rt as DEFAULT_STORE_ORIGIN, z as selectTraceStorage } from "./review-home-paths-
|
|
8
|
-
import { D as scopedCoverage, f as sourceBranchName, l as pullRequestKey, p as pullRequestProvider } from "./stored-document-migration-
|
|
3
|
+
import { C as StreamLimitError, D as shareIdSchema, E as shareBundleSchema, S as uncategorizedReport, T as parseShareLink, _ as validateShareSources, a as shareKey, b as progressUpdateSchema, c as readShareRepository, d as ShareRequestError, f as parseWhiteboardFile, g as exportShare, h as whiteboardFileText, i as receiveShare, l as ShareAuthError, m as whiteboardFileName, n as needsRepository, p as whiteboardFileKey, r as planShareCopy, s as forgetSharedCommits, t as importShare, u as ShareClient, v as legacyModeSchema, w as readBoundedStream, x as reviewProgress, y as lensReport } from "./import-CZ1958L4.js";
|
|
4
|
+
import { I as stepSources, T as selectionReferences, U as animationCode, et as formatAnchor, n as anchorPins, o as boardRepositoryId, ot as ReviewInputError, rt as selectionKey, u as elements } from "./src-BQDlJXkG.js";
|
|
5
|
+
import { c as invalidateStructuralComparisons, d as readGitHubPullRequests, i as inspectSnapshot, l as diffrExecutable, r as commandSchema, u as diffrMissingError } from "./local-data-DdFkxFK_.js";
|
|
6
|
+
import { A as ReviewFeedbackSchema, F as reviewDiffrSummarizerInputSchema, L as decodeStructuralDiffEvent, M as ReviewOnboardingRunRequestSchema, P as reviewDiffrLoginsSchema, a as parseReviewVerbRequest, c as boardIconKind, i as parseReviewDesktopVerbResult, k as ReviewFeedbackContactSchema, l as boardTitle, m as REVIEW_CLIENT_REMOTE, p as REVIEW_CLIENT_HEADER, y as REVIEW_HOST_HEADER } from "./src-CrrW7lcY.js";
|
|
7
|
+
import { B as TraceStorageDeniedError, D as listReviewTraceSessions, I as isTraceStorageConfigured, O as loadReviewAgentTrace, R as resolveTraceStorage, T as traceMachineEnabled, at as readStoreAuth, et as shellQuote, it as clearStoreAuth, ot as runStoreLogin, rt as DEFAULT_STORE_ORIGIN, z as selectTraceStorage } from "./review-home-paths-BmZX2qiz.js";
|
|
8
|
+
import { D as scopedCoverage, f as sourceBranchName, l as pullRequestKey, p as pullRequestProvider } from "./stored-document-migration-D1wK4yID.js";
|
|
9
9
|
import { A as processStartIdentity, L as writePrivateJsonAtomic, N as devReviewHome, h as TraceConfigurationError, i as isS3MockMode, j as withFileLock } from "./s3-config-CjcLyFV5.js";
|
|
10
10
|
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
11
|
-
import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPackageRoot } from "./package-paths-
|
|
12
|
-
import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-
|
|
13
|
-
import { c as askPicksSchema, d as selectionMarkdown, l as askQuestionSchema, n as askAgentIds, r as askChoiceKinds, s as askPermissionsSchema, t as applyAskChange, u as AgentSelectionSchema } from "./thread-state-
|
|
14
|
-
import { t as openReviewProfile } from "./profile
|
|
15
|
-
import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-
|
|
16
|
-
import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-
|
|
17
|
-
import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-
|
|
18
|
-
import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-
|
|
11
|
+
import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPackageRoot } from "./package-paths-Dk7MOXxN.js";
|
|
12
|
+
import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-CV5qf1J8.js";
|
|
13
|
+
import { c as askPicksSchema, d as selectionMarkdown, l as askQuestionSchema, n as askAgentIds, r as askChoiceKinds, s as askPermissionsSchema, t as applyAskChange, u as AgentSelectionSchema } from "./thread-state-WTqSborE.js";
|
|
14
|
+
import { t as openReviewProfile } from "./profile-Bsgzi9PA.js";
|
|
15
|
+
import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-D6B-zrL8.js";
|
|
16
|
+
import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-CxcsLQGx.js";
|
|
17
|
+
import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-CkfxTVJm.js";
|
|
18
|
+
import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-BraP8pJ9.js";
|
|
19
19
|
import crypto$1, { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -28,9 +28,9 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
|
28
28
|
import { promisify } from "node:util";
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { PROTOCOL_VERSION, RequestError, client, methods } from "@agentclientprotocol/sdk";
|
|
31
|
+
import { getRequestListener } from "@hono/node-server";
|
|
31
32
|
import { Hono } from "hono";
|
|
32
33
|
import { stringify } from "smol-toml";
|
|
33
|
-
import { getRequestListener } from "@hono/node-server";
|
|
34
34
|
import { createServer } from "node:http";
|
|
35
35
|
import { streamSSE } from "hono/streaming";
|
|
36
36
|
import { once } from "node:events";
|
|
@@ -1404,6 +1404,399 @@ var AskThreads = class {
|
|
|
1404
1404
|
}
|
|
1405
1405
|
};
|
|
1406
1406
|
//#endregion
|
|
1407
|
+
//#region src/review-preferences.ts
|
|
1408
|
+
/**
|
|
1409
|
+
* Machine-wide Review preferences the server itself needs. Workbench settings
|
|
1410
|
+
* do not work here: the review server never reads the workbench
|
|
1411
|
+
* configuration.
|
|
1412
|
+
*/
|
|
1413
|
+
function reviewPreferencesPath(devHome = devReviewHome()) {
|
|
1414
|
+
return path.join(devHome, "preferences.json");
|
|
1415
|
+
}
|
|
1416
|
+
/** Whether agents are offered animation authoring; existing animations
|
|
1417
|
+
* always render. A missing or unreadable file means off; it never throws. */
|
|
1418
|
+
async function readAnimationsEnabled(devHome) {
|
|
1419
|
+
try {
|
|
1420
|
+
const raw = parseJsonText(await readFile(reviewPreferencesPath(devHome), "utf8"));
|
|
1421
|
+
return isJsonObject(raw) && raw.animationsEnabled === true;
|
|
1422
|
+
} catch {
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
async function writeAnimationsEnabled(enabled, devHome) {
|
|
1427
|
+
await writePrivateJsonAtomic(reviewPreferencesPath(devHome), { animationsEnabled: enabled });
|
|
1428
|
+
return enabled;
|
|
1429
|
+
}
|
|
1430
|
+
//#endregion
|
|
1431
|
+
//#region src/server/http-json.ts
|
|
1432
|
+
const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
1433
|
+
var HttpJsonError = class extends Error {
|
|
1434
|
+
statusCode;
|
|
1435
|
+
constructor(message, statusCode) {
|
|
1436
|
+
super(message);
|
|
1437
|
+
this.statusCode = statusCode;
|
|
1438
|
+
this.name = "HttpJsonError";
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
/** An error whose status and machine code the JSON routes hand straight back. */
|
|
1442
|
+
var ReviewServerError = class extends Error {
|
|
1443
|
+
statusCode;
|
|
1444
|
+
code;
|
|
1445
|
+
name = "ReviewServerError";
|
|
1446
|
+
constructor(message, statusCode, code) {
|
|
1447
|
+
super(message);
|
|
1448
|
+
this.statusCode = statusCode;
|
|
1449
|
+
this.code = code;
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
//#endregion
|
|
1453
|
+
//#region src/server/hono-http.ts
|
|
1454
|
+
const H2_FORBIDDEN_HEADERS = [
|
|
1455
|
+
"connection",
|
|
1456
|
+
"keep-alive",
|
|
1457
|
+
"proxy-connection",
|
|
1458
|
+
"transfer-encoding",
|
|
1459
|
+
"upgrade"
|
|
1460
|
+
];
|
|
1461
|
+
function createNodeRequestListener(app) {
|
|
1462
|
+
return getRequestListener(async (request, env) => {
|
|
1463
|
+
const response = await app.fetch(request, env);
|
|
1464
|
+
if (env.incoming.httpVersionMajor !== 2 || !H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))) return response;
|
|
1465
|
+
const stripped = new Response(response.body, response);
|
|
1466
|
+
for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
|
|
1467
|
+
return stripped;
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
function jsonResponse(body, status, options = {}) {
|
|
1471
|
+
const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
|
|
1472
|
+
if (options.cacheControl) headers.set("cache-control", options.cacheControl);
|
|
1473
|
+
const serialized = JSON.stringify(body);
|
|
1474
|
+
return new Response(options.newline === false ? serialized : `${serialized}\n`, {
|
|
1475
|
+
status,
|
|
1476
|
+
headers
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
function applyCorsHeaders(request, response) {
|
|
1480
|
+
const origin = request.headers.get("origin");
|
|
1481
|
+
if (origin) {
|
|
1482
|
+
response.headers.set("access-control-allow-origin", origin);
|
|
1483
|
+
const vary = response.headers.get("vary");
|
|
1484
|
+
const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
|
|
1485
|
+
if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
|
|
1486
|
+
}
|
|
1487
|
+
response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`);
|
|
1488
|
+
response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1489
|
+
response.headers.set("access-control-allow-private-network", "true");
|
|
1490
|
+
response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
|
|
1491
|
+
return response;
|
|
1492
|
+
}
|
|
1493
|
+
/** The allowed origin, headers and methods hold for the server's lifetime, so
|
|
1494
|
+
* the client may reuse a preflight. Chromium otherwise keeps one for 5 s, and
|
|
1495
|
+
* nearly every request paid a second round trip. Two hours is Chromium's cap. */
|
|
1496
|
+
const PREFLIGHT_MAX_AGE_SECONDS = 7200;
|
|
1497
|
+
function corsPreflightResponse(request) {
|
|
1498
|
+
const response = applyCorsHeaders(request, new Response(null, { status: 204 }));
|
|
1499
|
+
response.headers.set("access-control-max-age", String(PREFLIGHT_MAX_AGE_SECONDS));
|
|
1500
|
+
return response;
|
|
1501
|
+
}
|
|
1502
|
+
function isAuthorizedRequest(request, expectedToken) {
|
|
1503
|
+
const supplied = request.headers.get("x-review-token") ?? new URL(request.url).searchParams.get("token");
|
|
1504
|
+
if (!supplied) return false;
|
|
1505
|
+
const expected = Buffer.from(expectedToken);
|
|
1506
|
+
const actual = Buffer.from(supplied);
|
|
1507
|
+
return expected.length === actual.length && crypto$1.timingSafeEqual(expected, actual);
|
|
1508
|
+
}
|
|
1509
|
+
async function readBoundedRequestJson(request, maxBytes = DEFAULT_MAX_REQUEST_BYTES, emptyValue, options = {}) {
|
|
1510
|
+
assertJsonContentType(request, options);
|
|
1511
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
1512
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) throw requestTooLarge(maxBytes);
|
|
1513
|
+
let body;
|
|
1514
|
+
try {
|
|
1515
|
+
body = request.body ? (await readBoundedStream(request.body, maxBytes)).toString("utf8") : "";
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
if (error instanceof StreamLimitError) throw requestTooLarge(maxBytes);
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
1520
|
+
if (!body && emptyValue !== void 0) return emptyValue;
|
|
1521
|
+
try {
|
|
1522
|
+
return parseJsonText(body);
|
|
1523
|
+
} catch {
|
|
1524
|
+
throw new HttpJsonError("Invalid JSON body.", 400);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function assertJsonContentType(request, options) {
|
|
1528
|
+
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1529
|
+
if (mediaType !== "application/json" && !mediaType?.endsWith("+json") && !(options.allowTextPlain && mediaType === "text/plain")) throw new HttpJsonError("Content-Type must be application/json.", 415);
|
|
1530
|
+
}
|
|
1531
|
+
function requestTooLarge(maxBytes) {
|
|
1532
|
+
return new HttpJsonError(`Request body exceeds ${maxBytes === 1048576 ? "1 MiB" : `${maxBytes} bytes`}.`, 413);
|
|
1533
|
+
}
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region src/sharing/auth.ts
|
|
1536
|
+
/** CI credentials stay in the server environment and are never persisted to a profile. */
|
|
1537
|
+
async function readSharingAuth(env = process.env) {
|
|
1538
|
+
if (env.DEV_REVIEW_SHARE_TOKEN === void 0) {
|
|
1539
|
+
if (env.DEV_REVIEW_SHARE_ORIGIN !== void 0) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN requires DEV_REVIEW_SHARE_TOKEN.");
|
|
1540
|
+
return readStoreAuth(env);
|
|
1541
|
+
}
|
|
1542
|
+
const token = env.DEV_REVIEW_SHARE_TOKEN.trim();
|
|
1543
|
+
if (!token) throw new ReviewInputError("DEV_REVIEW_SHARE_TOKEN is empty.");
|
|
1544
|
+
let url;
|
|
1545
|
+
try {
|
|
1546
|
+
url = new URL(env.DEV_REVIEW_SHARE_ORIGIN ?? DEFAULT_STORE_ORIGIN);
|
|
1547
|
+
} catch {
|
|
1548
|
+
throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
1549
|
+
}
|
|
1550
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
1551
|
+
return {
|
|
1552
|
+
origin: url.origin,
|
|
1553
|
+
token
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
//#endregion
|
|
1557
|
+
//#region src/sharing/host.ts
|
|
1558
|
+
const publishSchema = z.strictObject({
|
|
1559
|
+
reviewId: z.string().min(1),
|
|
1560
|
+
version: z.number().int().nonnegative().optional(),
|
|
1561
|
+
requestId: z.uuid().optional()
|
|
1562
|
+
});
|
|
1563
|
+
const importSchema = z.union([z.strictObject({ url: z.string().max(2048) }), z.strictObject({ path: z.string().min(1).max(4096) })]);
|
|
1564
|
+
/** Mounted behind local host authentication. Account credentials never enter the renderer. */
|
|
1565
|
+
function mountSharingHost(app, store, data, options = {}) {
|
|
1566
|
+
const startLogin = options.login ?? runStoreLogin;
|
|
1567
|
+
let login = { pending: false };
|
|
1568
|
+
let warmedAt = 0;
|
|
1569
|
+
const readAccount = async () => {
|
|
1570
|
+
const account = await readStoreAuth();
|
|
1571
|
+
if (account && Date.now() - warmedAt > 6e4) {
|
|
1572
|
+
warmedAt = Date.now();
|
|
1573
|
+
new ShareClient(account.origin, account.token, options.fetch).warm().catch(() => {});
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
account: account ? {
|
|
1577
|
+
login: account.login,
|
|
1578
|
+
origin: account.origin,
|
|
1579
|
+
email: account.email,
|
|
1580
|
+
githubLogin: account.githubLogin
|
|
1581
|
+
} : null,
|
|
1582
|
+
...login
|
|
1583
|
+
};
|
|
1584
|
+
};
|
|
1585
|
+
app.get("/sharing/account", async (context) => context.json(await readAccount()));
|
|
1586
|
+
app.post("/sharing/logout", async (context) => {
|
|
1587
|
+
await clearStoreAuth();
|
|
1588
|
+
return context.json(await readAccount());
|
|
1589
|
+
});
|
|
1590
|
+
app.post("/sharing/login", async (context) => {
|
|
1591
|
+
if (!login.pending) {
|
|
1592
|
+
login = { pending: true };
|
|
1593
|
+
options.onLogin?.("started");
|
|
1594
|
+
const discard = new Writable({ write(_chunk, _encoding, done) {
|
|
1595
|
+
done();
|
|
1596
|
+
} });
|
|
1597
|
+
startLogin({
|
|
1598
|
+
traces: false,
|
|
1599
|
+
stdout: discard,
|
|
1600
|
+
stderr: discard,
|
|
1601
|
+
openUrl: options.openUrl,
|
|
1602
|
+
onDeviceCode: (device) => {
|
|
1603
|
+
Object.assign(login, device);
|
|
1604
|
+
}
|
|
1605
|
+
}).then((code) => {
|
|
1606
|
+
login = { pending: false };
|
|
1607
|
+
if (code) login.error = "Sign-in did not finish. Try again.";
|
|
1608
|
+
options.onLogin?.(code ? "failed" : "succeeded", code ? "did_not_finish" : void 0);
|
|
1609
|
+
}).catch(() => {
|
|
1610
|
+
login = {
|
|
1611
|
+
pending: false,
|
|
1612
|
+
error: "Sign-in failed. Try again."
|
|
1613
|
+
};
|
|
1614
|
+
options.onLogin?.("failed", "error");
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
return context.json(login);
|
|
1618
|
+
});
|
|
1619
|
+
mountSharingPublisher(app, store, data, options);
|
|
1620
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1621
|
+
let importing = Promise.resolve();
|
|
1622
|
+
const serially = (task) => {
|
|
1623
|
+
const run = importing.then(task);
|
|
1624
|
+
importing = run.then(() => {}, () => {});
|
|
1625
|
+
return run;
|
|
1626
|
+
};
|
|
1627
|
+
app.get("/sharing/import/:id", (context) => {
|
|
1628
|
+
const id = context.req.param("id");
|
|
1629
|
+
const { reviewId = id, ...status } = imports.get(id) ?? {
|
|
1630
|
+
stage: "error",
|
|
1631
|
+
error: "Shared review is not available."
|
|
1632
|
+
};
|
|
1633
|
+
return context.json({
|
|
1634
|
+
reviewId,
|
|
1635
|
+
...status,
|
|
1636
|
+
...status.stage === "ready" && { title: store.read(reviewId).title }
|
|
1637
|
+
});
|
|
1638
|
+
});
|
|
1639
|
+
const shareInput = (onStage) => ({
|
|
1640
|
+
store,
|
|
1641
|
+
data,
|
|
1642
|
+
fetchRepository: options.fetchRepository,
|
|
1643
|
+
cloneRepository: options.cloneRepository,
|
|
1644
|
+
onStage
|
|
1645
|
+
});
|
|
1646
|
+
const startImport = (id, initial, receive) => {
|
|
1647
|
+
const known = imports.get(id)?.stage;
|
|
1648
|
+
if (!known || known === "ready" || known === "error") {
|
|
1649
|
+
const status = { stage: initial };
|
|
1650
|
+
imports.set(id, status);
|
|
1651
|
+
receive((stage) => {
|
|
1652
|
+
status.stage = stage;
|
|
1653
|
+
}).then((reviewId) => {
|
|
1654
|
+
status.reviewId = reviewId;
|
|
1655
|
+
status.stage = "ready";
|
|
1656
|
+
}).catch((error) => {
|
|
1657
|
+
status.stage = "error";
|
|
1658
|
+
status.error = error instanceof ReviewInputError || error instanceof ShareRequestError && error.status !== 404 ? error.message : "This share is unavailable, revoked, or could not be read.";
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
const { reviewId: _reviewId, ...status } = imports.get(id);
|
|
1662
|
+
return {
|
|
1663
|
+
reviewId: id,
|
|
1664
|
+
...status
|
|
1665
|
+
};
|
|
1666
|
+
};
|
|
1667
|
+
app.post("/sharing/import", async (context) => {
|
|
1668
|
+
const input = importSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
1669
|
+
if ("path" in input) {
|
|
1670
|
+
if (!path.isAbsolute(input.path)) throw new ReviewInputError("Use an absolute file path.", 400);
|
|
1671
|
+
const text = await readFile(input.path, "utf8").catch(() => {
|
|
1672
|
+
throw new ReviewInputError(`Could not read ${path.basename(input.path)}. Check that the file still exists and Whiteboard can open it.`, 400);
|
|
1673
|
+
});
|
|
1674
|
+
const key = whiteboardFileKey(text);
|
|
1675
|
+
const copy = () => store.copiesOf(key)[0]?.reviewId;
|
|
1676
|
+
return context.json(startImport(key, "reading", async (onStage) => copy() ?? serially(async () => copy() ?? importShare(shareInput(onStage), key, parseWhiteboardFile(text)))), 202);
|
|
1677
|
+
}
|
|
1678
|
+
const parsed = parseShareLink(input.url);
|
|
1679
|
+
const account = await readStoreAuth();
|
|
1680
|
+
if (parsed.origin !== DEFAULT_STORE_ORIGIN && parsed.origin !== account?.origin) throw new ReviewInputError("This share uses an untrusted service. Sign in to that service before opening its links.", 400);
|
|
1681
|
+
const { origin, capability, revision } = parsed;
|
|
1682
|
+
const key = shareKey(origin, capability);
|
|
1683
|
+
const client = new ShareClient(origin, void 0, options.fetch);
|
|
1684
|
+
return context.json(startImport(revision ? `${key}-${revision}` : key, "downloading", async (onStage) => {
|
|
1685
|
+
let meta;
|
|
1686
|
+
try {
|
|
1687
|
+
meta = await client.shared(capability, revision);
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
const copies = store.copiesOf(key);
|
|
1690
|
+
const copy = copies.find(({ shared }) => revision ? shared.revisionId === revision : !shared.pinned) ?? copies[0];
|
|
1691
|
+
if (!copy) throw error;
|
|
1692
|
+
return copy.reviewId;
|
|
1693
|
+
}
|
|
1694
|
+
const pinned = revision !== void 0;
|
|
1695
|
+
const plan = planShareCopy(store.copiesOf(key), meta, pinned);
|
|
1696
|
+
return plan.kind === "open" ? plan.reviewId : serially(() => receiveShare(shareInput(onStage), key, meta, () => client.download(capability, meta.revision.id), pinned));
|
|
1697
|
+
}), 202);
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
const preparedShareSchema = z.strictObject({
|
|
1701
|
+
title: z.string(),
|
|
1702
|
+
version: z.number().int().nonnegative(),
|
|
1703
|
+
bundle: shareBundleSchema,
|
|
1704
|
+
needsRepository: z.boolean()
|
|
1705
|
+
});
|
|
1706
|
+
function remoteShare(isRemote, fetch) {
|
|
1707
|
+
return ({ reviewId, version }) => isRemote(reviewId) ? (async () => {
|
|
1708
|
+
const response = await fetch(new Request(`http://whiteboard/reviews-api/${encodeURIComponent(reviewId)}/share-bundle`, {
|
|
1709
|
+
method: "POST",
|
|
1710
|
+
headers: { "content-type": "application/json" },
|
|
1711
|
+
body: JSON.stringify({ version })
|
|
1712
|
+
}));
|
|
1713
|
+
const body = await response.json().catch(() => void 0);
|
|
1714
|
+
if (!response.ok) throw new ReviewInputError(z.object({ error: z.string() }).safeParse(body).data?.error ?? "Could not read the board from its host.", response.status === 400 ? 400 : 409);
|
|
1715
|
+
return preparedShareSchema.parse(body);
|
|
1716
|
+
})() : void 0;
|
|
1717
|
+
}
|
|
1718
|
+
/** Publishing needs only an authored store, never Desktop or recipient workspaces. */
|
|
1719
|
+
function mountSharingPublisher(app, store, data, options = {}) {
|
|
1720
|
+
const prepareLocal = async (input) => {
|
|
1721
|
+
const snapshot = store.read(input.reviewId, input.version);
|
|
1722
|
+
if (snapshot.target?.kind !== "worktree") validateShareSources(snapshot);
|
|
1723
|
+
const repositoryId = snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
|
|
1724
|
+
const root = repositoryId ? store.repositoryPath(repositoryId) : void 0;
|
|
1725
|
+
const repository = root ? (options.readRepository ?? readShareRepository)(root).catch(() => void 0) : void 0;
|
|
1726
|
+
const bundle = await exportShare({
|
|
1727
|
+
store,
|
|
1728
|
+
data,
|
|
1729
|
+
reviewId: snapshot.reviewId,
|
|
1730
|
+
version: snapshot.version
|
|
1731
|
+
});
|
|
1732
|
+
const verified = await repository;
|
|
1733
|
+
if (verified) bundle.repository = verified;
|
|
1734
|
+
return {
|
|
1735
|
+
title: snapshot.title,
|
|
1736
|
+
version: snapshot.version,
|
|
1737
|
+
bundle,
|
|
1738
|
+
needsRepository: needsRepository(bundle, snapshot)
|
|
1739
|
+
};
|
|
1740
|
+
};
|
|
1741
|
+
const prepare = (input) => options.remoteShare?.(input) ?? prepareLocal(input);
|
|
1742
|
+
app.post("/:id/share-bundle", async (context) => {
|
|
1743
|
+
const { version } = publishSchema.pick({ version: true }).parse(await readBoundedRequestJson(context.req.raw));
|
|
1744
|
+
return context.json(await prepareLocal({
|
|
1745
|
+
reviewId: context.req.param("id"),
|
|
1746
|
+
version
|
|
1747
|
+
}));
|
|
1748
|
+
});
|
|
1749
|
+
app.post("/sharing/export", async (context) => {
|
|
1750
|
+
const { title, bundle, needsRepository } = await prepare(publishSchema.parse(await readBoundedRequestJson(context.req.raw)));
|
|
1751
|
+
if (bundle.frozen) throw new ReviewInputError("Commit your changes to export this whiteboard as a file.");
|
|
1752
|
+
else if (!bundle.repository && needsRepository) throw new ReviewInputError("Exporting pinned code needs a GitHub remote the file's reader can fetch from.");
|
|
1753
|
+
return context.json({
|
|
1754
|
+
filename: whiteboardFileName(title),
|
|
1755
|
+
text: whiteboardFileText(bundle)
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
app.post("/sharing/publish", async (context) => {
|
|
1759
|
+
const input = publishSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
1760
|
+
const account = await readSharingAuth();
|
|
1761
|
+
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login before sharing.", 409);
|
|
1762
|
+
const { bundle, version } = await prepare(input);
|
|
1763
|
+
try {
|
|
1764
|
+
const result = await new ShareClient(account.origin, account.token, options.fetch).create(bundle, input.requestId ?? randomUUID());
|
|
1765
|
+
const url = new URL(result.url);
|
|
1766
|
+
if (process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview") url.searchParams.set("app", "preview");
|
|
1767
|
+
options.onPublished?.({
|
|
1768
|
+
reviewId: input.reviewId,
|
|
1769
|
+
version
|
|
1770
|
+
});
|
|
1771
|
+
return context.json({
|
|
1772
|
+
...result,
|
|
1773
|
+
url: url.href,
|
|
1774
|
+
version
|
|
1775
|
+
});
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
if (error instanceof ReviewInputError) throw error;
|
|
1778
|
+
if (error instanceof ShareRequestError) throw new ReviewInputError(error.message, 409);
|
|
1779
|
+
if (error instanceof ShareAuthError) {
|
|
1780
|
+
if (process.env.DEV_REVIEW_SHARE_TOKEN === void 0) await clearStoreAuth();
|
|
1781
|
+
throw new ReviewInputError("Your sign-in has expired. Sign in again to share.", 401);
|
|
1782
|
+
}
|
|
1783
|
+
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) throw new ReviewInputError("The sharing request timed out. Please retry.", 409);
|
|
1784
|
+
throw new ReviewInputError(error instanceof TypeError ? "Could not reach the sharing service. Check your connection, then retry." : "The sharing service returned an unexpected response. Update Whiteboard or report this issue.", 409);
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
app.post("/sharing/revoke", async (context) => {
|
|
1788
|
+
const { shareId } = z.strictObject({ shareId: shareIdSchema }).parse(await readBoundedRequestJson(context.req.raw));
|
|
1789
|
+
const account = await readSharingAuth();
|
|
1790
|
+
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.", 409);
|
|
1791
|
+
await new ShareClient(account.origin, account.token, options.fetch).revoke(shareId);
|
|
1792
|
+
options.onRevoked?.({ shareId });
|
|
1793
|
+
return context.json({
|
|
1794
|
+
shareId,
|
|
1795
|
+
revoked: true
|
|
1796
|
+
});
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
//#endregion
|
|
1407
1800
|
//#region src/server/diffr-config.ts
|
|
1408
1801
|
const execFileAsync = promisify(execFile);
|
|
1409
1802
|
let writes = Promise.resolve();
|
|
@@ -1839,130 +2232,26 @@ var GlobalReviewDesktopVerbRelay = class {
|
|
|
1839
2232
|
const listener = this.clients.get(writer);
|
|
1840
2233
|
if (!listener) return;
|
|
1841
2234
|
writer.signal.removeEventListener("abort", listener);
|
|
1842
|
-
this.clients.delete(writer);
|
|
1843
|
-
for (const [id, verb] of [...this.pending]) if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
|
|
1844
|
-
if (this.clients.size === 0) this.notifyAttached();
|
|
1845
|
-
}
|
|
1846
|
-
/** Drops one client's id; with nobody left to answer, the verb fails. */
|
|
1847
|
-
stopWaiting(verb, id) {
|
|
1848
|
-
verb.waiting.delete(id);
|
|
1849
|
-
this.pending.delete(id);
|
|
1850
|
-
if (verb.waiting.size === 0) this.settle(verb, verb.lastFailure ?? {
|
|
1851
|
-
ok: false,
|
|
1852
|
-
error: NOT_ATTACHED
|
|
1853
|
-
});
|
|
1854
|
-
}
|
|
1855
|
-
settle(verb, response) {
|
|
1856
|
-
for (const id of verb.waiting.keys()) this.pending.delete(id);
|
|
1857
|
-
verb.waiting.clear();
|
|
1858
|
-
clearTimeout(verb.timer);
|
|
1859
|
-
verb.resolve(response);
|
|
1860
|
-
}
|
|
1861
|
-
};
|
|
1862
|
-
//#endregion
|
|
1863
|
-
//#region src/server/http-json.ts
|
|
1864
|
-
const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
1865
|
-
var HttpJsonError = class extends Error {
|
|
1866
|
-
statusCode;
|
|
1867
|
-
constructor(message, statusCode) {
|
|
1868
|
-
super(message);
|
|
1869
|
-
this.statusCode = statusCode;
|
|
1870
|
-
this.name = "HttpJsonError";
|
|
1871
|
-
}
|
|
1872
|
-
};
|
|
1873
|
-
/** An error whose status and machine code the JSON routes hand straight back. */
|
|
1874
|
-
var ReviewServerError = class extends Error {
|
|
1875
|
-
statusCode;
|
|
1876
|
-
code;
|
|
1877
|
-
name = "ReviewServerError";
|
|
1878
|
-
constructor(message, statusCode, code) {
|
|
1879
|
-
super(message);
|
|
1880
|
-
this.statusCode = statusCode;
|
|
1881
|
-
this.code = code;
|
|
1882
|
-
}
|
|
1883
|
-
};
|
|
1884
|
-
//#endregion
|
|
1885
|
-
//#region src/server/hono-http.ts
|
|
1886
|
-
const H2_FORBIDDEN_HEADERS = [
|
|
1887
|
-
"connection",
|
|
1888
|
-
"keep-alive",
|
|
1889
|
-
"proxy-connection",
|
|
1890
|
-
"transfer-encoding",
|
|
1891
|
-
"upgrade"
|
|
1892
|
-
];
|
|
1893
|
-
function createNodeRequestListener(app) {
|
|
1894
|
-
return getRequestListener(async (request, env) => {
|
|
1895
|
-
const response = await app.fetch(request, env);
|
|
1896
|
-
if (env.incoming.httpVersionMajor !== 2 || !H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))) return response;
|
|
1897
|
-
const stripped = new Response(response.body, response);
|
|
1898
|
-
for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
|
|
1899
|
-
return stripped;
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
function jsonResponse(body, status, options = {}) {
|
|
1903
|
-
const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
|
|
1904
|
-
if (options.cacheControl) headers.set("cache-control", options.cacheControl);
|
|
1905
|
-
const serialized = JSON.stringify(body);
|
|
1906
|
-
return new Response(options.newline === false ? serialized : `${serialized}\n`, {
|
|
1907
|
-
status,
|
|
1908
|
-
headers
|
|
1909
|
-
});
|
|
1910
|
-
}
|
|
1911
|
-
function applyCorsHeaders(request, response) {
|
|
1912
|
-
const origin = request.headers.get("origin");
|
|
1913
|
-
if (origin) {
|
|
1914
|
-
response.headers.set("access-control-allow-origin", origin);
|
|
1915
|
-
const vary = response.headers.get("vary");
|
|
1916
|
-
const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
|
|
1917
|
-
if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
|
|
1918
|
-
}
|
|
1919
|
-
response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`);
|
|
1920
|
-
response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1921
|
-
response.headers.set("access-control-allow-private-network", "true");
|
|
1922
|
-
response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
|
|
1923
|
-
return response;
|
|
1924
|
-
}
|
|
1925
|
-
/** The allowed origin, headers and methods hold for the server's lifetime, so
|
|
1926
|
-
* the client may reuse a preflight. Chromium otherwise keeps one for 5 s, and
|
|
1927
|
-
* nearly every request paid a second round trip. Two hours is Chromium's cap. */
|
|
1928
|
-
const PREFLIGHT_MAX_AGE_SECONDS = 7200;
|
|
1929
|
-
function corsPreflightResponse(request) {
|
|
1930
|
-
const response = applyCorsHeaders(request, new Response(null, { status: 204 }));
|
|
1931
|
-
response.headers.set("access-control-max-age", String(PREFLIGHT_MAX_AGE_SECONDS));
|
|
1932
|
-
return response;
|
|
1933
|
-
}
|
|
1934
|
-
function isAuthorizedRequest(request, expectedToken) {
|
|
1935
|
-
const supplied = request.headers.get("x-review-token") ?? new URL(request.url).searchParams.get("token");
|
|
1936
|
-
if (!supplied) return false;
|
|
1937
|
-
const expected = Buffer.from(expectedToken);
|
|
1938
|
-
const actual = Buffer.from(supplied);
|
|
1939
|
-
return expected.length === actual.length && crypto$1.timingSafeEqual(expected, actual);
|
|
1940
|
-
}
|
|
1941
|
-
async function readBoundedRequestJson(request, maxBytes = DEFAULT_MAX_REQUEST_BYTES, emptyValue, options = {}) {
|
|
1942
|
-
assertJsonContentType(request, options);
|
|
1943
|
-
const contentLength = Number(request.headers.get("content-length"));
|
|
1944
|
-
if (Number.isFinite(contentLength) && contentLength > maxBytes) throw requestTooLarge(maxBytes);
|
|
1945
|
-
let body;
|
|
1946
|
-
try {
|
|
1947
|
-
body = request.body ? (await readBoundedStream(request.body, maxBytes)).toString("utf8") : "";
|
|
1948
|
-
} catch (error) {
|
|
1949
|
-
if (error instanceof StreamLimitError) throw requestTooLarge(maxBytes);
|
|
1950
|
-
throw error;
|
|
2235
|
+
this.clients.delete(writer);
|
|
2236
|
+
for (const [id, verb] of [...this.pending]) if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
|
|
2237
|
+
if (this.clients.size === 0) this.notifyAttached();
|
|
1951
2238
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2239
|
+
/** Drops one client's id; with nobody left to answer, the verb fails. */
|
|
2240
|
+
stopWaiting(verb, id) {
|
|
2241
|
+
verb.waiting.delete(id);
|
|
2242
|
+
this.pending.delete(id);
|
|
2243
|
+
if (verb.waiting.size === 0) this.settle(verb, verb.lastFailure ?? {
|
|
2244
|
+
ok: false,
|
|
2245
|
+
error: NOT_ATTACHED
|
|
2246
|
+
});
|
|
1957
2247
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
}
|
|
2248
|
+
settle(verb, response) {
|
|
2249
|
+
for (const id of verb.waiting.keys()) this.pending.delete(id);
|
|
2250
|
+
verb.waiting.clear();
|
|
2251
|
+
clearTimeout(verb.timer);
|
|
2252
|
+
verb.resolve(response);
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
1966
2255
|
//#endregion
|
|
1967
2256
|
//#region src/error-telemetry.ts
|
|
1968
2257
|
/**
|
|
@@ -2225,6 +2514,52 @@ async function captureSanitizedUiTelemetry(telemetry, request, name, properties,
|
|
|
2225
2514
|
}
|
|
2226
2515
|
}
|
|
2227
2516
|
//#endregion
|
|
2517
|
+
//#region src/review-api/animation-render.ts
|
|
2518
|
+
const renderQuerySchema = z.object({
|
|
2519
|
+
targetId: z.string().min(1),
|
|
2520
|
+
/** A frame key, or a clip's marker key; omitted, the first stop. */
|
|
2521
|
+
frame: z.string().min(1).optional()
|
|
2522
|
+
});
|
|
2523
|
+
/** What the Desktop answers: the stop it showed, and the figure's PNG. */
|
|
2524
|
+
const renderedFrameSchema = z.object({
|
|
2525
|
+
frame: z.object({
|
|
2526
|
+
key: z.string(),
|
|
2527
|
+
title: z.string(),
|
|
2528
|
+
position: z.number().int().nonnegative(),
|
|
2529
|
+
count: z.number().int().positive()
|
|
2530
|
+
}),
|
|
2531
|
+
scenario: z.string().optional(),
|
|
2532
|
+
/** The figure ran past the window, so the capture cuts it. */
|
|
2533
|
+
clipped: z.boolean(),
|
|
2534
|
+
/** The error the stage reported, if its script threw. */
|
|
2535
|
+
error: z.string().nullable(),
|
|
2536
|
+
/** The capture, base64. */
|
|
2537
|
+
png: z.string().min(1)
|
|
2538
|
+
});
|
|
2539
|
+
const isStageBlock = (element) => element.type === "animation" || element.type === "clip";
|
|
2540
|
+
/** The stop to render: `frame` checked against the block's frames or markers, or
|
|
2541
|
+
* the first stop of the first scenario when omitted. */
|
|
2542
|
+
function renderTarget(snapshot, targetId, frame) {
|
|
2543
|
+
const target = elements(snapshot.document).find((element) => element.id === targetId);
|
|
2544
|
+
if (!target) throw new ReviewInputError("Target not found in this version.", 404);
|
|
2545
|
+
if (!isStageBlock(target)) throw new ReviewInputError(`${targetId} is a ${target.type}; only an animation or a clip renders a frame.`);
|
|
2546
|
+
const noun = target.type === "clip" ? "marker" : "frame";
|
|
2547
|
+
const keys = target.type === "clip" ? target.markers.map((marker) => marker.key) : target.frames.map((item) => item.key);
|
|
2548
|
+
if (frame === void 0) {
|
|
2549
|
+
const first = target.type === "animation" ? target.scenarios?.[0]?.frames[0] ?? keys[0] : keys[0];
|
|
2550
|
+
if (first === void 0) throw new ReviewInputError(`${targetId} has no ${noun}s to render.`);
|
|
2551
|
+
return {
|
|
2552
|
+
type: target.type,
|
|
2553
|
+
key: first
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
if (!keys.includes(frame)) throw new ReviewInputError(`${frame} is not a ${noun} of ${targetId}. Its ${noun}s: ${keys.join(", ")}.`);
|
|
2557
|
+
return {
|
|
2558
|
+
type: target.type,
|
|
2559
|
+
key: frame
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
//#endregion
|
|
2228
2563
|
//#region src/ask/checkout-files.ts
|
|
2229
2564
|
/** The checkout's files relative to its root: tracked ones, and new ones git
|
|
2230
2565
|
* does not ignore. None where git cannot list them. */
|
|
@@ -2579,259 +2914,6 @@ async function defaultRunGitHubApi(host, endpoint) {
|
|
|
2579
2914
|
return stdout;
|
|
2580
2915
|
}
|
|
2581
2916
|
//#endregion
|
|
2582
|
-
//#region src/sharing/auth.ts
|
|
2583
|
-
/** CI credentials stay in the server environment and are never persisted to a profile. */
|
|
2584
|
-
async function readSharingAuth(env = process.env) {
|
|
2585
|
-
if (env.DEV_REVIEW_SHARE_TOKEN === void 0) {
|
|
2586
|
-
if (env.DEV_REVIEW_SHARE_ORIGIN !== void 0) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN requires DEV_REVIEW_SHARE_TOKEN.");
|
|
2587
|
-
return readStoreAuth(env);
|
|
2588
|
-
}
|
|
2589
|
-
const token = env.DEV_REVIEW_SHARE_TOKEN.trim();
|
|
2590
|
-
if (!token) throw new ReviewInputError("DEV_REVIEW_SHARE_TOKEN is empty.");
|
|
2591
|
-
let url;
|
|
2592
|
-
try {
|
|
2593
|
-
url = new URL(env.DEV_REVIEW_SHARE_ORIGIN ?? DEFAULT_STORE_ORIGIN);
|
|
2594
|
-
} catch {
|
|
2595
|
-
throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
2596
|
-
}
|
|
2597
|
-
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
|
|
2598
|
-
return {
|
|
2599
|
-
origin: url.origin,
|
|
2600
|
-
token
|
|
2601
|
-
};
|
|
2602
|
-
}
|
|
2603
|
-
//#endregion
|
|
2604
|
-
//#region src/slug.ts
|
|
2605
|
-
/** Lowercase ASCII slug: runs of anything but `a-z0-9` become one `-`, outer
|
|
2606
|
-
* dashes go. Empty input, or input with no letters or digits, yields `""`;
|
|
2607
|
-
* callers choose their own fallback (`"section"`, `"diagram"`, `"database"`).
|
|
2608
|
-
* This is the rule sequence and lens ids were already published with, so it
|
|
2609
|
-
* must not change. */
|
|
2610
|
-
function slugify(text) {
|
|
2611
|
-
return text.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2612
|
-
}
|
|
2613
|
-
//#endregion
|
|
2614
|
-
//#region src/sharing/file.ts
|
|
2615
|
-
const WHITEBOARD_FILE_EXTENSION = ".whiteboard";
|
|
2616
|
-
const headerSchema = z.looseObject({ format: z.string() });
|
|
2617
|
-
function whiteboardFileName(title) {
|
|
2618
|
-
return `${slugify(title) || "untitled"}${WHITEBOARD_FILE_EXTENSION}`;
|
|
2619
|
-
}
|
|
2620
|
-
function whiteboardFileText(bundle) {
|
|
2621
|
-
return JSON.stringify(bundle, null, 2) + "\n";
|
|
2622
|
-
}
|
|
2623
|
-
function parseWhiteboardFile(text) {
|
|
2624
|
-
let parsed;
|
|
2625
|
-
try {
|
|
2626
|
-
parsed = JSON.parse(text);
|
|
2627
|
-
} catch {
|
|
2628
|
-
parsed = void 0;
|
|
2629
|
-
}
|
|
2630
|
-
const header = headerSchema.safeParse(parsed);
|
|
2631
|
-
if (!header.success) throw new ReviewInputError("This is not a Whiteboard file.", 400);
|
|
2632
|
-
if (header.data.format !== "review-share/2") throw new ReviewInputError("This file needs a different Whiteboard version.", 400);
|
|
2633
|
-
return shareBundleSchema.parse(parsed);
|
|
2634
|
-
}
|
|
2635
|
-
function whiteboardFileKey(text) {
|
|
2636
|
-
return createHash("sha256").update("whiteboard-file\0").update(text).digest("hex");
|
|
2637
|
-
}
|
|
2638
|
-
//#endregion
|
|
2639
|
-
//#region src/sharing/host.ts
|
|
2640
|
-
const publishSchema = z.strictObject({
|
|
2641
|
-
reviewId: z.string().min(1),
|
|
2642
|
-
version: z.number().int().nonnegative().optional(),
|
|
2643
|
-
requestId: z.uuid().optional()
|
|
2644
|
-
});
|
|
2645
|
-
const importSchema = z.union([z.strictObject({ url: z.string().max(2048) }), z.strictObject({ path: z.string().min(1).max(4096) })]);
|
|
2646
|
-
/** Mounted behind local host authentication. Account credentials never enter the renderer. */
|
|
2647
|
-
function mountSharingHost(app, store, data, options = {}) {
|
|
2648
|
-
const startLogin = options.login ?? runStoreLogin;
|
|
2649
|
-
let login = { pending: false };
|
|
2650
|
-
let warmedAt = 0;
|
|
2651
|
-
const readAccount = async () => {
|
|
2652
|
-
const account = await readStoreAuth();
|
|
2653
|
-
if (account && Date.now() - warmedAt > 6e4) {
|
|
2654
|
-
warmedAt = Date.now();
|
|
2655
|
-
new ShareClient(account.origin, account.token, options.fetch).warm().catch(() => {});
|
|
2656
|
-
}
|
|
2657
|
-
return {
|
|
2658
|
-
account: account ? {
|
|
2659
|
-
login: account.login,
|
|
2660
|
-
origin: account.origin,
|
|
2661
|
-
email: account.email,
|
|
2662
|
-
githubLogin: account.githubLogin
|
|
2663
|
-
} : null,
|
|
2664
|
-
...login
|
|
2665
|
-
};
|
|
2666
|
-
};
|
|
2667
|
-
app.get("/sharing/account", async (context) => context.json(await readAccount()));
|
|
2668
|
-
app.post("/sharing/logout", async (context) => {
|
|
2669
|
-
await clearStoreAuth();
|
|
2670
|
-
return context.json(await readAccount());
|
|
2671
|
-
});
|
|
2672
|
-
app.post("/sharing/login", async (context) => {
|
|
2673
|
-
if (!login.pending) {
|
|
2674
|
-
login = { pending: true };
|
|
2675
|
-
options.onLogin?.("started");
|
|
2676
|
-
const discard = new Writable({ write(_chunk, _encoding, done) {
|
|
2677
|
-
done();
|
|
2678
|
-
} });
|
|
2679
|
-
startLogin({
|
|
2680
|
-
traces: false,
|
|
2681
|
-
stdout: discard,
|
|
2682
|
-
stderr: discard,
|
|
2683
|
-
openUrl: options.openUrl,
|
|
2684
|
-
onDeviceCode: (device) => {
|
|
2685
|
-
Object.assign(login, device);
|
|
2686
|
-
}
|
|
2687
|
-
}).then((code) => {
|
|
2688
|
-
login = { pending: false };
|
|
2689
|
-
if (code) login.error = "Sign-in did not finish. Try again.";
|
|
2690
|
-
options.onLogin?.(code ? "failed" : "succeeded", code ? "did_not_finish" : void 0);
|
|
2691
|
-
}).catch(() => {
|
|
2692
|
-
login = {
|
|
2693
|
-
pending: false,
|
|
2694
|
-
error: "Sign-in failed. Try again."
|
|
2695
|
-
};
|
|
2696
|
-
options.onLogin?.("failed", "error");
|
|
2697
|
-
});
|
|
2698
|
-
}
|
|
2699
|
-
return context.json(login);
|
|
2700
|
-
});
|
|
2701
|
-
mountSharingPublisher(app, store, data, options);
|
|
2702
|
-
const imports = /* @__PURE__ */ new Map();
|
|
2703
|
-
let importing = Promise.resolve();
|
|
2704
|
-
app.get("/sharing/import/:id", (context) => {
|
|
2705
|
-
const id = context.req.param("id");
|
|
2706
|
-
const { reviewId = id, ...status } = imports.get(id) ?? {
|
|
2707
|
-
stage: "error",
|
|
2708
|
-
error: "Shared review is not available."
|
|
2709
|
-
};
|
|
2710
|
-
return context.json({
|
|
2711
|
-
reviewId,
|
|
2712
|
-
...status,
|
|
2713
|
-
...status.stage === "ready" && { title: store.read(reviewId).title }
|
|
2714
|
-
});
|
|
2715
|
-
});
|
|
2716
|
-
const startImport = (id, initial, load) => {
|
|
2717
|
-
const known = imports.get(id)?.stage;
|
|
2718
|
-
if (!known || known === "ready" || known === "error") {
|
|
2719
|
-
const copy = store.copyOf(id);
|
|
2720
|
-
const status = copy ? {
|
|
2721
|
-
stage: "ready",
|
|
2722
|
-
reviewId: copy
|
|
2723
|
-
} : { stage: initial };
|
|
2724
|
-
imports.set(id, status);
|
|
2725
|
-
if (!copy) importing = importing.then(async () => {
|
|
2726
|
-
const { bundle, attribution } = await load();
|
|
2727
|
-
status.reviewId = await importShare({
|
|
2728
|
-
store,
|
|
2729
|
-
data,
|
|
2730
|
-
fetchRepository: options.fetchRepository,
|
|
2731
|
-
cloneRepository: options.cloneRepository,
|
|
2732
|
-
onStage: (stage) => {
|
|
2733
|
-
status.stage = stage;
|
|
2734
|
-
}
|
|
2735
|
-
}, id, bundle, attribution);
|
|
2736
|
-
status.stage = "ready";
|
|
2737
|
-
}).catch((error) => {
|
|
2738
|
-
status.stage = "error";
|
|
2739
|
-
status.error = error instanceof ReviewInputError ? error.message : "This share is unavailable, revoked, or needs a newer Whiteboard version.";
|
|
2740
|
-
});
|
|
2741
|
-
}
|
|
2742
|
-
const { reviewId: _reviewId, ...status } = imports.get(id);
|
|
2743
|
-
return {
|
|
2744
|
-
reviewId: id,
|
|
2745
|
-
...status
|
|
2746
|
-
};
|
|
2747
|
-
};
|
|
2748
|
-
app.post("/sharing/import", async (context) => {
|
|
2749
|
-
const input = importSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
2750
|
-
if ("path" in input) {
|
|
2751
|
-
if (!path.isAbsolute(input.path)) throw new ReviewInputError("Use an absolute file path.", 400);
|
|
2752
|
-
const text = await readFile(input.path, "utf8").catch(() => {
|
|
2753
|
-
throw new ReviewInputError(`Could not read ${path.basename(input.path)}. Check that the file still exists and Whiteboard can open it.`, 400);
|
|
2754
|
-
});
|
|
2755
|
-
return context.json(startImport(whiteboardFileKey(text), "reading", async () => ({ bundle: parseWhiteboardFile(text) })), 202);
|
|
2756
|
-
}
|
|
2757
|
-
const parsed = parseShareLink(input.url);
|
|
2758
|
-
const account = await readStoreAuth();
|
|
2759
|
-
if (parsed.origin !== DEFAULT_STORE_ORIGIN && parsed.origin !== account?.origin) throw new ReviewInputError("This share uses an untrusted service. Sign in to that service before opening its links.", 400);
|
|
2760
|
-
return context.json(startImport(shareKey(parsed.origin, parsed.capability), "downloading", () => new ShareClient(parsed.origin).download(parsed.capability)), 202);
|
|
2761
|
-
});
|
|
2762
|
-
}
|
|
2763
|
-
/** Publishing needs only an authored store, never Desktop or recipient workspaces. */
|
|
2764
|
-
function mountSharingPublisher(app, store, data, options = {}) {
|
|
2765
|
-
const shareable = (input) => {
|
|
2766
|
-
const snapshot = store.read(input.reviewId, input.version);
|
|
2767
|
-
if (snapshot.target?.kind === "worktree") throw new ReviewInputError("Pin this review to commits before sharing it.");
|
|
2768
|
-
validateShareSources(snapshot);
|
|
2769
|
-
return snapshot;
|
|
2770
|
-
};
|
|
2771
|
-
const shareBundle = async (snapshot) => {
|
|
2772
|
-
const repositoryId = snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
|
|
2773
|
-
const root = repositoryId ? store.repositoryPath(repositoryId) : void 0;
|
|
2774
|
-
const repository = root ? (options.readRepository ?? readShareRepository)(root).catch(() => void 0) : void 0;
|
|
2775
|
-
const bundle = await exportShare({
|
|
2776
|
-
store,
|
|
2777
|
-
data,
|
|
2778
|
-
reviewId: snapshot.reviewId,
|
|
2779
|
-
version: snapshot.version
|
|
2780
|
-
});
|
|
2781
|
-
const verified = await repository;
|
|
2782
|
-
if (verified) bundle.repository = verified;
|
|
2783
|
-
return bundle;
|
|
2784
|
-
};
|
|
2785
|
-
app.post("/sharing/export", async (context) => {
|
|
2786
|
-
const snapshot = shareable(publishSchema.parse(await readBoundedRequestJson(context.req.raw)));
|
|
2787
|
-
const bundle = await shareBundle(snapshot);
|
|
2788
|
-
if (!bundle.repository && needsRepository(bundle, snapshot)) throw new ReviewInputError("Exporting pinned code needs a GitHub remote the file's reader can fetch from.");
|
|
2789
|
-
return context.json({
|
|
2790
|
-
filename: whiteboardFileName(snapshot.title),
|
|
2791
|
-
text: whiteboardFileText(bundle)
|
|
2792
|
-
});
|
|
2793
|
-
});
|
|
2794
|
-
app.post("/sharing/publish", async (context) => {
|
|
2795
|
-
const input = publishSchema.parse(await readBoundedRequestJson(context.req.raw));
|
|
2796
|
-
const snapshot = shareable(input);
|
|
2797
|
-
const account = await readSharingAuth();
|
|
2798
|
-
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login before sharing.", 409);
|
|
2799
|
-
const bundle = await shareBundle(snapshot);
|
|
2800
|
-
try {
|
|
2801
|
-
const result = await new ShareClient(account.origin, account.token, options.fetch).create(bundle, input.requestId ?? randomUUID());
|
|
2802
|
-
const url = new URL(result.url);
|
|
2803
|
-
if (process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview") url.searchParams.set("app", "preview");
|
|
2804
|
-
options.onPublished?.({
|
|
2805
|
-
reviewId: input.reviewId,
|
|
2806
|
-
version: snapshot.version
|
|
2807
|
-
});
|
|
2808
|
-
return context.json({
|
|
2809
|
-
...result,
|
|
2810
|
-
url: url.href,
|
|
2811
|
-
version: snapshot.version
|
|
2812
|
-
});
|
|
2813
|
-
} catch (error) {
|
|
2814
|
-
if (error instanceof ReviewInputError) throw error;
|
|
2815
|
-
if (error instanceof ShareAuthError) {
|
|
2816
|
-
if (process.env.DEV_REVIEW_SHARE_TOKEN === void 0) await clearStoreAuth();
|
|
2817
|
-
throw new ReviewInputError("Your sign-in has expired. Sign in again to share.", 401);
|
|
2818
|
-
}
|
|
2819
|
-
throw new ReviewInputError("Sharing failed. Check your connection and login, then retry.", 409);
|
|
2820
|
-
}
|
|
2821
|
-
});
|
|
2822
|
-
app.post("/sharing/revoke", async (context) => {
|
|
2823
|
-
const { shareId } = z.strictObject({ shareId: shareIdSchema }).parse(await readBoundedRequestJson(context.req.raw));
|
|
2824
|
-
const account = await readSharingAuth();
|
|
2825
|
-
if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.", 409);
|
|
2826
|
-
await new ShareClient(account.origin, account.token).revoke(shareId);
|
|
2827
|
-
options.onRevoked?.({ shareId });
|
|
2828
|
-
return context.json({
|
|
2829
|
-
shareId,
|
|
2830
|
-
revoked: true
|
|
2831
|
-
});
|
|
2832
|
-
});
|
|
2833
|
-
}
|
|
2834
|
-
//#endregion
|
|
2835
2917
|
//#region src/review-api/anchor-quotes.ts
|
|
2836
2918
|
const QUOTE_LIMIT = 20;
|
|
2837
2919
|
const clip = (line) => line.trim() === "" ? "(blank line)" : line.length > 120 ? `${line.slice(0, 119)}…` : line;
|
|
@@ -2971,12 +3053,21 @@ function documentText(snapshot, targetId, full = false, relations, runs) {
|
|
|
2971
3053
|
for (const edge of element.edges) detail(`[${edge.id}] ${edge.from} → ${edge.to}${edge.label ? `: ${edge.label}` : ""}`);
|
|
2972
3054
|
break;
|
|
2973
3055
|
case "animation":
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
3056
|
+
case "clip":
|
|
3057
|
+
if (element.type === "animation") {
|
|
3058
|
+
for (const [index, frame] of element.frames.entries()) {
|
|
3059
|
+
detail(`Frame ${index + 1} [${frame.key}]: ${frame.title}${frame.caption ? ` — ${frame.caption}` : ""}`);
|
|
3060
|
+
if (frame.code) write(depth + 2, `Code: ${frame.code.join(", ")}`);
|
|
3061
|
+
if (detailed && frame.data !== void 0) write(depth + 2, `Data: ${JSON.stringify(frame.data)}`);
|
|
3062
|
+
}
|
|
3063
|
+
for (const scenario of element.scenarios ?? []) detail(`Scenario [${scenario.key}] ${scenario.label}: ${scenario.frames.join(" → ")}`);
|
|
3064
|
+
} else {
|
|
3065
|
+
for (const [index, marker] of element.markers.entries()) {
|
|
3066
|
+
detail(`Marker ${index + 1} [${marker.key}]: ${marker.title}${marker.caption ? ` — ${marker.caption}` : ""}`);
|
|
3067
|
+
if (marker.code) write(depth + 2, `Code: ${marker.code.join(", ")}`);
|
|
3068
|
+
}
|
|
3069
|
+
for (const scenario of element.scenarios ?? []) detail(`Scenario [${scenario.key}] ${scenario.label}`);
|
|
2978
3070
|
}
|
|
2979
|
-
for (const scenario of element.scenarios ?? []) detail(`Scenario [${scenario.key}] ${scenario.label}: ${scenario.frames.join(" → ")}`);
|
|
2980
3071
|
if (runs) {
|
|
2981
3072
|
const run = element.id === void 0 ? void 0 : runs.get(element.id);
|
|
2982
3073
|
write(depth + 1, run?.error ? `Script error: ${run.error}` : run ? "Ran in an open whiteboard without a script error." : "Not run yet: its script runs only where the whiteboard is open.");
|
|
@@ -3553,7 +3644,7 @@ const remoteCaller = (context) => context.req.header(REVIEW_CLIENT_HEADER) === R
|
|
|
3553
3644
|
function createReviewApi(store, data, open, sharing = false, capabilities = () => ({
|
|
3554
3645
|
desktopAvailable: Boolean(open),
|
|
3555
3646
|
softwareMapEnabled: false
|
|
3556
|
-
}), traceEnabled = async () => false, status = () => ({}), hooks = {}, ask, animationsEnabled = () => false) {
|
|
3647
|
+
}), traceEnabled = async () => false, status = () => ({}), hooks = {}, ask, animationsEnabled = () => false, render) {
|
|
3557
3648
|
const app = new Hono();
|
|
3558
3649
|
app.onError((error, context) => {
|
|
3559
3650
|
if (error instanceof HttpJsonError) return context.json({ error: error.message }, error.statusCode);
|
|
@@ -4561,6 +4652,23 @@ function createReviewApi(store, data, open, sharing = false, capabilities = () =
|
|
|
4561
4652
|
...listedRelations(store.relations(id))
|
|
4562
4653
|
});
|
|
4563
4654
|
});
|
|
4655
|
+
app.get("/:id/render", async (context) => {
|
|
4656
|
+
const query = renderQuerySchema.parse(context.req.query());
|
|
4657
|
+
const id = context.req.param("id");
|
|
4658
|
+
const target = renderTarget(readReview(id), query.targetId, query.frame);
|
|
4659
|
+
if (!render) throw new ReviewInputError("Rendering a frame needs Whiteboard Desktop, which this server cannot reach.", 409);
|
|
4660
|
+
const rendered = await render({
|
|
4661
|
+
reviewId: id,
|
|
4662
|
+
targetId: query.targetId,
|
|
4663
|
+
frame: target.key
|
|
4664
|
+
});
|
|
4665
|
+
return context.json({
|
|
4666
|
+
reviewId: id,
|
|
4667
|
+
targetId: query.targetId,
|
|
4668
|
+
type: target.type,
|
|
4669
|
+
...rendered
|
|
4670
|
+
});
|
|
4671
|
+
});
|
|
4564
4672
|
app.get("/:id", async (context) => {
|
|
4565
4673
|
const query = readQuerySchemas.get.parse(context.req.query());
|
|
4566
4674
|
let snapshot = { ...readReview(context.req.param("id"), query.version) };
|
|
@@ -4594,7 +4702,8 @@ function createReviewApi(store, data, open, sharing = false, capabilities = () =
|
|
|
4594
4702
|
const { edit, reviewId } = input.operation;
|
|
4595
4703
|
const incoming = "content" in edit ? elements([edit.content]) : [];
|
|
4596
4704
|
const target = "targetId" in edit ? elements(readReview(reviewId).document).find((element) => element.id === edit.targetId) : void 0;
|
|
4597
|
-
|
|
4705
|
+
const animated = (type) => type === "animation" || type === "clip";
|
|
4706
|
+
if (incoming.some((element) => animated(element.type)) || animated(target?.type)) throw new ReviewInputError("Animation authoring is disabled. Enable Animations in Settings under Experimental Features to edit animations and clips. Existing ones can still be viewed.", 409);
|
|
4598
4707
|
}
|
|
4599
4708
|
const threadId = remoteCaller(context) ? void 0 : context.req.header("x-review-thread")?.trim() || void 0;
|
|
4600
4709
|
const author = !remoteCaller(context) && context.req.header("x-review-person") !== void 0 ? { kind: "person" } : {
|
|
@@ -4605,7 +4714,7 @@ function createReviewApi(store, data, open, sharing = false, capabilities = () =
|
|
|
4605
4714
|
const result = await store.execute(input, void 0, author);
|
|
4606
4715
|
const key = copy?.shared?.key;
|
|
4607
4716
|
const kept = copy && boardRepositoryId(copy);
|
|
4608
|
-
if (result.deleted && key && kept) await Promise.resolve().then(() => forgetSharedCommits(store.repositoryPath(kept), key)).catch((error) => console.error("Could not drop a copy's kept commits:", error));
|
|
4717
|
+
if (result.deleted && key && kept && !store.copiesOf(key).some(({ reviewId }) => boardRepositoryId(store.read(reviewId)) === kept)) await Promise.resolve().then(() => forgetSharedCommits(store.repositoryPath(kept), key)).catch((error) => console.error("Could not drop a copy's kept commits:", error));
|
|
4609
4718
|
if (input.operation.type === "edit" && data && !result.deleted) {
|
|
4610
4719
|
const { quotes, unquoted } = await anchorQuotes(result.version > 0 ? store.read(result.reviewId, result.version - 1) : void 0, store.read(result.reviewId, result.version), async (pins, side, file) => (await data.file(pins, side, file)).text);
|
|
4611
4720
|
return context.json({
|
|
@@ -4902,7 +5011,7 @@ function createReviewServerApp(input) {
|
|
|
4902
5011
|
app.get("/control", (context) => openControlEvents(context, input.relay));
|
|
4903
5012
|
mountFeedback(app);
|
|
4904
5013
|
app.post("/control/result", async (context) => {
|
|
4905
|
-
const accepted = input.relay.acceptResult(await readBoundedRequestJson(context.req.raw));
|
|
5014
|
+
const accepted = input.relay.acceptResult(await readBoundedRequestJson(context.req.raw, VERB_RESULT_MAX_BYTES));
|
|
4906
5015
|
return serverJson(accepted ? 200 : 404, { ok: accepted });
|
|
4907
5016
|
});
|
|
4908
5017
|
answerErrorsAsJson(app);
|
|
@@ -4926,6 +5035,9 @@ function answerErrorsAsJson(app) {
|
|
|
4926
5035
|
});
|
|
4927
5036
|
});
|
|
4928
5037
|
}
|
|
5038
|
+
/** A verb result may carry a captured frame: a PNG of up to 1600 pixels on
|
|
5039
|
+
* its longest edge, base64. */
|
|
5040
|
+
const VERB_RESULT_MAX_BYTES = 16777216;
|
|
4929
5041
|
function createWhiteboardCore(input) {
|
|
4930
5042
|
const { store, data } = input.profile;
|
|
4931
5043
|
const app = createReviewServerApp({
|
|
@@ -4934,7 +5046,7 @@ function createWhiteboardCore(input) {
|
|
|
4934
5046
|
serverId: store.serverId(),
|
|
4935
5047
|
relay: input.relay
|
|
4936
5048
|
});
|
|
4937
|
-
const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
|
|
5049
|
+
const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled, input.animations);
|
|
4938
5050
|
const { ask } = input;
|
|
4939
5051
|
const launch = ask?.launch ?? launchAskAgent;
|
|
4940
5052
|
const askThreads = ask && new AskThreads(async (agent, cwd, options) => launch(agent, cwd, {
|
|
@@ -4951,21 +5063,54 @@ function createWhiteboardCore(input) {
|
|
|
4951
5063
|
const api = createReviewApi(store, data, callbacks.open, input.sharing, callbacks.capabilities, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
|
|
4952
5064
|
threads: askThreads,
|
|
4953
5065
|
agents: async () => detectAskAgents(await ask?.env)
|
|
4954
|
-
},
|
|
5066
|
+
}, callbacks.animations, callbacks.render);
|
|
4955
5067
|
if (!input.sharing) mountSharingPublisher(api, store, data);
|
|
4956
5068
|
return {
|
|
4957
5069
|
app,
|
|
4958
5070
|
api,
|
|
4959
5071
|
close: () => {
|
|
5072
|
+
callbacks.close();
|
|
4960
5073
|
stopLocal?.();
|
|
4961
5074
|
closeDeletedThreads();
|
|
4962
5075
|
askThreads?.closeAll();
|
|
4963
5076
|
}
|
|
4964
5077
|
};
|
|
4965
5078
|
}
|
|
4966
|
-
|
|
4967
|
-
|
|
5079
|
+
const capabilitiesAnswerSchema = z.object({
|
|
5080
|
+
softwareMapEnabled: z.boolean(),
|
|
5081
|
+
animationsEnabled: z.boolean().optional()
|
|
5082
|
+
});
|
|
5083
|
+
/**
|
|
5084
|
+
* The Desktop callbacks `createReviewApi` takes, answered over the relay.
|
|
5085
|
+
* `animations` never asks Desktop: it is the last answer while one is
|
|
5086
|
+
* attached, else the host's own.
|
|
5087
|
+
*/
|
|
5088
|
+
function relayReviewCallbacks(relay, softwareMapEnabled = false, animations = () => false) {
|
|
5089
|
+
let desktopAnimations;
|
|
5090
|
+
const capabilities = async () => {
|
|
5091
|
+
if (!relay.attached) return {
|
|
5092
|
+
desktopAvailable: false,
|
|
5093
|
+
softwareMapEnabled
|
|
5094
|
+
};
|
|
5095
|
+
const result = await relay.dispatch({
|
|
5096
|
+
name: "authoringCapabilities",
|
|
5097
|
+
args: {}
|
|
5098
|
+
});
|
|
5099
|
+
if (!result.ok) throw new ReviewInputError(result.error, 409);
|
|
5100
|
+
const answer = capabilitiesAnswerSchema.parse(result.result);
|
|
5101
|
+
desktopAnimations = answer.animationsEnabled;
|
|
5102
|
+
return {
|
|
5103
|
+
desktopAvailable: true,
|
|
5104
|
+
softwareMapEnabled: answer.softwareMapEnabled
|
|
5105
|
+
};
|
|
5106
|
+
};
|
|
4968
5107
|
return {
|
|
5108
|
+
capabilities,
|
|
5109
|
+
animations: () => desktopAnimations ?? animations(),
|
|
5110
|
+
close: relay.onAttachedChange(() => {
|
|
5111
|
+
desktopAnimations = void 0;
|
|
5112
|
+
if (relay.attached) capabilities().catch(() => {});
|
|
5113
|
+
}),
|
|
4969
5114
|
async open(review) {
|
|
4970
5115
|
const result = await relay.dispatch({
|
|
4971
5116
|
name: "openApiReview",
|
|
@@ -4974,20 +5119,19 @@ function relayReviewCallbacks(relay, softwareMapEnabled = false) {
|
|
|
4974
5119
|
if (!result.ok) throw new ReviewInputError(result.error, 409);
|
|
4975
5120
|
return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
|
|
4976
5121
|
},
|
|
4977
|
-
async
|
|
4978
|
-
if (!relay.attached)
|
|
4979
|
-
|
|
4980
|
-
softwareMapEnabled
|
|
4981
|
-
};
|
|
5122
|
+
async render(request) {
|
|
5123
|
+
if (!relay.attached) throw new ReviewInputError("No Whiteboard Desktop is attached, so nothing can show the animation. Open the whiteboard in Desktop and render again.", 409);
|
|
5124
|
+
const { reviewId, targetId, frame } = request;
|
|
4982
5125
|
const result = await relay.dispatch({
|
|
4983
|
-
name: "
|
|
4984
|
-
args: {
|
|
5126
|
+
name: "renderAnimation",
|
|
5127
|
+
args: {
|
|
5128
|
+
reviewId,
|
|
5129
|
+
targetId,
|
|
5130
|
+
frame
|
|
5131
|
+
}
|
|
4985
5132
|
});
|
|
4986
5133
|
if (!result.ok) throw new ReviewInputError(result.error, 409);
|
|
4987
|
-
return
|
|
4988
|
-
desktopAvailable: true,
|
|
4989
|
-
...z.object({ softwareMapEnabled: z.boolean() }).parse(result.result)
|
|
4990
|
-
};
|
|
5134
|
+
return renderedFrameSchema.parse(result.result);
|
|
4991
5135
|
}
|
|
4992
5136
|
};
|
|
4993
5137
|
}
|
|
@@ -5220,6 +5364,7 @@ async function serve(input) {
|
|
|
5220
5364
|
await migrateDiffrConfig(input.signal);
|
|
5221
5365
|
if (input.telemetry) await drainServerCrashReport(input.telemetry);
|
|
5222
5366
|
const local = await openReviewProfile(input.stateDir, { manageWorkspaces: true });
|
|
5367
|
+
const animationsEnabled = await readAnimationsEnabled(input.stateDir);
|
|
5223
5368
|
const discovery = {
|
|
5224
5369
|
version: 1,
|
|
5225
5370
|
instanceId: randomUUID(),
|
|
@@ -5236,6 +5381,7 @@ async function serve(input) {
|
|
|
5236
5381
|
token: discovery.token,
|
|
5237
5382
|
instanceId: discovery.instanceId,
|
|
5238
5383
|
softwareMapEnabled: input.softwareMapEnabled,
|
|
5384
|
+
animations: () => animationsEnabled,
|
|
5239
5385
|
status: () => ({
|
|
5240
5386
|
key: "headless",
|
|
5241
5387
|
home: input.stateDir
|
|
@@ -5285,4 +5431,4 @@ async function serve(input) {
|
|
|
5285
5431
|
}
|
|
5286
5432
|
}
|
|
5287
5433
|
//#endregion
|
|
5288
|
-
export {
|
|
5434
|
+
export { DEFAULT_MAX_REQUEST_BYTES as C, writeAnimationsEnabled as D, readAnimationsEnabled as E, cliAskTools as O, readBoundedRequestJson as S, ReviewServerError as T, saveDiffrSummarizer as _, drainServerCrashReport as a, remoteShare as b, answerErrorsAsJson as c, captureSanitizedUiTelemetry as d, clientErrorsForSession as f, readDiffrConfig as g, migrateDiffrConfig as h, withHeadlessServerLock as i, createWhiteboardCore as l, GlobalReviewDesktopVerbRelay as m, runHeadlessServer as n, installProcessErrorTelemetry as o, recordClientError as p, takeOverServerHome as r, serverCrashReportPath as s, HeadlessServerBusyError as t, serverJson as u, setDiffrConfigValue as v, HttpJsonError as w, createNodeRequestListener as x, testDiffrSummarizer as y };
|