@weatherboard/annotation 0.1.0 → 0.2.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 +36 -0
- package/COMPATIBILITY.md +97 -0
- package/README.md +9 -3
- package/dist/arming.d.cts +142 -0
- package/dist/arming.d.mts +142 -0
- package/dist/arming.d.ts +142 -0
- package/dist/host.cjs +1 -1
- package/dist/host.cjs.map +2 -2
- package/dist/host.mjs +1 -1
- package/dist/host.mjs.map +2 -2
- package/dist/index.cjs +64 -1
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.cts +5 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +64 -1
- package/dist/index.mjs.map +4 -4
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.cjs.map +3 -3
- package/dist/protocol.d.cts +114 -0
- package/dist/protocol.d.mts +114 -0
- package/dist/protocol.d.ts +114 -0
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.mjs.map +3 -3
- package/dist/sdk.d.cts +92 -3
- package/dist/sdk.d.mts +92 -3
- package/dist/sdk.d.ts +92 -3
- package/dist/toolbar.d.cts +185 -0
- package/dist/toolbar.d.mts +185 -0
- package/dist/toolbar.d.ts +185 -0
- package/dist/transport.d.cts +218 -0
- package/dist/transport.d.mts +218 -0
- package/dist/transport.d.ts +218 -0
- package/fixtures/README.md +35 -0
- package/fixtures/v1-minimal.json +17 -0
- package/fixtures/v1-no-source-ref.json +22 -0
- package/fixtures/v1-region-selection.json +23 -0
- package/fixtures/v1-screenshot.json +19 -0
- package/fixtures/v1-source-chain.json +24 -0
- package/package.json +11 -4
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { type FrameToParent } from "./protocol.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Egress (A-239).
|
|
4
|
+
*
|
|
5
|
+
* ===========================================================================
|
|
6
|
+
* WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`
|
|
7
|
+
* ===========================================================================
|
|
8
|
+
*
|
|
9
|
+
* `sdk.ts` had exactly one way out — `win.parent.postMessage(...)` — and
|
|
10
|
+
* first-party there is no parent: `win.parent === win`. Every capture then
|
|
11
|
+
* posts a message to the page that sent it and nothing receives it. **That
|
|
12
|
+
* fails silently**, which is the shape to be careful of here: the overlay
|
|
13
|
+
* behaves correctly, the reviewer clicks, and no issue is ever filed.
|
|
14
|
+
*
|
|
15
|
+
* See `docs/plans/first-party-annotation.md`. The console stops framing the
|
|
16
|
+
* product and links out to it instead, so the capture has to leave the page
|
|
17
|
+
* over HTTP. The frame is NOT being deleted — `postMessageTransport` is
|
|
18
|
+
* today's behaviour, extracted unchanged, and it is still the default.
|
|
19
|
+
*
|
|
20
|
+
* ===========================================================================
|
|
21
|
+
* TWO PROPERTIES THAT HAD TO SURVIVE
|
|
22
|
+
* ===========================================================================
|
|
23
|
+
*
|
|
24
|
+
* 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still
|
|
25
|
+
* guards the message path, and `assertConcreteEndpoint` is its equivalent
|
|
26
|
+
* for HTTP. Both throw AT CONSTRUCTION rather than at first capture,
|
|
27
|
+
* because a misconfiguration that only surfaces when a reviewer finally
|
|
28
|
+
* clicks something is a misconfiguration nobody finds in review.
|
|
29
|
+
*
|
|
30
|
+
* 2. **The screenshot never costs the capture**, and neither may the network.
|
|
31
|
+
* `attachScreenshot` resolves within its own timeout and never rejects, so
|
|
32
|
+
* the payload goes out either way. An HTTP transport would reintroduce
|
|
33
|
+
* exactly the failure mode that was designed out — a slow network losing a
|
|
34
|
+
* capture — unless it carries its own timeout and its own `catch`, and
|
|
35
|
+
* unless a failed send is REPORTABLE rather than swallowed. A dropped
|
|
36
|
+
* capture that looks like a successful one is the worst outcome available
|
|
37
|
+
* here, so `send` rejects loudly and `createEgress` routes that to the
|
|
38
|
+
* caller.
|
|
39
|
+
*/
|
|
40
|
+
/** Every message the page half can emit. */
|
|
41
|
+
export type FrameMessageKind = FrameToParent["type"];
|
|
42
|
+
/**
|
|
43
|
+
* What the far end said about a capture it accepted (A-240).
|
|
44
|
+
*
|
|
45
|
+
* Everything on it is optional and always will be. `postMessageTransport`
|
|
46
|
+
* cannot answer at all — `postMessage` has no reply — and the ingest may
|
|
47
|
+
* answer with nothing. A reviewer who is told *sent* and given no link is in a
|
|
48
|
+
* strictly better position than one told nothing, so an absent `issueUrl` is a
|
|
49
|
+
* successful send and never a failed one.
|
|
50
|
+
*/
|
|
51
|
+
export interface SendReceipt {
|
|
52
|
+
/**
|
|
53
|
+
* The issue this capture became, when the far end names one.
|
|
54
|
+
*
|
|
55
|
+
* ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type
|
|
56
|
+
* alone is not the contract — see `safeIssueUrl`, which is what every value
|
|
57
|
+
* crossing the network boundary is put through before it gets to be one.
|
|
58
|
+
*/
|
|
59
|
+
issueUrl?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A URL from the network that is safe to put in somebody else's page (A-240).
|
|
63
|
+
*
|
|
64
|
+
* ===========================================================================
|
|
65
|
+
* WHY THIS IS NOT PARANOIA
|
|
66
|
+
* ===========================================================================
|
|
67
|
+
*
|
|
68
|
+
* `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets
|
|
69
|
+
* it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there
|
|
70
|
+
* executes in the product's origin the moment the reviewer clicks *View
|
|
71
|
+
* issue* — a script injection into a third party's site, shipped by us, on
|
|
72
|
+
* every page of every managed product.
|
|
73
|
+
*
|
|
74
|
+
* `assertConcreteEndpoint` does not cover this and was never going to:
|
|
75
|
+
* it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is
|
|
76
|
+
* misconfigured, compromised, or simply behind a proxy that rewrites bodies is
|
|
77
|
+
* exactly the case a transport has to survive, and "our own console would
|
|
78
|
+
* never" is not a security boundary.
|
|
79
|
+
*
|
|
80
|
+
* It lives HERE and not in the toolbar so the guarantee travels with the
|
|
81
|
+
* receipt rather than with one renderer of it. The toolbar calls it too — see
|
|
82
|
+
* `showResult` — because it renders receipts from ANY transport, including one
|
|
83
|
+
* a consumer wrote, which never passes through this function.
|
|
84
|
+
*
|
|
85
|
+
* Anything not `http(s)` is dropped rather than repaired. A dropped link is a
|
|
86
|
+
* toast that says the capture was sent and offers no link, which is the
|
|
87
|
+
* outcome the receipt is already designed to survive.
|
|
88
|
+
*/
|
|
89
|
+
export declare function safeIssueUrl(value: unknown): string | undefined;
|
|
90
|
+
export interface AnnotationTransport {
|
|
91
|
+
/** For diagnostics and for tests; never sent anywhere. */
|
|
92
|
+
readonly name: string;
|
|
93
|
+
/**
|
|
94
|
+
* May this transport carry this kind of message?
|
|
95
|
+
*
|
|
96
|
+
* The interface needs this because `annotate:hover` fires once per animation
|
|
97
|
+
* frame while the reviewer moves the mouse. Over `postMessage` that is a
|
|
98
|
+
* structured clone to a frame that is already listening; over HTTP it is one
|
|
99
|
+
* request per frame, which is a denial of service aimed at our own console.
|
|
100
|
+
*
|
|
101
|
+
* A transport that DROPPED such a message silently would be the same bug
|
|
102
|
+
* this issue exists to remove, one layer down. So declining is an explicit
|
|
103
|
+
* answer the SDK asks for and respects, and `send` is never called for a
|
|
104
|
+
* kind that was declined.
|
|
105
|
+
*/
|
|
106
|
+
carries(kind: FrameMessageKind): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Deliver the message. May be synchronous.
|
|
109
|
+
*
|
|
110
|
+
* A returned promise MUST reject on failure. Resolving on a failed send is
|
|
111
|
+
* the one thing a transport may not do.
|
|
112
|
+
*/
|
|
113
|
+
send(message: FrameToParent): void | Promise<void | SendReceipt>;
|
|
114
|
+
}
|
|
115
|
+
/** What went wrong, handed back to whoever constructed the SDK. */
|
|
116
|
+
export interface EgressFailure {
|
|
117
|
+
message: FrameToParent;
|
|
118
|
+
transport: string;
|
|
119
|
+
error: unknown;
|
|
120
|
+
}
|
|
121
|
+
export type EgressErrorHandler = (failure: EgressFailure) => void;
|
|
122
|
+
/**
|
|
123
|
+
* The parent-window transport — today's behaviour, extracted unchanged.
|
|
124
|
+
*
|
|
125
|
+
* It carries everything: the frame is a live listener on the other end of a
|
|
126
|
+
* cheap channel, and the handshake (`annotate:ready`) and the hover highlight
|
|
127
|
+
* both belong to it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function postMessageTransport(parentOrigin: string, win?: Pick<Window, "parent">): AnnotationTransport;
|
|
130
|
+
/**
|
|
131
|
+
* Reject anything that is not one concrete `http(s)` URL.
|
|
132
|
+
*
|
|
133
|
+
* `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL
|
|
134
|
+
* resolves against whatever page the SDK happens to be on — so a capture
|
|
135
|
+
* POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`
|
|
136
|
+
* or `data:` endpoint is a script-injection seam handed to whoever controls
|
|
137
|
+
* the deep link. Neither is a thing to discover at first capture.
|
|
138
|
+
*/
|
|
139
|
+
export declare function assertConcreteEndpoint(endpoint: string): string;
|
|
140
|
+
/** Node's `fetch` and the browser's, narrowed to what we use. */
|
|
141
|
+
export type FetchLike = (input: string, init: {
|
|
142
|
+
method: string;
|
|
143
|
+
headers: Record<string, string>;
|
|
144
|
+
body: string;
|
|
145
|
+
credentials?: "omit";
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
}) => Promise<{
|
|
148
|
+
ok: boolean;
|
|
149
|
+
status: number;
|
|
150
|
+
/**
|
|
151
|
+
* Optional because `serve()`'s loopback responses are read through the
|
|
152
|
+
* platform `fetch`, which has it, while a stub written to assert the `init`
|
|
153
|
+
* need not. A transport that made the body mandatory would be asserting
|
|
154
|
+
* something about the ingest that the ingest does not promise.
|
|
155
|
+
*/
|
|
156
|
+
json?: () => Promise<unknown>;
|
|
157
|
+
}>;
|
|
158
|
+
export interface HttpTransportOptions {
|
|
159
|
+
/** The console's ingest URL. Absolute, `http(s)`, never `*`. */
|
|
160
|
+
endpoint: string;
|
|
161
|
+
/**
|
|
162
|
+
* The capture token from the deep link — signed by the console, carried by
|
|
163
|
+
* the product, never validated by it. See the plan's *Authentication*
|
|
164
|
+
* section for why this is a second credential and not the arming one.
|
|
165
|
+
*/
|
|
166
|
+
captureToken: string;
|
|
167
|
+
/**
|
|
168
|
+
* Own timeout, own catch. The default is deliberately short: a capture the
|
|
169
|
+
* reviewer is told failed can be retried, and a request that hangs is
|
|
170
|
+
* indistinguishable from one that worked.
|
|
171
|
+
*/
|
|
172
|
+
timeoutMs?: number;
|
|
173
|
+
/** Injectable only so the tests can drive a transport with no `fetch` at all. */
|
|
174
|
+
fetch?: FetchLike;
|
|
175
|
+
}
|
|
176
|
+
export declare const HTTP_TRANSPORT_TIMEOUT_MS = 8000;
|
|
177
|
+
/**
|
|
178
|
+
* The first-party transport: POST the capture to the console's ingest.
|
|
179
|
+
*
|
|
180
|
+
* It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one
|
|
181
|
+
* request per animation frame, and `annotate:ready` / `annotate:cancel` are
|
|
182
|
+
* handshake with a parent window that does not exist first-party — sending
|
|
183
|
+
* them would be filing HTTP requests nobody asked for.
|
|
184
|
+
*/
|
|
185
|
+
export declare function httpTransport(options: HttpTransportOptions): AnnotationTransport;
|
|
186
|
+
/**
|
|
187
|
+
* What became of one message (A-240).
|
|
188
|
+
*
|
|
189
|
+
* `createEgress` used to return `void`, which was enough while the only reader
|
|
190
|
+
* of a failure was `console.error`. The composer has to TELL THE REVIEWER — the
|
|
191
|
+
* issue link on success, a retry on failure — and it cannot do that from a
|
|
192
|
+
* function that answers nothing. So the outcome is returned as a value, and the
|
|
193
|
+
* promise NEVER REJECTS: a rejection here would be a second way for a lost
|
|
194
|
+
* capture to disappear, one `await`-less caller away from silence.
|
|
195
|
+
*
|
|
196
|
+
* `declined` is its own case rather than a failure, because it is not one:
|
|
197
|
+
* `annotate:hover` over HTTP is a kind the transport correctly refuses, and
|
|
198
|
+
* calling that a failed capture would put a red toast in front of a reviewer
|
|
199
|
+
* who did nothing wrong. It is only a problem if `annotate:capture` is what was
|
|
200
|
+
* declined, and that is the caller's judgement rather than this function's.
|
|
201
|
+
*/
|
|
202
|
+
export type EgressOutcome = {
|
|
203
|
+
status: "sent";
|
|
204
|
+
receipt: SendReceipt;
|
|
205
|
+
} | {
|
|
206
|
+
status: "declined";
|
|
207
|
+
} | {
|
|
208
|
+
status: "failed";
|
|
209
|
+
error: unknown;
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* The SDK's one way out, given a transport.
|
|
213
|
+
*
|
|
214
|
+
* Pure and DOM-free on purpose, so the two things worth arguing about — that a
|
|
215
|
+
* declined kind is never sent, and that a failed send reaches the caller — are
|
|
216
|
+
* testable without a browser. `sdk.ts` holds no other egress path.
|
|
217
|
+
*/
|
|
218
|
+
export declare function createEgress(transport: AnnotationTransport, onError?: EgressErrorHandler): (message: FrameToParent) => Promise<EgressOutcome>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { type FrameToParent } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Egress (A-239).
|
|
4
|
+
*
|
|
5
|
+
* ===========================================================================
|
|
6
|
+
* WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`
|
|
7
|
+
* ===========================================================================
|
|
8
|
+
*
|
|
9
|
+
* `sdk.ts` had exactly one way out — `win.parent.postMessage(...)` — and
|
|
10
|
+
* first-party there is no parent: `win.parent === win`. Every capture then
|
|
11
|
+
* posts a message to the page that sent it and nothing receives it. **That
|
|
12
|
+
* fails silently**, which is the shape to be careful of here: the overlay
|
|
13
|
+
* behaves correctly, the reviewer clicks, and no issue is ever filed.
|
|
14
|
+
*
|
|
15
|
+
* See `docs/plans/first-party-annotation.md`. The console stops framing the
|
|
16
|
+
* product and links out to it instead, so the capture has to leave the page
|
|
17
|
+
* over HTTP. The frame is NOT being deleted — `postMessageTransport` is
|
|
18
|
+
* today's behaviour, extracted unchanged, and it is still the default.
|
|
19
|
+
*
|
|
20
|
+
* ===========================================================================
|
|
21
|
+
* TWO PROPERTIES THAT HAD TO SURVIVE
|
|
22
|
+
* ===========================================================================
|
|
23
|
+
*
|
|
24
|
+
* 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still
|
|
25
|
+
* guards the message path, and `assertConcreteEndpoint` is its equivalent
|
|
26
|
+
* for HTTP. Both throw AT CONSTRUCTION rather than at first capture,
|
|
27
|
+
* because a misconfiguration that only surfaces when a reviewer finally
|
|
28
|
+
* clicks something is a misconfiguration nobody finds in review.
|
|
29
|
+
*
|
|
30
|
+
* 2. **The screenshot never costs the capture**, and neither may the network.
|
|
31
|
+
* `attachScreenshot` resolves within its own timeout and never rejects, so
|
|
32
|
+
* the payload goes out either way. An HTTP transport would reintroduce
|
|
33
|
+
* exactly the failure mode that was designed out — a slow network losing a
|
|
34
|
+
* capture — unless it carries its own timeout and its own `catch`, and
|
|
35
|
+
* unless a failed send is REPORTABLE rather than swallowed. A dropped
|
|
36
|
+
* capture that looks like a successful one is the worst outcome available
|
|
37
|
+
* here, so `send` rejects loudly and `createEgress` routes that to the
|
|
38
|
+
* caller.
|
|
39
|
+
*/
|
|
40
|
+
/** Every message the page half can emit. */
|
|
41
|
+
export type FrameMessageKind = FrameToParent["type"];
|
|
42
|
+
/**
|
|
43
|
+
* What the far end said about a capture it accepted (A-240).
|
|
44
|
+
*
|
|
45
|
+
* Everything on it is optional and always will be. `postMessageTransport`
|
|
46
|
+
* cannot answer at all — `postMessage` has no reply — and the ingest may
|
|
47
|
+
* answer with nothing. A reviewer who is told *sent* and given no link is in a
|
|
48
|
+
* strictly better position than one told nothing, so an absent `issueUrl` is a
|
|
49
|
+
* successful send and never a failed one.
|
|
50
|
+
*/
|
|
51
|
+
export interface SendReceipt {
|
|
52
|
+
/**
|
|
53
|
+
* The issue this capture became, when the far end names one.
|
|
54
|
+
*
|
|
55
|
+
* ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type
|
|
56
|
+
* alone is not the contract — see `safeIssueUrl`, which is what every value
|
|
57
|
+
* crossing the network boundary is put through before it gets to be one.
|
|
58
|
+
*/
|
|
59
|
+
issueUrl?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A URL from the network that is safe to put in somebody else's page (A-240).
|
|
63
|
+
*
|
|
64
|
+
* ===========================================================================
|
|
65
|
+
* WHY THIS IS NOT PARANOIA
|
|
66
|
+
* ===========================================================================
|
|
67
|
+
*
|
|
68
|
+
* `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets
|
|
69
|
+
* it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there
|
|
70
|
+
* executes in the product's origin the moment the reviewer clicks *View
|
|
71
|
+
* issue* — a script injection into a third party's site, shipped by us, on
|
|
72
|
+
* every page of every managed product.
|
|
73
|
+
*
|
|
74
|
+
* `assertConcreteEndpoint` does not cover this and was never going to:
|
|
75
|
+
* it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is
|
|
76
|
+
* misconfigured, compromised, or simply behind a proxy that rewrites bodies is
|
|
77
|
+
* exactly the case a transport has to survive, and "our own console would
|
|
78
|
+
* never" is not a security boundary.
|
|
79
|
+
*
|
|
80
|
+
* It lives HERE and not in the toolbar so the guarantee travels with the
|
|
81
|
+
* receipt rather than with one renderer of it. The toolbar calls it too — see
|
|
82
|
+
* `showResult` — because it renders receipts from ANY transport, including one
|
|
83
|
+
* a consumer wrote, which never passes through this function.
|
|
84
|
+
*
|
|
85
|
+
* Anything not `http(s)` is dropped rather than repaired. A dropped link is a
|
|
86
|
+
* toast that says the capture was sent and offers no link, which is the
|
|
87
|
+
* outcome the receipt is already designed to survive.
|
|
88
|
+
*/
|
|
89
|
+
export declare function safeIssueUrl(value: unknown): string | undefined;
|
|
90
|
+
export interface AnnotationTransport {
|
|
91
|
+
/** For diagnostics and for tests; never sent anywhere. */
|
|
92
|
+
readonly name: string;
|
|
93
|
+
/**
|
|
94
|
+
* May this transport carry this kind of message?
|
|
95
|
+
*
|
|
96
|
+
* The interface needs this because `annotate:hover` fires once per animation
|
|
97
|
+
* frame while the reviewer moves the mouse. Over `postMessage` that is a
|
|
98
|
+
* structured clone to a frame that is already listening; over HTTP it is one
|
|
99
|
+
* request per frame, which is a denial of service aimed at our own console.
|
|
100
|
+
*
|
|
101
|
+
* A transport that DROPPED such a message silently would be the same bug
|
|
102
|
+
* this issue exists to remove, one layer down. So declining is an explicit
|
|
103
|
+
* answer the SDK asks for and respects, and `send` is never called for a
|
|
104
|
+
* kind that was declined.
|
|
105
|
+
*/
|
|
106
|
+
carries(kind: FrameMessageKind): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Deliver the message. May be synchronous.
|
|
109
|
+
*
|
|
110
|
+
* A returned promise MUST reject on failure. Resolving on a failed send is
|
|
111
|
+
* the one thing a transport may not do.
|
|
112
|
+
*/
|
|
113
|
+
send(message: FrameToParent): void | Promise<void | SendReceipt>;
|
|
114
|
+
}
|
|
115
|
+
/** What went wrong, handed back to whoever constructed the SDK. */
|
|
116
|
+
export interface EgressFailure {
|
|
117
|
+
message: FrameToParent;
|
|
118
|
+
transport: string;
|
|
119
|
+
error: unknown;
|
|
120
|
+
}
|
|
121
|
+
export type EgressErrorHandler = (failure: EgressFailure) => void;
|
|
122
|
+
/**
|
|
123
|
+
* The parent-window transport — today's behaviour, extracted unchanged.
|
|
124
|
+
*
|
|
125
|
+
* It carries everything: the frame is a live listener on the other end of a
|
|
126
|
+
* cheap channel, and the handshake (`annotate:ready`) and the hover highlight
|
|
127
|
+
* both belong to it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function postMessageTransport(parentOrigin: string, win?: Pick<Window, "parent">): AnnotationTransport;
|
|
130
|
+
/**
|
|
131
|
+
* Reject anything that is not one concrete `http(s)` URL.
|
|
132
|
+
*
|
|
133
|
+
* `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL
|
|
134
|
+
* resolves against whatever page the SDK happens to be on — so a capture
|
|
135
|
+
* POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`
|
|
136
|
+
* or `data:` endpoint is a script-injection seam handed to whoever controls
|
|
137
|
+
* the deep link. Neither is a thing to discover at first capture.
|
|
138
|
+
*/
|
|
139
|
+
export declare function assertConcreteEndpoint(endpoint: string): string;
|
|
140
|
+
/** Node's `fetch` and the browser's, narrowed to what we use. */
|
|
141
|
+
export type FetchLike = (input: string, init: {
|
|
142
|
+
method: string;
|
|
143
|
+
headers: Record<string, string>;
|
|
144
|
+
body: string;
|
|
145
|
+
credentials?: "omit";
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
}) => Promise<{
|
|
148
|
+
ok: boolean;
|
|
149
|
+
status: number;
|
|
150
|
+
/**
|
|
151
|
+
* Optional because `serve()`'s loopback responses are read through the
|
|
152
|
+
* platform `fetch`, which has it, while a stub written to assert the `init`
|
|
153
|
+
* need not. A transport that made the body mandatory would be asserting
|
|
154
|
+
* something about the ingest that the ingest does not promise.
|
|
155
|
+
*/
|
|
156
|
+
json?: () => Promise<unknown>;
|
|
157
|
+
}>;
|
|
158
|
+
export interface HttpTransportOptions {
|
|
159
|
+
/** The console's ingest URL. Absolute, `http(s)`, never `*`. */
|
|
160
|
+
endpoint: string;
|
|
161
|
+
/**
|
|
162
|
+
* The capture token from the deep link — signed by the console, carried by
|
|
163
|
+
* the product, never validated by it. See the plan's *Authentication*
|
|
164
|
+
* section for why this is a second credential and not the arming one.
|
|
165
|
+
*/
|
|
166
|
+
captureToken: string;
|
|
167
|
+
/**
|
|
168
|
+
* Own timeout, own catch. The default is deliberately short: a capture the
|
|
169
|
+
* reviewer is told failed can be retried, and a request that hangs is
|
|
170
|
+
* indistinguishable from one that worked.
|
|
171
|
+
*/
|
|
172
|
+
timeoutMs?: number;
|
|
173
|
+
/** Injectable only so the tests can drive a transport with no `fetch` at all. */
|
|
174
|
+
fetch?: FetchLike;
|
|
175
|
+
}
|
|
176
|
+
export declare const HTTP_TRANSPORT_TIMEOUT_MS = 8000;
|
|
177
|
+
/**
|
|
178
|
+
* The first-party transport: POST the capture to the console's ingest.
|
|
179
|
+
*
|
|
180
|
+
* It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one
|
|
181
|
+
* request per animation frame, and `annotate:ready` / `annotate:cancel` are
|
|
182
|
+
* handshake with a parent window that does not exist first-party — sending
|
|
183
|
+
* them would be filing HTTP requests nobody asked for.
|
|
184
|
+
*/
|
|
185
|
+
export declare function httpTransport(options: HttpTransportOptions): AnnotationTransport;
|
|
186
|
+
/**
|
|
187
|
+
* What became of one message (A-240).
|
|
188
|
+
*
|
|
189
|
+
* `createEgress` used to return `void`, which was enough while the only reader
|
|
190
|
+
* of a failure was `console.error`. The composer has to TELL THE REVIEWER — the
|
|
191
|
+
* issue link on success, a retry on failure — and it cannot do that from a
|
|
192
|
+
* function that answers nothing. So the outcome is returned as a value, and the
|
|
193
|
+
* promise NEVER REJECTS: a rejection here would be a second way for a lost
|
|
194
|
+
* capture to disappear, one `await`-less caller away from silence.
|
|
195
|
+
*
|
|
196
|
+
* `declined` is its own case rather than a failure, because it is not one:
|
|
197
|
+
* `annotate:hover` over HTTP is a kind the transport correctly refuses, and
|
|
198
|
+
* calling that a failed capture would put a red toast in front of a reviewer
|
|
199
|
+
* who did nothing wrong. It is only a problem if `annotate:capture` is what was
|
|
200
|
+
* declined, and that is the caller's judgement rather than this function's.
|
|
201
|
+
*/
|
|
202
|
+
export type EgressOutcome = {
|
|
203
|
+
status: "sent";
|
|
204
|
+
receipt: SendReceipt;
|
|
205
|
+
} | {
|
|
206
|
+
status: "declined";
|
|
207
|
+
} | {
|
|
208
|
+
status: "failed";
|
|
209
|
+
error: unknown;
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* The SDK's one way out, given a transport.
|
|
213
|
+
*
|
|
214
|
+
* Pure and DOM-free on purpose, so the two things worth arguing about — that a
|
|
215
|
+
* declined kind is never sent, and that a failed send reaches the caller — are
|
|
216
|
+
* testable without a browser. `sdk.ts` holds no other egress path.
|
|
217
|
+
*/
|
|
218
|
+
export declare function createEgress(transport: AnnotationTransport, onError?: EgressErrorHandler): (message: FrameToParent) => Promise<EgressOutcome>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Fixtures — one per protocol shape that exists in the wild
|
|
2
|
+
|
|
3
|
+
These are not test data. They are a **record of what has been sent**, and every
|
|
4
|
+
reader in this repository is checked against all of them (A-189).
|
|
5
|
+
|
|
6
|
+
Each file is a shape some deployed bundle emits or has emitted. They are added
|
|
7
|
+
when a field is added and **retired only deliberately**, by a change that says in
|
|
8
|
+
its own commit message why no consumer can still be emitting that shape.
|
|
9
|
+
|
|
10
|
+
| Fixture | What it records | Why it cannot be dropped |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `v1-minimal.json` | Every optional field absent | The floor. A reader that needs any optional field fails here first. |
|
|
13
|
+
| `v1-no-source-ref.json` | No `source`, no `sourceChain`, with console and network entries | Production carries no source refs at all, and a page whose element has no annotated ancestor is ordinary. |
|
|
14
|
+
| `v1-source-chain.json` | `source` + `sourceChain` + `componentName` | The composed-component case: the file that writes the markup is not the call site. |
|
|
15
|
+
| `v1-screenshot.json` | `screenshot` present | Added by A-31; the largest payload shape and the one that meets the size limit. |
|
|
16
|
+
| `v1-region-selection.json` | `selection: "region"` | Added by A-179. **Absent means `element`** — every fixture above proves that reading. |
|
|
17
|
+
|
|
18
|
+
## What these are for
|
|
19
|
+
|
|
20
|
+
A hundred consumers will not upgrade at the same time, and some will not upgrade
|
|
21
|
+
at all. Fixtures rather than types, because **a type change that alters meaning
|
|
22
|
+
while keeping the shape is exactly what a type checker cannot see** — making
|
|
23
|
+
`selection` required with a default would compile, and would make every payload
|
|
24
|
+
above claim a value nobody sent.
|
|
25
|
+
|
|
26
|
+
## Adding one
|
|
27
|
+
|
|
28
|
+
Add a fixture in the same change that adds the field, and give it a row above.
|
|
29
|
+
A field added without a fixture is a field nothing proves is optional.
|
|
30
|
+
|
|
31
|
+
## Retiring one
|
|
32
|
+
|
|
33
|
+
Only when no deployed bundle can still emit it — which for a published package
|
|
34
|
+
means a major version and evidence, not a guess. The test names the fixture that
|
|
35
|
+
is missing, so retiring one is a deliberate act rather than a silent deletion.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"capturedAt": "2026-08-23T10:00:00.000Z",
|
|
4
|
+
"build": { "gitSha": "464d52e1", "env": "preview" },
|
|
5
|
+
"page": {
|
|
6
|
+
"url": "https://example.test/",
|
|
7
|
+
"route": "/",
|
|
8
|
+
"viewport": { "w": 1440, "h": 900, "dpr": 2 },
|
|
9
|
+
"userAgent": "Mozilla/5.0"
|
|
10
|
+
},
|
|
11
|
+
"target": {
|
|
12
|
+
"domPath": "body > div",
|
|
13
|
+
"boundingBox": { "x": 0, "y": 0, "w": 100, "h": 40 }
|
|
14
|
+
},
|
|
15
|
+
"state": { "consoleErrors": [], "failedRequests": [], "featureFlags": {} },
|
|
16
|
+
"comment": ""
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"capturedAt": "2026-08-24T11:30:00.000Z",
|
|
4
|
+
"build": { "gitSha": "a00d6c8d", "env": "preview", "branch": "main" },
|
|
5
|
+
"page": {
|
|
6
|
+
"url": "https://example.test/guides/soho",
|
|
7
|
+
"route": "/guides/[slug]",
|
|
8
|
+
"viewport": { "w": 390, "h": 844, "dpr": 3 },
|
|
9
|
+
"userAgent": "Mozilla/5.0"
|
|
10
|
+
},
|
|
11
|
+
"target": {
|
|
12
|
+
"domPath": "main > section > p",
|
|
13
|
+
"boundingBox": { "x": 12, "y": 340, "w": 366, "h": 22 },
|
|
14
|
+
"textContent": "Open until 11pm"
|
|
15
|
+
},
|
|
16
|
+
"state": {
|
|
17
|
+
"consoleErrors": [{ "level": "error", "message": "Failed to load", "at": "2026-08-24T11:29:58.000Z" }],
|
|
18
|
+
"failedRequests": [{ "url": "https://example.test/api/hours", "status": 500, "at": "2026-08-24T11:29:57.000Z" }],
|
|
19
|
+
"featureFlags": {}
|
|
20
|
+
},
|
|
21
|
+
"comment": ""
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"capturedAt": "2026-08-30T18:00:00.000Z",
|
|
4
|
+
"build": { "gitSha": "7e93477", "env": "preview" },
|
|
5
|
+
"page": {
|
|
6
|
+
"url": "https://example.test/guides/soho",
|
|
7
|
+
"route": "/guides/[slug]",
|
|
8
|
+
"viewport": { "w": 1440, "h": 900, "dpr": 2 },
|
|
9
|
+
"userAgent": "Mozilla/5.0"
|
|
10
|
+
},
|
|
11
|
+
"target": {
|
|
12
|
+
"selection": "region",
|
|
13
|
+
"source": { "file": "apps/web/src/GuideList.tsx", "line": 30 },
|
|
14
|
+
"sourceChain": [
|
|
15
|
+
{ "file": "apps/web/src/GuideList.tsx", "line": 30 },
|
|
16
|
+
{ "file": "apps/web/src/GuideCard.tsx", "line": 14 }
|
|
17
|
+
],
|
|
18
|
+
"domPath": "main > ul",
|
|
19
|
+
"boundingBox": { "x": 100, "y": 200, "w": 600, "h": 300 }
|
|
20
|
+
},
|
|
21
|
+
"state": { "consoleErrors": [], "failedRequests": [], "featureFlags": {} },
|
|
22
|
+
"comment": ""
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"capturedAt": "2026-08-25T09:20:00.000Z",
|
|
4
|
+
"build": { "gitSha": "1187f3b2", "env": "preview" },
|
|
5
|
+
"page": {
|
|
6
|
+
"url": "https://example.test/",
|
|
7
|
+
"route": "/",
|
|
8
|
+
"viewport": { "w": 1440, "h": 900, "dpr": 2 },
|
|
9
|
+
"userAgent": "Mozilla/5.0"
|
|
10
|
+
},
|
|
11
|
+
"target": {
|
|
12
|
+
"source": { "file": "apps/web/src/Hero.tsx", "line": 12 },
|
|
13
|
+
"domPath": "header > img",
|
|
14
|
+
"boundingBox": { "x": 0, "y": 0, "w": 1440, "h": 400 }
|
|
15
|
+
},
|
|
16
|
+
"state": { "consoleErrors": [], "failedRequests": [], "featureFlags": {} },
|
|
17
|
+
"screenshot": { "dataUrl": "data:image/png;base64,iVBORw0KGgo=", "region": "element" },
|
|
18
|
+
"comment": ""
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"capturedAt": "2026-08-25T09:15:00.000Z",
|
|
4
|
+
"build": { "gitSha": "1187f3b2", "env": "preview" },
|
|
5
|
+
"page": {
|
|
6
|
+
"url": "https://example.test/guides/soho",
|
|
7
|
+
"route": "/guides/[slug]",
|
|
8
|
+
"viewport": { "w": 1280, "h": 800, "dpr": 2 },
|
|
9
|
+
"userAgent": "Mozilla/5.0"
|
|
10
|
+
},
|
|
11
|
+
"target": {
|
|
12
|
+
"source": { "file": "packages/design-system/src/MediaCard.tsx", "line": 545, "column": 5 },
|
|
13
|
+
"sourceChain": [
|
|
14
|
+
{ "file": "packages/design-system/src/MediaCard.tsx", "line": 545, "column": 5 },
|
|
15
|
+
{ "file": "apps/web/src/GuideDetailCard.tsx", "line": 88, "column": 7 }
|
|
16
|
+
],
|
|
17
|
+
"componentName": "MediaCard",
|
|
18
|
+
"domPath": "article > div > img",
|
|
19
|
+
"boundingBox": { "x": 40, "y": 120, "w": 320, "h": 200 },
|
|
20
|
+
"textContent": "A sunny terrace"
|
|
21
|
+
},
|
|
22
|
+
"state": { "consoleErrors": [], "failedRequests": [], "featureFlags": { "newHours": true } },
|
|
23
|
+
"comment": ""
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weatherboard/annotation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Click an element in a running app, get the source location that rendered it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"annotation",
|
|
@@ -57,16 +57,20 @@
|
|
|
57
57
|
"access": "public",
|
|
58
58
|
"//exports": "`types` INSIDE each condition, not above it. A single declaration shared by both formats hands a `require()` consumer types describing a module shape they do not get; and the ESM and CJS declarations differ in the extension their relative imports carry, which is what `moduleResolution: node16` checks. `build.mjs` explains the three spellings."
|
|
59
59
|
},
|
|
60
|
+
"//files": "The fixtures ship too (A-189). They are a record of every payload shape ever sent, and a consumer writing their own reader has the same compatibility problem we do — shipping them is cheaper than them guessing which optional fields are really optional.",
|
|
60
61
|
"files": [
|
|
61
62
|
"dist",
|
|
63
|
+
"fixtures",
|
|
62
64
|
"README.md",
|
|
63
65
|
"ADOPTING.md",
|
|
66
|
+
"COMPATIBILITY.md",
|
|
64
67
|
"LICENSE"
|
|
65
68
|
],
|
|
66
69
|
"//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-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.",
|
|
67
71
|
"sizeBudget": {
|
|
68
|
-
"protocol":
|
|
69
|
-
"index":
|
|
72
|
+
"protocol": 850,
|
|
73
|
+
"index": 9400,
|
|
70
74
|
"host": 660
|
|
71
75
|
},
|
|
72
76
|
"engines": {
|
|
@@ -84,10 +88,13 @@
|
|
|
84
88
|
"typecheck": "tsc --noEmit",
|
|
85
89
|
"//lint": "A-97. The root `eslint.config.mjs` is the single rule set — ESLint finds it by walking up from here, so there is nothing per-package to keep in sync. It is a script in EVERY package on purpose: A-52's verify tier runs `pnpm -r --if-present lint` and counts how many packages DECLARED it, so a package without this line is invisible to that gate rather than merely unlinted. `--max-warnings 0` because the React and Next plugins report at `warn` by default, and a gate that exits 0 while printing problems is the exact failure this repository keeps finding.",
|
|
86
90
|
"lint": "eslint . --max-warnings 0",
|
|
87
|
-
"test": "
|
|
91
|
+
"//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
|
+
"test": "tsx --test --test-timeout=30000 src/*.test.ts",
|
|
88
93
|
"e2e": "playwright test",
|
|
89
94
|
"//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.",
|
|
90
95
|
"smoke": "node smoke.mjs",
|
|
96
|
+
"//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`.",
|
|
97
|
+
"drift": "tsx published-drift.mjs",
|
|
91
98
|
"//prepack": "Runs on `npm pack` and `npm publish`, which is what makes the `publishConfig` swap above safe: the tarball cannot be built without `dist/` having just been produced from this source. Without it the two could disagree and the failure would be a published package pointing at files that are not there."
|
|
92
99
|
},
|
|
93
100
|
"module": "./dist/index.mjs"
|