@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
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/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { type JsonValue, parseJsonText } from "@dev.fast/json";
|
|
4
|
+
import {
|
|
5
|
+
MAX_SHARE_MANIFEST_BYTES,
|
|
6
|
+
type ShareManifest,
|
|
7
|
+
shareManifestSchema,
|
|
8
|
+
} from "@dev.fast/review-share-protocol";
|
|
9
|
+
import {
|
|
10
|
+
StreamLimitError,
|
|
11
|
+
readBoundedStream,
|
|
12
|
+
} from "@review/server/bounded-stream.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import { type ShareBundle, digestBytes } from "./export.js";
|
|
16
|
+
import { validateShareBundle } from "./import.js";
|
|
17
|
+
|
|
18
|
+
const uploadSchema = z.strictObject({
|
|
19
|
+
url: z.url(),
|
|
20
|
+
headers: z.record(z.string(), z.string()),
|
|
21
|
+
expiresAt: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createdSchema = z.strictObject({
|
|
25
|
+
shareId: z.uuid(),
|
|
26
|
+
upload: uploadSchema,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const registeredSchema = z.object({
|
|
30
|
+
registered: z.literal(true),
|
|
31
|
+
uploads: z.record(z.string(), uploadSchema),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
35
|
+
|
|
36
|
+
// Presigned URLs and headers are larger than manifest object descriptors.
|
|
37
|
+
const MAX_UPLOAD_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
const linkSchema = z.strictObject({ shareId: z.uuid(), url: z.url() });
|
|
40
|
+
|
|
41
|
+
const receivedSchema = z.strictObject({
|
|
42
|
+
manifest: shareManifestSchema,
|
|
43
|
+
sender: z.strictObject({ login: z.string().min(1).max(256) }),
|
|
44
|
+
sharedAt: z.number(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const signedUrlSchema = z.url().refine((value) => {
|
|
48
|
+
const url = new URL(value);
|
|
49
|
+
|
|
50
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const downloadSchema = z.strictObject({ url: z.url(), expiresAt: z.string() });
|
|
54
|
+
|
|
55
|
+
/** Size is checked while reading, even if Content-Length is absent or false. */
|
|
56
|
+
export async function readBoundedBytes(
|
|
57
|
+
response: Response,
|
|
58
|
+
limit: number,
|
|
59
|
+
): Promise<Uint8Array> {
|
|
60
|
+
if (!response.ok || !response.body)
|
|
61
|
+
throw new Error(`Download failed (${response.status}).`);
|
|
62
|
+
const declared = response.headers.get("content-length");
|
|
63
|
+
|
|
64
|
+
if (declared && Number(declared) > limit) {
|
|
65
|
+
await response.body.cancel().catch(() => {});
|
|
66
|
+
throw new Error("Download exceeds its declared limit.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return await readBoundedStream(response.body, limit);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof StreamLimitError)
|
|
73
|
+
throw new Error("Download exceeds its declared limit.");
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The share service rejected the account token; the stored login is stale. */
|
|
79
|
+
export class ShareAuthError extends Error {
|
|
80
|
+
constructor() {
|
|
81
|
+
super("The share service rejected the stored login.");
|
|
82
|
+
this.name = "ShareAuthError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class SharePreflightError extends Error {
|
|
87
|
+
constructor(cause: unknown) {
|
|
88
|
+
super(
|
|
89
|
+
cause instanceof Error
|
|
90
|
+
? cause.message
|
|
91
|
+
: "Repository verification failed.",
|
|
92
|
+
{ cause },
|
|
93
|
+
);
|
|
94
|
+
this.name = "SharePreflightError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class ShareRequestError extends Error {
|
|
99
|
+
constructor(readonly status: number) {
|
|
100
|
+
super(`Share request failed (${status}).`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Account tokens go only to the configured API origin, never to object storage. */
|
|
105
|
+
export class ShareClient {
|
|
106
|
+
private readonly origin: string;
|
|
107
|
+
constructor(
|
|
108
|
+
origin: string,
|
|
109
|
+
private readonly token?: string,
|
|
110
|
+
private readonly send: typeof fetch = fetch,
|
|
111
|
+
) {
|
|
112
|
+
const url = new URL(origin);
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
url.username ||
|
|
116
|
+
url.password ||
|
|
117
|
+
(url.protocol !== "https:" &&
|
|
118
|
+
!(url.protocol === "http:" && url.hostname === "localhost"))
|
|
119
|
+
)
|
|
120
|
+
throw new Error("Use a secure share service origin.");
|
|
121
|
+
this.origin = url.origin;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async api(
|
|
125
|
+
route: string,
|
|
126
|
+
method = "GET",
|
|
127
|
+
body?: JsonValue,
|
|
128
|
+
capability?: string,
|
|
129
|
+
maxBytes = MAX_SHARE_MANIFEST_BYTES,
|
|
130
|
+
): Promise<JsonValue> {
|
|
131
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
132
|
+
|
|
133
|
+
if (capability) headers.set("x-review-share-token", capability);
|
|
134
|
+
else if (this.token) headers.set("authorization", `Bearer ${this.token}`);
|
|
135
|
+
|
|
136
|
+
const init: RequestInit = {
|
|
137
|
+
method,
|
|
138
|
+
redirect: "error",
|
|
139
|
+
signal: AbortSignal.timeout(60_000),
|
|
140
|
+
headers,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (body !== undefined) init.body = JSON.stringify(body);
|
|
144
|
+
const response = await this.send(this.origin + route, init);
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
await response.body?.cancel();
|
|
148
|
+
|
|
149
|
+
if (response.status === 401 && !capability) throw new ShareAuthError();
|
|
150
|
+
throw new ShareRequestError(response.status);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parseJsonText(
|
|
154
|
+
Buffer.from(await readBoundedBytes(response, maxBytes)).toString(),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async upload(
|
|
159
|
+
upload: z.infer<typeof uploadSchema>,
|
|
160
|
+
bytes: Uint8Array,
|
|
161
|
+
) {
|
|
162
|
+
if (!signedUrlSchema.safeParse(upload.url).success)
|
|
163
|
+
throw new Error("Invalid object upload URL.");
|
|
164
|
+
|
|
165
|
+
const response = await this.send(upload.url, {
|
|
166
|
+
method: "PUT",
|
|
167
|
+
headers: upload.headers,
|
|
168
|
+
body: Buffer.from(bytes),
|
|
169
|
+
redirect: "error",
|
|
170
|
+
signal: AbortSignal.timeout(120_000),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Conditional create can return 412 after a successful upload whose response
|
|
174
|
+
// was lost. Completion checks the actual stored digest before publication.
|
|
175
|
+
await response.body?.cancel();
|
|
176
|
+
|
|
177
|
+
if (!response.ok && response.status !== 412)
|
|
178
|
+
throw new Error(`Object upload failed (${response.status}).`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async create(
|
|
182
|
+
bundle: ShareBundle,
|
|
183
|
+
requestId: string = randomUUID(),
|
|
184
|
+
beforeUpload?: () => Promise<void>,
|
|
185
|
+
) {
|
|
186
|
+
validateShareBundle(bundle);
|
|
187
|
+
const manifestBytes = Buffer.from(JSON.stringify(bundle.manifest));
|
|
188
|
+
|
|
189
|
+
const created = createdSchema.parse(
|
|
190
|
+
await this.api("/api/shares", "POST", {
|
|
191
|
+
requestId,
|
|
192
|
+
manifest: {
|
|
193
|
+
size: manifestBytes.byteLength,
|
|
194
|
+
sha256: digestBytes(manifestBytes),
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const route = `/api/shares/${created.shareId}`;
|
|
200
|
+
await this.upload(created.upload, manifestBytes);
|
|
201
|
+
|
|
202
|
+
const { uploads } = registeredSchema.parse(
|
|
203
|
+
await this.api(
|
|
204
|
+
`${route}/manifest`,
|
|
205
|
+
"POST",
|
|
206
|
+
{},
|
|
207
|
+
undefined,
|
|
208
|
+
MAX_UPLOAD_RESPONSE_BYTES,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (bundle.manifest.objects.some(({ id }) => !uploads[id]))
|
|
214
|
+
throw new Error("The share service omitted an object upload URL.");
|
|
215
|
+
|
|
216
|
+
await beforeUpload?.();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// A retry may recover an already published share after a lost response.
|
|
219
|
+
await this.recoverLink(created.shareId)
|
|
220
|
+
.catch(async (failure) => {
|
|
221
|
+
if (failure instanceof ShareRequestError && failure.status === 409)
|
|
222
|
+
await this.revoke(created.shareId);
|
|
223
|
+
})
|
|
224
|
+
.catch(() => {});
|
|
225
|
+
throw new SharePreflightError(error);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (
|
|
229
|
+
let offset = 0;
|
|
230
|
+
offset < bundle.manifest.objects.length;
|
|
231
|
+
offset += UPLOAD_CONCURRENCY
|
|
232
|
+
) {
|
|
233
|
+
const results = await Promise.allSettled(
|
|
234
|
+
bundle.manifest.objects
|
|
235
|
+
.slice(offset, offset + UPLOAD_CONCURRENCY)
|
|
236
|
+
.map(async (object) => {
|
|
237
|
+
await this.upload(
|
|
238
|
+
uploads[object.id]!,
|
|
239
|
+
bundle.objects.get(object.id)!,
|
|
240
|
+
);
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
245
|
+
|
|
246
|
+
if (failed) throw failed.reason;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (
|
|
250
|
+
let batch = 0;
|
|
251
|
+
batch <= Math.ceil(bundle.manifest.objects.length / 32);
|
|
252
|
+
batch++
|
|
253
|
+
) {
|
|
254
|
+
const result = await this.api(`${route}/complete`, "POST", {});
|
|
255
|
+
|
|
256
|
+
if (linkSchema.safeParse(result).success) return linkSchema.parse(result);
|
|
257
|
+
z.strictObject({ complete: z.literal(false) }).parse(result);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new Error("Share verification did not finish.");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async list(cursor?: string) {
|
|
264
|
+
const query = cursor ? `?cursor=${z.uuid().parse(cursor)}` : "";
|
|
265
|
+
|
|
266
|
+
return this.api(`/api/shares${query}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async recoverLink(shareId: string) {
|
|
270
|
+
return linkSchema.parse(
|
|
271
|
+
await this.api(`/api/shares/${z.uuid().parse(shareId)}/link`),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async revoke(shareId: string) {
|
|
276
|
+
return this.api(`/api/shares/${z.uuid().parse(shareId)}`, "DELETE");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async download(shareId: string, capability: string): Promise<ShareBundle> {
|
|
280
|
+
const route = `/api/shared/${z.uuid().parse(shareId)}`;
|
|
281
|
+
|
|
282
|
+
const received = receivedSchema.parse(
|
|
283
|
+
await this.api(route, "GET", undefined, capability),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const manifest: ShareManifest = received.manifest;
|
|
287
|
+
const objects = new Map<string, Uint8Array>();
|
|
288
|
+
|
|
289
|
+
const download = async (object: ShareManifest["objects"][number]) => {
|
|
290
|
+
const signed = downloadSchema.parse(
|
|
291
|
+
await this.api(
|
|
292
|
+
`${route}/objects/${object.id}`,
|
|
293
|
+
"GET",
|
|
294
|
+
undefined,
|
|
295
|
+
capability,
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (!signedUrlSchema.safeParse(signed.url).success)
|
|
300
|
+
throw new Error("Invalid object download URL.");
|
|
301
|
+
|
|
302
|
+
const bytes = await readBoundedBytes(
|
|
303
|
+
await this.send(signed.url, {
|
|
304
|
+
redirect: "error",
|
|
305
|
+
signal: AbortSignal.timeout(120_000),
|
|
306
|
+
}),
|
|
307
|
+
object.size,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (
|
|
311
|
+
bytes.byteLength !== object.size ||
|
|
312
|
+
digestBytes(bytes) !== object.sha256
|
|
313
|
+
)
|
|
314
|
+
throw new Error("Downloaded share object failed its integrity check.");
|
|
315
|
+
objects.set(object.id, bytes);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
for (let start = 0; start < manifest.objects.length; start += 4)
|
|
319
|
+
await Promise.all(manifest.objects.slice(start, start + 4).map(download));
|
|
320
|
+
|
|
321
|
+
const bundle = {
|
|
322
|
+
manifest,
|
|
323
|
+
objects,
|
|
324
|
+
attribution: {
|
|
325
|
+
login: received.sender.login,
|
|
326
|
+
sharedAt: received.sharedAt,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Document validation happens once at the importer boundary.
|
|
331
|
+
|
|
332
|
+
return bundle;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MAX_SHARE_BYTES,
|
|
5
|
+
MAX_SHARE_MANIFEST_BYTES,
|
|
6
|
+
MAX_SHARE_OBJECT_BYTES,
|
|
7
|
+
SHARE_FORMAT,
|
|
8
|
+
type ShareManifest,
|
|
9
|
+
shareManifestSchema,
|
|
10
|
+
} from "@dev.fast/review-share-protocol";
|
|
11
|
+
import { sourceAnchors } from "@review/lens-selection.js";
|
|
12
|
+
import { markdownNodes, parseMarkdown } from "@review/markdown.js";
|
|
13
|
+
import { lensSelections } from "@review/review-api/diff-lenses.js";
|
|
14
|
+
import { ReviewInputError } from "@review/review-api/document.js";
|
|
15
|
+
import {
|
|
16
|
+
anchorPins,
|
|
17
|
+
checkReferences,
|
|
18
|
+
documentSchema,
|
|
19
|
+
elements,
|
|
20
|
+
hasCodeReferences,
|
|
21
|
+
resourceReference,
|
|
22
|
+
resourceReferences,
|
|
23
|
+
sourceReferences,
|
|
24
|
+
} from "@review/review-api/document.js";
|
|
25
|
+
import type { LocalReviewData } from "@review/review-api/local-data.js";
|
|
26
|
+
import type { ReviewStore, Snapshot } from "@review/review-api/store.js";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
29
|
+
export interface ShareBundle {
|
|
30
|
+
manifest: ShareManifest;
|
|
31
|
+
objects: Map<string, Uint8Array>;
|
|
32
|
+
attribution?: { login: string; sharedAt: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ShareExportStore = Pick<ReviewStore, "read" | "resource">;
|
|
36
|
+
|
|
37
|
+
export type ShareExportData = Pick<
|
|
38
|
+
LocalReviewData,
|
|
39
|
+
"map" | "validateResource" | "validateSource"
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
export function digestBytes(bytes: Uint8Array): string {
|
|
43
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateShareSources(
|
|
47
|
+
snapshot: Pick<Snapshot, "pins" | "document" | "lenses">,
|
|
48
|
+
) {
|
|
49
|
+
if (!snapshot.pins && hasCodeReferences(snapshot))
|
|
50
|
+
throw new ReviewInputError(
|
|
51
|
+
"Reviews with code references need source pins before sharing.",
|
|
52
|
+
409,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Freeze one stored version before any asynchronous source reads. */
|
|
57
|
+
export async function exportShare(input: {
|
|
58
|
+
store: ShareExportStore;
|
|
59
|
+
data: ShareExportData;
|
|
60
|
+
reviewId: string;
|
|
61
|
+
version?: number;
|
|
62
|
+
repository?: ShareManifest["repository"];
|
|
63
|
+
}): Promise<ShareBundle> {
|
|
64
|
+
// A share is the finished document: what edit last touched it stays home.
|
|
65
|
+
const {
|
|
66
|
+
target,
|
|
67
|
+
kind: _kind,
|
|
68
|
+
staleSources: _staleSources,
|
|
69
|
+
sourceUnavailable: _sourceUnavailable,
|
|
70
|
+
lastEdit: _lastEdit,
|
|
71
|
+
...snapshot
|
|
72
|
+
} = structuredClone(input.store.read(input.reviewId, input.version));
|
|
73
|
+
|
|
74
|
+
if (target?.kind === "worktree")
|
|
75
|
+
throw new ReviewInputError("Pin this review to commits before sharing it.");
|
|
76
|
+
|
|
77
|
+
documentSchema.parse(snapshot.document);
|
|
78
|
+
checkReferences(snapshot.document);
|
|
79
|
+
const objects = new Map<string, Uint8Array>();
|
|
80
|
+
let totalBytes = 0;
|
|
81
|
+
|
|
82
|
+
const add = (bytes: Uint8Array) => {
|
|
83
|
+
if (bytes.byteLength > MAX_SHARE_OBJECT_BYTES)
|
|
84
|
+
throw new ReviewInputError("A shared object exceeds the upload limit.");
|
|
85
|
+
const id = digestBytes(bytes);
|
|
86
|
+
|
|
87
|
+
if (!objects.has(id)) {
|
|
88
|
+
totalBytes += bytes.byteLength;
|
|
89
|
+
|
|
90
|
+
if (totalBytes > MAX_SHARE_BYTES)
|
|
91
|
+
throw new ReviewInputError("Review exceeds the sharing limit.");
|
|
92
|
+
objects.set(id, Uint8Array.from(bytes));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return id;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const json = <Value>(value: Value) => add(Buffer.from(JSON.stringify(value)));
|
|
99
|
+
const resources: ShareManifest["resources"] = [];
|
|
100
|
+
const maps: Record<string, Awaited<ReturnType<LocalReviewData["map"]>>> = {};
|
|
101
|
+
|
|
102
|
+
// Lens ranges travel with the snapshot and must resolve at its pins too.
|
|
103
|
+
const sources = [
|
|
104
|
+
...sourceReferences(snapshot.document),
|
|
105
|
+
...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
|
|
106
|
+
sourceAnchors(selection.source).map((source) => ({
|
|
107
|
+
...selection,
|
|
108
|
+
source,
|
|
109
|
+
peek: false,
|
|
110
|
+
})),
|
|
111
|
+
),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const pins = snapshot.pins;
|
|
115
|
+
|
|
116
|
+
validateShareSources(snapshot);
|
|
117
|
+
|
|
118
|
+
if (Boolean(pins) !== Boolean(input.repository))
|
|
119
|
+
throw new ReviewInputError("Shared source pins require a repository.", 409);
|
|
120
|
+
|
|
121
|
+
for (const block of elements(snapshot.document)) {
|
|
122
|
+
if (block.type !== "markdown") continue;
|
|
123
|
+
|
|
124
|
+
for (const node of markdownNodes(parseMarkdown(block.markdown)))
|
|
125
|
+
if (node.type === "image")
|
|
126
|
+
throw new ReviewInputError(
|
|
127
|
+
"Convert Markdown images to managed image blocks before sharing.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const block of resourceReferences(snapshot.document)) {
|
|
132
|
+
await input.data.validateResource(pins, block);
|
|
133
|
+
|
|
134
|
+
const reference = resourceReference(block);
|
|
135
|
+
|
|
136
|
+
if (
|
|
137
|
+
!reference ||
|
|
138
|
+
resources.some((resource) => resource.id === reference.id)
|
|
139
|
+
)
|
|
140
|
+
continue;
|
|
141
|
+
const { id } = reference;
|
|
142
|
+
const resource = input.store.resource(id);
|
|
143
|
+
resources.push({
|
|
144
|
+
id,
|
|
145
|
+
kind: z.enum(["image", "trace", "map"]).parse(resource.kind),
|
|
146
|
+
mimeType: z
|
|
147
|
+
.enum(["image/png", "application/json"])
|
|
148
|
+
.parse(resource.mimeType),
|
|
149
|
+
object: add(resource.data),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (block.type === "software_map") {
|
|
153
|
+
const map = await input.data.map(pins, id);
|
|
154
|
+
Object.defineProperty(maps, id, { value: map, enumerable: true });
|
|
155
|
+
|
|
156
|
+
for (const element of map.elements)
|
|
157
|
+
for (const range of element.sourceRanges ?? [])
|
|
158
|
+
sources.push({
|
|
159
|
+
id: `${id}:${element.id}`,
|
|
160
|
+
source: { ...range, side: map.side },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const reference of sources)
|
|
166
|
+
await input.data.validateSource(
|
|
167
|
+
anchorPins(reference.source, pins),
|
|
168
|
+
reference.source,
|
|
169
|
+
{ peek: reference.peek ?? false },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const snapshotId = json(snapshot);
|
|
173
|
+
const presentationId = json({ maps });
|
|
174
|
+
|
|
175
|
+
const manifest = shareManifestSchema.parse({
|
|
176
|
+
format: SHARE_FORMAT,
|
|
177
|
+
reviewId: snapshot.reviewId,
|
|
178
|
+
version: snapshot.version,
|
|
179
|
+
title: snapshot.title,
|
|
180
|
+
snapshot: snapshotId,
|
|
181
|
+
presentation: presentationId,
|
|
182
|
+
objects: [...objects].map(([id, bytes]) => ({
|
|
183
|
+
id,
|
|
184
|
+
sha256: id,
|
|
185
|
+
size: bytes.byteLength,
|
|
186
|
+
})),
|
|
187
|
+
resources,
|
|
188
|
+
repository: input.repository,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_SHARE_MANIFEST_BYTES)
|
|
192
|
+
throw new ReviewInputError("The share manifest exceeds the upload limit.");
|
|
193
|
+
|
|
194
|
+
return { manifest, objects };
|
|
195
|
+
}
|