@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.0

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 (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +8 -1
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +7 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +3 -1
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -11
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +123 -0
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,75 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * The toolbar's size follows the screen it is on, not the window: a laptop's display gets a smaller step and a
5
+ * large monitor a larger one, so the bar, cards and type take about the same room to the eye everywhere. Every
6
+ * length in the stylesheet is `calc(N * var(--cs-u))`; the step sets `--cs-u` on `#cs-root`. Browser zoom keeps
7
+ * working because `window.screen` is in CSS pixels at the display's own scale and page zoom never changes it.
8
+ */
9
+ export type ScreenTier = "compact" | "laptop" | "desktop" | "wide";
10
+
11
+ /** Pixels per design pixel for each tier; `desktop` is the drawn size. */
12
+ export const TIER_UNIT: Record<ScreenTier, number> = {
13
+ compact: 0.8125,
14
+ laptop: 0.875,
15
+ desktop: 1,
16
+ wide: 1.125,
17
+ };
18
+
19
+ /** A screen is ranked by its long side and its short side separately and gets the smaller tier of the two. */
20
+ const LONG_SIDE: Array<[ScreenTier, number]> = [
21
+ ["wide", 2400],
22
+ ["desktop", 1800],
23
+ ["laptop", 1440],
24
+ ];
25
+ const SHORT_SIDE: Array<[ScreenTier, number]> = [
26
+ ["wide", 1350],
27
+ ["desktop", 1050],
28
+ ["laptop", 940],
29
+ ];
30
+ const ORDER: ScreenTier[] = ["compact", "laptop", "desktop", "wide"];
31
+ /** Below this long side (tablets, phones) nothing is scaled: tap targets stay as drawn. */
32
+ const MIN_LONG_SIDE = 1024;
33
+
34
+ function rank(size: number, steps: Array<[ScreenTier, number]>): ScreenTier {
35
+ for (const [tier, min] of steps) if (size >= min) return tier;
36
+ return "compact";
37
+ }
38
+
39
+ export function screenTier(screen: { width: number; height: number }, coarse = false): ScreenTier {
40
+ const long = Math.max(screen.width, screen.height);
41
+ const short = Math.min(screen.width, screen.height);
42
+ if (coarse || !Number.isFinite(long) || long < MIN_LONG_SIDE) return "desktop";
43
+ const byLong = rank(long, LONG_SIDE);
44
+ const byShort = rank(short, SHORT_SIDE);
45
+ return ORDER[Math.min(ORDER.indexOf(byLong), ORDER.indexOf(byShort))]!;
46
+ }
47
+
48
+ function currentTier(): ScreenTier {
49
+ if (typeof window === "undefined" || !window.screen) return "desktop";
50
+ const coarse =
51
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
52
+ return screenTier(window.screen, coarse);
53
+ }
54
+
55
+ function subscribe(onChange: () => void) {
56
+ window.addEventListener("resize", onChange);
57
+ const screen = window.screen as Screen & Partial<EventTarget>;
58
+ screen.addEventListener?.("change", onChange);
59
+ return () => {
60
+ window.removeEventListener("resize", onChange);
61
+ screen.removeEventListener?.("change", onChange);
62
+ };
63
+ }
64
+
65
+ /** The tier of the screen the toolbar is on now; follows the window to another monitor. */
66
+ export function useScreenTier(): ScreenTier {
67
+ return useSyncExternalStore(subscribe, currentTier, () => "desktop");
68
+ }
69
+
70
+ /** The toolbar's pixels per design pixel right now, for lengths computed in script. */
71
+ export function uiUnit(root: Element | null = document.getElementById("cs-root")): number {
72
+ if (!root) return 1;
73
+ const value = parseFloat(getComputedStyle(root).getPropertyValue("--cs-u"));
74
+ return Number.isFinite(value) && value > 0 ? value : 1;
75
+ }
@@ -0,0 +1,98 @@
1
+ import { runtimeBuild } from "./build-id.js";
2
+ import { runtimeVersion } from "./runtime-version.js";
3
+
4
+ /**
5
+ * Between checks of the served build while the page stays open. The worker also announces its build over the
6
+ * live socket when its project object starts, and reconnects and tab switches check too; this is the backstop
7
+ * for a toolbar whose project object stayed asleep through a deploy.
8
+ */
9
+ export const CHECK_EVERY_MS = 60_000;
10
+
11
+ type BuildListener = (build: string) => void;
12
+ const listeners = new Set<BuildListener>();
13
+
14
+ /** Called with the build the worker says it serves (a live-socket message). */
15
+ export function announceBuild(build: string) {
16
+ for (const listener of listeners) listener(build);
17
+ }
18
+
19
+ /** Hears every announced build until the returned function is called. */
20
+ export function onBuildAnnounced(listener: BuildListener) {
21
+ listeners.add(listener);
22
+ return () => {
23
+ listeners.delete(listener);
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Loads the runtime at `url` and remounts the toolbar from it when it is `build`; resolves false (nothing remounted)
29
+ * when the bundle that came back is some other build. `runtime.tsx#mount` registers it.
30
+ */
31
+ export type Swapper = (url: string, build: string) => Promise<boolean>;
32
+ let swapper: Swapper | null = null;
33
+ let swapping = false;
34
+ /** Tries per build before this page gives up on it until a reload. */
35
+ const MAX_SWAP_ATTEMPTS = 3;
36
+ const attempts = new Map<string, number>();
37
+
38
+ export function registerSwapper(next: Swapper | null) {
39
+ swapper = next;
40
+ }
41
+
42
+ function base(apiUrl: string) {
43
+ return apiUrl.replace(/\/+$/, "");
44
+ }
45
+
46
+ /**
47
+ * The bundle URL for `build`. The query defeats the module cache; the chunks it imports carry their own hashes.
48
+ * A page imports a module URL once and keeps it, so each retry (`attempt` > 0) gets a URL of its own.
49
+ */
50
+ export function runtimeUrl(apiUrl: string, build: string, attempt = 0) {
51
+ const url = `${base(apiUrl)}/v1/toolbar/${runtimeVersion}.js?b=${encodeURIComponent(build)}`;
52
+ return attempt > 0 ? `${url}&n=${attempt}` : url;
53
+ }
54
+
55
+ /** The build the worker serves now, or null when it can't be read. Never throws. */
56
+ export async function servedBuild(
57
+ apiUrl: string,
58
+ fetchFn: typeof fetch = fetch,
59
+ ): Promise<string | null> {
60
+ try {
61
+ const response = await fetchFn(`${base(apiUrl)}/v1/toolbar/build.json`, {
62
+ cache: "no-store",
63
+ mode: "cors",
64
+ });
65
+ if (!response.ok) return null;
66
+ const body = (await response.json()) as { build?: unknown };
67
+ return typeof body.build === "string" && body.build ? body.build : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** True when the worker serves a different build than the one running here. A "dev" build never swaps. */
74
+ export function needsSwap(served: string | null, current: string = runtimeBuild) {
75
+ return Boolean(served) && current !== "dev" && served !== current;
76
+ }
77
+
78
+ /**
79
+ * Swaps to `build`. Resolves false when no runtime registered, a swap is under way, the bundle that loaded is not
80
+ * `build`, or this page already tried `build` too often.
81
+ *
82
+ * Right after a deploy an edge can still answer `1.js?b=<new>` with the previous bundle. Mounting it would loop:
83
+ * it sees the newer build served, imports the same URL, gets itself back from the module cache and swaps again,
84
+ * remounting the toolbar (and dropping its socket) as fast as it can. The swapper refuses such a bundle instead,
85
+ * and the next try uses a URL this page hasn't imported.
86
+ */
87
+ export async function swapRuntime(apiUrl: string, build: string): Promise<boolean> {
88
+ if (!swapper || swapping) return false;
89
+ const attempt = attempts.get(build) ?? 0;
90
+ if (attempt >= MAX_SWAP_ATTEMPTS) return false;
91
+ attempts.set(build, attempt + 1);
92
+ swapping = true;
93
+ try {
94
+ return await swapper(runtimeUrl(apiUrl, build, attempt), build);
95
+ } finally {
96
+ swapping = false;
97
+ }
98
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The toolbar's debug context: running while someone is signed in to the toolbar and the host turned it on
3
+ * in code, stopped otherwise (the toolbar's effect calls `start`). The host's functions are read through a ref, so a new options object on every
4
+ * render does not restart the capture and lose what it has seen; only a change of what is captured does.
5
+ */
6
+ import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
7
+ import { startDebugContext, type DebugCapture, type DebugSnapshotInput } from "./debug-context.js";
8
+ import { DEBUG_ERROR_EVENT, type DebugErrorDetail } from "./debug-context-host.js";
9
+ import {
10
+ debugContextKey,
11
+ debugContextName,
12
+ liveDebugOptions,
13
+ resolveDebugContext,
14
+ type DebugContextConfig,
15
+ } from "./debug-context-model.js";
16
+
17
+ export type DebugContextControl = {
18
+ /** The running capture, or null. */
19
+ capture: MutableRefObject<DebugCapture | null>;
20
+ /** Changes when what is captured changes: the effect that calls `start` depends on it. */
21
+ key: string;
22
+ /**
23
+ * Starts the capture and returns its stop. The caller starts it after the activity trail and stops it
24
+ * first, so each puts back exactly the patches it made.
25
+ */
26
+ start: (apiUrl: string) => () => void;
27
+ };
28
+
29
+ export function useDebugContext(config: DebugContextConfig | undefined): DebugContextControl {
30
+ const capture = useRef<DebugCapture | null>(null);
31
+ const configRef = useRef(config);
32
+ // Declared before the caller's effect that starts the capture, so it runs first and the capture reads this
33
+ // render's config.
34
+ useEffect(() => {
35
+ configRef.current = config;
36
+ });
37
+ const key = debugContextKey(config);
38
+ const start = useCallback((apiUrl: string) => {
39
+ const resolved = resolveDebugContext(
40
+ configRef.current === true ? true : liveDebugOptions(() => configRef.current),
41
+ );
42
+ if (!resolved) return () => {};
43
+ const own = apiUrl.replace(/\/+$/, "");
44
+ const running = startDebugContext(resolved, { ignoreUrl: (url) => url.startsWith(own) });
45
+ capture.current = running;
46
+ const onError = (event: Event) => {
47
+ const detail = (event as CustomEvent<DebugErrorDetail>).detail;
48
+ if (detail) running.reportError(detail.error, detail.componentStack);
49
+ };
50
+ window.addEventListener(DEBUG_ERROR_EVENT, onError);
51
+ return () => {
52
+ window.removeEventListener(DEBUG_ERROR_EVENT, onError);
53
+ running.stop();
54
+ if (capture.current === running) capture.current = null;
55
+ };
56
+ }, []);
57
+ return { capture, key, start };
58
+ }
59
+
60
+ /**
61
+ * Builds the context file and uploads it as `context-<stamp>.json`. Resolves with the attachment id, or null
62
+ * when there is none to send or it could not be sent: a report is never held back by its context.
63
+ */
64
+ export async function uploadDebugContext(
65
+ capture: DebugCapture | null,
66
+ input: DebugSnapshotInput,
67
+ upload: (blob: Blob, name: string) => Promise<{ id: string }>,
68
+ ): Promise<string | null> {
69
+ if (!capture) return null;
70
+ try {
71
+ const started = performance.now();
72
+ const file = await capture.snapshot(input);
73
+ // Where a host can read what a report cost: `performance.getEntriesByName("comment-tool:debug-context")`.
74
+ performance.measure("comment-tool:debug-context", { start: started, end: performance.now() });
75
+ if (!file) return null;
76
+ const blob = new Blob([JSON.stringify(file)], { type: "application/json" });
77
+ return (await upload(blob, debugContextName(new Date(file.capturedAt)))).id;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,133 @@
1
+ import { APP_ORIGIN_HEADER, appOriginScheme } from "../native-wire.js";
2
+
3
+ export type CommentServiceConfig = {
4
+ url: string;
5
+ project: string;
6
+ environment: string;
7
+ secret: string;
8
+ /**
9
+ * Other environments of the project whose comments this one shows, live, alongside its own.
10
+ * Replies are allowed; resolving stays in the comment's own environment. Sent on every session, so this list is the
11
+ * whole truth: removing a name unlinks it.
12
+ */
13
+ linked?: string[];
14
+ };
15
+
16
+ export function readCommentServiceConfig(
17
+ env: Record<string, string | undefined> = process.env,
18
+ ): CommentServiceConfig | null {
19
+ const url = env.COMMENT_SERVICE_URL?.replace(/\/$/, "");
20
+ const project = env.COMMENT_SERVICE_PROJECT;
21
+ const environment = env.COMMENT_SERVICE_ENVIRONMENT;
22
+ const secret = env.COMMENT_SERVICE_SECRET;
23
+ if (!url || !project || !environment || !secret) return null;
24
+ const linked = (env.COMMENT_SERVICE_LINKED ?? "").split(/[\s,]+/).filter(Boolean);
25
+ return { url, project, environment, secret, linked };
26
+ }
27
+
28
+ /**
29
+ * Same-origin session route used by Next.js and TanStack Start.
30
+ * The integration secret stays on the server. The browser only receives a short-lived host token,
31
+ * which still cannot read or write comments until someone signs in to the toolbar.
32
+ *
33
+ * ## The origin is derived, never taken from a caller-chosen header
34
+ *
35
+ * The worker binds a host token to an origin, and it allow-lists every origin registered for the project —
36
+ * which includes linked environments. When this function forwarded the caller's header, any caller could
37
+ * name a *different* registered origin and receive a token bound to it. The header is a request field, so it
38
+ * was never a statement about which site made the request.
39
+ *
40
+ * Two clients legitimately need different answers, and they are distinguishable here:
41
+ *
42
+ * - **A native app** has no browser origin. It declares `<scheme>://app` in `x-comment-service-origin`, and
43
+ * only that *shape* is accepted — never a web origin, which the worker would otherwise honour. Its host
44
+ * proves it is the app by requiring a bearer session before it calls this at all.
45
+ * - **A browser** is bound to the origin the deployment is served from. A page cannot change that by sending a
46
+ * header, so the caller-chosen `origin` is not consulted for a web request at all.
47
+ */
48
+
49
+ /** The session this deployment may bind a token to: the origin it is served from, or one native app scheme. */
50
+ export function requestedOrigin(request: Request): string {
51
+ const declared = request.headers.get(APP_ORIGIN_HEADER);
52
+ // A native app sends no `Origin`. Only a scheme of the documented shape is accepted from the header, so a web
53
+ // page cannot use this path to name a registered web origin of another environment.
54
+ if (
55
+ request.headers.get("origin") === null &&
56
+ declared !== null &&
57
+ appOriginScheme(declared) !== null
58
+ ) {
59
+ return declared;
60
+ }
61
+ // The origin the platform accepted the request on. `x-forwarded-host`/`x-forwarded-proto` are written by the
62
+ // host's edge from the actual connection, not by the page; the page cannot choose them. This is the whole fix:
63
+ // the header a caller controls is no longer consulted for a web request.
64
+ const url = new URL(request.url);
65
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host;
66
+ if (!host || host.includes("/")) return url.origin;
67
+ const proto = (request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", ""))
68
+ .split(",")[0]
69
+ .trim();
70
+ return `${proto}://${host}`;
71
+ }
72
+
73
+ export async function handleSessionRequest(
74
+ request: Request,
75
+ config: CommentServiceConfig | null = readCommentServiceConfig(),
76
+ ): Promise<Response> {
77
+ const headers = { "cache-control": "no-store" };
78
+ if (!config) return Response.json({ enabled: false }, { headers });
79
+ const origin = requestedOrigin(request);
80
+ let response: Response;
81
+ try {
82
+ response = await fetch(`${config.url}/v1/host/session`, {
83
+ method: "POST",
84
+ headers: {
85
+ authorization: `Bearer ${config.secret}`,
86
+ "content-type": "application/json",
87
+ },
88
+ body: JSON.stringify({
89
+ project: config.project,
90
+ environment: config.environment,
91
+ origin,
92
+ linked: config.linked ?? [],
93
+ }),
94
+ });
95
+ } catch {
96
+ return Response.json(
97
+ { enabled: false, error: "Comment service is unreachable." },
98
+ { status: 502, headers },
99
+ );
100
+ }
101
+ if (!response.ok) {
102
+ const payload = (await response.json().catch(() => null)) as {
103
+ error?: { message?: string };
104
+ } | null;
105
+ return Response.json(
106
+ {
107
+ enabled: false,
108
+ error: payload?.error?.message ?? "Comment service rejected this environment.",
109
+ origin,
110
+ status: response.status,
111
+ },
112
+ { status: 502, headers },
113
+ );
114
+ }
115
+ const payload = (await response.json()) as { token: string; unknownLinked?: string[] };
116
+ if (payload.unknownLinked?.length) {
117
+ // Server-side, in the host app: its logs are the only place this misconfiguration shows.
118
+ // oxlint-disable-next-line no-console
119
+ console.warn(
120
+ `[comment-service] COMMENT_SERVICE_LINKED: no environment named ${payload.unknownLinked.join(", ")} in project ${config.project}.`,
121
+ );
122
+ }
123
+ return Response.json(
124
+ {
125
+ enabled: true,
126
+ apiUrl: config.url,
127
+ project: config.project,
128
+ environment: config.environment,
129
+ hostToken: payload.token,
130
+ },
131
+ { headers },
132
+ );
133
+ }