@weatherboard/annotation 0.2.2 → 0.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.
- package/ADOPTING.md +52 -0
- package/README.md +9 -1
- package/dist/anchor.cjs +2 -0
- package/dist/anchor.cjs.map +7 -0
- package/dist/anchor.d.cts +295 -0
- package/dist/anchor.d.mts +295 -0
- package/dist/anchor.d.ts +295 -0
- package/dist/anchor.mjs +2 -0
- package/dist/anchor.mjs.map +7 -0
- package/dist/arming.d.cts +30 -2
- package/dist/arming.d.mts +30 -2
- package/dist/arming.d.ts +30 -2
- package/dist/host.cjs +1 -1
- package/dist/host.cjs.map +1 -1
- package/dist/host.mjs.map +1 -1
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/index.mjs.map +3 -3
- package/dist/markers.cjs +57 -0
- package/dist/markers.cjs.map +7 -0
- package/dist/markers.d.cts +246 -0
- package/dist/markers.d.mts +246 -0
- package/dist/markers.d.ts +246 -0
- package/dist/markers.mjs +57 -0
- package/dist/markers.mjs.map +7 -0
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.cjs.map +3 -3
- package/dist/protocol.d.cts +29 -0
- package/dist/protocol.d.mts +29 -0
- package/dist/protocol.d.ts +29 -0
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.mjs.map +3 -3
- package/dist/read.cjs +2 -0
- package/dist/read.cjs.map +7 -0
- package/dist/read.d.cts +149 -0
- package/dist/read.d.mts +149 -0
- package/dist/read.d.ts +149 -0
- package/dist/read.mjs +2 -0
- package/dist/read.mjs.map +7 -0
- package/dist/transport.d.cts +7 -1
- package/dist/transport.d.mts +7 -1
- package/dist/transport.d.ts +7 -1
- package/package.json +48 -5
package/dist/read.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Anchor } from "./anchor.js";
|
|
2
|
+
import { type FetchLike } from "./transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reading the captures already made on this page (A-276).
|
|
5
|
+
*
|
|
6
|
+
* ===========================================================================
|
|
7
|
+
* WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`
|
|
8
|
+
* ===========================================================================
|
|
9
|
+
*
|
|
10
|
+
* `./anchor`'s note applies unchanged: this runs on the product page and only
|
|
11
|
+
* for a product that RENDERS markers, while the SDK that only captures does
|
|
12
|
+
* not need a byte of it. Folding it into `index` would tax every page of every
|
|
13
|
+
* managed product with code most of them never call.
|
|
14
|
+
*
|
|
15
|
+
* ===========================================================================
|
|
16
|
+
* WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER
|
|
17
|
+
* ===========================================================================
|
|
18
|
+
*
|
|
19
|
+
* A-268 is the reason, and it is worth restating because it cost a night. The
|
|
20
|
+
* SDK posted `{type, payload}` with the comment nested inside `payload`; the
|
|
21
|
+
* ingest read `comment` as a sibling of it. Both halves were correct. Both were
|
|
22
|
+
* tested. The two suites had different ideas of the bytes, and nothing anywhere
|
|
23
|
+
* held the two ends of the same string — a human clicking Send on staging is
|
|
24
|
+
* what found it.
|
|
25
|
+
*
|
|
26
|
+
* So the request this file builds and the response it parses are the ones the
|
|
27
|
+
* console's `read-wire.test.ts` runs against the REAL handler: SDK client →
|
|
28
|
+
* real URL and headers → real `GET` export → real response → this parser. If
|
|
29
|
+
* the two ends disagree about a field name, that test is red on the commit that
|
|
30
|
+
* did it rather than on a staging page a week later.
|
|
31
|
+
*
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
* EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE
|
|
34
|
+
* ===========================================================================
|
|
35
|
+
*
|
|
36
|
+
* `transport.ts`'s `safeIssueUrl` header has the full argument; the same one
|
|
37
|
+
* applies here with more surface. A marker's `comment` and `reviewer` are
|
|
38
|
+
* rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken
|
|
39
|
+
* console must not be able to put a screenful of control characters into a
|
|
40
|
+
* panel nobody can dismiss. Strings only, bounded, control characters dropped;
|
|
41
|
+
* anything that fails is dropped rather than repaired, and a marker that cannot
|
|
42
|
+
* be read is SKIPPED rather than rendered half-built.
|
|
43
|
+
*
|
|
44
|
+
* ===========================================================================
|
|
45
|
+
* THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED
|
|
46
|
+
* ===========================================================================
|
|
47
|
+
*
|
|
48
|
+
* The console and the product deploy on different cadences from different
|
|
49
|
+
* repositories, so an SDK meeting a newer console is the normal state. A marker
|
|
50
|
+
* whose `anchor.kind` this version does not recognise is kept in the counts and
|
|
51
|
+
* placed by nothing: `resolveAnchor` already answers `"none"` for an unknown
|
|
52
|
+
* kind. It must never be guessed at — a marker on the wrong element does not
|
|
53
|
+
* degrade this feature, it inverts it.
|
|
54
|
+
*/
|
|
55
|
+
/** The wire version this client was written against. */
|
|
56
|
+
export declare const ANNOTATION_READ_WIRE_VERSION = 1;
|
|
57
|
+
export type AnnotationStatus = "open" | "in-progress" | "done" | "cancelled" | "unknown";
|
|
58
|
+
export interface AnnotationStatusView {
|
|
59
|
+
state: AnnotationStatus;
|
|
60
|
+
display: string;
|
|
61
|
+
}
|
|
62
|
+
export interface AnnotationMarker {
|
|
63
|
+
id: string;
|
|
64
|
+
anchor: Anchor;
|
|
65
|
+
comment: string;
|
|
66
|
+
/** A display name. The console never sends an email; this never expects one. */
|
|
67
|
+
reviewer: string;
|
|
68
|
+
createdAt: string;
|
|
69
|
+
status: AnnotationStatusView;
|
|
70
|
+
}
|
|
71
|
+
export type AnnotationsReason = "no-path" | "scan-truncated" | "capped" | "rate-limited" | "not-authorised"
|
|
72
|
+
/** The token is a format the console has retired (A-277). Press the button again. */
|
|
73
|
+
| "retired-link";
|
|
74
|
+
/**
|
|
75
|
+
* The counted envelope, never a bare array.
|
|
76
|
+
*
|
|
77
|
+
* On a read path zero markers is exactly what "working, nothing here" looks
|
|
78
|
+
* like, and at least nine causes produce zero. A caller renders these counts —
|
|
79
|
+
* *"3 annotations here, 1 shown"* — so that a failure disagrees with itself
|
|
80
|
+
* instead of being absent. The numerator is the PAGE's tally and the denominator
|
|
81
|
+
* is `total`; `anchored` is a diagnostic and not the page's answer. See
|
|
82
|
+
* `./markers`.
|
|
83
|
+
*/
|
|
84
|
+
export interface AnnotationsResponse {
|
|
85
|
+
ok: boolean;
|
|
86
|
+
wireVersion: number;
|
|
87
|
+
asOf: string;
|
|
88
|
+
total: number;
|
|
89
|
+
/**
|
|
90
|
+
* The CONSOLE's count of servable anchors, not a count of markers on screen.
|
|
91
|
+
*
|
|
92
|
+
* Named `anchored` and not `placed` (A-277): the server has no DOM, so what it
|
|
93
|
+
* counts is whether an anchor COULD resolve to something — a chain, a
|
|
94
|
+
* dom-path, or a route path — while whether one DID is `tallyAnchors` in
|
|
95
|
+
* `./anchor`, measured against this document. The two disagree routinely; a
|
|
96
|
+
* listing page yields about 25% unique resolution. The word `placed` belongs
|
|
97
|
+
* to the page, because that is what a human means by it.
|
|
98
|
+
*/
|
|
99
|
+
anchored: number;
|
|
100
|
+
anchorless: number;
|
|
101
|
+
reason?: AnnotationsReason;
|
|
102
|
+
annotations: AnnotationMarker[];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse a response body into an envelope, whatever it turns out to be.
|
|
106
|
+
*
|
|
107
|
+
* NEVER THROWS, and never returns `undefined`. A caller on a product page has to
|
|
108
|
+
* render something, and "the console said something I could not read" is a state
|
|
109
|
+
* with an answer — an envelope whose counts are zero and whose `ok` is false —
|
|
110
|
+
* rather than an exception that takes the marker layer down.
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseAnnotationsResponse(value: unknown): AnnotationsResponse;
|
|
113
|
+
export interface AnnotationReaderOptions {
|
|
114
|
+
/** The console's ingest URL — the same one `httpTransport` posts to. */
|
|
115
|
+
endpoint: string;
|
|
116
|
+
/**
|
|
117
|
+
* A READ-scoped claim, and never the capture token.
|
|
118
|
+
*
|
|
119
|
+
* The console refuses a write claim here and refuses this one at the ingest;
|
|
120
|
+
* the two are structurally different credentials with different lifetimes (one
|
|
121
|
+
* hour against four). Handing the capture token to this function does not
|
|
122
|
+
* degrade to a read — it fails, which is the intended outcome.
|
|
123
|
+
*/
|
|
124
|
+
readToken: string;
|
|
125
|
+
/** Own timeout, own catch. A hung read must not hang the page. */
|
|
126
|
+
timeoutMs?: number;
|
|
127
|
+
fetch?: FetchLike;
|
|
128
|
+
}
|
|
129
|
+
export declare const READ_TIMEOUT_MS = 8000;
|
|
130
|
+
export interface AnnotationReader {
|
|
131
|
+
/**
|
|
132
|
+
* The markers for one resolved path.
|
|
133
|
+
*
|
|
134
|
+
* `path` is `location.pathname` and NOT the route pattern. The pattern is one
|
|
135
|
+
* value for every page of a dynamic route, so asking by pattern returns
|
|
136
|
+
* another page's captures — measured, and the reason this parameter exists at
|
|
137
|
+
* all rather than being derived from the payload.
|
|
138
|
+
*
|
|
139
|
+
* Rejects only on a transport failure. Every answer the console gives,
|
|
140
|
+
* including a refusal and a 429, comes back as an envelope with a `reason`,
|
|
141
|
+
* because a caller cannot tell "your budget is spent" from "nothing here"
|
|
142
|
+
* out of a status code.
|
|
143
|
+
*/
|
|
144
|
+
read(path: string): Promise<AnnotationsResponse>;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The read client. `httpTransport`'s sibling, pointed the other way.
|
|
148
|
+
*/
|
|
149
|
+
export declare function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader;
|
package/dist/read.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function h(e){if(!e||e==="*")throw new Error("[annotation] a concrete ingest endpoint is required; '*' is not an address and an empty one posts nowhere.");let n;try{n=new URL(e)}catch{throw new Error(`[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(e)}; a relative one resolves against the product's own origin and the capture is lost there.`)}if(n.protocol!=="https:"&&n.protocol!=="http:")throw new Error(`[annotation] the ingest endpoint must be http(s), got ${n.protocol}`);return n.toString()}var R=1,g=["open","in-progress","done","cancelled","unknown"],m=4e3,p=200;function d(e,n){if(typeof e!="string")return;let r=e.replace(/[\u0000-\u001f\u007f-\u009f]+/g," ").replace(/\s+/g," ").trim();if(r.length!==0)return r.length>n?`${r.slice(0,n-1).trimEnd()}\u2026`:r}function l(e){return typeof e=="number"&&Number.isFinite(e)&&e>=0?Math.floor(e):0}function w(e){if(typeof e!="object"||e===null)return;let n=e,r=n.kind;if(r==="route"){let t=typeof n.path=="string"?n.path:void 0;return t===void 0?{kind:"route"}:{kind:"route",path:t}}if(r==="dom-path"){let t=typeof n.path=="string"?n.path:void 0;return t===void 0?void 0:{kind:"dom-path",path:t}}if(r==="source-ref"){let t=n.file,o=n.line,a=n.chain;if(typeof t!="string"||typeof o!="number"||!Array.isArray(a))return;let s=a.flatMap(f=>{if(typeof f!="object"||f===null)return[];let i=f;if(typeof i.file!="string"||typeof i.line!="number")return[];let c={file:i.file,line:i.line};return typeof i.column=="number"&&(c.column=i.column),[c]}),u={kind:"source-ref",file:t,line:o,chain:s};return typeof n.column=="number"&&(u.column=n.column),typeof n.ordinal=="number"&&(u.ordinal=n.ordinal),u}}function y(e){if(typeof e!="object"||e===null)return;let n=e,r=d(n.id,p),t=d(n.comment,m),o=w(n.anchor);if(r===void 0||t===void 0||o===void 0)return;let a=typeof n.status=="object"&&n.status!==null?n.status:{},s=a.state;return{id:r,anchor:o,comment:t,reviewer:d(n.reviewer,p)??"A reviewer",createdAt:d(n.createdAt,p)??"",status:{state:typeof s=="string"&&g.includes(s)?s:"unknown",display:d(a.display,p)??"Unknown"}}}function k(e){let n=typeof e=="object"&&e!==null?e:{},t=(Array.isArray(n.annotations)?n.annotations:[]).flatMap(a=>{let s=y(a);return s===void 0?[]:[s]}),o=n.reason;return{ok:n.ok===!0,wireVersion:l(n.wireVersion),asOf:d(n.asOf,p)??"",total:l(n.total),anchored:l(n.anchored),anchorless:l(n.anchorless),...typeof o=="string"?{reason:o}:{},annotations:t}}var A=8e3;function E(e){let n=h(e.endpoint);if(!e.readToken)throw new Error("[annotation] a read token is required; the console refuses an unsigned read and the page would render an empty marker layer with no way to say why.");let r=e.readToken,t=e.timeoutMs??A,o=e.fetch??globalThis.fetch;return{async read(a){if(!o)throw new Error("[annotation] no fetch implementation is available for the read");let s=new URL(n);s.searchParams.set("path",a);let u=new AbortController,f=setTimeout(()=>u.abort(),t);try{let i=await o(s.toString(),{method:"GET",headers:{authorization:`Bearer ${r}`},credentials:"omit",signal:u.signal}),c;try{c=await i.json?.()}catch{c=void 0}return k(c)}catch(i){throw u.signal.aborted?new Error(`[annotation] the read timed out after ${t}ms`,{cause:i}):i}finally{clearTimeout(f)}}}}export{R as ANNOTATION_READ_WIRE_VERSION,A as READ_TIMEOUT_MS,E as createAnnotationReader,k as parseAnnotationsResponse};
|
|
2
|
+
//# sourceMappingURL=read.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/transport.ts", "../src/read.ts"],
|
|
4
|
+
"sourcesContent": ["import { assertConcreteOrigin, type FrameToParent } from \"./protocol\";\n\n/**\n * Egress (A-239).\n *\n * ===========================================================================\n * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`\n * ===========================================================================\n *\n * `sdk.ts` had exactly one way out \u2014 `win.parent.postMessage(...)` \u2014 and\n * first-party there is no parent: `win.parent === win`. Every capture then\n * posts a message to the page that sent it and nothing receives it. **That\n * fails silently**, which is the shape to be careful of here: the overlay\n * behaves correctly, the reviewer clicks, and no issue is ever filed.\n *\n * See `docs/plans/first-party-annotation.md`. The console stops framing the\n * product and links out to it instead, so the capture has to leave the page\n * over HTTP. The frame is NOT being deleted \u2014 `postMessageTransport` is\n * today's behaviour, extracted unchanged, and it is still the default.\n *\n * ===========================================================================\n * TWO PROPERTIES THAT HAD TO SURVIVE\n * ===========================================================================\n *\n * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still\n * guards the message path, and `assertConcreteEndpoint` is its equivalent\n * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,\n * because a misconfiguration that only surfaces when a reviewer finally\n * clicks something is a misconfiguration nobody finds in review.\n *\n * 2. **The screenshot never costs the capture**, and neither may the network.\n * `attachScreenshot` resolves within its own timeout and never rejects, so\n * the payload goes out either way. An HTTP transport would reintroduce\n * exactly the failure mode that was designed out \u2014 a slow network losing a\n * capture \u2014 unless it carries its own timeout and its own `catch`, and\n * unless a failed send is REPORTABLE rather than swallowed. A dropped\n * capture that looks like a successful one is the worst outcome available\n * here, so `send` rejects loudly and `createEgress` routes that to the\n * caller.\n */\n\n/** Every message the page half can emit. */\nexport type FrameMessageKind = FrameToParent[\"type\"];\n\n/**\n * What the far end said about a capture it accepted (A-240).\n *\n * Everything on it is optional and always will be. `postMessageTransport`\n * cannot answer at all \u2014 `postMessage` has no reply \u2014 and the ingest may\n * answer with nothing. A reviewer who is told *sent* and given no link is in a\n * strictly better position than one told nothing, so an absent `issueUrl` is a\n * successful send and never a failed one.\n */\nexport interface SendReceipt {\n /**\n * The issue this capture became, when the far end names one.\n *\n * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type\n * alone is not the contract \u2014 see `safeIssueUrl`, which is what every value\n * crossing the network boundary is put through before it gets to be one.\n */\n issueUrl?: string;\n}\n\n/** Longer than any issue URL and short enough not to be a payload. */\nconst MAX_ISSUE_URL = 2048;\n\n/**\n * A URL from the network that is safe to put in somebody else's page (A-240).\n *\n * ===========================================================================\n * WHY THIS IS NOT PARANOIA\n * ===========================================================================\n *\n * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets\n * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there\n * executes in the product's origin the moment the reviewer clicks *View\n * issue* \u2014 a script injection into a third party's site, shipped by us, on\n * every page of every managed product.\n *\n * `assertConcreteEndpoint` does not cover this and was never going to:\n * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is\n * misconfigured, compromised, or simply behind a proxy that rewrites bodies is\n * exactly the case a transport has to survive, and \"our own console would\n * never\" is not a security boundary.\n *\n * It lives HERE and not in the toolbar so the guarantee travels with the\n * receipt rather than with one renderer of it. The toolbar calls it too \u2014 see\n * `showResult` \u2014 because it renders receipts from ANY transport, including one\n * a consumer wrote, which never passes through this function.\n *\n * Anything not `http(s)` is dropped rather than repaired. A dropped link is a\n * toast that says the capture was sent and offers no link, which is the\n * outcome the receipt is already designed to survive.\n */\nexport function safeIssueUrl(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0 || value.length > MAX_ISSUE_URL) {\n return undefined;\n }\n let url: URL;\n try {\n // Absolute only: `new URL` throws on a relative one, and a relative href in\n // somebody else's page points somewhere in THEIR app.\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return undefined;\n return url.href;\n}\n\n/** Long enough for any refusal the ingest writes, short enough not to be a body. */\nconst MAX_REFUSAL_REASON = 300;\n\n/**\n * A refusal sentence from the network that is safe to put in front of a\n * reviewer (A-285).\n *\n * `safeIssueUrl`'s argument, applied to prose. The ingest composes refusals a\n * human can act on \u2014 a link issued for a different deployment, an empty\n * comment \u2014 and until this slice the transport threw `HTTP 403` and discarded\n * every one of them, so **not one of those sentences had ever reached a\n * human.**\n *\n * The value is still untrusted, and it ends up as `textContent` in the\n * CUSTOMER'S OWN PAGE. `textContent` is not an injection seam the way `href`\n * is, so the risk here is not script execution \u2014 it is a hostile or broken\n * ingest writing a screenful of control characters, ANSI escapes or a whole\n * JSON document into a toast the reviewer cannot dismiss. So: strings only,\n * control characters dropped, whitespace collapsed, and a hard ceiling.\n *\n * Anything that fails is dropped rather than repaired, and the caller falls\n * back to the HTTP status \u2014 which is what every refusal rendered as before\n * this existed, so the floor cannot get worse.\n */\nexport function safeRefusalReason(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > MAX_REFUSAL_REASON\n ? `${cleaned.slice(0, MAX_REFUSAL_REASON - 1).trimEnd()}\u2026`\n : cleaned;\n}\n\n/**\n * The error a refused capture throws, carrying the reason as a FIELD.\n *\n * The reason is in the `message` too, prefixed for a log. A renderer that had\n * to recover it by string-slicing that message would be parsing our own prose,\n * which breaks the first time somebody edits the prefix \u2014 so the sentence a\n * human should see is a property, and `refusalReason` is the one way to read\n * it.\n */\nexport interface IngestRefusal extends Error {\n /** The ingest's own words, already through `safeRefusalReason`. */\n reason?: string;\n /** The status, always \u2014 it is the fallback and it is never absent. */\n status?: number;\n}\n\n/**\n * The reviewer-facing sentence for a failed capture, or `undefined`.\n *\n * SANITISED AGAIN, deliberately, exactly as `safeIssueUrl` is called at both\n * ends: this reads an `EgressOutcome.error` from WHATEVER transport produced\n * it, including one a consumer wrote, which never passed through\n * `httpTransport`. One function, so the two ends cannot disagree.\n */\nexport function refusalReason(error: unknown): string | undefined {\n return safeRefusalReason((error as IngestRefusal | null | undefined)?.reason);\n}\n\nexport interface AnnotationTransport {\n /** For diagnostics and for tests; never sent anywhere. */\n readonly name: string;\n /**\n * May this transport carry this kind of message?\n *\n * The interface needs this because `annotate:hover` fires once per animation\n * frame while the reviewer moves the mouse. Over `postMessage` that is a\n * structured clone to a frame that is already listening; over HTTP it is one\n * request per frame, which is a denial of service aimed at our own console.\n *\n * A transport that DROPPED such a message silently would be the same bug\n * this issue exists to remove, one layer down. So declining is an explicit\n * answer the SDK asks for and respects, and `send` is never called for a\n * kind that was declined.\n */\n carries(kind: FrameMessageKind): boolean;\n /**\n * Deliver the message. May be synchronous.\n *\n * A returned promise MUST reject on failure. Resolving on a failed send is\n * the one thing a transport may not do.\n */\n send(message: FrameToParent): void | Promise<void | SendReceipt>;\n}\n\n/** What went wrong, handed back to whoever constructed the SDK. */\nexport interface EgressFailure {\n message: FrameToParent;\n transport: string;\n error: unknown;\n}\n\nexport type EgressErrorHandler = (failure: EgressFailure) => void;\n\n/**\n * The parent-window transport \u2014 today's behaviour, extracted unchanged.\n *\n * It carries everything: the frame is a live listener on the other end of a\n * cheap channel, and the handshake (`annotate:ready`) and the hover highlight\n * both belong to it.\n */\nexport function postMessageTransport(\n parentOrigin: string,\n win: Pick<Window, \"parent\"> = window,\n): AnnotationTransport {\n const origin = assertConcreteOrigin(parentOrigin);\n return {\n name: \"postMessage\",\n carries: () => true,\n send(message) {\n // Never a wildcard, in either direction.\n win.parent.postMessage(message, origin);\n },\n };\n}\n\n/**\n * Reject anything that is not one concrete `http(s)` URL.\n *\n * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL\n * resolves against whatever page the SDK happens to be on \u2014 so a capture\n * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`\n * or `data:` endpoint is a script-injection seam handed to whoever controls\n * the deep link. Neither is a thing to discover at first capture.\n */\nexport function assertConcreteEndpoint(endpoint: string): string {\n if (!endpoint || endpoint === \"*\") {\n throw new Error(\n \"[annotation] a concrete ingest endpoint is required; \" +\n \"'*' is not an address and an empty one posts nowhere.\",\n );\n }\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw new Error(\n `[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}; ` +\n \"a relative one resolves against the product's own origin and the capture is lost there.\",\n );\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\n `[annotation] the ingest endpoint must be http(s), got ${url.protocol}`,\n );\n }\n return url.toString();\n}\n\n/** Node's `fetch` and the browser's, narrowed to what we use. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n /**\n * Optional since A-276: the read client issues a `GET`, which has no body,\n * and a required field would make it pass `undefined` explicitly \u2014 a value\n * a stub asserting on `init` then has to know to ignore. `httpTransport`\n * still always sends one.\n */\n body?: string;\n credentials?: \"omit\";\n signal?: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n /**\n * Optional because `serve()`'s loopback responses are read through the\n * platform `fetch`, which has it, while a stub written to assert the `init`\n * need not. A transport that made the body mandatory would be asserting\n * something about the ingest that the ingest does not promise.\n */\n json?: () => Promise<unknown>;\n}>;\n\nexport interface HttpTransportOptions {\n /** The console's ingest URL. Absolute, `http(s)`, never `*`. */\n endpoint: string;\n /**\n * The capture token from the deep link \u2014 signed by the console, carried by\n * the product, never validated by it. See the plan's *Authentication*\n * section for why this is a second credential and not the arming one.\n */\n captureToken: string;\n /**\n * Own timeout, own catch. The default is deliberately short: a capture the\n * reviewer is told failed can be retried, and a request that hangs is\n * indistinguishable from one that worked.\n */\n timeoutMs?: number;\n /** Injectable only so the tests can drive a transport with no `fetch` at all. */\n fetch?: FetchLike;\n}\n\nexport const HTTP_TRANSPORT_TIMEOUT_MS = 8_000;\n\n/**\n * The first-party transport: POST the capture to the console's ingest.\n *\n * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one\n * request per animation frame, and `annotate:ready` / `annotate:cancel` are\n * handshake with a parent window that does not exist first-party \u2014 sending\n * them would be filing HTTP requests nobody asked for.\n */\nexport function httpTransport(options: HttpTransportOptions): AnnotationTransport {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.captureToken) {\n throw new Error(\n \"[annotation] a capture token is required; the ingest rejects an unsigned capture \" +\n \"and the reviewer would only find out after writing one.\",\n );\n }\n const token = options.captureToken;\n const timeoutMs = options.timeoutMs ?? HTTP_TRANSPORT_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n name: \"http\",\n carries: (kind) => kind === \"annotate:capture\",\n async send(message) {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the ingest\");\n }\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${token}`,\n },\n // Never the reviewer's cookies. The token is the whole credential,\n // and an ambient one would make this endpoint CSRF-shaped.\n credentials: \"omit\",\n body: JSON.stringify(message),\n signal: controller.signal,\n });\n if (!response.ok) {\n /**\n * THE REFUSAL IS READ, AND THE STATUS IS THE FALLBACK (A-285).\n *\n * The ingest composes sentences a reviewer can act on \u2014 \"this link\n * was issued for a different deployment\", \"an empty comment files an\n * issue nobody can action\" \u2014 and this line used to throw `HTTP 403`\n * and drop every one of them on the floor. A status code is not a\n * refusal a human can do anything with; it is what we say when the\n * far end gave us nothing better.\n *\n * The body read is inside its own `catch` for the same reason the\n * success path's is: a refusal we could not parse must still be a\n * refusal, never a `TypeError` from `json()` masquerading as one.\n * Unlike the success path, this branch was ALREADY a failure, so a\n * parse problem loses information rather than a capture.\n */\n let reason: string | undefined;\n try {\n const refusal = (await response.json?.()) as { error?: unknown } | undefined;\n reason = safeRefusalReason(refusal?.error);\n } catch {\n reason = undefined;\n }\n const failure: IngestRefusal = new Error(\n `[annotation] the ingest rejected the capture: ${reason ?? `HTTP ${response.status}`}`,\n );\n failure.status = response.status;\n if (reason !== undefined) failure.reason = reason;\n throw failure;\n }\n /**\n * The capture is delivered at this point and nothing below may undo\n * that. A body that is empty, not JSON, or JSON of some other shape is\n * an ingest that chose not to name an issue \u2014 telling the reviewer\n * their capture failed because we could not parse a courtesy would be\n * the same lie as a silent drop, pointing the other way.\n *\n * A-285 read a body on the REFUSAL path above and this property is\n * untouched by that: the two branches are opposites. Up there the\n * capture was refused and an unparseable body costs only the wording;\n * down here the capture is DELIVERED, and no failure to read a\n * courtesy may ever turn it into a failed one.\n */\n try {\n const body = (await response.json?.()) as { issueUrl?: unknown } | undefined;\n // Untrusted, all of it. `safeIssueUrl` returns undefined for a\n // non-string, a relative URL, an over-long one and any scheme that\n // is not http(s) \u2014 so a hostile body degrades to a capture that was\n // sent and named no issue.\n const issueUrl = safeIssueUrl(body?.issueUrl);\n if (issueUrl !== undefined) return { issueUrl };\n } catch {}\n return {};\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the capture timed out after ${timeoutMs}ms`, {\n cause: error,\n });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n\n/**\n * What became of one message (A-240).\n *\n * `createEgress` used to return `void`, which was enough while the only reader\n * of a failure was `console.error`. The composer has to TELL THE REVIEWER \u2014 the\n * issue link on success, a retry on failure \u2014 and it cannot do that from a\n * function that answers nothing. So the outcome is returned as a value, and the\n * promise NEVER REJECTS: a rejection here would be a second way for a lost\n * capture to disappear, one `await`-less caller away from silence.\n *\n * `declined` is its own case rather than a failure, because it is not one:\n * `annotate:hover` over HTTP is a kind the transport correctly refuses, and\n * calling that a failed capture would put a red toast in front of a reviewer\n * who did nothing wrong. It is only a problem if `annotate:capture` is what was\n * declined, and that is the caller's judgement rather than this function's.\n */\nexport type EgressOutcome =\n | { status: \"sent\"; receipt: SendReceipt }\n | { status: \"declined\" }\n | { status: \"failed\"; error: unknown };\n\n/**\n * The SDK's one way out, given a transport.\n *\n * Pure and DOM-free on purpose, so the two things worth arguing about \u2014 that a\n * declined kind is never sent, and that a failed send reaches the caller \u2014 are\n * testable without a browser. `sdk.ts` holds no other egress path.\n */\nexport function createEgress(\n transport: AnnotationTransport,\n onError?: EgressErrorHandler,\n): (message: FrameToParent) => Promise<EgressOutcome> {\n const report = (message: FrameToParent, error: unknown) => {\n const failure: EgressFailure = { message, transport: transport.name, error };\n if (onError) {\n onError(failure);\n return;\n }\n /**\n * No handler is not a licence to swallow it. Until A-240 puts a toast in\n * front of the reviewer, the console is the only surface there is, and a\n * lost capture must leave a trace somewhere.\n */\n console.error(\"[annotation] a capture could not be delivered\", failure);\n };\n\n /**\n * `async`, and the synchronous half still runs synchronously \u2014 everything up\n * to the first `await` does, which is `transport.send` itself. So\n * `postMessageTransport` still posts before this returns, and a transport\n * that throws is still reported before this returns. That is not an\n * incidental detail: `sdk.test.ts` asserts the frame path's handshake has\n * been posted the instant `createAnnotationSdk` returns, and it must stay\n * true, because the frame is not being deleted.\n */\n return async (message: FrameToParent): Promise<EgressOutcome> => {\n if (!transport.carries(message.type)) return { status: \"declined\" };\n try {\n const receipt = await transport.send(message);\n return { status: \"sent\", receipt: receipt ?? {} };\n } catch (error) {\n report(message, error);\n return { status: \"failed\", error };\n }\n };\n}\n", "import type { Anchor } from \"./anchor\";\nimport { assertConcreteEndpoint, type FetchLike } from \"./transport\";\n\n/**\n * Reading the captures already made on this page (A-276).\n *\n * ===========================================================================\n * WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`\n * ===========================================================================\n *\n * `./anchor`'s note applies unchanged: this runs on the product page and only\n * for a product that RENDERS markers, while the SDK that only captures does\n * not need a byte of it. Folding it into `index` would tax every page of every\n * managed product with code most of them never call.\n *\n * ===========================================================================\n * WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER\n * ===========================================================================\n *\n * A-268 is the reason, and it is worth restating because it cost a night. The\n * SDK posted `{type, payload}` with the comment nested inside `payload`; the\n * ingest read `comment` as a sibling of it. Both halves were correct. Both were\n * tested. The two suites had different ideas of the bytes, and nothing anywhere\n * held the two ends of the same string \u2014 a human clicking Send on staging is\n * what found it.\n *\n * So the request this file builds and the response it parses are the ones the\n * console's `read-wire.test.ts` runs against the REAL handler: SDK client \u2192\n * real URL and headers \u2192 real `GET` export \u2192 real response \u2192 this parser. If\n * the two ends disagree about a field name, that test is red on the commit that\n * did it rather than on a staging page a week later.\n *\n * ===========================================================================\n * EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE\n * ===========================================================================\n *\n * `transport.ts`'s `safeIssueUrl` header has the full argument; the same one\n * applies here with more surface. A marker's `comment` and `reviewer` are\n * rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken\n * console must not be able to put a screenful of control characters into a\n * panel nobody can dismiss. Strings only, bounded, control characters dropped;\n * anything that fails is dropped rather than repaired, and a marker that cannot\n * be read is SKIPPED rather than rendered half-built.\n *\n * ===========================================================================\n * THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED\n * ===========================================================================\n *\n * The console and the product deploy on different cadences from different\n * repositories, so an SDK meeting a newer console is the normal state. A marker\n * whose `anchor.kind` this version does not recognise is kept in the counts and\n * placed by nothing: `resolveAnchor` already answers `\"none\"` for an unknown\n * kind. It must never be guessed at \u2014 a marker on the wrong element does not\n * degrade this feature, it inverts it.\n */\n\n/** The wire version this client was written against. */\nexport const ANNOTATION_READ_WIRE_VERSION = 1;\n\nexport type AnnotationStatus = \"open\" | \"in-progress\" | \"done\" | \"cancelled\" | \"unknown\";\n\n/**\n * The statuses this version knows.\n *\n * A status the console adds later parses to `\"unknown\"` and keeps its display\n * string, which is the additive-only rule applied to an enum: the SDK renders\n * the console's words and switches on a value it understands, rather than\n * dropping a marker because a vocabulary grew.\n */\nconst KNOWN_STATUSES: readonly string[] = [\"open\", \"in-progress\", \"done\", \"cancelled\", \"unknown\"];\n\nexport interface AnnotationStatusView {\n state: AnnotationStatus;\n display: string;\n}\n\nexport interface AnnotationMarker {\n id: string;\n anchor: Anchor;\n comment: string;\n /** A display name. The console never sends an email; this never expects one. */\n reviewer: string;\n createdAt: string;\n status: AnnotationStatusView;\n}\n\nexport type AnnotationsReason =\n | \"no-path\"\n | \"scan-truncated\"\n | \"capped\"\n | \"rate-limited\"\n | \"not-authorised\"\n /** The token is a format the console has retired (A-277). Press the button again. */\n | \"retired-link\";\n\n/**\n * The counted envelope, never a bare array.\n *\n * On a read path zero markers is exactly what \"working, nothing here\" looks\n * like, and at least nine causes produce zero. A caller renders these counts \u2014\n * *\"3 annotations here, 1 shown\"* \u2014 so that a failure disagrees with itself\n * instead of being absent. The numerator is the PAGE's tally and the denominator\n * is `total`; `anchored` is a diagnostic and not the page's answer. See\n * `./markers`.\n */\nexport interface AnnotationsResponse {\n ok: boolean;\n wireVersion: number;\n asOf: string;\n total: number;\n /**\n * The CONSOLE's count of servable anchors, not a count of markers on screen.\n *\n * Named `anchored` and not `placed` (A-277): the server has no DOM, so what it\n * counts is whether an anchor COULD resolve to something \u2014 a chain, a\n * dom-path, or a route path \u2014 while whether one DID is `tallyAnchors` in\n * `./anchor`, measured against this document. The two disagree routinely; a\n * listing page yields about 25% unique resolution. The word `placed` belongs\n * to the page, because that is what a human means by it.\n */\n anchored: number;\n anchorless: number;\n reason?: AnnotationsReason;\n annotations: AnnotationMarker[];\n}\n\n/** Long enough for any comment a reviewer writes, short enough not to be a payload. */\nconst MAX_COMMENT = 4000;\nconst MAX_SHORT = 200;\n\nfunction cleanText(value: unknown, max: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}\u2026` : cleaned;\n}\n\nfunction cleanCount(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n ? Math.floor(value)\n : 0;\n}\n\n/**\n * An anchor from the network, or `undefined`.\n *\n * Shape-checked rather than cast. `resolveAnchor` reads `chain` as an array and\n * `path` as a string, and a marker whose anchor is `null` would take the\n * placement call down for every other marker on the page.\n */\nfunction parseAnchor(value: unknown): Anchor | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n const kind = raw[\"kind\"];\n\n if (kind === \"route\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? { kind: \"route\" } : { kind: \"route\", path };\n }\n if (kind === \"dom-path\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? undefined : { kind: \"dom-path\", path };\n }\n if (kind === \"source-ref\") {\n const file = raw[\"file\"];\n const line = raw[\"line\"];\n const chain = raw[\"chain\"];\n if (typeof file !== \"string\" || typeof line !== \"number\" || !Array.isArray(chain)) {\n return undefined;\n }\n const frames = chain.flatMap((frame) => {\n if (typeof frame !== \"object\" || frame === null) return [];\n const f = frame as Record<string, unknown>;\n if (typeof f[\"file\"] !== \"string\" || typeof f[\"line\"] !== \"number\") return [];\n const out: { file: string; line: number; column?: number } = {\n file: f[\"file\"],\n line: f[\"line\"],\n };\n if (typeof f[\"column\"] === \"number\") out.column = f[\"column\"];\n return [out];\n });\n const anchor: Anchor = { kind: \"source-ref\", file, line, chain: frames };\n if (typeof raw[\"column\"] === \"number\") anchor.column = raw[\"column\"];\n if (typeof raw[\"ordinal\"] === \"number\") anchor.ordinal = raw[\"ordinal\"];\n return anchor;\n }\n\n /**\n * A kind this version does not know. Skipped \u2014 never placed on a guess. The\n * console's own `wireVersion` is what tells a caller this happened, and\n * `resolveAnchor` would answer `\"none\"` even if one got through.\n */\n return undefined;\n}\n\nfunction parseMarker(value: unknown): AnnotationMarker | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n\n const id = cleanText(raw[\"id\"], MAX_SHORT);\n const comment = cleanText(raw[\"comment\"], MAX_COMMENT);\n const anchor = parseAnchor(raw[\"anchor\"]);\n if (id === undefined || comment === undefined || anchor === undefined) return undefined;\n\n const statusRaw = (\n typeof raw[\"status\"] === \"object\" && raw[\"status\"] !== null ? raw[\"status\"] : {}\n ) as Record<string, unknown>;\n const state = statusRaw[\"state\"];\n\n return {\n id,\n anchor,\n comment,\n reviewer: cleanText(raw[\"reviewer\"], MAX_SHORT) ?? \"A reviewer\",\n createdAt: cleanText(raw[\"createdAt\"], MAX_SHORT) ?? \"\",\n status: {\n state: (typeof state === \"string\" && KNOWN_STATUSES.includes(state)\n ? state\n : \"unknown\") as AnnotationStatus,\n display: cleanText(statusRaw[\"display\"], MAX_SHORT) ?? \"Unknown\",\n },\n };\n}\n\n/**\n * Parse a response body into an envelope, whatever it turns out to be.\n *\n * NEVER THROWS, and never returns `undefined`. A caller on a product page has to\n * render something, and \"the console said something I could not read\" is a state\n * with an answer \u2014 an envelope whose counts are zero and whose `ok` is false \u2014\n * rather than an exception that takes the marker layer down.\n */\nexport function parseAnnotationsResponse(value: unknown): AnnotationsResponse {\n const raw = (typeof value === \"object\" && value !== null ? value : {}) as Record<string, unknown>;\n const list = Array.isArray(raw[\"annotations\"]) ? raw[\"annotations\"] : [];\n const annotations = list.flatMap((entry) => {\n const marker = parseMarker(entry);\n return marker === undefined ? [] : [marker];\n });\n const reason = raw[\"reason\"];\n\n return {\n ok: raw[\"ok\"] === true,\n wireVersion: cleanCount(raw[\"wireVersion\"]),\n asOf: cleanText(raw[\"asOf\"], MAX_SHORT) ?? \"\",\n total: cleanCount(raw[\"total\"]),\n anchored: cleanCount(raw[\"anchored\"]),\n anchorless: cleanCount(raw[\"anchorless\"]),\n ...(typeof reason === \"string\" ? { reason: reason as AnnotationsReason } : {}),\n annotations,\n };\n}\n\nexport interface AnnotationReaderOptions {\n /** The console's ingest URL \u2014 the same one `httpTransport` posts to. */\n endpoint: string;\n /**\n * A READ-scoped claim, and never the capture token.\n *\n * The console refuses a write claim here and refuses this one at the ingest;\n * the two are structurally different credentials with different lifetimes (one\n * hour against four). Handing the capture token to this function does not\n * degrade to a read \u2014 it fails, which is the intended outcome.\n */\n readToken: string;\n /** Own timeout, own catch. A hung read must not hang the page. */\n timeoutMs?: number;\n fetch?: FetchLike;\n}\n\nexport const READ_TIMEOUT_MS = 8_000;\n\nexport interface AnnotationReader {\n /**\n * The markers for one resolved path.\n *\n * `path` is `location.pathname` and NOT the route pattern. The pattern is one\n * value for every page of a dynamic route, so asking by pattern returns\n * another page's captures \u2014 measured, and the reason this parameter exists at\n * all rather than being derived from the payload.\n *\n * Rejects only on a transport failure. Every answer the console gives,\n * including a refusal and a 429, comes back as an envelope with a `reason`,\n * because a caller cannot tell \"your budget is spent\" from \"nothing here\"\n * out of a status code.\n */\n read(path: string): Promise<AnnotationsResponse>;\n}\n\n/**\n * The read client. `httpTransport`'s sibling, pointed the other way.\n */\nexport function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.readToken) {\n throw new Error(\n \"[annotation] a read token is required; the console refuses an unsigned read \" +\n \"and the page would render an empty marker layer with no way to say why.\",\n );\n }\n const token = options.readToken;\n const timeoutMs = options.timeoutMs ?? READ_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n async read(path: string): Promise<AnnotationsResponse> {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the read\");\n }\n const url = new URL(endpoint);\n /**\n * `searchParams.set` and not string concatenation, so a path containing a\n * `&`, a `#` or a space is encoded once and correctly. A hand-built query\n * string is how one page's markers end up requested for another.\n */\n url.searchParams.set(\"path\", path);\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(url.toString(), {\n method: \"GET\",\n headers: { authorization: `Bearer ${token}` },\n // Never the reviewer's cookies; the token is the whole credential.\n credentials: \"omit\",\n signal: controller.signal,\n });\n let body: unknown;\n try {\n body = await response.json?.();\n } catch {\n body = undefined;\n }\n return parseAnnotationsResponse(body);\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the read timed out after ${timeoutMs}ms`, { cause: error });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["assertConcreteEndpoint", "endpoint", "url", "ANNOTATION_READ_WIRE_VERSION", "KNOWN_STATUSES", "MAX_COMMENT", "MAX_SHORT", "cleanText", "value", "max", "cleaned", "cleanCount", "parseAnchor", "raw", "kind", "path", "file", "line", "chain", "frames", "frame", "f", "out", "anchor", "parseMarker", "id", "comment", "statusRaw", "state", "parseAnnotationsResponse", "annotations", "entry", "marker", "reason", "READ_TIMEOUT_MS", "createAnnotationReader", "options", "endpoint", "assertConcreteEndpoint", "token", "timeoutMs", "doFetch", "url", "controller", "timer", "response", "body", "error"]
|
|
7
|
+
}
|
package/dist/transport.d.cts
CHANGED
|
@@ -187,7 +187,13 @@ export declare function assertConcreteEndpoint(endpoint: string): string;
|
|
|
187
187
|
export type FetchLike = (input: string, init: {
|
|
188
188
|
method: string;
|
|
189
189
|
headers: Record<string, string>;
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Optional since A-276: the read client issues a `GET`, which has no body,
|
|
192
|
+
* and a required field would make it pass `undefined` explicitly — a value
|
|
193
|
+
* a stub asserting on `init` then has to know to ignore. `httpTransport`
|
|
194
|
+
* still always sends one.
|
|
195
|
+
*/
|
|
196
|
+
body?: string;
|
|
191
197
|
credentials?: "omit";
|
|
192
198
|
signal?: AbortSignal;
|
|
193
199
|
}) => Promise<{
|
package/dist/transport.d.mts
CHANGED
|
@@ -187,7 +187,13 @@ export declare function assertConcreteEndpoint(endpoint: string): string;
|
|
|
187
187
|
export type FetchLike = (input: string, init: {
|
|
188
188
|
method: string;
|
|
189
189
|
headers: Record<string, string>;
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Optional since A-276: the read client issues a `GET`, which has no body,
|
|
192
|
+
* and a required field would make it pass `undefined` explicitly — a value
|
|
193
|
+
* a stub asserting on `init` then has to know to ignore. `httpTransport`
|
|
194
|
+
* still always sends one.
|
|
195
|
+
*/
|
|
196
|
+
body?: string;
|
|
191
197
|
credentials?: "omit";
|
|
192
198
|
signal?: AbortSignal;
|
|
193
199
|
}) => Promise<{
|
package/dist/transport.d.ts
CHANGED
|
@@ -187,7 +187,13 @@ export declare function assertConcreteEndpoint(endpoint: string): string;
|
|
|
187
187
|
export type FetchLike = (input: string, init: {
|
|
188
188
|
method: string;
|
|
189
189
|
headers: Record<string, string>;
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Optional since A-276: the read client issues a `GET`, which has no body,
|
|
192
|
+
* and a required field would make it pass `undefined` explicitly — a value
|
|
193
|
+
* a stub asserting on `init` then has to know to ignore. `httpTransport`
|
|
194
|
+
* still always sends one.
|
|
195
|
+
*/
|
|
196
|
+
body?: string;
|
|
191
197
|
credentials?: "omit";
|
|
192
198
|
signal?: AbortSignal;
|
|
193
199
|
}) => Promise<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weatherboard/annotation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Click an element in a running app, get the source location that rendered it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"annotation",
|
|
@@ -49,6 +49,36 @@
|
|
|
49
49
|
"default": "./dist/host.cjs"
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
|
+
"./anchor": {
|
|
53
|
+
"import": {
|
|
54
|
+
"types": "./dist/anchor.d.mts",
|
|
55
|
+
"default": "./dist/anchor.mjs"
|
|
56
|
+
},
|
|
57
|
+
"require": {
|
|
58
|
+
"types": "./dist/anchor.d.cts",
|
|
59
|
+
"default": "./dist/anchor.cjs"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"./read": {
|
|
63
|
+
"import": {
|
|
64
|
+
"types": "./dist/read.d.mts",
|
|
65
|
+
"default": "./dist/read.mjs"
|
|
66
|
+
},
|
|
67
|
+
"require": {
|
|
68
|
+
"types": "./dist/read.d.cts",
|
|
69
|
+
"default": "./dist/read.cjs"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"./markers": {
|
|
73
|
+
"import": {
|
|
74
|
+
"types": "./dist/markers.d.mts",
|
|
75
|
+
"default": "./dist/markers.mjs"
|
|
76
|
+
},
|
|
77
|
+
"require": {
|
|
78
|
+
"types": "./dist/markers.d.cts",
|
|
79
|
+
"default": "./dist/markers.cjs"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
52
82
|
"./package.json": "./package.json"
|
|
53
83
|
},
|
|
54
84
|
"main": "./dist/index.cjs",
|
|
@@ -67,11 +97,15 @@
|
|
|
67
97
|
"LICENSE"
|
|
68
98
|
],
|
|
69
99
|
"//sizeBudget": "Gzipped bytes per published entry point, checked by `build.mjs` on every build and therefore in CI. A budget is here rather than in a separate config because the number is part of the package's contract: this code runs on every page of every managed product, and 'it grew a bit each time' is how a 4KB SDK becomes a 40KB one with nobody having decided. Raising a number is allowed; doing it in the same commit as the growth, where a reviewer sees both, is the point.",
|
|
70
|
-
"//sizeBudget-
|
|
100
|
+
"//sizeBudget-anchor": "A-275, and the reason it is a FOURTH ENTRY POINT rather than a re-export from `.`: the marker layer (A-277) runs on the product page and needs `resolveAnchor`, but the SDK that only CAPTURES does not, and folding it into index would put it in the bundle of every managed product whether or not that product renders markers. THE NUMBER HERE WAS WRONG ONCE AND THAT IS WHY IT IS SPELLED OUT: the first commit of this note said 961, measured by bundling `src/anchor.ts` alone with esbuild, and `build.mjs` printed 996 for the same commit - the standalone bundle and the real build are not the same measurement, and a stated figure that disagrees with the gate is worse than no figure. MEASURED BY RUNNING `node build.mjs` AND READING ITS OUTPUT, which is the only number the budget is ever compared against: 1036 as shipped, after A-275's review cycle added `RouteAnchor.path` and the path check. Budget 1100, so headroom is 1100/1036 = 6.2%. Deliberately not raised to restore the slack the review spent: this entry point exists to grow when A-277 needs it to, and the budget's job is to make that a decision somebody makes. Looser in PROPORTION than index (0.83%) for the reason protocol is: 64 bytes is 6% here and would be 0.7% of index.",
|
|
101
|
+
"//sizeBudget-index": "A-241 adds arming: 8700 -> 9400 for index and 700 -> 850 for protocol. MEASURED by rebuilding, four numbers. Baseline at A-240's merge commit fceb183: index 8562, protocol 646 - the two figures the previous note recorded, re-measured rather than trusted. As shipped: index 9281, protocol 788. And with the ./arming re-export block deleted from src/index.ts but everything else present: 8913. SO readArming AND ITS TYPES ARE 368 OF THE 719, AND 351 ARE NOT: 142 of that is protocol's own growth (armedCookie, clearedCookie, ANNOTATION_ARMED_VALUE, REVIEW_RUN_PARAM), which index pays too because it re-exports protocol whole, and the rest is isAnnotatableBuild with its ANNOTATABLE_ENVS allowlist, the two-gate expression in mountAnnotation, and Done's teardown. That 351 is the number worth knowing: a consumer who never calls readArming still pays it, because the kill-switch is not optional and must not be tree-shakeable. TWO REVIEW CYCLES MOVED IT AND BOTH COSTS ARE MEASURED: the fail-closed allowlist +27 (9239 -> 9266) and Done's teardown +15 (9266 -> 9281). HEADROOM IS PER ENTRY POINT AND THE TWO ARE STATED SEPARATELY, because one figure for both was false here once already: index 9400/9281 = 1.28%, against A-240's 8700/8562 = 1.61%, so tighter and DELIBERATELY NOT TOPPED UP - the budget's job is to make the next growth a decision, and quietly restoring slack each time is how it stops doing that; protocol 850/788 = 7.87%, against its previous 700/646 = 8.36%. protocol runs looser in PROPORTION only because it is small - 62 bytes is 7.87% there and would be 0.67% of index. The argument for the toolbar's own 2553 bytes is in docs/decisions/a-240-annotation-toolbar-size.md, extended there for this slice rather than restated here. A-254 MOVED IT AGAIN AND THE BUDGET IS DELIBERATELY NOT RAISED: index 9281 -> 9300, +19 gzipped bytes for the armed-before-persist early return in readArming, re-measured by running build.mjs before and after. Headroom is now 9400/9300 = 1.06%. That is tight on purpose - 19 bytes bought the end of an unarmed page storing an attacker-supplied capture token, and the next growth after it should be a decision somebody makes rather than slack somebody spends. A-277 MOVED IT AGAIN, TWICE, AND BOTH ARE MEASURED BY REBUILDING: index 9471 -> 9531, +60 gzipped bytes, and protocol 788 -> 800, +12. The 12 is READ_TOKEN_PARAM and its comment-free constant; the remaining 48 in index is arming carrying the read token - a fourth stripped parameter, a third storage key, a `readToken` field on `Arming`, and the read/write branches for it in `readArming`. THAT 48 IS PAID BY EVERY CONSUMER INCLUDING ONES THAT NEVER MOUNT A MARKER, and it is paid deliberately rather than moved into ./markers: the strip is the thing that gets a signed credential out of an address bar, it has to run in the first milliseconds of the page, and a product that mounts only the toolbar still receives an `ai_read` in its URL and still must not leave it there. Budgets 9550 -> 9650 and 850 -> 870. 9650 rather than 9600: at 9600 the headroom is 69 bytes, which is a budget that fails on the next sentence somebody writes rather than on the next decision somebody makes. 9650/9531 = 1.25% and 870/800 = 8.75%, so index is LOOSER than A-285 left it (0.83%) and that is stated rather than hidden - the slack was restored on purpose this once, because A-279 (replies) is the next slice into this file and a budget with 19 bytes in it makes that slice's first commit a budget argument instead of a design one. A-285 MOVED IT AGAIN AND THE BUDGET IS RAISED, WITH THE COST SPLIT AND MEASURED: index 9300 -> 9471, +171 gzipped bytes, by rebuilding three times. 9300 is the baseline at this branch point, re-measured rather than trusted. 9426 with transport.ts alone - safeRefusalReason, the IngestRefusal fields and the body read on the refusal branch, 126 bytes. 9471 with the toolbar rendering it and index re-exporting the two functions, a further 45. THAT 171 IS WHAT IT COSTS FOR A REFUSAL TO REACH A HUMAN AT ALL: before it, every sentence the ingest composed - the link issued for another deployment, the empty comment - was thrown away by the only client that could show it, and every failure read 'That did not send'. 9550 rather than 9500: 9500 would leave 29 bytes, which is a budget that fails on the next comment somebody writes rather than on the next decision somebody makes. 9550/9471 = 0.83%, against A-254's 9400/9300 = 1.06% - so still tighter than it was, and deliberately not topped up.",
|
|
71
102
|
"sizeBudget": {
|
|
72
|
-
"protocol":
|
|
73
|
-
"index":
|
|
74
|
-
"host": 660
|
|
103
|
+
"protocol": 870,
|
|
104
|
+
"index": 9650,
|
|
105
|
+
"host": 660,
|
|
106
|
+
"anchor": 1100,
|
|
107
|
+
"read": 1700,
|
|
108
|
+
"markers": 3950
|
|
75
109
|
},
|
|
76
110
|
"engines": {
|
|
77
111
|
"node": ">=18"
|
|
@@ -80,9 +114,12 @@
|
|
|
80
114
|
"@playwright/test": "^1.62.1",
|
|
81
115
|
"@types/node": "^22.0.0",
|
|
82
116
|
"esbuild": "^0.28.2",
|
|
117
|
+
"linkedom": "^0.18.13",
|
|
83
118
|
"tsx": "^4.21.0",
|
|
84
119
|
"typescript": "~5.7.2"
|
|
85
120
|
},
|
|
121
|
+
"//sizeBudget-markers": "A-277, and a SIXTH ENTRY POINT for ./anchor and ./read's reason, which applies here with the most weight of the three: the marker layer, its panel and its stylesheet run ONLY in a product that renders markers, and folding them into index would put all of it in the bundle of every page of every managed product including the ones that will never show one. MEASURED BY RUNNING `node build.mjs` AND READING ITS OUTPUT, which is the only number the budget is ever compared against: 3449 as shipped. The first draft of this note said 3400 and the build printed 3449 on the same commit - the number is written down AFTER the gate has been run, never before, because a stated figure that disagrees with the gate is worse than no figure. 3449 was the figure BEFORE a browser was pointed at it; the clamp-into-viewport, the collision nudge and the date formatting cost a further 181, so the shipped number is 3630 and every one of those 181 bytes bought a defect that no fixture test could see (a pin at y:-11, two pins overlapping by 22 of their 22 pixels, and an ISO instant rendered raw beside a name). Tokenising the CSS onto `:host` custom properties - the same names and values `toolbar.ts` uses, so the two surfaces a reviewer sees on one page are one design - cost a further 168 and cleared the design audit's untokenised-colour and untokenised-space findings, which must not be answered by lengthening `gyde-allowance.json`. Shipped at 3800 (rebuilt after two 16px offsets also became a var). A REVIEW CYCLE THEN ADDED 79 MORE, and they are the most load-bearing bytes in this entry point: `freeSpot`'s bound. The collision walk it replaced was a bare `while` with no exit but success, and on a viewport measuring zero - a hidden iframe, a prerender, a stubbed `win` - two pins were enough to make it spin forever, LOCKING THE MAIN THREAD OF A CUSTOMER'S PAGE. Shipped at 3879. Budget 3950, so headroom is 3950/3879 = 1.8% - TIGHTER in proportion than anchor (6.2%) and read (6.4%) on purpose, because this is the largest entry point after index and a percentage of it is a lot of bytes. What is IN it: the CSS string and the DOM construction are most of it, and neither is optional - the layer lives in a shadow root so the product's stylesheet cannot reach in and it cannot reach out, and every value reaches the DOM through textContent because everything rendered here came off the network from a console this page does not control. placeMarkers, the clustering and the count-line sentence are small; resolveAnchor is pulled in from ./anchor by tree-shaking and is 1036 of the total on its own, which is why this is not 2400.",
|
|
122
|
+
"//sizeBudget-read": "A-276, and a FIFTH ENTRY POINT for ./anchor reason: the read client runs only in a product that RENDERS markers, and folding it into index would tax every page of every managed product with code most of them never call. MEASURED BY RUNNING node build.mjs AND READING ITS OUTPUT, which is the only number the budget is ever compared against: 1597 as shipped. Budget 1700, so headroom is 1700/1597 = 6.4% - deliberately in line with anchor 1100/1036 = 6.2% rather than tighter, because A-277 is the slice that will grow this one and the budget job is to make that growth a decision somebody makes. It is looser in PROPORTION than index 0.83% for the reason protocol is: 100 bytes is 6% here and would be 1% of index. What is IN it: the envelope and marker types are erased at build, so the bytes are parseAnnotationsResponse and its three shape checks (the anchor union, the marker, the counts), createAnnotationReader, and assertConcreteEndpoint pulled in from transport.ts by tree-shaking. The sanitising is most of it and is not optional - comment and reviewer are rendered inside the customers own page, and safeIssueUrl header has the argument.",
|
|
86
123
|
"scripts": {
|
|
87
124
|
"build": "node build.mjs",
|
|
88
125
|
"typecheck": "tsc --noEmit",
|
|
@@ -91,6 +128,12 @@
|
|
|
91
128
|
"//test": "`--test-timeout` is not decoration (A-240). Without it, breaking `httpTransport`'s abort did not turn this suite RED — it hung `pnpm test` forever, because the pending fetch keeps the event loop alive and node:test has no default deadline. CI times out, so it never read as a pass; but a wall-clock stall names no test and blames whatever was printed last, and this repository's standard is a NAMED FAILING TEST. 30s is a ceiling and not a budget: MEASURED, the slowest test in this suite is 203ms (`a 500 reaches the SDK's error handler`, which sleeps 200ms on purpose), so the limit is ~150x it and no honest test can reach it.",
|
|
92
129
|
"test": "tsx --test --test-timeout=30000 src/*.test.ts",
|
|
93
130
|
"e2e": "playwright test",
|
|
131
|
+
"//measure:anchors": "A-275's mutation battery, against a LIVE deployment. `pnpm --filter @weatherboard/annotation measure:anchors -- --url https://<host>/ --compare-url /other/id`. NOT in `pnpm verify` and it must never be: it needs the network and a browser, and verify is forbidden to acquire either. It exists because the numbers in docs/decisions/a-275-anchor-placement.md were, for one review cycle, reproducible only by the agent that took them - a measurement nobody else can re-run is indistinguishable from an asserted one. Read-only: it navigates and evaluates, and there is no click or form submission in it, because staging shares the production database. Exits 2 (COULD NOT ANSWER, not a pass) when the page carries no source refs, which is what production looks like.",
|
|
132
|
+
"measure:anchors": "node anchor-measure.mjs",
|
|
133
|
+
"//fixtures:anchors": "A-275. Rebuilds `test-fixtures/anchors/page-after-fix.html`, `page-moved.html` and `anchors.json` from `page.html`. NOT part of `pnpm test`: the outputs are checked in and the tests read them, so running it in the gate would let a resolver change quietly regenerate the fixtures it is being measured against. Regenerate on purpose, and re-pin the counts in `anchor.test.ts` when you do.",
|
|
134
|
+
"fixtures:anchors": "tsx anchor-fixtures.mjs",
|
|
135
|
+
"//bundle:gate": "A-277 done-condition 5. Does a PRODUCTION build of a product's bundle contain the toolbar or the marker layer? NOT part of `pnpm test` and NOT part of `pnpm verify`, for one reason and it is not speed: it runs a bundler and reads the emitted artifact, and a unit test structurally cannot answer this question - `isAnnotatableBuild` returning false is a different claim from the bundler having DELETED the code behind it. N-905 shipped that exact confusion four times: passing `process.env` as a VALUE defeats per-key inlining, so the branch cannot be folded and the import ships, while every unit test passes because a unit test injects the value it expects. THREE builds and two of them must find the code: gated+production is the assertion, gated+preview and N-905's own shape+production are what prove the grep can SEE the toolbar rather than reporting an absence from a bundle that never held anything. It walks `metafile.outputs[].imports` from the entry rather than reading every .js on disk, because esbuild emits a chunk for every dynamic-import target whether or not anything reaches it - MEASURED, the folded build's entry is 57 bytes with an EMPTY imports list beside four emitted files. It is in CI as its own job for the standing reason: a gate nobody runs is a gate that does not exist. Exit 0/1/2, and 2 is COULD NOT ANSWER rather than a pass.",
|
|
136
|
+
"bundle:gate": "node bundle-gate.mjs",
|
|
94
137
|
"//smoke": "The consumer test — pack the real tarball, install it outside the workspace, import it four ways. NOT part of `pnpm test`, and not because it is slow: it shells out to `npm install`, which needs the network, and `verify` is forbidden to acquire one. It runs as its own CI job. See the header of smoke.mjs for why nothing else in this repository can catch what it catches.",
|
|
95
138
|
"smoke": "node smoke.mjs",
|
|
96
139
|
"//drift": "A-187. Compares this source's wire names against the version ALREADY PUBLISHED, when they are the same version. `smoke.mjs` proves the tarball we are about to publish; every unit test reads this source; neither looks at the registry. The hole is publish 0.1.0, then edit protocol.ts without bumping — a hundred consumers have one spelling, this console has another, and everything here is green. Needs the network, so it runs in the package-consumer job rather than in `verify`.",
|