@tokenoftrust/storefront-runner 2.1.9 → 2.1.11
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/apps/storefront/public/shared/commerce-chrome.css +4 -0
- package/apps/storefront/src/components/Seo.astro +14 -14
- package/apps/storefront/src/layouts/Layout.astro +6 -1
- package/apps/storefront/src/lib/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/rawChrome.ts +40 -5
- package/apps/storefront/src/lib/seo/meta.ts +14 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/image-dependencies.ts +164 -15
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +70 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +63 -35
- package/packages/public-runtime/src/integration/post-paint.ts +95 -0
|
@@ -64,6 +64,10 @@ body { overflow-x: clip; }
|
|
|
64
64
|
caught as a real color-contrast failure once accent is a deep brand hue). */
|
|
65
65
|
.announce-bar { background: var(--color-accent); color: var(--color-primary-contrast); }
|
|
66
66
|
.announce-bar[hidden] { display: none; }
|
|
67
|
+
/* A returning visitor who already dismissed the bar gets `.announce-dismissed`
|
|
68
|
+
stamped on <html> pre-paint (see rawChrome.ts's head script) so it never
|
|
69
|
+
flashes in before CHROME_BEHAVIOR_JS's end-of-body script sets [hidden]. */
|
|
70
|
+
.announce-dismissed .announce-bar { display: none; }
|
|
67
71
|
.announce-bar .container { display: flex; align-items: center; justify-content: center; gap: 14px; padding-block: 9px; position: relative; }
|
|
68
72
|
.announce-bar__msg { font-size: .875rem; font-weight: 700; letter-spacing: .01em; text-align: center; }
|
|
69
73
|
.announce-bar__msg a { color: var(--color-primary-contrast); text-decoration: underline; }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* <Seo> — per-page title/meta, Open Graph, Twitter card, canonical, JSON-LD.
|
|
4
4
|
* (brief §8). Tenant-aware: OG image + site name come from the resolved tenant.
|
|
5
5
|
*/
|
|
6
|
-
import { articleSeoMetaTags, type ArticleMeta } from "@/lib/seo/meta.js";
|
|
6
|
+
import { articleSeoMetaTags, resolveOgImage, type ArticleMeta } from "@/lib/seo/meta.js";
|
|
7
7
|
import { withXDefault, type Alternate } from "@/lib/seo/alternates.js";
|
|
8
8
|
import { safeJsonScriptContent } from "@/lib/jsonScript";
|
|
9
9
|
|
|
@@ -64,23 +64,23 @@ const {
|
|
|
64
64
|
const { tenant, canonicalBase } = Astro.locals;
|
|
65
65
|
const canonical =
|
|
66
66
|
canonicalOverride ?? new URL(path, canonicalBase + "/").toString();
|
|
67
|
+
// Don't double-append the brand: merchant SEO titles (title_tag) often already
|
|
68
|
+
// end with the store name.
|
|
69
|
+
const fullTitle = title.includes(tenant.displayName)
|
|
70
|
+
? title
|
|
71
|
+
: `${title} — ${tenant.displayName}`;
|
|
72
|
+
const image = resolveOgImage({
|
|
73
|
+
ogImage, themeOgImage: tenant.theme_tokens?.brand?.ogImage ?? null, canonicalBase,
|
|
74
|
+
});
|
|
67
75
|
const articleMeta = articleSeoMetaTags({
|
|
68
76
|
ogType,
|
|
69
77
|
article,
|
|
70
|
-
imageAlt,
|
|
71
|
-
imageWidth,
|
|
72
|
-
imageHeight,
|
|
78
|
+
imageAlt: image ? imageAlt : undefined,
|
|
79
|
+
imageWidth: image ? imageWidth : undefined,
|
|
80
|
+
imageHeight: image ? imageHeight : undefined,
|
|
73
81
|
twitterSite,
|
|
74
82
|
twitterCreator,
|
|
75
83
|
});
|
|
76
|
-
// Don't double-append the brand: merchant SEO titles (title_tag) often already
|
|
77
|
-
// end with the store name.
|
|
78
|
-
const fullTitle = title.includes(tenant.displayName)
|
|
79
|
-
? title
|
|
80
|
-
: `${title} — ${tenant.displayName}`;
|
|
81
|
-
const image = ogImage
|
|
82
|
-
? new URL(ogImage, canonicalBase + "/").toString()
|
|
83
|
-
: new URL(tenant.theme_tokens?.brand?.ogImage ?? "/brand/northwind-og.svg", canonicalBase + "/").toString();
|
|
84
84
|
|
|
85
85
|
const graphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|
86
86
|
const alternateLinks = withXDefault(alternates);
|
|
@@ -108,12 +108,12 @@ const alternateLinks = withXDefault(alternates);
|
|
|
108
108
|
<meta property="og:title" content={fullTitle} />
|
|
109
109
|
{description && <meta property="og:description" content={description} />}
|
|
110
110
|
<meta property="og:url" content={canonical} />
|
|
111
|
-
<meta property="og:image" content={image} />
|
|
111
|
+
{image && <meta property="og:image" content={image} />}
|
|
112
112
|
|
|
113
113
|
<meta name="twitter:card" content="summary_large_image" />
|
|
114
114
|
<meta name="twitter:title" content={fullTitle} />
|
|
115
115
|
{description && <meta name="twitter:description" content={description} />}
|
|
116
|
-
<meta name="twitter:image" content={image} />
|
|
116
|
+
{image && <meta name="twitter:image" content={image} />}
|
|
117
117
|
|
|
118
118
|
{
|
|
119
119
|
articleMeta.map((t) =>
|
|
@@ -23,7 +23,7 @@ import { referenceTheme } from "@/themes/reference";
|
|
|
23
23
|
import { mergeTheme } from "@/themes/schema";
|
|
24
24
|
import { organizationLd, websiteLd } from "@/lib/jsonld";
|
|
25
25
|
import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
|
|
26
|
-
import { CHROME_BEHAVIOR_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
|
|
26
|
+
import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
|
|
27
27
|
import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
|
|
28
28
|
import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
|
|
29
29
|
import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
|
|
@@ -264,6 +264,11 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
264
264
|
<meta charset="utf-8" />
|
|
265
265
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
266
266
|
|
|
267
|
+
{/* MUST run before body content paints — see CHROME_HEAD_JS's doc comment
|
|
268
|
+
in rawChrome.ts (the same statement CHROME_BEHAVIOR_JS used to run at
|
|
269
|
+
end of body, which shifted the mobile nav's layout post-paint). */}
|
|
270
|
+
<script is:inline nonce={cspNonce} set:html={CHROME_HEAD_JS}></script>
|
|
271
|
+
|
|
267
272
|
{/* Renderer-owned head phases (platform.environment → consent.defaults →
|
|
268
273
|
head.early), emitted before any tenant/app code — the Layout half of "one
|
|
269
274
|
engine, two mounts" (rawHtmlResponse is the other). Same engine output, so
|
|
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
|
|
|
19
19
|
"'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
|
|
20
20
|
"'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
|
|
21
21
|
"'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
|
|
22
|
-
"'sha256-
|
|
22
|
+
"'sha256-Rl3sizqw4AmEkfQgkgajKo4uJC7JkyVCYiPJY/DJbIE='",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
export const STATIC_STYLE_HASHES: readonly string[] = [
|
|
@@ -531,6 +531,45 @@ function renderFooter(footer: ChromeFooter): string {
|
|
|
531
531
|
);
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The ONE canonical statement that marks the document JS-enabled so
|
|
536
|
+
* commerce-chrome.css can turn the always-reachable stacked mobile nav into an
|
|
537
|
+
* off-canvas drawer (`.js-nav` gate, commerce-chrome.css's max-width:860px
|
|
538
|
+
* block) — progressive enhancement, not a requirement. Shared verbatim by both
|
|
539
|
+
* document mounts that emit this chrome (the raw-HTML wrapper below and
|
|
540
|
+
* Layout.astro's Astro-rendered mount) so it is one source, never copy-pasted.
|
|
541
|
+
*
|
|
542
|
+
* MUST run from `<head>`, before any body content paints: run from end of body
|
|
543
|
+
* (as CHROME_BEHAVIOR_JS used to), the browser paints the stacked in-flow nav
|
|
544
|
+
* first and then reflows it into the fixed drawer once this line runs,
|
|
545
|
+
* producing a mobile-only layout shift (measured CLS 0.232 on
|
|
546
|
+
* `body > main#main`). Run from `<head>`, the drawer layout is what the
|
|
547
|
+
* browser paints first — no reflow, no shift.
|
|
548
|
+
*/
|
|
549
|
+
export const CHROME_HEAD_JS = `document.documentElement.classList.add('js-nav');`;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The wrapper's `<head>` behavior script: {@link CHROME_HEAD_JS}, plus — only when
|
|
553
|
+
* the announcement is a dismissible "bar" — a pre-paint check of the SAME
|
|
554
|
+
* localStorage key CHROME_BEHAVIOR_JS's dismiss handler reads, so a returning
|
|
555
|
+
* visitor who already dismissed it never sees it flash in before being hidden
|
|
556
|
+
* (the same before/after-paint shape as `.js-nav`, for a returning visitor
|
|
557
|
+
* rather than every visitor). Marks `<html>` with `.announce-dismissed`, which
|
|
558
|
+
* commerce-chrome.css hides the bar behind; CHROME_BEHAVIOR_JS's own
|
|
559
|
+
* `bar.hidden` logic (which also gates the message-rotation interval) is
|
|
560
|
+
* unchanged and still runs.
|
|
561
|
+
*/
|
|
562
|
+
function renderChromeHeadScript(chrome: ChromeConfig): string {
|
|
563
|
+
const announcement = chrome.header.announcement;
|
|
564
|
+
const announceKey = announcement?.variant === "bar" && announcement.dismissible
|
|
565
|
+
? announcement.storageKey || "tot_announce_dismissed"
|
|
566
|
+
: null;
|
|
567
|
+
const announceCheck = announceKey
|
|
568
|
+
? ` try { if (localStorage.getItem(${JSON.stringify(announceKey)}) === '1') document.documentElement.classList.add('announce-dismissed'); } catch (e) {}`
|
|
569
|
+
: "";
|
|
570
|
+
return `<script>(function () { ${CHROME_HEAD_JS}${announceCheck} })();</script>`;
|
|
571
|
+
}
|
|
572
|
+
|
|
534
573
|
/**
|
|
535
574
|
* The ONE shared, nonce-safe chrome behavior script. Inlined into the wrapper (the
|
|
536
575
|
* rawMarketingHtml pipeline stamps it with the page nonce, so a strict CSP admits it)
|
|
@@ -542,11 +581,6 @@ function renderFooter(footer: ChromeFooter): string {
|
|
|
542
581
|
* defaults open only when JS enables it).
|
|
543
582
|
*/
|
|
544
583
|
export const CHROME_BEHAVIOR_JS = `(function () {
|
|
545
|
-
// Marks the document as JS-enabled so commerce-chrome.css can turn the
|
|
546
|
-
// always-reachable stacked mobile nav into an off-canvas drawer (progressive
|
|
547
|
-
// enhancement, not a requirement — see the .js-nav rules there).
|
|
548
|
-
document.documentElement.classList.add('js-nav');
|
|
549
|
-
|
|
550
584
|
// Mobile nav drawer
|
|
551
585
|
var burger = document.getElementById('hamburger');
|
|
552
586
|
var nav = document.getElementById('primaryNav');
|
|
@@ -808,6 +842,7 @@ export function renderRawChrome(
|
|
|
808
842
|
`<head>\n` +
|
|
809
843
|
`<meta charset="UTF-8">\n` +
|
|
810
844
|
`<meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
845
|
+
`${renderChromeHeadScript(chrome)}\n` +
|
|
811
846
|
`${headLinks}\n` +
|
|
812
847
|
`${CHROME_HEAD_MARKER}\n` +
|
|
813
848
|
`</head>\n` +
|
|
@@ -34,6 +34,20 @@ export interface ArticleSeoInput {
|
|
|
34
34
|
twitterCreator?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The og:image/twitter:image URL, or `null` when neither the page nor the
|
|
39
|
+
* tenant's theme declares one. Never falls back to a platform placeholder --
|
|
40
|
+
* a shared image path that 404s live for every tenant that hasn't set one.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveOgImage(input: {
|
|
43
|
+
ogImage?: string;
|
|
44
|
+
themeOgImage?: string | null;
|
|
45
|
+
canonicalBase: string;
|
|
46
|
+
}): string | null {
|
|
47
|
+
const declared = input.ogImage ?? input.themeOgImage ?? null;
|
|
48
|
+
return declared ? new URL(declared, input.canonicalBase + "/").toString() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* The article-only OG/Twitter meta tags. Returns `[]` for any non-article page
|
|
39
53
|
* so `article:*`, `og:image:alt`, and `twitter:site`/`creator` can never leak
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.11",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -43,6 +43,17 @@ export interface PlatformHostedImageConfig {
|
|
|
43
43
|
|
|
44
44
|
export interface ImageDependencyScanOptions {
|
|
45
45
|
tenantId: string;
|
|
46
|
+
/**
|
|
47
|
+
* The tenant's own public origins (e.g. `https://tokenoftrust.com`), from declared tenant
|
|
48
|
+
* config -- never derived from the tenant id. An absolute URL on one of these origins is
|
|
49
|
+
* classified by its path exactly as a root-relative locator would be (tenant-hosted,
|
|
50
|
+
* platform-hosted, capture-required/local, or cross-tenant), instead of being treated as a
|
|
51
|
+
* third-party reference. Required (empty is valid) so every caller states its origins
|
|
52
|
+
* explicitly; six call sites compute finding identities and must agree, or a capture and a
|
|
53
|
+
* later publish scanning with different origins would diverge and the publish gate would
|
|
54
|
+
* refuse on an identity mismatch it can't explain.
|
|
55
|
+
*/
|
|
56
|
+
tenantOrigins: readonly string[];
|
|
46
57
|
platformHosted?: PlatformHostedImageConfig;
|
|
47
58
|
}
|
|
48
59
|
|
|
@@ -164,6 +175,8 @@ const URL_TAB_OR_NEWLINE_RE = /[\t\n\r]/g;
|
|
|
164
175
|
const ASCII_WHITESPACE_RE = /[\t\n\f\r ]/;
|
|
165
176
|
const ASCII_WHITESPACE_RUN_RE = /[\t\n\f\r ]+/;
|
|
166
177
|
const MAX_VISIBLE_FINDINGS = 100;
|
|
178
|
+
/** Shared artifact-scan cap for both the publish gate and the preview capture endpoint. */
|
|
179
|
+
export const MAX_IMAGE_SCAN_ARTIFACTS = 5000;
|
|
167
180
|
const MAX_PERCENT_DECODE_PASSES = 8;
|
|
168
181
|
const MAX_CATALOG_WALK_ITEMS = 1_000_000;
|
|
169
182
|
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
@@ -447,12 +460,25 @@ function adoptCssReferences(refs: ExtractedReference[], cssRefs: readonly Extrac
|
|
|
447
460
|
for (const ref of cssRefs) refs.push(afterBase ? { ...ref, afterBase: true } : ref);
|
|
448
461
|
}
|
|
449
462
|
|
|
463
|
+
const NO_DECLARED_CUSTOM_PROPERTIES: ReadonlySet<string> = new Set();
|
|
464
|
+
|
|
450
465
|
/**
|
|
451
466
|
* CSS extraction uses a JavaScript parser because this module is imported by
|
|
452
467
|
* Worker routes. A native CSS binding cannot be evaluated by workerd, which
|
|
453
468
|
* would prevent every route sharing its bundle chunk from loading.
|
|
469
|
+
*
|
|
470
|
+
* `declaredCustomProperties` names every `--foo` declared anywhere in the
|
|
471
|
+
* scanned artifact set (see `collectDeclaredCustomProperties`). The scanner
|
|
472
|
+
* already scans `url(...)` wherever it appears, including inside a custom
|
|
473
|
+
* property's own declaration, so a `var(--foo)` reference is only deferred
|
|
474
|
+
* (unresolvable statically) when `--foo` is not declared anywhere — or when
|
|
475
|
+
* its fallback argument itself contains no scannable image source.
|
|
454
476
|
*/
|
|
455
|
-
function cssReferences(
|
|
477
|
+
function cssReferences(
|
|
478
|
+
css: string,
|
|
479
|
+
kind: "stylesheet" | "declaration",
|
|
480
|
+
declaredCustomProperties: ReadonlySet<string> = NO_DECLARED_CUSTOM_PROPERTIES,
|
|
481
|
+
): ExtractedReference[] {
|
|
456
482
|
if (!css) return [];
|
|
457
483
|
const refs: ExtractedReference[] = [];
|
|
458
484
|
const parseValue = (value: string): CssScanNode | null => {
|
|
@@ -467,6 +493,28 @@ function cssReferences(css: string, kind: "stylesheet" | "declaration"): Extract
|
|
|
467
493
|
if (imageValue) rememberDeferred(refs, name);
|
|
468
494
|
};
|
|
469
495
|
|
|
496
|
+
// `var(--foo)`'s own declaration site already captured any image inside it (scanValue's
|
|
497
|
+
// Url case is unconditional); a top-level `var()` reference only needs its own deferred
|
|
498
|
+
// marker when `--foo` is not declared anywhere AND its fallback argument (present only
|
|
499
|
+
// when the parse produced [Identifier, Operator, Raw]) has no scannable image source
|
|
500
|
+
// either. Scoped to scanValue's top-level property-value position only: a var() nested
|
|
501
|
+
// inside image()/image-set() keeps the existing conservative "always defer, never resolve
|
|
502
|
+
// its fallback" behaviour — those constructs are rare/synthetic and untouched by this rule.
|
|
503
|
+
const resolveVar = (node: CssScanNode, imageValue: boolean): void => {
|
|
504
|
+
if (!imageValue) return;
|
|
505
|
+
// cssChildren() is css-tree's own iterable List, not a real array (no index access,
|
|
506
|
+
// no .length) — spread it once to get an indexable copy for the fixed [name, ",", fallback] shape.
|
|
507
|
+
const children = [...cssChildren(node)];
|
|
508
|
+
const propertyName = children[0]?.type === "Identifier" ? children[0].name ?? "" : "";
|
|
509
|
+
if (propertyName && declaredCustomProperties.has(propertyName)) return;
|
|
510
|
+
const rawFallback = children[2];
|
|
511
|
+
if (rawFallback?.type === "Raw") {
|
|
512
|
+
const parsedFallback = parseValue(rawFallback.value ?? "");
|
|
513
|
+
if (parsedFallback && firstImageSource(cssChildren(parsedFallback))) return;
|
|
514
|
+
}
|
|
515
|
+
rememberDeferred(refs, "var");
|
|
516
|
+
};
|
|
517
|
+
|
|
470
518
|
// image() resolves one source. A discarded non-image argument does not consume it.
|
|
471
519
|
const scanNestedDeferred = (node: CssScanNode): void => {
|
|
472
520
|
if (node.type !== "Function") {
|
|
@@ -481,7 +529,7 @@ function cssReferences(css: string, kind: "stylesheet" | "declaration"): Extract
|
|
|
481
529
|
for (const child of cssChildren(node)) scanNestedDeferred(child);
|
|
482
530
|
};
|
|
483
531
|
|
|
484
|
-
|
|
532
|
+
function firstImageSource(nodes: CssScanNode[]): boolean {
|
|
485
533
|
let chosen = false;
|
|
486
534
|
for (const node of nodes) {
|
|
487
535
|
if (chosen) {
|
|
@@ -509,7 +557,7 @@ function cssReferences(css: string, kind: "stylesheet" | "declaration"): Extract
|
|
|
509
557
|
if (firstImageSource(cssChildren(node))) chosen = true;
|
|
510
558
|
}
|
|
511
559
|
return chosen;
|
|
512
|
-
}
|
|
560
|
+
}
|
|
513
561
|
|
|
514
562
|
const imageSetSources = (nodes: CssScanNode[]): void => {
|
|
515
563
|
let option: CssScanNode[] = [];
|
|
@@ -541,6 +589,10 @@ function cssReferences(css: string, kind: "stylesheet" | "declaration"): Extract
|
|
|
541
589
|
continue;
|
|
542
590
|
}
|
|
543
591
|
const name = (node.name ?? "").toLowerCase();
|
|
592
|
+
if (name === "var") {
|
|
593
|
+
resolveVar(node, imageValue);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
544
596
|
const deferred = deferredFunctionName(name);
|
|
545
597
|
if (deferred) {
|
|
546
598
|
noteDeferred(deferred, imageValue);
|
|
@@ -706,6 +758,7 @@ function collectHtmlElement(
|
|
|
706
758
|
sourceContext: HtmlSourceContext,
|
|
707
759
|
artifactPath: string,
|
|
708
760
|
inheritedBase: ResolvedBase | null,
|
|
761
|
+
declaredCustomProperties: ReadonlySet<string>,
|
|
709
762
|
): void {
|
|
710
763
|
const tag = element.tagName;
|
|
711
764
|
const html = element.namespaceURI === HTML_NAMESPACE;
|
|
@@ -717,7 +770,7 @@ function collectHtmlElement(
|
|
|
717
770
|
if (tag === "style") return;
|
|
718
771
|
const styleValue = htmlAttribute(element, "style");
|
|
719
772
|
const emitStyle = (): void => {
|
|
720
|
-
if (styleValue != null) adoptCssReferences(refs, cssReferences(styleValue, "declaration"), afterBase);
|
|
773
|
+
if (styleValue != null) adoptCssReferences(refs, cssReferences(styleValue, "declaration", declaredCustomProperties), afterBase);
|
|
721
774
|
};
|
|
722
775
|
if (!html) {
|
|
723
776
|
if (element.namespaceURI === SVG_NAMESPACE && (tag === "image" || tag.toLowerCase() === "feimage")) {
|
|
@@ -732,7 +785,7 @@ function collectHtmlElement(
|
|
|
732
785
|
if (tag === "iframe") {
|
|
733
786
|
const srcdoc = htmlAttribute(element, "srcdoc");
|
|
734
787
|
if (srcdoc != null && !isBlankLocator(srcdoc)) {
|
|
735
|
-
for (const nested of htmlReferences(srcdoc, artifactPath, inheritedBase)) {
|
|
788
|
+
for (const nested of htmlReferences(srcdoc, artifactPath, inheritedBase, declaredCustomProperties)) {
|
|
736
789
|
refs.push({
|
|
737
790
|
locator: nested.locator,
|
|
738
791
|
surface: nested.surface,
|
|
@@ -800,7 +853,12 @@ function collectHtmlElement(
|
|
|
800
853
|
emitStyle();
|
|
801
854
|
}
|
|
802
855
|
|
|
803
|
-
function htmlReferences(
|
|
856
|
+
function htmlReferences(
|
|
857
|
+
html: string,
|
|
858
|
+
artifactPath: string,
|
|
859
|
+
inherited: ResolvedBase | null = null,
|
|
860
|
+
declaredCustomProperties: ReadonlySet<string> = NO_DECLARED_CUSTOM_PROPERTIES,
|
|
861
|
+
): ExtractedReference[] {
|
|
804
862
|
const refs: ExtractedReference[] = [];
|
|
805
863
|
let baseHref: string | null = null;
|
|
806
864
|
let sawBase = false;
|
|
@@ -815,7 +873,7 @@ function htmlReferences(html: string, artifactPath: string, inherited: ResolvedB
|
|
|
815
873
|
const frame = stack.pop()!;
|
|
816
874
|
const { node, inTemplate, sourceContext } = frame;
|
|
817
875
|
if (isHtmlText(node)) {
|
|
818
|
-
if (frame.cssText) adoptCssReferences(refs, cssReferences(node.value, "stylesheet"), sawBase);
|
|
876
|
+
if (frame.cssText) adoptCssReferences(refs, cssReferences(node.value, "stylesheet", declaredCustomProperties), sawBase);
|
|
819
877
|
continue;
|
|
820
878
|
}
|
|
821
879
|
if (node.nodeName === "#document" || node.nodeName === "#document-fragment") {
|
|
@@ -823,7 +881,7 @@ function htmlReferences(html: string, artifactPath: string, inherited: ResolvedB
|
|
|
823
881
|
continue;
|
|
824
882
|
}
|
|
825
883
|
if (!isHtmlElement(node)) continue;
|
|
826
|
-
collectHtmlElement(node, refs, inTemplate, noteBase, sawBase, sourceContext, artifactPath, enclosingBase());
|
|
884
|
+
collectHtmlElement(node, refs, inTemplate, noteBase, sawBase, sourceContext, artifactPath, enclosingBase(), declaredCustomProperties);
|
|
827
885
|
const childContext = childSourceContext(node.tagName, node.namespaceURI);
|
|
828
886
|
if (node.tagName === "template" && node.namespaceURI === HTML_NAMESPACE && "content" in node) {
|
|
829
887
|
pushHtmlChildren(stack, node.content.childNodes, true, false, childContext);
|
|
@@ -1134,15 +1192,23 @@ export function classifyImageDependency(
|
|
|
1134
1192
|
tenantId: string;
|
|
1135
1193
|
artifactPath: string;
|
|
1136
1194
|
platformHosted?: PlatformHostedImageConfig;
|
|
1195
|
+
tenantOrigins?: readonly string[];
|
|
1137
1196
|
},
|
|
1138
1197
|
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1139
|
-
return classifyImageDependencyWithPrepared(
|
|
1198
|
+
return classifyImageDependencyWithPrepared(
|
|
1199
|
+
locator, context, preparePlatformHostedConfig(context.platformHosted), prepareTenantOrigins(context.tenantOrigins ?? []),
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function prepareTenantOrigins(tenantOrigins: readonly string[]): ReadonlySet<string> {
|
|
1204
|
+
return new Set(tenantOrigins.map(normalizedOrigin).filter((origin): origin is string => origin != null));
|
|
1140
1205
|
}
|
|
1141
1206
|
|
|
1142
1207
|
function classifyParsedHttpUrl(
|
|
1143
1208
|
absolute: URL,
|
|
1144
|
-
context: { tenantId: string },
|
|
1209
|
+
context: { tenantId: string; artifactPath: string },
|
|
1145
1210
|
platformHosted: PreparedPlatformHostedImageConfig,
|
|
1211
|
+
tenantOrigins: ReadonlySet<string>,
|
|
1146
1212
|
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1147
1213
|
if (!absolute.hostname || absolute.username || absolute.password) {
|
|
1148
1214
|
return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
|
|
@@ -1150,6 +1216,13 @@ function classifyParsedHttpUrl(
|
|
|
1150
1216
|
if (absolutePathEscapes(absolute.pathname)) {
|
|
1151
1217
|
return { canonicalLocator: "escape:", disposition: "security-error", reason: "tenant-escape" };
|
|
1152
1218
|
}
|
|
1219
|
+
if (tenantOrigins.has(absolute.origin) && !absolute.pathname.startsWith("//")) {
|
|
1220
|
+
// The tenant's own origin: classify by path exactly as a root-relative locator would be,
|
|
1221
|
+
// instead of the third-party fallback below. The pathname.startsWith("//") exclusion
|
|
1222
|
+
// guards a URL like https://tenant.example//evil.example/x.png, whose pathname would
|
|
1223
|
+
// otherwise re-parse as scheme-relative to a DIFFERENT origin on the recursive call.
|
|
1224
|
+
return classifyImageDependencyWithPrepared(`${absolute.pathname}${absolute.search}`, context, platformHosted, tenantOrigins);
|
|
1225
|
+
}
|
|
1153
1226
|
absolute.hash = "";
|
|
1154
1227
|
const canonical = absolute.toString();
|
|
1155
1228
|
if (platformHosted.origins.has(absolute.origin) || platformHosted.references.has(stripQueryHash(canonical))) {
|
|
@@ -1166,6 +1239,7 @@ function classifyImageDependencyWithPrepared(
|
|
|
1166
1239
|
locator: string,
|
|
1167
1240
|
context: { tenantId: string; artifactPath: string },
|
|
1168
1241
|
platformHosted: PreparedPlatformHostedImageConfig,
|
|
1242
|
+
tenantOrigins: ReadonlySet<string>,
|
|
1169
1243
|
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1170
1244
|
const raw = normalizeUrlInput(locator);
|
|
1171
1245
|
if (!raw || CONTROL_RE.test(raw)) {
|
|
@@ -1184,14 +1258,14 @@ function classifyImageDependencyWithPrepared(
|
|
|
1184
1258
|
} catch {
|
|
1185
1259
|
return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
|
|
1186
1260
|
}
|
|
1187
|
-
return classifyParsedHttpUrl(absolute, context, platformHosted);
|
|
1261
|
+
return classifyParsedHttpUrl(absolute, context, platformHosted, tenantOrigins);
|
|
1188
1262
|
}
|
|
1189
1263
|
|
|
1190
1264
|
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
|
1191
1265
|
return { canonicalLocator: "unsafe:", disposition: "security-error", reason: "unsafe-scheme" };
|
|
1192
1266
|
}
|
|
1193
1267
|
const networkUrl = backslashNetworkUrl(raw, context.artifactPath);
|
|
1194
|
-
if (networkUrl) return classifyParsedHttpUrl(networkUrl, context, platformHosted);
|
|
1268
|
+
if (networkUrl) return classifyParsedHttpUrl(networkUrl, context, platformHosted, tenantOrigins);
|
|
1195
1269
|
const pathOnly = stripQueryHash(raw);
|
|
1196
1270
|
const structural = structuralPath(pathOnly);
|
|
1197
1271
|
if (structural.startsWith("/") && hasDotDotSegment(pathOnly)) {
|
|
@@ -1279,6 +1353,79 @@ export function buildImageDependencyWarningBatch(
|
|
|
1279
1353
|
};
|
|
1280
1354
|
}
|
|
1281
1355
|
|
|
1356
|
+
function declaredCustomPropertyNames(css: string, kind: "stylesheet" | "declaration"): Set<string> {
|
|
1357
|
+
const declared = new Set<string>();
|
|
1358
|
+
const walk = (node: CssScanNode): void => {
|
|
1359
|
+
if (node.type === "Declaration") {
|
|
1360
|
+
const property = node.property ?? "";
|
|
1361
|
+
if (property.startsWith("--")) declared.add(property);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (node.block) walk(node.block);
|
|
1365
|
+
for (const child of cssChildren(node)) walk(child);
|
|
1366
|
+
};
|
|
1367
|
+
try {
|
|
1368
|
+
const parsed = parse(css, {
|
|
1369
|
+
context: kind === "declaration" ? "declarationList" : "stylesheet",
|
|
1370
|
+
list: false,
|
|
1371
|
+
}) as unknown as CssScanNode;
|
|
1372
|
+
walk(parsed);
|
|
1373
|
+
} catch {
|
|
1374
|
+
// Invalid CSS contributes no declarations; the real scan fails open the same way.
|
|
1375
|
+
}
|
|
1376
|
+
return declared;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** Every `style=""` attribute value (declaration context) and `<style>` text (stylesheet context). */
|
|
1380
|
+
function rawCssChunksInHtml(html: string): Array<{ css: string; kind: "stylesheet" | "declaration" }> {
|
|
1381
|
+
const chunks: Array<{ css: string; kind: "stylesheet" | "declaration" }> = [];
|
|
1382
|
+
const stack: HtmlNode[] = [parseFragment(html)];
|
|
1383
|
+
while (stack.length > 0) {
|
|
1384
|
+
const node = stack.pop()!;
|
|
1385
|
+
if (isHtmlText(node)) continue;
|
|
1386
|
+
if (node.nodeName === "#document" || node.nodeName === "#document-fragment") {
|
|
1387
|
+
for (const child of node.childNodes) stack.push(child);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (!isHtmlElement(node)) continue;
|
|
1391
|
+
const styleValue = htmlAttribute(node, "style");
|
|
1392
|
+
if (styleValue != null) chunks.push({ css: styleValue, kind: "declaration" });
|
|
1393
|
+
if (node.tagName === "style") {
|
|
1394
|
+
for (const child of node.childNodes) {
|
|
1395
|
+
if (isHtmlText(child)) chunks.push({ css: child.value, kind: "stylesheet" });
|
|
1396
|
+
}
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
if (node.tagName === "template" && node.namespaceURI === HTML_NAMESPACE && "content" in node) {
|
|
1400
|
+
for (const child of node.content.childNodes) stack.push(child);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
for (const child of node.childNodes) stack.push(child);
|
|
1404
|
+
}
|
|
1405
|
+
return chunks;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Pre-pass: every `--foo` declared anywhere in the artifact set (CSS files, `<style>` blocks,
|
|
1410
|
+
* `style=""` attributes). `scanImageDependencies` threads this into every `var()` decision so a
|
|
1411
|
+
* reference to a declared custom property is never deferred — its image, if any, was already
|
|
1412
|
+
* captured at the declaration itself.
|
|
1413
|
+
*/
|
|
1414
|
+
function collectDeclaredCustomProperties(artifacts: readonly ImageDependencyArtifact[]): Set<string> {
|
|
1415
|
+
const declared = new Set<string>();
|
|
1416
|
+
for (const artifact of artifacts) {
|
|
1417
|
+
const kind = inferKind(artifact);
|
|
1418
|
+
if (kind === "css") {
|
|
1419
|
+
for (const name of declaredCustomPropertyNames(artifact.content, "stylesheet")) declared.add(name);
|
|
1420
|
+
} else if (kind === "html") {
|
|
1421
|
+
for (const chunk of rawCssChunksInHtml(artifact.content)) {
|
|
1422
|
+
for (const name of declaredCustomPropertyNames(chunk.css, chunk.kind)) declared.add(name);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return declared;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1282
1429
|
/** Scan a revision's already-read artifacts with no network or filesystem I/O. */
|
|
1283
1430
|
export function scanImageDependencies(
|
|
1284
1431
|
artifacts: readonly ImageDependencyArtifact[],
|
|
@@ -1286,12 +1433,14 @@ export function scanImageDependencies(
|
|
|
1286
1433
|
): ImageDependencyScan {
|
|
1287
1434
|
const dependencies: ImageDependency[] = [];
|
|
1288
1435
|
const platformHosted = preparePlatformHostedConfig(options.platformHosted);
|
|
1436
|
+
const tenantOrigins = prepareTenantOrigins(options.tenantOrigins);
|
|
1437
|
+
const declaredCustomProperties = collectDeclaredCustomProperties(artifacts);
|
|
1289
1438
|
let occurrence = 0;
|
|
1290
1439
|
for (const [artifactIndex, artifact] of artifacts.entries()) {
|
|
1291
1440
|
const kind = inferKind(artifact);
|
|
1292
1441
|
let refs: ExtractedReference[];
|
|
1293
|
-
if (kind === "html") refs = htmlReferences(artifact.content, artifact.path);
|
|
1294
|
-
else if (kind === "css") refs = cssReferences(artifact.content, "stylesheet");
|
|
1442
|
+
if (kind === "html") refs = htmlReferences(artifact.content, artifact.path, null, declaredCustomProperties);
|
|
1443
|
+
else if (kind === "css") refs = cssReferences(artifact.content, "stylesheet", declaredCustomProperties);
|
|
1295
1444
|
else {
|
|
1296
1445
|
const parsed = catalogReferences(artifact.content, artifact.path);
|
|
1297
1446
|
if (parsed == null) {
|
|
@@ -1317,7 +1466,7 @@ export function scanImageDependencies(
|
|
|
1317
1466
|
const classified = classifyImageDependencyWithPrepared(ref.classificationLocator ?? ref.locator, {
|
|
1318
1467
|
tenantId: options.tenantId,
|
|
1319
1468
|
artifactPath: artifact.path,
|
|
1320
|
-
}, platformHosted);
|
|
1469
|
+
}, platformHosted, tenantOrigins);
|
|
1321
1470
|
dependencies.push({
|
|
1322
1471
|
locator: ref.locator,
|
|
1323
1472
|
artifactIndex,
|
|
@@ -46,7 +46,9 @@ import { resolveNativeGa4SandboxUrl, type NativeGa4Transport } from "./native-ga
|
|
|
46
46
|
import {
|
|
47
47
|
createBrowserNativeGa4SandboxHost,
|
|
48
48
|
createNativeGa4SandboxTransport,
|
|
49
|
+
type NativeGa4SandboxScheduler,
|
|
49
50
|
} from "./native-ga4-sandbox.js";
|
|
51
|
+
import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
|
|
50
52
|
import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
51
53
|
import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
52
54
|
import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
|
|
@@ -121,6 +123,15 @@ export interface BootLifecycleOptions {
|
|
|
121
123
|
importModule?: (url: string) => Promise<unknown>;
|
|
122
124
|
/** Reviewed isolated-origin GA4 transport; injectable for DOM-free tests. */
|
|
123
125
|
nativeGa4Transport?: NativeGa4Transport;
|
|
126
|
+
/**
|
|
127
|
+
* Defers non-essential platform-adapter work (the GA4 sandbox iframe mount and
|
|
128
|
+
* the Amplitude HTTP capability) until after first paint and idle — see
|
|
129
|
+
* `post-paint.ts`. Defaults to the real post-paint scheduler over `window`
|
|
130
|
+
* (a guarded synchronous no-op off the browser). Injectable so a test can
|
|
131
|
+
* assert nothing runs before the scheduler fires with a fake, controllable
|
|
132
|
+
* scheduler instead of real paint/idle timing.
|
|
133
|
+
*/
|
|
134
|
+
schedulePlatformAdapterStart?: NativeGa4SandboxScheduler;
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
/**
|
|
@@ -314,6 +325,58 @@ function defaultConsentGate(
|
|
|
314
325
|
return (integration) => integrationConsentEligible(integration, readConsentSnapshot(win));
|
|
315
326
|
}
|
|
316
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The default `schedulePlatformAdapterStart`: the real post-paint scheduler over the
|
|
330
|
+
* global `window`, or a synchronous immediate start off the browser (SSR, tests that
|
|
331
|
+
* import this module without a DOM) — never a hang.
|
|
332
|
+
*/
|
|
333
|
+
function defaultPlatformAdapterScheduler(start: () => void): { cancel(): void } {
|
|
334
|
+
if (typeof window === "undefined") {
|
|
335
|
+
start();
|
|
336
|
+
return { cancel() {} };
|
|
337
|
+
}
|
|
338
|
+
return schedulePostPaint(window as unknown as PostPaintWindow, start);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Wrap a `fetch` capability so calls queue instead of running immediately, and are
|
|
343
|
+
* all issued together once `schedule` fires. This is what keeps the Amplitude
|
|
344
|
+
* adapter's HTTP collection off the pre-paint critical path exactly like the GA4
|
|
345
|
+
* sandbox's deferred iframe mount, without touching `native-amplitude.ts` (which
|
|
346
|
+
* stays paint-timing-agnostic — it just calls the `fetch` capability it was given).
|
|
347
|
+
*/
|
|
348
|
+
function deferFetchCapability(
|
|
349
|
+
fetchCapability: typeof fetch,
|
|
350
|
+
schedule: (start: () => void) => { cancel(): void },
|
|
351
|
+
): typeof fetch {
|
|
352
|
+
interface Pending {
|
|
353
|
+
args: Parameters<typeof fetch>;
|
|
354
|
+
resolve: (value: Response) => void;
|
|
355
|
+
reject: (reason: unknown) => void;
|
|
356
|
+
}
|
|
357
|
+
let queue: Pending[] = [];
|
|
358
|
+
let scheduled = false;
|
|
359
|
+
const flush = (): void => {
|
|
360
|
+
scheduled = false;
|
|
361
|
+
const pending = queue;
|
|
362
|
+
queue = [];
|
|
363
|
+
for (const { args, resolve, reject } of pending) {
|
|
364
|
+
fetchCapability(...args).then(resolve, reject);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
return ((...args: Parameters<typeof fetch>) =>
|
|
368
|
+
new Promise<Response>((resolve, reject) => {
|
|
369
|
+
queue.push({ args, resolve, reject });
|
|
370
|
+
// Re-arm on every fresh burst: a flush disarms itself so a LATER call (e.g. a
|
|
371
|
+
// same-document route commit long after boot) still gets scheduled, not just
|
|
372
|
+
// the first ever call through this capability.
|
|
373
|
+
if (!scheduled) {
|
|
374
|
+
scheduled = true;
|
|
375
|
+
schedule(flush);
|
|
376
|
+
}
|
|
377
|
+
})) as typeof fetch;
|
|
378
|
+
}
|
|
379
|
+
|
|
317
380
|
/**
|
|
318
381
|
* Boot the lifecycle for `win` exactly once. A second call returns the same instance
|
|
319
382
|
* (nested-layout safety). Returns `null` when there is no boot descriptor to act on.
|
|
@@ -330,6 +393,8 @@ export function bootIntegrationLifecycle(
|
|
|
330
393
|
|
|
331
394
|
const hostFetch = options.fetchCapability ??
|
|
332
395
|
(typeof win.fetch === "function" ? win.fetch.bind(win) : undefined);
|
|
396
|
+
const schedulePlatformAdapterStart =
|
|
397
|
+
options.schedulePlatformAdapterStart ?? defaultPlatformAdapterScheduler;
|
|
333
398
|
const fallbackLoader = options.importModule
|
|
334
399
|
? makeSameOriginEntryLoader(options.importModule)
|
|
335
400
|
: sameOriginEntryLoader;
|
|
@@ -340,7 +405,10 @@ export function bootIntegrationLifecycle(
|
|
|
340
405
|
// Platform authority is resolved before any caller/window override: exact registry
|
|
341
406
|
// matches run; unknown/mismatched platform claims fail closed without delegation.
|
|
342
407
|
const loadModule = makePlatformAdapterRuntimeLoader({
|
|
343
|
-
|
|
408
|
+
// Deferred so the Amplitude adapter's HTTP collection never starts pre-paint
|
|
409
|
+
// (F4/CLASS 1). The subscribe/mapping logic in native-amplitude.ts is unchanged
|
|
410
|
+
// and unaware of this — it just calls whatever `fetch` capability it was given.
|
|
411
|
+
fetchCapability: hostFetch ? deferFetchCapability(hostFetch, schedulePlatformAdapterStart) : hostFetch,
|
|
344
412
|
importModule: options.importModule,
|
|
345
413
|
fallback: ordinaryLoader,
|
|
346
414
|
nativeGa4Transport: () => {
|
|
@@ -351,6 +419,7 @@ export function bootIntegrationLifecycle(
|
|
|
351
419
|
return createNativeGa4SandboxTransport(
|
|
352
420
|
createBrowserNativeGa4SandboxHost(document, MessageChannel),
|
|
353
421
|
resolveNativeGa4SandboxUrl(win.location.href),
|
|
422
|
+
schedulePlatformAdapterStart,
|
|
354
423
|
);
|
|
355
424
|
},
|
|
356
425
|
});
|
|
@@ -92,15 +92,34 @@ export function createBrowserNativeGa4SandboxHost(
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Starts (or cancels) the deferred mount of the sandbox iframe. Defaults to
|
|
97
|
+
* running `start` synchronously, so a caller that does not care about paint
|
|
98
|
+
* timing (every existing test, and any future non-browser host) gets the
|
|
99
|
+
* original immediate-mount behavior unchanged.
|
|
100
|
+
*/
|
|
101
|
+
export type NativeGa4SandboxScheduler = (start: () => void) => { cancel(): void };
|
|
102
|
+
|
|
103
|
+
const IMMEDIATE_SCHEDULER: NativeGa4SandboxScheduler = (start) => {
|
|
104
|
+
start();
|
|
105
|
+
return { cancel() {} };
|
|
106
|
+
};
|
|
107
|
+
|
|
95
108
|
/**
|
|
96
109
|
* Mount one hidden same-site, cross-origin iframe and transfer exactly one MessagePort
|
|
97
110
|
* after load. gtag suppresses collection in an opaque third-party frame, so the receiver
|
|
98
111
|
* needs a real same-site origin. The exact origin, iframe window, and one-shot transfer
|
|
99
112
|
* are the authority boundary. No general parent `message` listener is installed.
|
|
113
|
+
*
|
|
114
|
+
* The frame is not created or appended until `schedule` fires (default: immediately,
|
|
115
|
+
* i.e. unchanged from before) — that append is what starts gtag.js loading, so gating
|
|
116
|
+
* it behind a paint-aware scheduler (see `post-paint.ts`) keeps it off the LCP path.
|
|
117
|
+
* `configure`/`send` calls before the frame mounts queue exactly as they always have.
|
|
100
118
|
*/
|
|
101
119
|
export function createNativeGa4SandboxTransport(
|
|
102
120
|
host: NativeGa4SandboxHost,
|
|
103
121
|
sandboxUrl: string,
|
|
122
|
+
schedule: NativeGa4SandboxScheduler = IMMEDIATE_SCHEDULER,
|
|
104
123
|
): NativeGa4SandboxTransport {
|
|
105
124
|
const sandboxOrigin = new URL(sandboxUrl, host.parentOrigin).origin;
|
|
106
125
|
if (!host.parentOrigin || sandboxOrigin === host.parentOrigin) {
|
|
@@ -110,15 +129,8 @@ export function createNativeGa4SandboxTransport(
|
|
|
110
129
|
if (sandboxOrigin !== expectedSandboxOrigin) {
|
|
111
130
|
throw new Error("ga4_sandbox_origin_not_same_site");
|
|
112
131
|
}
|
|
113
|
-
const frame = host.createFrame();
|
|
114
|
-
frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
115
|
-
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
116
|
-
frame.setAttribute("aria-hidden", "true");
|
|
117
|
-
frame.setAttribute("tabindex", "-1");
|
|
118
|
-
frame.setAttribute("title", "Analytics transport");
|
|
119
|
-
frame.hidden = true;
|
|
120
|
-
frame.src = sandboxUrl;
|
|
121
132
|
|
|
133
|
+
let frame: NativeGa4SandboxFrame | null = null;
|
|
122
134
|
let port: NativeGa4MessagePort | null = null;
|
|
123
135
|
let disposed = false;
|
|
124
136
|
let unavailable = false;
|
|
@@ -129,34 +141,49 @@ export function createNativeGa4SandboxTransport(
|
|
|
129
141
|
unavailable = true;
|
|
130
142
|
queue = [];
|
|
131
143
|
host.clearTimer(timer);
|
|
132
|
-
frame
|
|
144
|
+
frame?.remove();
|
|
133
145
|
};
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
if (disposed ||
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
port
|
|
149
|
-
|
|
150
|
-
return reject();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
147
|
+
const mount = (): void => {
|
|
148
|
+
if (disposed || unavailable || frame) return;
|
|
149
|
+
const mounted = host.createFrame();
|
|
150
|
+
frame = mounted;
|
|
151
|
+
mounted.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
152
|
+
mounted.setAttribute("referrerpolicy", "no-referrer");
|
|
153
|
+
mounted.setAttribute("aria-hidden", "true");
|
|
154
|
+
mounted.setAttribute("tabindex", "-1");
|
|
155
|
+
mounted.setAttribute("title", "Analytics transport");
|
|
156
|
+
mounted.hidden = true;
|
|
157
|
+
mounted.src = sandboxUrl;
|
|
158
|
+
|
|
159
|
+
mounted.addEventListener("load", () => {
|
|
160
|
+
if (disposed || port) return;
|
|
161
|
+
const target = mounted.contentWindow;
|
|
162
|
+
if (!target) return reject();
|
|
163
|
+
const channel = host.createChannel();
|
|
164
|
+
port = channel.port1;
|
|
165
|
+
try {
|
|
166
|
+
target.postMessage(
|
|
167
|
+
Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind", parentOrigin: host.parentOrigin }),
|
|
168
|
+
sandboxOrigin,
|
|
169
|
+
[channel.port2],
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
port.close();
|
|
173
|
+
port = null;
|
|
174
|
+
return reject();
|
|
175
|
+
}
|
|
176
|
+
host.clearTimer(timer);
|
|
177
|
+
const pending = queue;
|
|
178
|
+
queue = [];
|
|
179
|
+
for (const message of pending) port.postMessage(Object.freeze(message));
|
|
180
|
+
}, { once: true });
|
|
181
|
+
mounted.addEventListener("error", reject, { once: true });
|
|
182
|
+
timer = host.setTimer(reject, 5_000);
|
|
183
|
+
host.appendFrame(mounted);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const scheduled = schedule(mount);
|
|
160
187
|
|
|
161
188
|
const post = (message: NativeGa4SandboxPortMessage): void => {
|
|
162
189
|
if (disposed || unavailable) throw new Error("ga4_sandbox_unavailable");
|
|
@@ -174,6 +201,7 @@ export function createNativeGa4SandboxTransport(
|
|
|
174
201
|
},
|
|
175
202
|
dispose() {
|
|
176
203
|
if (disposed) return;
|
|
204
|
+
scheduled.cancel();
|
|
177
205
|
if (port) {
|
|
178
206
|
post({ schemaVersion: 1, type: "dispose" });
|
|
179
207
|
port.close();
|
|
@@ -183,7 +211,7 @@ export function createNativeGa4SandboxTransport(
|
|
|
183
211
|
port = null;
|
|
184
212
|
disposed = true;
|
|
185
213
|
host.clearTimer(timer);
|
|
186
|
-
frame
|
|
214
|
+
frame?.remove();
|
|
187
215
|
},
|
|
188
216
|
};
|
|
189
217
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paint-aware scheduling for non-essential platform work (ext-kernel-05 follow-on).
|
|
3
|
+
*
|
|
4
|
+
* Nothing in the lifecycle boot chain waited for a real paint before starting
|
|
5
|
+
* heavy adapter transports (GA4's isolated-origin iframe, Amplitude's HTTP
|
|
6
|
+
* collection), so Lighthouse's simulated dependency graph charged their bytes
|
|
7
|
+
* and main-thread time straight to LCP. This scheduler defers a callback until
|
|
8
|
+
* AFTER the document has painted and the main thread is otherwise idle:
|
|
9
|
+
*
|
|
10
|
+
* - if the document has already finished loading, schedule on idle immediately;
|
|
11
|
+
* - otherwise wait for `load`, then schedule on idle;
|
|
12
|
+
* - "idle" is `requestIdleCallback` with a bounded timeout, falling back to a
|
|
13
|
+
* macrotask (`setTimeout(0)`) where `requestIdleCallback` does not exist.
|
|
14
|
+
*
|
|
15
|
+
* Cancellable at every stage (before `load`, and while idle-queued), so a
|
|
16
|
+
* document torn down before the deferred work starts never runs it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The narrow slice of `window` this scheduler depends on — structural, edge-safe. */
|
|
20
|
+
export interface PostPaintWindow {
|
|
21
|
+
document?: { readyState?: string };
|
|
22
|
+
addEventListener(type: "load", listener: () => void, options?: { once?: boolean }): void;
|
|
23
|
+
removeEventListener?(type: "load", listener: () => void): void;
|
|
24
|
+
requestIdleCallback?(callback: (deadline: unknown) => void, options?: { timeout: number }): number;
|
|
25
|
+
cancelIdleCallback?(handle: number): void;
|
|
26
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
27
|
+
clearTimeout(handle: unknown): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PostPaintScheduleOptions {
|
|
31
|
+
/** Bound on how long `requestIdleCallback` may wait before firing anyway. */
|
|
32
|
+
idleTimeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PostPaintHandle {
|
|
36
|
+
/** Idempotent. Prevents the callback from running if it has not run yet. */
|
|
37
|
+
cancel(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Default bound so a busy main thread cannot indefinitely starve deferred work. */
|
|
41
|
+
export const DEFAULT_POST_PAINT_IDLE_TIMEOUT_MS = 4_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Schedule `callback` to run after first paint and idle. Never runs synchronously,
|
|
45
|
+
* even when the document is already `complete` — always at least one idle/macrotask
|
|
46
|
+
* turn later — so a caller can rely on "nothing runs before this returns".
|
|
47
|
+
*/
|
|
48
|
+
export function schedulePostPaint(
|
|
49
|
+
win: PostPaintWindow,
|
|
50
|
+
callback: () => void,
|
|
51
|
+
options: PostPaintScheduleOptions = {},
|
|
52
|
+
): PostPaintHandle {
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
let ran = false;
|
|
55
|
+
let idleHandle: number | undefined;
|
|
56
|
+
let timeoutHandle: unknown;
|
|
57
|
+
let loadListener: (() => void) | undefined;
|
|
58
|
+
|
|
59
|
+
const runIdle = (): void => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
if (typeof win.requestIdleCallback === "function") {
|
|
62
|
+
idleHandle = win.requestIdleCallback(
|
|
63
|
+
() => {
|
|
64
|
+
if (cancelled || ran) return;
|
|
65
|
+
ran = true;
|
|
66
|
+
callback();
|
|
67
|
+
},
|
|
68
|
+
{ timeout: options.idleTimeoutMs ?? DEFAULT_POST_PAINT_IDLE_TIMEOUT_MS },
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
timeoutHandle = win.setTimeout(() => {
|
|
72
|
+
if (cancelled || ran) return;
|
|
73
|
+
ran = true;
|
|
74
|
+
callback();
|
|
75
|
+
}, 0);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (win.document?.readyState === "complete") {
|
|
80
|
+
runIdle();
|
|
81
|
+
} else {
|
|
82
|
+
loadListener = runIdle;
|
|
83
|
+
win.addEventListener("load", loadListener, { once: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
cancel() {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
cancelled = true;
|
|
90
|
+
if (loadListener) win.removeEventListener?.("load", loadListener);
|
|
91
|
+
if (idleHandle !== undefined) win.cancelIdleCallback?.(idleHandle);
|
|
92
|
+
if (timeoutHandle !== undefined) win.clearTimeout(timeoutHandle);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|