@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.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,190 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import {
4
+ type JsonValue,
5
+ type ReviewVerbRequest,
6
+ type ReviewVerbResponse,
7
+ parseReviewDesktopVerbResult,
8
+ parseReviewVerbRequest,
9
+ } from "@dev.fast/review-protocol";
10
+
11
+ const DEFAULT_VERB_TIMEOUT_MS = 45_000;
12
+
13
+ const DEFAULT_MAX_CLIENTS = 16;
14
+
15
+ const NOT_ATTACHED = "No Whiteboard Desktop is attached.";
16
+
17
+ interface PendingVerb {
18
+ resolve(response: ReviewVerbResponse): void;
19
+ timer: ReturnType<typeof setTimeout>;
20
+ /** The clients yet to answer, by the frame id each was sent. */
21
+ waiting: Map<string, GlobalReviewDesktopVerbWriter>;
22
+ lastFailure?: ReviewVerbResponse;
23
+ }
24
+
25
+ export interface GlobalReviewDesktopVerbWriter {
26
+ readonly signal: AbortSignal;
27
+ write(frame: string): void | Promise<void>;
28
+ close(): void | Promise<void>;
29
+ }
30
+
31
+ /** The server's view of the desktop relay, so tests can supply their own. */
32
+ export interface ReviewDesktopVerbRelay {
33
+ readonly attached: boolean;
34
+ attach(writer: GlobalReviewDesktopVerbWriter): boolean;
35
+ dispatch(value: JsonValue): Promise<ReviewVerbResponse>;
36
+ acceptResult(value: JsonValue): boolean;
37
+ close(): void;
38
+ }
39
+
40
+ /**
41
+ * Sends each verb to every attached client, each with its own frame id, and
42
+ * resolves with the first success, or with the last failure once no client
43
+ * is left to answer.
44
+ */
45
+ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
46
+ private readonly clients = new Map<
47
+ GlobalReviewDesktopVerbWriter,
48
+ () => void
49
+ >();
50
+ /** Each verb in flight, under every frame id it was sent with. */
51
+ private readonly pending = new Map<string, PendingVerb>();
52
+ private readonly timeoutMs: number;
53
+ private readonly maxClients: number;
54
+
55
+ constructor(options: { timeoutMs?: number; maxClients?: number } = {}) {
56
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_VERB_TIMEOUT_MS;
57
+ this.maxClients = options.maxClients ?? DEFAULT_MAX_CLIENTS;
58
+ }
59
+
60
+ get attached(): boolean {
61
+ return this.clients.size > 0;
62
+ }
63
+
64
+ attach(writer: GlobalReviewDesktopVerbWriter): boolean {
65
+ if (
66
+ this.clients.size >= this.maxClients ||
67
+ this.clients.has(writer) ||
68
+ writer.signal.aborted
69
+ )
70
+ return false;
71
+
72
+ const detach = () => this.detach(writer);
73
+ this.clients.set(writer, detach);
74
+ writer.signal.addEventListener("abort", detach, { once: true });
75
+
76
+ return true;
77
+ }
78
+
79
+ dispatch(value: JsonValue): Promise<ReviewVerbResponse> {
80
+ const request: ReviewVerbRequest = parseReviewVerbRequest(value);
81
+
82
+ if (this.clients.size === 0) {
83
+ return Promise.resolve({ ok: false, error: NOT_ATTACHED });
84
+ }
85
+
86
+ return new Promise<ReviewVerbResponse>((resolve) => {
87
+ const verb: PendingVerb = {
88
+ resolve,
89
+ waiting: new Map(
90
+ [...this.clients.keys()].map((client) => [
91
+ crypto.randomUUID(),
92
+ client,
93
+ ]),
94
+ ),
95
+ timer: setTimeout(
96
+ () =>
97
+ this.settle(
98
+ verb,
99
+ verb.lastFailure ?? {
100
+ ok: false,
101
+ error: "Whiteboard Desktop verb timed out.",
102
+ },
103
+ ),
104
+ this.timeoutMs,
105
+ ),
106
+ };
107
+
108
+ verb.timer.unref?.();
109
+
110
+ // Every id is registered before the first write, which may detach its
111
+ // client at once.
112
+ for (const id of verb.waiting.keys()) this.pending.set(id, verb);
113
+
114
+ for (const [id, client] of [...verb.waiting]) {
115
+ const frame = `data: ${JSON.stringify({ event: "desktop-verb", id, request })}\n\n`;
116
+
117
+ try {
118
+ void Promise.resolve(client.write(frame)).catch(() => {
119
+ this.detach(client);
120
+ });
121
+ } catch {
122
+ this.detach(client);
123
+ }
124
+ }
125
+ });
126
+ }
127
+
128
+ acceptResult(value: JsonValue): boolean {
129
+ const result = parseReviewDesktopVerbResult(value);
130
+ const verb = this.pending.get(result.id);
131
+
132
+ if (!verb) return false;
133
+
134
+ if (result.response.ok) {
135
+ this.settle(verb, result.response);
136
+ } else {
137
+ verb.lastFailure = result.response;
138
+ this.stopWaiting(verb, result.id);
139
+ }
140
+
141
+ return true;
142
+ }
143
+
144
+ close(): void {
145
+ for (const verb of new Set(this.pending.values())) {
146
+ this.settle(verb, {
147
+ ok: false,
148
+ error: "Whiteboard Desktop relay closed.",
149
+ });
150
+ }
151
+
152
+ for (const client of [...this.clients.keys()]) {
153
+ this.detach(client);
154
+
155
+ try {
156
+ void Promise.resolve(client.close()).catch(() => undefined);
157
+ } catch {
158
+ // Already closed.
159
+ }
160
+ }
161
+ }
162
+
163
+ private detach(writer: GlobalReviewDesktopVerbWriter): void {
164
+ const listener = this.clients.get(writer);
165
+
166
+ if (!listener) return;
167
+ writer.signal.removeEventListener("abort", listener);
168
+ this.clients.delete(writer);
169
+
170
+ for (const [id, verb] of [...this.pending]) {
171
+ if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
172
+ }
173
+ }
174
+
175
+ /** Drops one client's id; with nobody left to answer, the verb fails. */
176
+ private stopWaiting(verb: PendingVerb, id: string): void {
177
+ verb.waiting.delete(id);
178
+ this.pending.delete(id);
179
+
180
+ if (verb.waiting.size === 0)
181
+ this.settle(verb, verb.lastFailure ?? { ok: false, error: NOT_ATTACHED });
182
+ }
183
+
184
+ private settle(verb: PendingVerb, response: ReviewVerbResponse): void {
185
+ for (const id of verb.waiting.keys()) this.pending.delete(id);
186
+ verb.waiting.clear();
187
+ clearTimeout(verb.timer);
188
+ verb.resolve(response);
189
+ }
190
+ }
@@ -0,0 +1,149 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { once } from "node:events";
3
+ import { mkdir, realpath, rm } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+
6
+ import { isObjectValue } from "@dev.fast/json";
7
+ import { withFileLock, writePrivateJsonAtomic } from "@dev.fast/trace-core";
8
+ import { openReviewProfile } from "@review/review-api/profile.js";
9
+ import {
10
+ type ReviewServerDiscovery,
11
+ headlessServerLockPath,
12
+ reviewServerDiscoveryPath,
13
+ } from "@review/server-discovery.js";
14
+
15
+ import { GlobalReviewDesktopVerbRelay } from "./global-verb-relay.js";
16
+ import { createNodeRequestListener } from "./hono-http.js";
17
+ import {
18
+ drainServerCrashReport,
19
+ installProcessErrorTelemetry,
20
+ } from "./process-error-telemetry.js";
21
+ import { createWhiteboardCore } from "./review-server-core.js";
22
+ import type { ReviewTelemetryCapture } from "./ui-telemetry.js";
23
+
24
+ interface HeadlessServerInput {
25
+ stateDir: string;
26
+ port?: number;
27
+ softwareMapEnabled?: boolean;
28
+ signal: AbortSignal;
29
+ /** The CLI's instance, already on the `headless` surface. */
30
+ telemetry?: Pick<ReviewTelemetryCapture, "captureUiEvent">;
31
+ onReady(discovery: ReviewServerDiscovery): void;
32
+ }
33
+
34
+ /** One foreground headless endpoint per profile; Desktop shares its database. */
35
+ export async function runHeadlessServer(input: HeadlessServerInput) {
36
+ await mkdir(input.stateDir, { recursive: true, mode: 0o700 });
37
+ const stateDir = await realpath(input.stateDir);
38
+
39
+ const stopErrorTelemetry =
40
+ input.telemetry && installProcessErrorTelemetry(input.telemetry);
41
+
42
+ const outcome = await withHeadlessServerLock(stateDir, () =>
43
+ serve({ ...input, stateDir }),
44
+ ).finally(() => stopErrorTelemetry?.());
45
+
46
+ if (!outcome.acquired)
47
+ throw new Error(
48
+ `A Whiteboard server already owns ${stateDir}. Stop it first, or choose another --state-dir.`,
49
+ );
50
+ }
51
+
52
+ /** Held by a running server, so also by anything that must not run beside one. */
53
+ export function withHeadlessServerLock<T>(
54
+ stateDir: string,
55
+ operation: () => Promise<T>,
56
+ ) {
57
+ return withFileLock(
58
+ headlessServerLockPath(stateDir),
59
+ {
60
+ timeoutMs: 0,
61
+ retryMs: 20,
62
+ // A paused live owner must never lose exclusive access to its store.
63
+ staleMs: Infinity,
64
+ unownedGraceMs: 1_000,
65
+ // A reboot or kill can hand the pid to an unrelated live process.
66
+ identifyOwner: true,
67
+ },
68
+ operation,
69
+ );
70
+ }
71
+
72
+ async function serve(input: HeadlessServerInput) {
73
+ if (input.signal.aborted) return;
74
+
75
+ if (input.telemetry) await drainServerCrashReport(input.telemetry);
76
+
77
+ const local = await openReviewProfile(input.stateDir, {
78
+ manageWorkspaces: false,
79
+ });
80
+
81
+ const discovery: ReviewServerDiscovery = {
82
+ version: 1,
83
+ instanceId: randomUUID(),
84
+ url: "http://127.0.0.1:0",
85
+ serverPid: process.pid,
86
+ token: randomBytes(32).toString("base64url"),
87
+ };
88
+
89
+ const relay = new GlobalReviewDesktopVerbRelay();
90
+
91
+ const { app, api } = createWhiteboardCore({
92
+ profile: local,
93
+ relay,
94
+ token: discovery.token,
95
+ instanceId: discovery.instanceId,
96
+ softwareMapEnabled: input.softwareMapEnabled,
97
+ // The scratchpad is the laptop's alone, even with a Desktop attached.
98
+ scratchpad: () => false,
99
+ status: () => ({ key: "headless", home: input.stateDir }),
100
+ });
101
+
102
+ app.route("/reviews-api", api);
103
+
104
+ const server = createServer(createNodeRequestListener(app));
105
+ let published = false;
106
+
107
+ try {
108
+ const listening = once(server, "listening");
109
+ server.listen(input.port ?? 0, "127.0.0.1");
110
+ await listening;
111
+ const address = server.address();
112
+
113
+ if (!isObjectValue(address))
114
+ throw new Error("Whiteboard server did not bind a TCP port.");
115
+ discovery.url = `http://127.0.0.1:${address.port}`;
116
+ await writePrivateJsonAtomic(
117
+ reviewServerDiscoveryPath(input.stateDir),
118
+ discovery,
119
+ );
120
+ published = true;
121
+ input.onReady(discovery);
122
+
123
+ await new Promise<void>((resolve) => {
124
+ if (input.signal.aborted) resolve();
125
+ else
126
+ input.signal.addEventListener("abort", () => resolve(), { once: true });
127
+ });
128
+ } finally {
129
+ // Watch streams may live forever. Drain ordinary requests, then bound shutdown.
130
+ const forceClose = setTimeout(() => server.closeAllConnections(), 5_000);
131
+ forceClose.unref();
132
+
133
+ try {
134
+ if (published)
135
+ await rm(reviewServerDiscoveryPath(input.stateDir), { force: true });
136
+ } finally {
137
+ // An attached Desktop's stream would otherwise hold the close open.
138
+ relay.close();
139
+ await new Promise<void>((resolve) => server.close(() => resolve()));
140
+ clearTimeout(forceClose);
141
+
142
+ try {
143
+ await local.data.close();
144
+ } finally {
145
+ await local.store.close();
146
+ }
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,162 @@
1
+ import crypto from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+
4
+ import { type JsonValue, parseJsonText } from "@dev.fast/review-protocol";
5
+ import { type HttpBindings, getRequestListener } from "@hono/node-server";
6
+ import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
7
+ import type { Hono } from "hono";
8
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
9
+
10
+ import { StreamLimitError, readBoundedStream } from "./bounded-stream.js";
11
+ import { DEFAULT_MAX_REQUEST_BYTES, HttpJsonError } from "./http-json";
12
+
13
+ export type ReviewHonoEnv = {
14
+ Bindings: HttpBindings;
15
+ };
16
+
17
+ export function createNodeRequestListener(
18
+ app: Hono<ReviewHonoEnv>,
19
+ ): (request: IncomingMessage, response: ServerResponse) => Promise<void> {
20
+ return getRequestListener(app.fetch);
21
+ }
22
+
23
+ export function jsonResponse<T>(
24
+ body: T,
25
+ status: ContentfulStatusCode,
26
+ options: {
27
+ cacheControl?: string;
28
+ contentType?: string;
29
+ newline?: boolean;
30
+ } = {},
31
+ ): Response {
32
+ const headers = new Headers({
33
+ "content-type": options.contentType ?? "application/json; charset=utf-8",
34
+ });
35
+
36
+ if (options.cacheControl) {
37
+ headers.set("cache-control", options.cacheControl);
38
+ }
39
+
40
+ const serialized = JSON.stringify(body);
41
+
42
+ return new Response(
43
+ options.newline === false ? serialized : `${serialized}\n`,
44
+ {
45
+ status,
46
+ headers,
47
+ },
48
+ );
49
+ }
50
+
51
+ export function applyCorsHeaders(
52
+ request: Request,
53
+ response: Response,
54
+ ): Response {
55
+ const origin = request.headers.get("origin");
56
+
57
+ if (origin) {
58
+ response.headers.set("access-control-allow-origin", origin);
59
+ const vary = response.headers.get("vary");
60
+
61
+ const varyFields = vary
62
+ ?.split(",")
63
+ .map((field) => field.trim().toLowerCase());
64
+
65
+ if (!varyFields?.includes("*") && !varyFields?.includes("origin")) {
66
+ response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
67
+ }
68
+ }
69
+
70
+ response.headers.set(
71
+ "access-control-allow-headers",
72
+ `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`,
73
+ );
74
+ response.headers.set(
75
+ "access-control-allow-methods",
76
+ "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
77
+ );
78
+ response.headers.set("access-control-allow-private-network", "true");
79
+
80
+ return response;
81
+ }
82
+
83
+ export function corsPreflightResponse(request: Request): Response {
84
+ return applyCorsHeaders(request, new Response(null, { status: 204 }));
85
+ }
86
+
87
+ export function isAuthorizedRequest(
88
+ request: Request,
89
+ expectedToken: string,
90
+ ): boolean {
91
+ const supplied =
92
+ request.headers.get("x-review-token") ??
93
+ new URL(request.url).searchParams.get("token");
94
+
95
+ if (!supplied) return false;
96
+ const expected = Buffer.from(expectedToken);
97
+ const actual = Buffer.from(supplied);
98
+
99
+ return (
100
+ expected.length === actual.length &&
101
+ crypto.timingSafeEqual(expected, actual)
102
+ );
103
+ }
104
+
105
+ export async function readBoundedRequestJson(
106
+ request: Request,
107
+ maxBytes = DEFAULT_MAX_REQUEST_BYTES,
108
+ emptyValue?: JsonValue,
109
+ options: { allowTextPlain?: boolean } = {},
110
+ ): Promise<JsonValue> {
111
+ assertJsonContentType(request, options);
112
+ const contentLength = Number(request.headers.get("content-length"));
113
+
114
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
115
+ throw requestTooLarge(maxBytes);
116
+ }
117
+
118
+ let body: string;
119
+
120
+ try {
121
+ body = request.body
122
+ ? (await readBoundedStream(request.body, maxBytes)).toString("utf8")
123
+ : "";
124
+ } catch (error) {
125
+ if (error instanceof StreamLimitError) throw requestTooLarge(maxBytes);
126
+ throw error;
127
+ }
128
+
129
+ if (!body && emptyValue !== undefined) return emptyValue;
130
+
131
+ try {
132
+ return parseJsonText(body);
133
+ } catch {
134
+ throw new HttpJsonError("Invalid JSON body.", 400);
135
+ }
136
+ }
137
+
138
+ function assertJsonContentType(
139
+ request: Request,
140
+ options: { allowTextPlain?: boolean },
141
+ ): void {
142
+ const mediaType = request.headers
143
+ .get("content-type")
144
+ ?.split(";", 1)[0]
145
+ ?.trim()
146
+ .toLowerCase();
147
+
148
+ if (
149
+ mediaType !== "application/json" &&
150
+ !mediaType?.endsWith("+json") &&
151
+ !(options.allowTextPlain && mediaType === "text/plain")
152
+ ) {
153
+ throw new HttpJsonError("Content-Type must be application/json.", 415);
154
+ }
155
+ }
156
+
157
+ function requestTooLarge(maxBytes: number): HttpJsonError {
158
+ return new HttpJsonError(
159
+ `Request body exceeds ${maxBytes === DEFAULT_MAX_REQUEST_BYTES ? "1 MiB" : `${maxBytes} bytes`}.`,
160
+ 413,
161
+ );
162
+ }
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024;
2
+
3
+ export class HttpJsonError extends Error {
4
+ constructor(
5
+ message: string,
6
+ readonly statusCode: 400 | 413 | 415,
7
+ ) {
8
+ super(message);
9
+ this.name = "HttpJsonError";
10
+ }
11
+ }
12
+
13
+ /** An error whose status and machine code the JSON routes hand straight back. */
14
+ export class ReviewServerError extends Error {
15
+ override readonly name = "ReviewServerError";
16
+
17
+ constructor(
18
+ message: string,
19
+ readonly statusCode: number,
20
+ readonly code?: string,
21
+ ) {
22
+ super(message);
23
+ }
24
+ }
@@ -0,0 +1,174 @@
1
+ import { isJsonObject } from "@dev.fast/review-protocol";
2
+ import {
3
+ ReviewInputError,
4
+ resourceReferences,
5
+ } from "@review/review-api/document";
6
+ import { readQuerySchemas } from "@review/review-api/read-schemas";
7
+ import { ReviewStore, type Snapshot } from "@review/review-api/store";
8
+ import { resolveReviewDiffFiles } from "@review/review-diff-files";
9
+ import type { SharedReviewStore } from "@review/sharing/import.js";
10
+ import type { ReviewTelemetry } from "@review/telemetry";
11
+ import { Hono } from "hono";
12
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
13
+
14
+ import {
15
+ type BugReportSource,
16
+ BugReportUpstreamError,
17
+ submitReviewBugReport,
18
+ } from "./bug-report";
19
+ import { readBoundedRequestJson } from "./hono-http";
20
+ import {
21
+ parseReviewBugReportInput,
22
+ parseReviewTabTelemetryInput,
23
+ requestJsonErrorStatus,
24
+ } from "./review-api-parsers";
25
+ import {
26
+ captureSanitizedUiTelemetry,
27
+ clientErrorsForSession,
28
+ recordClientError,
29
+ } from "./ui-telemetry";
30
+
31
+ export function jsonReviewBugReportSource(
32
+ store: ReviewStore,
33
+ snapshot: Snapshot,
34
+ ): BugReportSource {
35
+ return {
36
+ async review() {
37
+ return {
38
+ files: { "review.json": JSON.stringify(snapshot, null, 2) },
39
+ omitted: [],
40
+ };
41
+ },
42
+ async map() {
43
+ const ids = [
44
+ ...new Set(
45
+ resourceReferences(snapshot.document).flatMap((block) =>
46
+ block.type === "software_map" ? [block.mapVersionId] : [],
47
+ ),
48
+ ),
49
+ ];
50
+
51
+ return ids.length
52
+ ? JSON.stringify(
53
+ ids.map((id) =>
54
+ JSON.parse(Buffer.from(store.resource(id).data).toString()),
55
+ ),
56
+ )
57
+ : null;
58
+ },
59
+ async diff() {
60
+ if (!snapshot.pins) return { files: [] };
61
+
62
+ return resolveReviewDiffFiles({
63
+ rootPath: store.repositoryPath(snapshot.pins.repositoryId),
64
+ baseRef: snapshot.pins.base,
65
+ headRef: snapshot.pins.head,
66
+ includePatch: true,
67
+ });
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Telemetry and report uploads for a pinned native review. */
73
+ export function createJsonReviewReporting(
74
+ store: ReviewStore,
75
+ telemetry: Pick<
76
+ ReviewTelemetry,
77
+ "captureUiEvent" | "captureTabViewed" | "envelope"
78
+ >,
79
+ options: {
80
+ submit?: typeof submitReviewBugReport;
81
+ shared?: SharedReviewStore;
82
+ } = {},
83
+ ) {
84
+ const { submit = submitReviewBugReport, shared } = options;
85
+ const app = new Hono();
86
+ app.onError((error, context) =>
87
+ context.json(
88
+ { ok: false, error: error.message },
89
+ // SAFETY: the report uploader returns HTTP error statuses; parser failures are 4xx.
90
+ (error instanceof BugReportUpstreamError
91
+ ? error.status
92
+ : error instanceof ReviewInputError
93
+ ? error.status
94
+ : requestJsonErrorStatus(error)) as ContentfulStatusCode,
95
+ ),
96
+ );
97
+ app.use("/:id/telemetry/*", async (context, next) => {
98
+ const id = context.req.param("id")!;
99
+
100
+ if (id.startsWith("shared-")) {
101
+ if (!shared)
102
+ throw new ReviewInputError("Shared review is not available.", 404);
103
+ shared.get(id);
104
+ } else store.assertExists(id);
105
+ await next();
106
+ });
107
+ app.post("/:id/telemetry/event", async (c) => {
108
+ const body = await readBoundedRequestJson(c.req.raw, undefined, {});
109
+ const payload = isJsonObject(body) ? body : {};
110
+ const rawContext = isJsonObject(payload.context) ? payload.context : {};
111
+ await captureSanitizedUiTelemetry(
112
+ telemetry,
113
+ c.req.raw,
114
+ payload.name,
115
+ payload.properties,
116
+ recordClientError,
117
+ payload.error,
118
+ // The path id wins: the middleware above already asserted it exists,
119
+ // so a payload trying to assert a different review id is overridden.
120
+ { ...rawContext, reviewUuid: c.req.param("id") },
121
+ payload.occurredAt,
122
+ );
123
+
124
+ return c.json({ ok: true });
125
+ });
126
+ app.post("/:id/telemetry/tab", async (context) => {
127
+ const input = await readBoundedRequestJson(
128
+ context.req.raw,
129
+ undefined,
130
+ {},
131
+ { allowTextPlain: true },
132
+ );
133
+
134
+ await telemetry.captureTabViewed(parseReviewTabTelemetryInput(input), {
135
+ reviewUuid: context.req.param("id"),
136
+ });
137
+
138
+ return context.json({ ok: true });
139
+ });
140
+ app.post("/:id/telemetry/bug-report", async (context) => {
141
+ const query = readQuerySchemas.get.parse({
142
+ version: context.req.query("version"),
143
+ });
144
+
145
+ const id = context.req.param("id");
146
+ const imported = id.startsWith("shared-") ? shared?.get(id) : undefined;
147
+ const snapshot = imported?.snapshot ?? store.read(id, query.version);
148
+
149
+ const report = parseReviewBugReportInput(
150
+ await readBoundedRequestJson(context.req.raw, 6 * 1024 * 1024, {}),
151
+ );
152
+
153
+ return context.json(
154
+ await submit({
155
+ report,
156
+ source: imported
157
+ ? {
158
+ review: async () => ({
159
+ files: { "review.json": JSON.stringify(snapshot) },
160
+ omitted: [],
161
+ }),
162
+ map: async () => JSON.stringify(imported.presentation.maps),
163
+ diff: () => jsonReviewBugReportSource(store, snapshot).diff(),
164
+ }
165
+ : jsonReviewBugReportSource(store, snapshot),
166
+ clientErrorNames: clientErrorsForSession(report.app_session_id),
167
+ // A locked telemetry config must not cost the user their report.
168
+ telemetryEnvelope: await telemetry.envelope().catch(() => undefined),
169
+ }),
170
+ );
171
+ });
172
+
173
+ return app;
174
+ }