@taprootio/site-authoring 0.8.4 → 0.8.5

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.
@@ -0,0 +1,276 @@
1
+ import { sanitizeDiagnostic } from "./errors.js";
2
+ import { withholdCredentialLocation } from "./staging-check.js";
3
+
4
+ /**
5
+ * The optional browser dimension of delivery verification (TR00824).
6
+ *
7
+ * HTTP evidence cannot say what a visitor's browser loaded: the shared runtime
8
+ * comes from a mutable major pointer that a returning browser may hold in its
9
+ * cache. This probe drives a real browser at the target twice — a fresh
10
+ * context, then a second navigation in the same context so the browser's own
11
+ * cache participates — and reports which runtime entry each load evaluated,
12
+ * whether the declared capability elements were defined, and whether the
13
+ * second load actually served the runtime entry from cache.
14
+ *
15
+ * Playwright is deliberately not a dependency of this package: basic CLI use
16
+ * must not carry a browser. The probe imports it only if the operator has
17
+ * installed it where this package's imports resolve (a global install of both
18
+ * packages, run through the installed command), and reports the dimension as
19
+ * unchecked with that recovery otherwise.
20
+ *
21
+ * Playwright's own request routing is not used: it disables the HTTP cache,
22
+ * which is the very thing the returning load measures. Instead a DevTools
23
+ * session on the browser itself — not on the page, because a page session
24
+ * sees only its own target and authored script can open a popup — intercepts
25
+ * every document request in the browser (the navigation, each redirect hop,
26
+ * a script-driven navigation, a frame, a popup) at the request stage, before
27
+ * it is transmitted, and fails any that would leave the target origin; the
28
+ * subresource cache is untouched. A page session reports whether the runtime
29
+ * entry came from the memory or disk cache. Without the browser session the
30
+ * boundary cannot be enforced, so the dimension is unchecked rather than
31
+ * probed without it. Every URL the probe reports passes through the
32
+ * credential withholding the staging check uses, so a handoff-bearing
33
+ * redirect never reaches the result. A staging cookie is attached only to the
34
+ * staging origin it was minted for.
35
+ */
36
+
37
+ // Node resolves `playwright` from the CLI's own dependency chain: a copy run
38
+ // through `npx` lives in npm's cache and cannot see a global Playwright, so
39
+ // the recovery installs the CLI and Playwright side by side and reruns
40
+ // through the installed `taproot-site` command rather than `npx`.
41
+ export const BROWSER_INSTALL_HINT = "npm install --global @taprootio/site-authoring@latest playwright && npx playwright install chromium, then rerun with the installed taproot-site command (not npx)";
42
+ const SETTLE_MILLISECONDS = 20_000;
43
+ const EVIDENCE_CHARACTERS = 512;
44
+ const BLOCKED_NAVIGATION = /ERR_BLOCKED_BY_CLIENT|ERR_ABORTED/u;
45
+
46
+ async function defaultImportPlaywright() {
47
+ return await import("playwright");
48
+ }
49
+
50
+ /** A URL or entry name as evidence: credentials withheld, controls removed, bounded. */
51
+ function evidence(value) {
52
+ return [...withholdCredentialLocation(typeof value === "string" ? value : "")].slice(0, EVIDENCE_CHARACTERS).join("");
53
+ }
54
+
55
+ async function observe(page, capabilities) {
56
+ return await page.evaluate((declared) => {
57
+ const root = document.querySelector("esp-root");
58
+ const entries = performance.getEntriesByType("resource")
59
+ .map((entry) => entry.name)
60
+ .filter((name) => /taproot-shared-runtime[^/]*\.js(?:$|\?)/u.test(name));
61
+ return {
62
+ rootDefined: customElements.get("esp-root") !== undefined,
63
+ themeReady: root?.hasAttribute("data-theme-ready") ?? false,
64
+ loadingHeld: root?.hasAttribute("data-taproot-loading") ?? false,
65
+ runtimeEntries: entries.slice(0, 4),
66
+ capabilitiesDefined: declared.filter((tag) => customElements.get(tag) !== undefined).length,
67
+ title: document.title,
68
+ };
69
+ }, capabilities);
70
+ }
71
+
72
+ const NOTHING_OBSERVED = Object.freeze({ rootDefined: false, themeReady: false, loadingHeld: false, runtimeEntries: [], capabilitiesDefined: 0 });
73
+
74
+ /** Every URL the navigation passed through, from the first request to the final document. */
75
+ function redirectChain(response) {
76
+ const hops = [];
77
+ let request = response?.request?.();
78
+ while (request && hops.length < 16) {
79
+ hops.unshift(request.url());
80
+ request = request.redirectedFrom?.();
81
+ }
82
+ return hops;
83
+ }
84
+
85
+ function sameOrigin(url, origin) {
86
+ try {
87
+ return new URL(url).origin === origin;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The DevTools sessions that enforce the navigation boundary (on the browser,
95
+ * so every target is covered) and observe cache reuse (on the page). Undefined
96
+ * when the browser offers no such sessions.
97
+ */
98
+ async function startDevTools(browser, context, page, { origin, expectedEntryUrl }) {
99
+ if (typeof browser.newBrowserCDPSession !== "function" || typeof context.newCDPSession !== "function") return undefined;
100
+ let boundary;
101
+ let session;
102
+ try {
103
+ boundary = await browser.newBrowserCDPSession();
104
+ session = await context.newCDPSession(page);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ const state = { blocked: [], requests: new Map(), fromCache: undefined };
109
+ boundary.on("Fetch.requestPaused", (event) => {
110
+ const url = event?.request?.url ?? "";
111
+ if (sameOrigin(url, origin)) {
112
+ boundary.send("Fetch.continueRequest", { requestId: event.requestId }).catch(() => undefined);
113
+ } else {
114
+ if (state.blocked.length < 8) state.blocked.push(url);
115
+ boundary.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" }).catch(() => undefined);
116
+ }
117
+ });
118
+ session.on("Network.requestWillBeSent", (event) => {
119
+ if (event?.request?.url === expectedEntryUrl) state.requests.set(event.requestId, event.request.url);
120
+ });
121
+ // A memory-cache hit is announced by requestServedFromCache; only a disk
122
+ // (or prefetch) cache hit is visible on the response itself.
123
+ session.on("Network.requestServedFromCache", (event) => {
124
+ if (state.requests.has(event?.requestId)) state.fromCache = true;
125
+ });
126
+ session.on("Network.responseReceived", (event) => {
127
+ if (event?.response?.url !== expectedEntryUrl || state.fromCache === true) return;
128
+ state.fromCache = Boolean(event.response.fromDiskCache || event.response.fromPrefetchCache);
129
+ });
130
+ await session.send("Network.enable");
131
+ await boundary.send("Fetch.enable", { patterns: [{ urlPattern: "*", resourceType: "Document", requestStage: "Request" }] });
132
+ return {
133
+ state,
134
+ reset() {
135
+ state.blocked = [];
136
+ state.requests.clear();
137
+ state.fromCache = undefined;
138
+ },
139
+ };
140
+ }
141
+
142
+ async function load(page, url, capabilities, origin, devtools) {
143
+ devtools.reset();
144
+ let response;
145
+ let blockedNavigation = false;
146
+ try {
147
+ response = await page.goto(url, { waitUntil: "domcontentloaded" });
148
+ } catch (error) {
149
+ // The boundary failed a document request before it was sent; the
150
+ // navigation error is the expected outcome, not a probe failure.
151
+ if (devtools.state.blocked.length === 0 && !BLOCKED_NAVIGATION.test(String(error?.message ?? ""))) throw error;
152
+ blockedNavigation = true;
153
+ }
154
+ if (!blockedNavigation) {
155
+ await page.waitForFunction(
156
+ () => {
157
+ const root = document.querySelector("esp-root");
158
+ return root !== null && !root.hasAttribute("data-taproot-loading");
159
+ },
160
+ undefined,
161
+ { timeout: SETTLE_MILLISECONDS },
162
+ ).catch(() => undefined);
163
+ }
164
+ const hops = blockedNavigation ? [] : [...new Set([...redirectChain(response), page.url()])];
165
+ const strayed = [...new Set([...devtools.state.blocked, ...hops.filter((hop) => !sameOrigin(hop, origin))])];
166
+ const observed = blockedNavigation ? NOTHING_OBSERVED : await observe(page, capabilities);
167
+ return { observed, strayed, finalUrl: blockedNavigation ? "" : page.url() };
168
+ }
169
+
170
+ function judge({ observed, strayed, finalUrl }, expectedEntryUrl, capabilityCount) {
171
+ const loadedEntry = observed.runtimeEntries.find((name) => name === expectedEntryUrl)
172
+ ?? observed.runtimeEntries[0]
173
+ ?? "";
174
+ return {
175
+ loadedEntry: evidence(loadedEntry),
176
+ expectedEntry: observed.runtimeEntries.includes(expectedEntryUrl),
177
+ rootDefined: observed.rootDefined,
178
+ themeReady: observed.themeReady,
179
+ capabilitiesDefined: observed.capabilitiesDefined,
180
+ capabilitiesDeclared: capabilityCount,
181
+ finalUrl: evidence(finalUrl),
182
+ ...(strayed.length > 0 ? { offOriginNavigation: strayed.slice(0, 4).map(evidence) } : {}),
183
+ ok: strayed.length === 0 && observed.rootDefined && observed.themeReady
184
+ && observed.runtimeEntries.includes(expectedEntryUrl)
185
+ && observed.capabilitiesDefined === capabilityCount,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param options.baseUrl The delivery origin.
191
+ * @param options.expectedEntryUrl The runtime entry the major pointer names right now.
192
+ * @param options.capabilities Custom-element tags the home page declares.
193
+ * @param options.cookie Optional staging cookie, sent only to `baseUrl`'s host.
194
+ * @param options.importPlaywright Test seam; defaults to a dynamic import.
195
+ */
196
+ export async function probeDeliveryWithBrowser({
197
+ baseUrl,
198
+ expectedEntryUrl,
199
+ capabilities = [],
200
+ cookie,
201
+ importPlaywright = defaultImportPlaywright,
202
+ }) {
203
+ let playwright;
204
+ try {
205
+ playwright = await importPlaywright();
206
+ } catch {
207
+ return {
208
+ status: "unchecked",
209
+ reason: "playwright_unavailable",
210
+ install: BROWSER_INSTALL_HINT,
211
+ note: "Fresh and returning browser behaviour was not verified; install the CLI and Playwright together and rerun through the installed taproot-site command to check it.",
212
+ };
213
+ }
214
+ const origin = new URL(baseUrl).origin;
215
+ let browser;
216
+ try {
217
+ browser = await playwright.chromium.launch({ headless: true });
218
+ const context = await browser.newContext();
219
+ if (cookie) {
220
+ const [name, value] = cookie.split("=", 2);
221
+ // A __Host- cookie carries no Domain attribute by definition; the URL
222
+ // form binds it to the staging origin without one.
223
+ await context.addCookies([{ name, value, url: baseUrl, secure: true, httpOnly: true, sameSite: "Lax" }]);
224
+ }
225
+ const page = await context.newPage();
226
+ const devtools = await startDevTools(browser, context, page, { origin, expectedEntryUrl });
227
+ if (!devtools) {
228
+ await context.close();
229
+ return {
230
+ status: "unchecked",
231
+ reason: "navigation_boundary_unavailable",
232
+ note: "The browser offers no DevTools sessions, so document requests could not be held to the site origin; nothing was loaded.",
233
+ };
234
+ }
235
+ const fresh = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
236
+ if (fresh.offOriginNavigation) {
237
+ // A target that leaves its origin is not probed again.
238
+ await context.close();
239
+ return {
240
+ status: "checked",
241
+ fresh,
242
+ returning: { skipped: true, reason: "fresh_load_left_origin", ok: false },
243
+ staleRuntimeObserved: false,
244
+ returningCache: { status: "unchecked", reason: "fresh_load_left_origin" },
245
+ ok: false,
246
+ };
247
+ }
248
+ // The same context navigates again: its HTTP cache now holds whatever the
249
+ // first load fetched, which is exactly what a returning visitor has.
250
+ const returning = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
251
+ const cacheObserved = devtools.state.fromCache;
252
+ await context.close();
253
+ return {
254
+ status: "checked",
255
+ fresh,
256
+ returning: {
257
+ ...returning,
258
+ runtimeFromCache: cacheObserved === undefined ? "unchecked" : cacheObserved,
259
+ },
260
+ staleRuntimeObserved: fresh.ok && !returning.expectedEntry,
261
+ returningCache: cacheObserved === undefined
262
+ ? { status: "unchecked", reason: "cache_observation_unavailable" }
263
+ : { status: "checked", servedFromCache: cacheObserved },
264
+ ok: fresh.ok && returning.ok,
265
+ };
266
+ } catch (error) {
267
+ return {
268
+ status: "failed",
269
+ reason: "browser_probe_failed",
270
+ error: sanitizeDiagnostic(error instanceof Error ? error.name : "Error", "Error"),
271
+ note: "The browser dimension could not be completed and is not verified.",
272
+ };
273
+ } finally {
274
+ await browser?.close().catch(() => undefined);
275
+ }
276
+ }