@weatherboard/annotation 0.1.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.
- package/ADOPTING.md +360 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/host.cjs +2 -0
- package/dist/host.cjs.map +7 -0
- package/dist/host.d.cts +60 -0
- package/dist/host.d.mts +60 -0
- package/dist/host.d.ts +60 -0
- package/dist/host.mjs +2 -0
- package/dist/host.mjs.map +7 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.cts +34 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/overlay.d.cts +31 -0
- package/dist/overlay.d.mts +31 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/protocol.cjs +2 -0
- package/dist/protocol.cjs.map +7 -0
- package/dist/protocol.d.cts +309 -0
- package/dist/protocol.d.mts +309 -0
- package/dist/protocol.d.ts +309 -0
- package/dist/protocol.mjs +2 -0
- package/dist/protocol.mjs.map +7 -0
- package/dist/screenshot.d.cts +182 -0
- package/dist/screenshot.d.mts +182 -0
- package/dist/screenshot.d.ts +182 -0
- package/dist/sdk.d.cts +52 -0
- package/dist/sdk.d.mts +52 -0
- package/dist/sdk.d.ts +52 -0
- package/dist/state.d.cts +62 -0
- package/dist/state.d.mts +62 -0
- package/dist/state.d.ts +62 -0
- package/dist/target.d.cts +74 -0
- package/dist/target.d.mts +74 -0
- package/dist/target.d.ts +74 -0
- package/package.json +94 -0
package/ADOPTING.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Adopting annotation
|
|
2
|
+
|
|
3
|
+
Eight steps. **Every one of them fails silently**, and all five of the common
|
|
4
|
+
failures look identical from the console: a frame that says *waiting for the
|
|
5
|
+
page to announce itself*, forever, with nothing logged anywhere.
|
|
6
|
+
|
|
7
|
+
So read [Troubleshooting](#troubleshooting-by-symptom) first if something is
|
|
8
|
+
already not working. It is keyed by what you can see, not by what is wrong.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What you are building
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
reviewer's browser
|
|
16
|
+
┌─────────────────────────────────────────┐
|
|
17
|
+
│ console ┌─────────────────┐ │
|
|
18
|
+
│ (the parent) ◄────┤ your app │ │
|
|
19
|
+
│ │ in an iframe │ │
|
|
20
|
+
│ createAnnotationHost└─────────────────┘ │
|
|
21
|
+
└─────────────────────────────────────────┘
|
|
22
|
+
▲ │
|
|
23
|
+
│ postMessage │ data-src="src/ui/Card.tsx:42:5"
|
|
24
|
+
└────────────────────┘ put there at build time
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Three things have to be true before a single message moves:
|
|
28
|
+
|
|
29
|
+
1. The browser must **permit the framing** — `frame-ancestors`.
|
|
30
|
+
2. The page must be **armed** — a token, exchanged for a cookie.
|
|
31
|
+
3. Elements must **carry source refs** — the build transform.
|
|
32
|
+
|
|
33
|
+
Miss any one and you get the same nothing.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 1. Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm i @weatherboard/annotation
|
|
41
|
+
npm i -D @weatherboard/annotation-next
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The runtime package has **zero dependencies** and ships in your app. The
|
|
45
|
+
transform runs only in your build.
|
|
46
|
+
|
|
47
|
+
**What breaks:** `-D` on the second one is not cosmetic. `annotation-next`
|
|
48
|
+
depends on a Babel parser, and npm installs a package's `dependencies` whichever
|
|
49
|
+
subpath you import — so putting it in `dependencies` puts a parser in your
|
|
50
|
+
production install, silently and forever. Nothing warns you; the app works.
|
|
51
|
+
|
|
52
|
+
## 2. Add the build plugin
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// next.config.ts
|
|
56
|
+
import { withSourceRefs } from "@weatherboard/annotation-next";
|
|
57
|
+
|
|
58
|
+
export default withSourceRefs(nextConfig, {
|
|
59
|
+
enabled: annotationEnabled(process.env),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**What breaks:** nothing visibly. The build succeeds, the app runs, and every
|
|
64
|
+
capture comes back with a box, a DOM path and **no file**. That is the single
|
|
65
|
+
most expensive failure here, because it looks like the feature working badly
|
|
66
|
+
rather than a step you skipped.
|
|
67
|
+
|
|
68
|
+
Check by opening the running app and inspecting any `<div>`. If it has no
|
|
69
|
+
`data-src`, this step is not working.
|
|
70
|
+
|
|
71
|
+
**Turbopack runs no webpack loaders.** `next dev --turbo` silently does nothing.
|
|
72
|
+
|
|
73
|
+
## 3. One switch, read by two places
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// annotation-enabled.ts — imports nothing
|
|
77
|
+
export function annotationEnabled(env: Record<string, string | undefined>): boolean {
|
|
78
|
+
if (env.ANNOTATION_ENABLED === "1") return true;
|
|
79
|
+
if (env.ANNOTATION_ENABLED === "0") return false;
|
|
80
|
+
return env.VERCEL_ENV === "preview";
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Your build config and your middleware must not be able to disagree. A build with
|
|
85
|
+
source refs and no `frame-ancestors` is a frame that refuses to load; a build
|
|
86
|
+
with `frame-ancestors` and no source refs is a frame that loads and reports
|
|
87
|
+
feedback pointing at nothing.
|
|
88
|
+
|
|
89
|
+
Keep it in a module that **imports nothing**. Middleware runs on the edge
|
|
90
|
+
runtime, and pulling a build-time compiler in to read two environment variables
|
|
91
|
+
is the sort of import that works until it doesn't.
|
|
92
|
+
|
|
93
|
+
**Preview only.** Production is deliberately excluded: the attributes bloat every
|
|
94
|
+
page of HTML and publish the shape of your source tree, and there is no console
|
|
95
|
+
pointed at production anyway.
|
|
96
|
+
|
|
97
|
+
## 4. Arm the session — middleware
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// middleware.ts
|
|
101
|
+
import { ANNOTATION_ARMED_COOKIE, readAnnotationToken } from "@weatherboard/annotation/protocol";
|
|
102
|
+
|
|
103
|
+
const presented = readAnnotationToken(request.nextUrl.searchParams);
|
|
104
|
+
const expected = process.env.ANNOTATION_TOKEN;
|
|
105
|
+
|
|
106
|
+
if (expected && presented && presented.length === expected.length && presented === expected) {
|
|
107
|
+
const overHttps = request.nextUrl.protocol === "https:";
|
|
108
|
+
response.cookies.set(ANNOTATION_ARMED_COOKIE, "1", {
|
|
109
|
+
httpOnly: false, // the client bundle is what reads it
|
|
110
|
+
sameSite: overHttps ? "none" : "lax",
|
|
111
|
+
secure: overHttps,
|
|
112
|
+
path: "/",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Four details, each of which has cost somebody a day:
|
|
118
|
+
|
|
119
|
+
- **The cookie holds `1`, never the token.** It records that the token was
|
|
120
|
+
presented once, so navigation inside the frame stays armed without the secret
|
|
121
|
+
riding on every URL.
|
|
122
|
+
- **`httpOnly: false`** because the client bundle consults it. `true` and the SDK
|
|
123
|
+
never boots.
|
|
124
|
+
- **`SameSite=None`**, because the whole point is a cross-site iframe. A `Lax`
|
|
125
|
+
cookie is not sent in one, so the page arms on the first request and disarms on
|
|
126
|
+
every navigation after it.
|
|
127
|
+
- **…but `None` is only honoured with `Secure`**, and `Secure` is dropped over
|
|
128
|
+
plain http — so a local `next dev` would set a cookie the browser silently
|
|
129
|
+
discards. Fall back to `Lax`, which is same-origin anyway.
|
|
130
|
+
|
|
131
|
+
`readAnnotationToken` returns the **value**, not a boolean, and returns `null`
|
|
132
|
+
for an absent *or empty* parameter. Compare it yourself: only you hold the
|
|
133
|
+
secret.
|
|
134
|
+
|
|
135
|
+
**Refuse when unconfigured.** If `ANNOTATION_TOKEN` is unset, every comparison
|
|
136
|
+
must be `false` — including against an empty presented value. Otherwise a bare
|
|
137
|
+
`?ai_annotate=` arms the deployment.
|
|
138
|
+
|
|
139
|
+
## 5. Let the console frame you — `frame-ancestors`
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const origins = (process.env.ANNOTATION_PARENT_ORIGINS ?? "")
|
|
143
|
+
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
144
|
+
|
|
145
|
+
if (annotationEnabled(process.env) && origins.length > 0) {
|
|
146
|
+
response.headers.set("Content-Security-Policy", `frame-ancestors ${origins.join(" ")};`);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Multiple CSP headers are enforced as their INTERSECTION, not their union.** If
|
|
151
|
+
your app already serves `frame-ancestors` anywhere — a static header in
|
|
152
|
+
`next.config`, another middleware — adding a second one intersects with it and
|
|
153
|
+
takes the existing embed down, with the old config still visibly listing an
|
|
154
|
+
origin that no longer works. Compute every `frame-ancestors` for your app in
|
|
155
|
+
**one place**.
|
|
156
|
+
|
|
157
|
+
**Never set `X-Frame-Options`.** It has no origin list — only `DENY` and
|
|
158
|
+
`SAMEORIGIN` — and where both headers are present, browsers have historically
|
|
159
|
+
honoured it over `frame-ancestors`. It silently overrules everything above.
|
|
160
|
+
|
|
161
|
+
**Validate the origins.** They are interpolated into a header, so a value
|
|
162
|
+
carrying a `;` ends `frame-ancestors` and starts a directive of the author's
|
|
163
|
+
choosing. Drop anything unrecognisable rather than escaping it: a typo should
|
|
164
|
+
cost one origin, not the integrity of the header.
|
|
165
|
+
|
|
166
|
+
**No default.** Your app does not know what manages it and must not. An empty
|
|
167
|
+
variable means same-origin only, which is the right failure: a frame the browser
|
|
168
|
+
refuses is visible immediately, unlike one that loads and accepts messages from
|
|
169
|
+
anywhere.
|
|
170
|
+
|
|
171
|
+
**What breaks:** the refusal happens in the browser, and the console never sees
|
|
172
|
+
it. Your server logs show a normal 200.
|
|
173
|
+
|
|
174
|
+
## 6. Mount the SDK
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
"use client";
|
|
178
|
+
import { useEffect } from "react";
|
|
179
|
+
import { isAnnotationArmed } from "@weatherboard/annotation/protocol";
|
|
180
|
+
|
|
181
|
+
export function AnnotationMount() {
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!isAnnotationArmed(document.cookie)) return;
|
|
184
|
+
if (window.parent === window) return; // nowhere to speak
|
|
185
|
+
|
|
186
|
+
let dispose: (() => void) | null = null;
|
|
187
|
+
let cancelled = false;
|
|
188
|
+
|
|
189
|
+
void (async () => {
|
|
190
|
+
const { createAnnotationSdk, installStateCollector, defaultStateCollectorOptions } =
|
|
191
|
+
await import("@weatherboard/annotation");
|
|
192
|
+
|
|
193
|
+
const collector = installStateCollector(window, {
|
|
194
|
+
...defaultStateCollectorOptions(),
|
|
195
|
+
getFeatureFlags: () => ({}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const sdk = createAnnotationSdk({
|
|
199
|
+
parentOrigin: resolveParentOrigin(),
|
|
200
|
+
build: { gitSha: process.env.NEXT_PUBLIC_GIT_SHA ?? "unknown", env: "preview" },
|
|
201
|
+
getRoute: () => matchRoutePattern(window.location.pathname),
|
|
202
|
+
getState: () => collector.getState(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
dispose = () => { sdk.destroy(); collector.destroy(); };
|
|
206
|
+
if (cancelled) dispose();
|
|
207
|
+
})();
|
|
208
|
+
|
|
209
|
+
return () => { cancelled = true; dispose?.(); dispose = null; };
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Put it in the **root layout**. It is on every route of a public site, so what an
|
|
217
|
+
ordinary visitor pays matters: a cookie read and a return. Everything else is
|
|
218
|
+
behind a dynamic `import()` that only an armed session reaches, so none of it is
|
|
219
|
+
in the bundle a normal page load parses.
|
|
220
|
+
|
|
221
|
+
**What breaks:** mounted anywhere but the root layout, annotation works on the
|
|
222
|
+
routes below that layout and silently does nothing everywhere else — so it looks
|
|
223
|
+
like an intermittent bug in the console rather than a missing mount. And a
|
|
224
|
+
`useEffect` that throws before `createAnnotationSdk` is reached fails the same
|
|
225
|
+
way as no mount at all: check the framed page's console for *your* errors before
|
|
226
|
+
concluding the SDK never booted.
|
|
227
|
+
|
|
228
|
+
**The collector still installs early.** The ring buffers must fill from page load
|
|
229
|
+
rather than from click time — by the time someone notices something is wrong, the
|
|
230
|
+
interesting error has already been logged and `console` no longer holds it. The
|
|
231
|
+
dynamic import costs a chunk fetch, so the first few milliseconds of an armed
|
|
232
|
+
page are genuinely unobserved. That is the trade. What you must *not* do is defer
|
|
233
|
+
the collector to click time, which observes nothing at all.
|
|
234
|
+
|
|
235
|
+
## 7. `parentOrigin` is one address, not a list
|
|
236
|
+
|
|
237
|
+
`postMessage`'s second argument is **not an allowlist**. It is an address, and
|
|
238
|
+
the browser's guarantee that a payload — carrying this page's console errors,
|
|
239
|
+
failed requests and URL — is delivered nowhere else. `*` is the only way to say
|
|
240
|
+
"several", and `*` is exactly what would broadcast annotations to any embedder.
|
|
241
|
+
|
|
242
|
+
But `frame-ancestors` legitimately names several origins: production, plus a
|
|
243
|
+
preview per branch.
|
|
244
|
+
|
|
245
|
+
**Do not pick from the list.** "The first entry" is a guess, and a wrong guess is
|
|
246
|
+
a channel that silently never connects. Ask the browser instead:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
function resolveParentOrigin(): string | null {
|
|
250
|
+
const allowed = parsePublicOrigins(process.env.NEXT_PUBLIC_ANNOTATION_PARENT_ORIGINS);
|
|
251
|
+
const referrer = document.referrer ? new URL(document.referrer).origin : null;
|
|
252
|
+
|
|
253
|
+
if (referrer && allowed.includes(referrer)) return referrer; // observed, then validated
|
|
254
|
+
if (allowed.length === 1) return allowed[0]; // no ambiguity to resolve
|
|
255
|
+
return null; // refuse rather than guess
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`document.referrer` on a framed document is the framing document's URL, and a
|
|
260
|
+
console serving `Referrer-Policy: strict-origin-when-cross-origin` gives you its
|
|
261
|
+
origin exactly. The referrer is not trusted on its own and does not need to be:
|
|
262
|
+
two independent things must hold before a payload moves — the browser permitted
|
|
263
|
+
the framing (checked server-side against your list) *and* the origin is in the
|
|
264
|
+
list here. A page that lies about its referrer cannot make itself a permitted
|
|
265
|
+
ancestor.
|
|
266
|
+
|
|
267
|
+
**A second, `NEXT_PUBLIC_` variable.** `NEXT_PUBLIC_*` is inlined at build time,
|
|
268
|
+
so promoting the server-side one would make your frame-ancestors policy a
|
|
269
|
+
build-time constant that needs a redeploy to change. Two variables holding the
|
|
270
|
+
same value is the cost of the header staying a runtime decision. Pin them
|
|
271
|
+
together with a test.
|
|
272
|
+
|
|
273
|
+
**When it returns `null`, log and do not boot.** Posting to the wrong origin
|
|
274
|
+
delivers to nobody and looks exactly like a frame that never loaded.
|
|
275
|
+
|
|
276
|
+
## 8. `getRoute` is the pattern, not the URL
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
getRoute: () => matchRoutePattern(window.location.pathname),
|
|
280
|
+
// "/pub/[pubSlug]", never "/pub/the-eagle"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`/pub/the-eagle` and `/pub/the-crown` are **one route**. Send the resolved URL
|
|
284
|
+
and every page is its own route, so nothing can ever say "twelve people
|
|
285
|
+
complained about the pub page".
|
|
286
|
+
|
|
287
|
+
Next will not tell you the matched pattern on the client, so you have to match it
|
|
288
|
+
yourself against a table generated from your `app/` tree. Get the precedence
|
|
289
|
+
right — a static segment beats `[param]`, which beats a catch-all — and test
|
|
290
|
+
`/best-pubs/summer` against `/best-pubs/[month]` specifically.
|
|
291
|
+
|
|
292
|
+
Return something honest and greppable when nothing matches. `"(unmatched)"` beats
|
|
293
|
+
a wrong pattern.
|
|
294
|
+
|
|
295
|
+
**Read it at capture time, not at mount.** Client-side navigation inside the
|
|
296
|
+
frame does not remount the effect, so a route captured at mount is the route the
|
|
297
|
+
frame opened on for the rest of the session.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Troubleshooting, by symptom
|
|
302
|
+
|
|
303
|
+
Everything below presents as **"waiting for the page to announce itself"**. Work
|
|
304
|
+
down the table; the checks are ordered so that an earlier failure cannot be
|
|
305
|
+
mistaken for a later one.
|
|
306
|
+
|
|
307
|
+
| # | Check, in the framed page's devtools | If it fails |
|
|
308
|
+
|---|---|---|
|
|
309
|
+
| 1 | **Network tab: did the document load at all?** A `frame-ancestors` refusal shows as a blocked frame with a console message in the **parent**, and a normal 200 in your server logs. | Step 5. Check for a second CSP header intersecting yours, and for `X-Frame-Options`. |
|
|
310
|
+
| 2 | `document.cookie` contains `ai_annotate_armed=1` | Step 4. See below — this one has four separate causes. |
|
|
311
|
+
| 3 | `window.parent !== window` | You are testing top-level. The SDK's only output is a `postMessage` to a parent; an unframed page has nowhere to speak. |
|
|
312
|
+
| 4 | Any element has a `data-src` attribute | Step 2. The build transform is not running — or you are on Turbopack, where it silently does nothing. |
|
|
313
|
+
| 5 | Console shows no `[annotation] armed, but no parent origin resolved` | Step 7. Your `NEXT_PUBLIC_` origin list is missing, or the referrer is not in it. |
|
|
314
|
+
|
|
315
|
+
### The cookie is missing (check 2), and why
|
|
316
|
+
|
|
317
|
+
Four causes, indistinguishable from the outside:
|
|
318
|
+
|
|
319
|
+
| Cause | How to tell |
|
|
320
|
+
|---|---|
|
|
321
|
+
| `ANNOTATION_TOKEN` is unset on the deployment | The comparison returns `false` for every input, by design. Check the env, not the code. |
|
|
322
|
+
| The presented token does not match | **Nothing is logged.** The length check fails before any comparison. Compare lengths first when debugging, never values — and never print either. |
|
|
323
|
+
| `httpOnly: true` | The cookie exists and `document.cookie` cannot see it. Check the Application tab, not the console. |
|
|
324
|
+
| `SameSite=Lax` over https in a cross-site frame | The cookie is set on the first request and dropped on every navigation after it. Symptom: it works once, then stops. |
|
|
325
|
+
|
|
326
|
+
### The refs are wrong rather than missing
|
|
327
|
+
|
|
328
|
+
A ref that is well-formed, plausible, and points at a doc comment means you are
|
|
329
|
+
**losing a loader race**. Another plugin that appends to `mod.loaders` during the
|
|
330
|
+
build runs before the entire rule-based chain, and if it strips types and
|
|
331
|
+
comments first, the source we parse is not the source you wrote.
|
|
332
|
+
|
|
333
|
+
```sh
|
|
334
|
+
ANNOTATION_SOURCE_REFS_DEBUG=src/ui/Card next build
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
prints each matching module's loader chain at push time. This failure is silent
|
|
338
|
+
and the chain is the only thing that shows who won.
|
|
339
|
+
|
|
340
|
+
A ref that is an absolute path, or contains `node_modules`, means the repository
|
|
341
|
+
root was resolved wrongly — pass `rootDir` explicitly.
|
|
342
|
+
|
|
343
|
+
### Everything checks out and still nothing
|
|
344
|
+
|
|
345
|
+
Confirm the console and the page agree on the **origin**, exactly:
|
|
346
|
+
`https://app.example.com` and `https://app.example.com:443` are different strings
|
|
347
|
+
to `postMessage`, and it fails silently when they differ.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## The shape of every failure here
|
|
352
|
+
|
|
353
|
+
Worth stating once, because it is the same shape every time and it is what makes
|
|
354
|
+
this integration expensive: **a rule that matches nothing reads exactly like a
|
|
355
|
+
rule that passes**, and a check that fails identically for several causes will be
|
|
356
|
+
misattributed.
|
|
357
|
+
|
|
358
|
+
Every step above is a thing that, when absent, produces no error, no warning and
|
|
359
|
+
no log line — only an absence. When you are debugging, prefer the check that
|
|
360
|
+
distinguishes causes over the check that confirms your guess.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Weatherboard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @weatherboard/annotation
|
|
2
|
+
|
|
3
|
+
Click an element in a running app, get the source location that rendered it.
|
|
4
|
+
|
|
5
|
+
**Setting it up? Read [ADOPTING.md](./ADOPTING.md).** There are eight steps and
|
|
6
|
+
every one of them fails silently — all five common failures look identical from
|
|
7
|
+
the console. The guide is keyed by symptom for exactly that reason.
|
|
8
|
+
|
|
9
|
+
A reviewer opens the product, clicks the thing that is wrong, and types a
|
|
10
|
+
sentence. What comes back is not a screenshot and a guess — it is the file and
|
|
11
|
+
line that rendered that element, the ancestors that led to it, the console
|
|
12
|
+
errors and failed requests from that session, and the build the page came from.
|
|
13
|
+
|
|
14
|
+
Zero dependencies. It runs inside your production site, so it has no business
|
|
15
|
+
adding to your supply chain.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm i @weatherboard/annotation
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Three entry points
|
|
24
|
+
|
|
25
|
+
They are split by **who imports them**, not by topic.
|
|
26
|
+
|
|
27
|
+
| Import | Runs where | For |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `@weatherboard/annotation` | The browser | The page SDK — overlay, target resolution, state collection, screenshots |
|
|
30
|
+
| `@weatherboard/annotation/protocol` | Anywhere, no DOM | The wire names and the two readers. This is what a middleware imports |
|
|
31
|
+
| `@weatherboard/annotation/host` | The reviewing console | The parent half of the channel |
|
|
32
|
+
|
|
33
|
+
`/protocol` is a subpath rather than a tree-shaking promise on purpose: an edge
|
|
34
|
+
middleware is often bundled as CJS, and CJS does not shake. Importing the root
|
|
35
|
+
there would drag the overlay into a bundle that has no `document`.
|
|
36
|
+
|
|
37
|
+
## The page half
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createAnnotationSdk, isAnnotationArmed, readAnnotationToken } from "@weatherboard/annotation";
|
|
41
|
+
|
|
42
|
+
if (isAnnotationArmed(document.cookie)) {
|
|
43
|
+
const sdk = createAnnotationSdk({
|
|
44
|
+
parentOrigin: "https://console.example.com",
|
|
45
|
+
build: { gitSha: process.env.NEXT_PUBLIC_GIT_SHA, env: "preview" },
|
|
46
|
+
});
|
|
47
|
+
sdk.start();
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**The SDK must not boot unless the page is armed.** Arming is a token in the URL
|
|
52
|
+
matched against a secret the app holds, set as a cookie by your middleware. The
|
|
53
|
+
package gives you the reader and the cookie check; deciding whether the token is
|
|
54
|
+
right is yours, because only you hold the secret.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// middleware.ts
|
|
58
|
+
import { ANNOTATION_ARMED_COOKIE, readAnnotationToken } from "@weatherboard/annotation/protocol";
|
|
59
|
+
|
|
60
|
+
const token = readAnnotationToken(request.nextUrl.searchParams);
|
|
61
|
+
if (token !== undefined && token === process.env.ANNOTATION_TOKEN) {
|
|
62
|
+
response.cookies.set(ANNOTATION_ARMED_COOKIE, "1", { httpOnly: false, sameSite: "lax" });
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`readAnnotationToken` returns the **value**, not a boolean, and returns `null`
|
|
67
|
+
for an absent *or empty* parameter. An empty token compared with `===` against
|
|
68
|
+
your secret is a rejection; an empty token treated as present is an app armed
|
|
69
|
+
with the empty string, which fails later and reads like a bad secret rather than
|
|
70
|
+
a missing one.
|
|
71
|
+
|
|
72
|
+
## Source refs
|
|
73
|
+
|
|
74
|
+
Target resolution reads a `data-src` attribute of the form `path/to/File.tsx:120`
|
|
75
|
+
off the nearest ancestor that has one. Something has to put it there:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm i -D @weatherboard/annotation-next
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// next.config.ts
|
|
83
|
+
import { withSourceRefs } from "@weatherboard/annotation-next";
|
|
84
|
+
|
|
85
|
+
export default withSourceRefs(nextConfig, {
|
|
86
|
+
enabled: process.env.VERCEL_ENV !== "production",
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It is a separate package because npm installs a package's `dependencies`
|
|
91
|
+
whichever subpath you import, and the transform needs a Babel parser. Keeping
|
|
92
|
+
them together would put that parser in the `node_modules` of every site that
|
|
93
|
+
only ever wanted to read a cookie.
|
|
94
|
+
|
|
95
|
+
Nothing on a page carries `data-src` by default, and a page with none produces
|
|
96
|
+
captures that succeed and are worthless: a box, a DOM path, and no file. If your
|
|
97
|
+
captures all report no source ref, the transform is not running.
|
|
98
|
+
|
|
99
|
+
## Compatibility
|
|
100
|
+
|
|
101
|
+
`0.x`. The wire protocol is still moving, and the version says so. The move to
|
|
102
|
+
1.0 will be the statement that it has stopped — not a decision to look finished.
|
|
103
|
+
|
|
104
|
+
Two of the wire names have a legacy spelling that readers still accept. See
|
|
105
|
+
`protocol.ts`; they are not changing again.
|
|
106
|
+
|
|
107
|
+
## Licence
|
|
108
|
+
|
|
109
|
+
MIT.
|
package/dist/host.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var s=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var p=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(e,t)=>{for(var a in t)s(e,a,{get:t[a],enumerable:!0})},f=(e,t,a,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of p(t))!l.call(e,r)&&r!==a&&s(e,r,{get:()=>t[r],enumerable:!(o=c(t,r))||o.enumerable});return e};var m=e=>f(s({},"__esModule",{value:!0}),e);var T={};g(T,{createAnnotationHost:()=>A,isFrameMessage:()=>d});module.exports=m(T);function u(e){if(!e||e==="*")throw new Error("[annotation] a concrete parent origin is required; '*' would broadcast annotation payloads to any embedder.");return e}var y=new Set(["annotate:ready","annotate:hover","annotate:capture","annotate:cancel"]);function d(e){if(typeof e!="object"||e===null)return!1;let t=e.type;return typeof t=="string"&&y.has(t)}function A(e){let t=u(e.frameOrigin),a=!1,o=!1,r=n=>{o||e.frame.postMessage(n,t)},i=n=>{if(n.origin===t&&n.source===e.frame&&d(n.data))switch(n.data.type){case"annotate:ready":a=!0,e.onReady({sdkVersion:n.data.sdkVersion,build:n.data.build});return;case"annotate:hover":e.onHover(n.data.target);return;case"annotate:capture":e.onCapture(n.data.payload);return;case"annotate:cancel":e.onCancel();return}};return e.hostWindow.addEventListener("message",i),{enable:()=>r({type:"annotate:enable"}),disable:()=>r({type:"annotate:disable"}),ping:()=>r({type:"annotate:ping"}),isReady:()=>a,destroy(){o=!0,a=!1,e.hostWindow.removeEventListener("message",i)}}}
|
|
2
|
+
//# sourceMappingURL=host.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/host.ts", "../src/protocol.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n assertConcreteOrigin,\n type AnnotationPayload,\n type BuildInfo,\n type FrameToParent,\n type ParentToFrame,\n type TargetInfo,\n} from \"./protocol\";\n\n/**\n * The parent half of the annotation channel (A-30).\n *\n * WHY THIS LIVES IN THE SDK AND NOT IN `apps/admin`.\n *\n * The protocol is this package's domain. It already owns the message union,\n * `isParentMessage` and `assertConcreteOrigin`; a second implementation of the\n * same handshake, written host-side because that is where the UI is, is exactly\n * the shape SHARED-PACKAGES.md warns about. The two halves of a protocol\n * drifting apart is not a hypothetical \u2014 it is the normal outcome when they are\n * maintained in different packages by different changes.\n *\n * Concretely: the frame's `sdk.ts` and this file are the only two things that\n * can disagree about what `annotate:capture` means, and keeping them in one\n * package means a change to the union is a compile error in both.\n *\n * Admin gets the UI. This gets the wire.\n */\n\nexport interface AnnotationHostOptions {\n /**\n * The window being framed. Passed in rather than looked up, because the host\n * knows which iframe it means and this module must never guess.\n */\n frame: Window;\n /**\n * The frame's origin \u2014 where the product is served from. Required and\n * concrete, checked by `assertConcreteOrigin`: posting to `*` would hand an\n * annotation payload, including console errors from a real session, to\n * whatever happens to be loaded.\n */\n frameOrigin: string;\n /** The window that receives messages. Injected so tests need no real iframe. */\n hostWindow: Window;\n\n onReady(info: { sdkVersion: string; build: BuildInfo }): void;\n onHover(target: TargetInfo): void;\n onCapture(payload: AnnotationPayload): void;\n onCancel(): void;\n}\n\nexport interface AnnotationHost {\n enable(): void;\n disable(): void;\n ping(): void;\n /** True once the frame has announced itself. */\n isReady(): boolean;\n destroy(): void;\n}\n\nconst FRAME_TYPES = new Set([\n \"annotate:ready\",\n \"annotate:hover\",\n \"annotate:capture\",\n \"annotate:cancel\",\n]);\n\n/**\n * Is this one of ours?\n *\n * The mirror of `isParentMessage`, and it carries the same warning: origin is\n * checked separately and FIRST. A well-formed message from the wrong origin is\n * precisely the case this guard must not be trusted to catch \u2014 anything can\n * post `{type: \"annotate:capture\"}` to a window.\n */\nexport function isFrameMessage(data: unknown): data is FrameToParent {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && FRAME_TYPES.has(type);\n}\n\nexport function createAnnotationHost(options: AnnotationHostOptions): AnnotationHost {\n const frameOrigin = assertConcreteOrigin(options.frameOrigin);\n let ready = false;\n let destroyed = false;\n\n const post = (message: ParentToFrame) => {\n if (destroyed) return;\n // Targeted, never `*`. `enable` is not sensitive in itself, but a habit of\n // posting to `*` is what eventually posts something that is.\n options.frame.postMessage(message, frameOrigin);\n };\n\n const onMessage = (event: MessageEvent) => {\n /**\n * Origin first, and by exact match.\n *\n * Not `endsWith`, not a prefix: for a product served from `example.com`,\n * `https://evil-example.com` ends with nothing suspicious and\n * `https://example.com.evil.test` starts with the right characters. The browser hands us a normalised origin, so an\n * equality test is both correct and the only one that cannot be widened by\n * a hostile hostname.\n */\n if (event.origin !== frameOrigin) return;\n\n /**\n * And the source must be the frame we are talking to.\n *\n * Origin alone is not enough: any other window on the same origin \u2014 a popup\n * the product opened, another iframe \u2014 can post a well-formed message from\n * an origin that matches. This is the check that makes \"the frame said so\"\n * mean the frame.\n */\n if (event.source !== options.frame) return;\n\n if (!isFrameMessage(event.data)) return;\n\n switch (event.data.type) {\n case \"annotate:ready\":\n ready = true;\n options.onReady({ sdkVersion: event.data.sdkVersion, build: event.data.build });\n return;\n case \"annotate:hover\":\n options.onHover(event.data.target);\n return;\n case \"annotate:capture\":\n options.onCapture(event.data.payload);\n return;\n case \"annotate:cancel\":\n options.onCancel();\n return;\n }\n };\n\n options.hostWindow.addEventListener(\"message\", onMessage);\n\n return {\n enable: () => post({ type: \"annotate:enable\" }),\n disable: () => post({ type: \"annotate:disable\" }),\n ping: () => post({ type: \"annotate:ping\" }),\n isReady: () => ready,\n destroy() {\n destroyed = true;\n ready = false;\n options.hostWindow.removeEventListener(\"message\", onMessage);\n },\n };\n}\n", "/**\n * The annotation postMessage protocol (A-36).\n *\n * Mirrors `docs/contracts/types.ts`. That file is the\n * cross-component contract; this is the runtime copy the product bundle ships,\n * because the product cannot import from a docs directory. Changing a shape\n * here is a change to both \u2014 say so in the PR.\n */\n\nexport const SDK_VERSION = \"1\";\n\n/**\n * ===========================================================================\n * THE ATTRIBUTES \u2014 THE OTHER WIRE (A-186)\n * ===========================================================================\n *\n * These live in `protocol.ts`, not in `target.ts` where they are read, because\n * they are a contract between TWO PACKAGES that a consumer installs separately:\n * `@weatherboard/annotation-next` WRITES them at build time and this package\n * READS them at runtime. Two spellings of `data-src` is not a bug anybody\n * catches \u2014 the transform runs, the attributes appear, and every capture comes\n * back with no source ref at all, which is indistinguishable from the transform\n * not running.\n *\n * `annotation-next` imports these from `@weatherboard/annotation/protocol`\n * rather than declaring its own, which is why the subpath has no DOM in it: a\n * build tool importing the page SDK to learn two strings would be absurd.\n */\nexport const SRC_ATTR = \"data-src\";\nexport const NAME_ATTR = \"data-src-name\";\n\nexport interface SourceRef {\n file: string;\n line: number;\n column?: number;\n}\n\nexport interface BuildInfo {\n /** Mandatory. Feedback not pinned to a revision is ambiguous the moment someone merges. */\n gitSha: string;\n /**\n * Where this build came from.\n *\n * `\"development\"` was added in A-37, when the SDK was first mounted in a\n * real app and the two halves turned out not to agree: `resolveBuildInfo` in\n * `resolveBuildInfo` in the product's own Next config package \u2014 the module\n * that stamps the build-env variable, and the only producer of this field \u2014\n * emits\n * `production | preview | development`, mirroring `VERCEL_ENV`. This union\n * instead offered `\"staging\"`, which nothing produces: a staging deploy IS a\n * Vercel preview and reports itself as one. Widening rather than forcing the\n * mount to lie: a locally-annotated build calling itself a preview would put\n * someone's laptop in the corpus as though it were a deployment.\n */\n env: \"production\" | \"preview\" | \"staging\" | \"development\";\n branch?: string;\n /** Set when reviewing an agent's preview deploy, so feedback routes to that run's session. */\n previewRunId?: string;\n}\n\nexport interface ConsoleEntry {\n level: \"error\" | \"warn\";\n message: string;\n stack?: string;\n at: string;\n}\n\nexport interface NetworkEntry {\n url: string;\n method: string;\n status: number;\n at: string;\n}\n\nexport interface TargetInfo {\n /**\n * What the reviewer selected \u2014 one element, or an area (A-179).\n *\n * -------------------------------------------------------------------------\n * WHY THIS EXISTS RATHER THAN BEING INFERRED\n * -------------------------------------------------------------------------\n *\n * A region capture and an element capture are the same SHAPE: a bounding box,\n * a chain of source refs, a DOM path. Nothing in the values distinguishes\n * them, and the two mean opposite things about the chain \u2014 for an element it\n * is ANCESTORS, innermost first, so entry two contains entry one; for a\n * region it is SIBLINGS the rectangle happened to cross, which contain\n * nothing of each other.\n *\n * A reader that guesses will describe a region's siblings as a call-site\n * chain, which is a confident false statement about the page. `describeCapture`\n * hands that description to a model that writes acceptance criteria from it.\n *\n * -------------------------------------------------------------------------\n * WHY IT IS OPTIONAL, AND WHY ABSENT MEANS `element`\n * -------------------------------------------------------------------------\n *\n * The two halves of this protocol live in different repositories and deploy\n * independently (see the note on the legacy parameter names below). Every\n * capture in the store today, and every capture from a product bundle that\n * predates region selection, has no such field \u2014 and all of them are element\n * captures. Absent therefore has to mean `element`, or this field would\n * retroactively make the entire history ambiguous.\n *\n * Required-with-a-default would have the same effect and a worse failure: an\n * older bundle omits it, the type says it is there, and every reader believes\n * a value nobody sent.\n */\n selection?: \"element\" | \"region\";\n source?: SourceRef;\n /**\n * Every `data-src` from the clicked element up to the document root, innermost\n * first \u2014 an addition to the contract, and the reason is structural.\n *\n * Only host elements carry a ref (A-33), so `source` names the file that\n * *writes the markup*. For a composed design-system component that is\n * `MediaCard.tsx`, not the `GuideDetailCard.tsx` a human would say they\n * clicked. The chain is what lets the PM agent and the coding agent see both,\n * and pick the call site over the leaf when the feedback is about content\n * rather than the component itself.\n */\n sourceChain?: SourceRef[];\n componentName?: string;\n /** CSS selector fallback, for when no ancestor carries a ref at all. */\n domPath: string;\n boundingBox: { x: number; y: number; w: number; h: number };\n /** Truncated to 200 characters. */\n textContent?: string;\n}\n\nexport interface AnnotationPayload {\n schemaVersion: 1;\n capturedAt: string;\n build: BuildInfo;\n page: {\n url: string;\n /** The matched route pattern, not the resolved URL. */\n route: string;\n viewport: { w: number; h: number; dpr: number };\n userAgent: string;\n };\n target: TargetInfo;\n state: {\n consoleErrors: ConsoleEntry[];\n failedRequests: NetworkEntry[];\n featureFlags: Record<string, unknown>;\n };\n screenshot?: { dataUrl: string; region: \"element\" | \"viewport\" };\n /** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */\n comment: string;\n}\n\nexport type ParentToFrame =\n | { type: \"annotate:enable\" }\n | { type: \"annotate:disable\" }\n | { type: \"annotate:ping\" };\n\nexport type FrameToParent =\n | { type: \"annotate:ready\"; sdkVersion: string; build: BuildInfo }\n | { type: \"annotate:hover\"; target: TargetInfo }\n | { type: \"annotate:capture\"; payload: AnnotationPayload }\n | { type: \"annotate:cancel\" };\n\nconst PARENT_TYPES = new Set([\"annotate:enable\", \"annotate:disable\", \"annotate:ping\"]);\n\n/**\n * Is this a message we recognise?\n *\n * Origin is checked separately and first \u2014 a well-formed message from the wrong\n * origin is the case this guard must not be trusted to catch.\n */\nexport function isParentMessage(data: unknown): data is ParentToFrame {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && PARENT_TYPES.has(type);\n}\n\n/**\n * Reject wildcards outright rather than treating them as \"any origin\".\n *\n * `postMessage(msg, \"*\")` broadcasts the payload to whatever happens to be\n * framing us, and an annotation payload carries page content, console errors and\n * failed request URLs. The spec's rule is no wildcards in either direction; this\n * makes it impossible to configure one by accident.\n */\nexport function assertConcreteOrigin(origin: string): string {\n if (!origin || origin === \"*\") {\n throw new Error(\n \"[annotation] a concrete parent origin is required; '*' would broadcast \" +\n \"annotation payloads to any embedder.\",\n );\n }\n return origin;\n}\n\n/**\n * The query parameter that arms the SDK in a managed product, and the cookie\n * that records it.\n *\n * THESE LIVE IN THE PROTOCOL BECAUSE THE TWO HALVES ARE NOW IN DIFFERENT\n * REPOSITORIES. The managed product's middleware looks for the parameter and\n * sets the cookie; the management app appends the parameter and the SDK reads\n * the cookie. While both halves were one monorepo, a test could read the other\n * side's source and assert the strings matched \u2014 that gate does not survive a\n * repository split, and a mismatch produces no error at all: the frame loads,\n * the cookie is never set, the SDK never boots, and the reviewer sees \"waiting\n * for the page to announce itself\" with nothing to go on.\n *\n * So they stop being two spellings that agree and become one exported\n * constant. A managed product that imports this package cannot drift from it.\n */\nexport const ANNOTATION_TOKEN_PARAM = \"ai_annotate\";\nexport const ANNOTATION_ARMED_COOKIE = \"ai_annotate_armed\";\n\n/**\n * The names these used to have (A-98).\n *\n * -------------------------------------------------------------------------\n * WHY THE RENAME, AND WHY IT COULD NOT JUST BE DONE\n * -------------------------------------------------------------------------\n *\n * They were `pits_annotate` and `pits_annotate_armed` \u2014 one managed product's\n * initialism, in a contract every managed product's bundle has to implement.\n * Onboarding a second product meant asking an unrelated codebase to adopt the\n * first product's name in its cookies and its query strings. That is the bug\n * A-98 is about, and it is a bug in a wire protocol rather than in a variable\n * name, which is what makes it expensive.\n *\n * The two halves live in DIFFERENT REPOSITORIES and deploy independently. A\n * clean rename is therefore a breaking change to every product bundle\n * simultaneously, and its failure mode is silence: the frame loads, the cookie\n * is never set, the SDK never boots, and the reviewer sees \"waiting for the\n * page to announce itself\" with nothing to go on. Nothing throws, nothing logs,\n * and the natural reading is that the agent broke the site.\n *\n * -------------------------------------------------------------------------\n * THE MIGRATION: READ BOTH, WRITE BOTH, THEN STOP WRITING THE OLD ONE\n * -------------------------------------------------------------------------\n *\n * 1. **Now.** Every reader accepts either name. Every writer emits BOTH, so a\n * product still running the old bundle arms on the legacy name and one\n * running the new bundle arms on the new one. Deploy order stops mattering,\n * which is the whole point \u2014 there is no window in which a reviewer sees the\n * silent failure above.\n * 2. **After every managed product's bundle is on this version** (A-102). Drop\n * the legacy names from the writers, then from the readers. Two steps, not\n * one: a reader that stops accepting the old name before the writers stop\n * emitting it re-creates the outage in the other direction.\n *\n * The legacy names are exported rather than inlined so the deletion in step 2\n * is `git grep LEGACY_` rather than an archaeology exercise.\n */\nexport const LEGACY_ANNOTATION_TOKEN_PARAM = \"pits_annotate\";\nexport const LEGACY_ANNOTATION_ARMED_COOKIE = \"pits_annotate_armed\";\n\n/**\n * ===========================================================================\n * AND THE NAMES ARE NOT CHANGING AGAIN FOR THE PUBLISHED PACKAGE (A-184)\n * ===========================================================================\n *\n * When this becomes `@weatherboard/annotation` the obvious next thought is that\n * the wire names should match the package \u2014 `weatherboard_annotate`. They\n * should not, and the reason is the migration described above rather than\n * taste.\n *\n * That migration is STILL IN FLIGHT. Readers accept both spellings; writers\n * emit both; step 2 waits on every managed product's bundle being current.\n * A third spelling introduced now does not replace the second, it joins it:\n * every reader accepts three names, every writer emits three, and the window in\n * which a product can be on any of them widens instead of closing.\n *\n * The cost is not the code. It is that each rename buys another instance of the\n * failure this file exists to prevent \u2014 the frame loads, the cookie is never\n * set, the SDK never boots, and the reviewer sees \"waiting for the page to\n * announce itself\" with nothing to go on. Twice is a migration. Three times is\n * a habit, and the third one would be paid by consumers who never asked for\n * either.\n *\n * `ai_annotate` is already product-neutral, which was the entire point of A-98.\n * A published package needs names that name no product; it does not need names\n * that name the package. Nobody reading a query string cares which npm scope\n * the library came from.\n *\n * The one real objection is collision: `ai_` is a common prefix and some other\n * tool could plausibly use it. That is survivable by construction \u2014 arming\n * requires the parameter to carry a token the product compares exactly, so a\n * coincidental `?ai_annotate=` from an unrelated tool arms nothing. A colliding\n * COOKIE cannot false-positive either; `isAnnotationArmed` matches on the\n * delimiter rather than by substring, for exactly this reason.\n *\n * If a rename is ever genuinely warranted, it happens ONCE, after step 2 has\n * landed and the reader accepts one name again \u2014 not layered on top of an\n * unfinished one.\n */\n\n/** Both spellings of the query parameter, current first. */\nexport const ANNOTATION_TOKEN_PARAMS = [\n ANNOTATION_TOKEN_PARAM,\n LEGACY_ANNOTATION_TOKEN_PARAM,\n] as const;\n\n/** Both spellings of the cookie, current first. */\nexport const ANNOTATION_ARMED_COOKIES = [\n ANNOTATION_ARMED_COOKIE,\n LEGACY_ANNOTATION_ARMED_COOKIE,\n] as const;\n\n/**\n * Whether a cookie jar carries the armed cookie, under either name.\n *\n * Delimiter-aware rather than a substring test: a cookie named\n * `not_ai_annotate_armed`, or another cookie whose VALUE contains the name,\n * satisfies `includes` \u2014 and arming an annotation channel on a coincidence is\n * not a failure anyone would think to look for.\n */\nexport function isAnnotationArmed(cookie: string): boolean {\n return cookie\n .split(\";\")\n .some((entry) => {\n const trimmed = entry.trimStart();\n return ANNOTATION_ARMED_COOKIES.some((name) => trimmed.startsWith(`${name}=`));\n });\n}\n\n/**\n * The arming token from a URL's query string, under either name.\n *\n * Current name wins when both are present. They should never disagree \u2014 the\n * management app writes both from one value \u2014 but if they ever do, the one this\n * version of the protocol owns is the one to believe.\n */\nexport function readAnnotationToken(params: URLSearchParams): string | null {\n for (const name of ANNOTATION_TOKEN_PARAMS) {\n const value = params.get(name);\n if (value !== null && value.length > 0) return value;\n }\n return null;\n}\n"],
|
|
5
|
+
"mappings": "yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,0BAAAE,EAAA,mBAAAC,IAAA,eAAAC,EAAAJ,GCyLO,SAASK,EAAqBC,EAAwB,CAC3D,GAAI,CAACA,GAAUA,IAAW,IACxB,MAAM,IAAI,MACR,6GAEF,EAEF,OAAOA,CACT,CDtIA,IAAMC,EAAc,IAAI,IAAI,CAC1B,iBACA,iBACA,mBACA,iBACF,CAAC,EAUM,SAASC,EAAeC,EAAsC,CACnE,GAAI,OAAOA,GAAS,UAAYA,IAAS,KAAM,MAAO,GACtD,IAAMC,EAAQD,EAA4B,KAC1C,OAAO,OAAOC,GAAS,UAAYH,EAAY,IAAIG,CAAI,CACzD,CAEO,SAASC,EAAqBC,EAAgD,CACnF,IAAMC,EAAcC,EAAqBF,EAAQ,WAAW,EACxDG,EAAQ,GACRC,EAAY,GAEVC,EAAQC,GAA2B,CACnCF,GAGJJ,EAAQ,MAAM,YAAYM,EAASL,CAAW,CAChD,EAEMM,EAAaC,GAAwB,CAUzC,GAAIA,EAAM,SAAWP,GAUjBO,EAAM,SAAWR,EAAQ,OAExBJ,EAAeY,EAAM,IAAI,EAE9B,OAAQA,EAAM,KAAK,KAAM,CACvB,IAAK,iBACHL,EAAQ,GACRH,EAAQ,QAAQ,CAAE,WAAYQ,EAAM,KAAK,WAAY,MAAOA,EAAM,KAAK,KAAM,CAAC,EAC9E,OACF,IAAK,iBACHR,EAAQ,QAAQQ,EAAM,KAAK,MAAM,EACjC,OACF,IAAK,mBACHR,EAAQ,UAAUQ,EAAM,KAAK,OAAO,EACpC,OACF,IAAK,kBACHR,EAAQ,SAAS,EACjB,MACJ,CACF,EAEA,OAAAA,EAAQ,WAAW,iBAAiB,UAAWO,CAAS,EAEjD,CACL,OAAQ,IAAMF,EAAK,CAAE,KAAM,iBAAkB,CAAC,EAC9C,QAAS,IAAMA,EAAK,CAAE,KAAM,kBAAmB,CAAC,EAChD,KAAM,IAAMA,EAAK,CAAE,KAAM,eAAgB,CAAC,EAC1C,QAAS,IAAMF,EACf,SAAU,CACRC,EAAY,GACZD,EAAQ,GACRH,EAAQ,WAAW,oBAAoB,UAAWO,CAAS,CAC7D,CACF,CACF",
|
|
6
|
+
"names": ["host_exports", "__export", "createAnnotationHost", "isFrameMessage", "__toCommonJS", "assertConcreteOrigin", "origin", "FRAME_TYPES", "isFrameMessage", "data", "type", "createAnnotationHost", "options", "frameOrigin", "assertConcreteOrigin", "ready", "destroyed", "post", "message", "onMessage", "event"]
|
|
7
|
+
}
|
package/dist/host.d.cts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type AnnotationPayload, type BuildInfo, type FrameToParent, type TargetInfo } from "./protocol.cjs";
|
|
2
|
+
/**
|
|
3
|
+
* The parent half of the annotation channel (A-30).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS LIVES IN THE SDK AND NOT IN `apps/admin`.
|
|
6
|
+
*
|
|
7
|
+
* The protocol is this package's domain. It already owns the message union,
|
|
8
|
+
* `isParentMessage` and `assertConcreteOrigin`; a second implementation of the
|
|
9
|
+
* same handshake, written host-side because that is where the UI is, is exactly
|
|
10
|
+
* the shape SHARED-PACKAGES.md warns about. The two halves of a protocol
|
|
11
|
+
* drifting apart is not a hypothetical — it is the normal outcome when they are
|
|
12
|
+
* maintained in different packages by different changes.
|
|
13
|
+
*
|
|
14
|
+
* Concretely: the frame's `sdk.ts` and this file are the only two things that
|
|
15
|
+
* can disagree about what `annotate:capture` means, and keeping them in one
|
|
16
|
+
* package means a change to the union is a compile error in both.
|
|
17
|
+
*
|
|
18
|
+
* Admin gets the UI. This gets the wire.
|
|
19
|
+
*/
|
|
20
|
+
export interface AnnotationHostOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The window being framed. Passed in rather than looked up, because the host
|
|
23
|
+
* knows which iframe it means and this module must never guess.
|
|
24
|
+
*/
|
|
25
|
+
frame: Window;
|
|
26
|
+
/**
|
|
27
|
+
* The frame's origin — where the product is served from. Required and
|
|
28
|
+
* concrete, checked by `assertConcreteOrigin`: posting to `*` would hand an
|
|
29
|
+
* annotation payload, including console errors from a real session, to
|
|
30
|
+
* whatever happens to be loaded.
|
|
31
|
+
*/
|
|
32
|
+
frameOrigin: string;
|
|
33
|
+
/** The window that receives messages. Injected so tests need no real iframe. */
|
|
34
|
+
hostWindow: Window;
|
|
35
|
+
onReady(info: {
|
|
36
|
+
sdkVersion: string;
|
|
37
|
+
build: BuildInfo;
|
|
38
|
+
}): void;
|
|
39
|
+
onHover(target: TargetInfo): void;
|
|
40
|
+
onCapture(payload: AnnotationPayload): void;
|
|
41
|
+
onCancel(): void;
|
|
42
|
+
}
|
|
43
|
+
export interface AnnotationHost {
|
|
44
|
+
enable(): void;
|
|
45
|
+
disable(): void;
|
|
46
|
+
ping(): void;
|
|
47
|
+
/** True once the frame has announced itself. */
|
|
48
|
+
isReady(): boolean;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Is this one of ours?
|
|
53
|
+
*
|
|
54
|
+
* The mirror of `isParentMessage`, and it carries the same warning: origin is
|
|
55
|
+
* checked separately and FIRST. A well-formed message from the wrong origin is
|
|
56
|
+
* precisely the case this guard must not be trusted to catch — anything can
|
|
57
|
+
* post `{type: "annotate:capture"}` to a window.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isFrameMessage(data: unknown): data is FrameToParent;
|
|
60
|
+
export declare function createAnnotationHost(options: AnnotationHostOptions): AnnotationHost;
|